Комментарии, файлы и права проекта
- Система комментариев к задачам с вложенными ответами - Редактирование и удаление комментариев - Прикрепление файлов к задачам и комментариям (картинки, архивы до 10 МБ) - Система прав проекта: админ проекта может удалять чужие комментарии и файлы - Универсальный класс FileUpload для загрузки файлов - Защита загрузки: только автор комментария может добавлять файлы - Каскадное удаление: задача → комментарии → файлы - Автообновление комментариев в реальном времени
This commit is contained in:
452
front_vue/src/components/TaskPanel/TaskCommentsTab.vue
Normal file
452
front_vue/src/components/TaskPanel/TaskCommentsTab.vue
Normal 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>
|
||||
Reference in New Issue
Block a user