1
0

Комментарии, файлы и права проекта

- Система комментариев к задачам с вложенными ответами
- Редактирование и удаление комментариев
- Прикрепление файлов к задачам и комментариям (картинки, архивы до 10 МБ)
- Система прав проекта: админ проекта может удалять чужие комментарии и файлы
- Универсальный класс FileUpload для загрузки файлов
- Защита загрузки: только автор комментария может добавлять файлы
- Каскадное удаление: задача → комментарии → файлы
- Автообновление комментариев в реальном времени
This commit is contained in:
2026-01-15 06:40:47 +07:00
parent 8ac497df63
commit 3bfa1e9e1b
25 changed files with 3353 additions and 904 deletions

View File

@@ -0,0 +1,452 @@
<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;
}
.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: 200px;
max-height: calc(100vh - 400px);
}
.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>