463 lines
12 KiB
Vue
463 lines
12 KiB
Vue
<template>
|
||
<div class="comments-tab">
|
||
<!-- Список комментариев -->
|
||
<div class="comments-list" ref="commentsListRef">
|
||
<div v-if="loading" class="comments-loading">
|
||
<span class="loader"></span>
|
||
Загрузка комментариев...
|
||
</div>
|
||
|
||
<div v-else-if="flatCommentsWithLevel.length === 0" class="comments-empty">
|
||
<i data-lucide="message-circle"></i>
|
||
<span>Пока нет комментариев</span>
|
||
</div>
|
||
|
||
<CommentItem
|
||
v-else
|
||
v-for="comment in flatCommentsWithLevel"
|
||
:key="comment.id"
|
||
:comment="comment"
|
||
:level="comment.level"
|
||
:is-own="comment.id_accounts === currentUserId"
|
||
:can-edit="canEditComment(comment)"
|
||
:is-editing="editingCommentId === comment.id"
|
||
:edit-text="editingCommentText"
|
||
:get-full-url="getFullUrl"
|
||
@update:edit-text="editingCommentText = $event"
|
||
@reply="startReply"
|
||
@edit="startEditComment"
|
||
@delete="confirmDeleteComment"
|
||
@cancel-edit="cancelEditComment"
|
||
@save-edit="saveEditComment"
|
||
@preview-file="(file) => previewFile(file, comment)"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Форма добавления комментария -->
|
||
<CommentForm
|
||
v-model="newCommentText"
|
||
:replying-to="replyingTo"
|
||
:is-sending="isSendingComment"
|
||
ref="commentFormRef"
|
||
@send="sendComment"
|
||
@cancel-reply="cancelReply"
|
||
/>
|
||
|
||
<!-- Диалог удаления комментария -->
|
||
<ConfirmDialog
|
||
:show="showDeleteDialog"
|
||
title="Удалить комментарий?"
|
||
message="Комментарий будет удалён навсегда."
|
||
confirm-text="Удалить"
|
||
variant="danger"
|
||
@confirm="deleteComment"
|
||
@cancel="showDeleteDialog = false"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, watch, onMounted, onUpdated, onUnmounted, nextTick } from 'vue'
|
||
import CommentItem from './CommentItem.vue'
|
||
import CommentForm from './CommentForm.vue'
|
||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||
import { commentsApi, commentImageApi, getFullUrl } from '../../api'
|
||
|
||
const props = defineProps({
|
||
taskId: {
|
||
type: Number,
|
||
default: null
|
||
},
|
||
currentUserId: {
|
||
type: Number,
|
||
default: null
|
||
},
|
||
isProjectAdmin: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
active: {
|
||
type: Boolean,
|
||
default: false
|
||
}
|
||
})
|
||
|
||
const emit = defineEmits(['comments-loaded', 'preview-file'])
|
||
|
||
// Состояние
|
||
const comments = ref([])
|
||
const loading = ref(false)
|
||
const newCommentText = ref('')
|
||
const isSendingComment = ref(false)
|
||
const editingCommentId = ref(null)
|
||
const editingCommentText = ref('')
|
||
const commentToDelete = ref(null)
|
||
const showDeleteDialog = ref(false)
|
||
const replyingTo = ref(null)
|
||
|
||
// Refs
|
||
const commentsListRef = ref(null)
|
||
const commentFormRef = ref(null)
|
||
|
||
// Интервал обновления
|
||
let refreshInterval = null
|
||
|
||
// Построение дерева комментариев
|
||
const commentsTree = computed(() => {
|
||
const map = new Map()
|
||
const roots = []
|
||
|
||
comments.value.forEach(c => {
|
||
map.set(c.id, { ...c, children: [] })
|
||
})
|
||
|
||
comments.value.forEach(c => {
|
||
const comment = map.get(c.id)
|
||
if (c.id_answer && map.has(c.id_answer)) {
|
||
map.get(c.id_answer).children.push(comment)
|
||
} else {
|
||
roots.push(comment)
|
||
}
|
||
})
|
||
|
||
return roots
|
||
})
|
||
|
||
// Плоский список с уровнями вложенности
|
||
const flatCommentsWithLevel = computed(() => {
|
||
const result = []
|
||
|
||
const flatten = (items, level = 0) => {
|
||
items.forEach(item => {
|
||
result.push({ ...item, level })
|
||
if (item.children?.length) {
|
||
flatten(item.children, level + 1)
|
||
}
|
||
})
|
||
}
|
||
|
||
flatten(commentsTree.value)
|
||
return result
|
||
})
|
||
|
||
// Загрузка комментариев
|
||
const loadComments = async (silent = false) => {
|
||
if (!props.taskId) return
|
||
|
||
if (!silent) {
|
||
loading.value = true
|
||
}
|
||
|
||
try {
|
||
const result = await commentsApi.getByTask(props.taskId)
|
||
if (result.success) {
|
||
const newData = result.data
|
||
const oldData = comments.value
|
||
|
||
// Проверяем изменения
|
||
const hasChanges =
|
||
newData.length !== oldData.length ||
|
||
newData.some((newComment, i) => {
|
||
const oldComment = oldData[i]
|
||
return !oldComment ||
|
||
newComment.id !== oldComment.id ||
|
||
newComment.text !== oldComment.text
|
||
})
|
||
|
||
if (hasChanges) {
|
||
comments.value = newData
|
||
emit('comments-loaded', newData.length)
|
||
await nextTick()
|
||
refreshIcons()
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Ошибка загрузки комментариев:', e)
|
||
} finally {
|
||
if (!silent) {
|
||
loading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// Автообновление
|
||
const startRefresh = () => {
|
||
stopRefresh()
|
||
const interval = (window.APP_CONFIG?.IDLE_REFRESH_SECONDS || 30) * 1000
|
||
refreshInterval = setInterval(async () => {
|
||
if (props.active && props.taskId) {
|
||
await loadComments(true)
|
||
}
|
||
}, interval)
|
||
}
|
||
|
||
const stopRefresh = () => {
|
||
if (refreshInterval) {
|
||
clearInterval(refreshInterval)
|
||
refreshInterval = null
|
||
}
|
||
}
|
||
|
||
// Отправка комментария
|
||
const sendComment = async () => {
|
||
const hasText = newCommentText.value.trim()
|
||
const files = commentFormRef.value?.getFiles() || []
|
||
const hasFiles = files.length > 0
|
||
|
||
if ((!hasText && !hasFiles) || !props.taskId) return
|
||
|
||
isSendingComment.value = true
|
||
try {
|
||
const id_answer = replyingTo.value?.id || null
|
||
// Если нет текста но есть файлы — отправляем пустой текст (или пробел)
|
||
const text = hasText ? newCommentText.value : ' '
|
||
const result = await commentsApi.create(props.taskId, text, id_answer)
|
||
|
||
if (result.success) {
|
||
const commentId = result.comment.id
|
||
|
||
// Загружаем файлы к созданному комментарию
|
||
if (hasFiles) {
|
||
const uploadedFiles = []
|
||
for (const file of files) {
|
||
try {
|
||
const uploadResult = await commentImageApi.upload(commentId, file.data, file.name)
|
||
if (uploadResult.success) {
|
||
uploadedFiles.push(uploadResult.file)
|
||
}
|
||
} catch (e) {
|
||
console.error('Ошибка загрузки файла:', e)
|
||
}
|
||
}
|
||
// Обновляем file_img в комментарии
|
||
result.comment.file_img = uploadedFiles
|
||
}
|
||
|
||
comments.value.push(result.comment)
|
||
emit('comments-loaded', comments.value.length)
|
||
newCommentText.value = ''
|
||
commentFormRef.value?.setContent('')
|
||
commentFormRef.value?.clearFiles()
|
||
replyingTo.value = null
|
||
await nextTick()
|
||
scrollToBottom()
|
||
refreshIcons()
|
||
}
|
||
} catch (e) {
|
||
console.error('Ошибка отправки комментария:', e)
|
||
} finally {
|
||
isSendingComment.value = false
|
||
}
|
||
}
|
||
|
||
// Ответ
|
||
const startReply = (comment) => {
|
||
replyingTo.value = comment
|
||
commentFormRef.value?.focus()
|
||
nextTick(refreshIcons)
|
||
}
|
||
|
||
const cancelReply = () => {
|
||
replyingTo.value = null
|
||
}
|
||
|
||
// Редактирование
|
||
const canEditComment = (comment) => {
|
||
return comment.id_accounts === props.currentUserId || props.isProjectAdmin
|
||
}
|
||
|
||
const startEditComment = (comment) => {
|
||
editingCommentId.value = comment.id
|
||
editingCommentText.value = comment.text
|
||
}
|
||
|
||
const cancelEditComment = () => {
|
||
editingCommentId.value = null
|
||
editingCommentText.value = ''
|
||
}
|
||
|
||
const saveEditComment = async () => {
|
||
if (!editingCommentText.value.trim() || !editingCommentId.value) return
|
||
|
||
try {
|
||
const result = await commentsApi.update(editingCommentId.value, editingCommentText.value)
|
||
if (result.success) {
|
||
const index = comments.value.findIndex(c => c.id === editingCommentId.value)
|
||
if (index !== -1) {
|
||
comments.value[index] = result.comment
|
||
}
|
||
cancelEditComment()
|
||
refreshIcons()
|
||
}
|
||
} catch (e) {
|
||
console.error('Ошибка обновления комментария:', e)
|
||
}
|
||
}
|
||
|
||
// Удаление
|
||
const confirmDeleteComment = (comment) => {
|
||
commentToDelete.value = comment
|
||
showDeleteDialog.value = true
|
||
}
|
||
|
||
const deleteComment = async () => {
|
||
if (!commentToDelete.value) return
|
||
|
||
try {
|
||
const result = await commentsApi.delete(commentToDelete.value.id)
|
||
if (result.success) {
|
||
comments.value = comments.value.filter(c => c.id !== commentToDelete.value.id)
|
||
emit('comments-loaded', comments.value.length)
|
||
}
|
||
} catch (e) {
|
||
console.error('Ошибка удаления комментария:', e)
|
||
} finally {
|
||
showDeleteDialog.value = false
|
||
commentToDelete.value = null
|
||
}
|
||
}
|
||
|
||
// Превью файла (пробрасываем в родитель)
|
||
const previewFile = (file, comment) => {
|
||
// Проверяем права на удаление: автор комментария или админ проекта
|
||
const canDelete = comment.id_accounts === props.currentUserId || props.isProjectAdmin
|
||
|
||
emit('preview-file', {
|
||
name: file.name,
|
||
size: file.size,
|
||
preview: file.url,
|
||
source: 'comment',
|
||
commentId: comment.id,
|
||
canDelete
|
||
})
|
||
}
|
||
|
||
// Хелперы
|
||
const scrollToBottom = () => {
|
||
if (commentsListRef.value) {
|
||
commentsListRef.value.scrollTop = commentsListRef.value.scrollHeight
|
||
}
|
||
}
|
||
|
||
const refreshIcons = () => {
|
||
if (window.lucide) {
|
||
window.lucide.createIcons()
|
||
}
|
||
}
|
||
|
||
// Сброс состояния
|
||
const reset = () => {
|
||
comments.value = []
|
||
newCommentText.value = ''
|
||
editingCommentId.value = null
|
||
editingCommentText.value = ''
|
||
replyingTo.value = null
|
||
stopRefresh()
|
||
}
|
||
|
||
// Watch для активации/деактивации
|
||
watch(() => props.active, (newVal) => {
|
||
if (newVal && props.taskId) {
|
||
if (comments.value.length === 0) {
|
||
loadComments()
|
||
}
|
||
startRefresh()
|
||
} else {
|
||
stopRefresh()
|
||
}
|
||
})
|
||
|
||
// Watch для смены задачи
|
||
watch(() => props.taskId, (newVal, oldVal) => {
|
||
if (newVal !== oldVal) {
|
||
reset()
|
||
if (props.active && newVal) {
|
||
loadComments()
|
||
startRefresh()
|
||
}
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
if (props.active && props.taskId) {
|
||
loadComments()
|
||
startRefresh()
|
||
}
|
||
refreshIcons()
|
||
})
|
||
|
||
onUpdated(refreshIcons)
|
||
onUnmounted(stopRefresh)
|
||
|
||
// Expose для родителя
|
||
defineExpose({
|
||
reset,
|
||
loadComments,
|
||
commentsCount: computed(() => comments.value.length)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.comments-tab {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0; /* Важно для flex overflow */
|
||
}
|
||
|
||
.comments-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding: 2px; /* Для outline при редактировании */
|
||
padding-right: 6px;
|
||
margin-bottom: 16px;
|
||
min-height: 100px;
|
||
max-height: calc(100vh - 400px);
|
||
}
|
||
|
||
/* Mobile: убираем max-height, используем flex */
|
||
@media (max-width: 768px) {
|
||
.comments-list {
|
||
max-height: none;
|
||
min-height: 50px;
|
||
margin-bottom: 12px;
|
||
}
|
||
}
|
||
|
||
.comments-loading,
|
||
.comments-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
padding: 40px 20px;
|
||
color: var(--text-muted);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.comments-empty i {
|
||
width: 32px;
|
||
height: 32px;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.loader {
|
||
width: 24px;
|
||
height: 24px;
|
||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||
border-top-color: var(--accent);
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
</style>
|