1
0
Files
TaskBoard/front_vue/src/components/TaskPanel/TaskCommentsTab.vue
Falknat 5018a2d123 Мобильная версия
1. Адаптация и разработка мобильного варианта.
2026-01-15 09:33:16 +07:00

463 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>