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,482 @@
<template>
<SlidePanel
:show="show"
@close="tryClose"
>
<template #header>
<h2>{{ isNew ? 'Новая задача' : 'Редактирование' }}</h2>
<span v-if="!isNew && card?.dateCreate" class="header-date">
Создано: {{ formatDate(card.dateCreate) }}
</span>
<!-- Вкладки (только для существующих задач) -->
<TabsPanel
v-if="!isNew"
v-model="activeTab"
:tabs="tabsConfig"
class="header-tabs"
/>
</template>
<template #default>
<!-- Вкладка редактирования -->
<TaskEditTab
v-show="activeTab === 'edit' || isNew"
ref="editTabRef"
:card="card"
:departments="departments"
:labels="labels"
:users="users"
@preview-image="openImagePreview"
/>
<!-- Вкладка комментариев -->
<TaskCommentsTab
v-show="activeTab === 'comments'"
ref="commentsTabRef"
:task-id="card?.id"
:current-user-id="currentUserId"
:is-project-admin="isProjectAdmin"
:active="activeTab === 'comments'"
@comments-loaded="commentsCount = $event"
@preview-file="openImagePreview"
/>
</template>
<template #footer>
<div class="footer-left">
<IconButton
v-if="!isNew"
icon="trash-2"
variant="danger"
title="Удалить"
@click="handleDelete"
/>
<IconButton
v-if="!isNew && canArchive && !isArchived"
icon="archive"
variant="warning"
title="В архив"
@click="handleArchive"
/>
<IconButton
v-if="!isNew && isArchived"
icon="archive-restore"
variant="warning"
title="Из архива"
@click="handleRestore"
/>
</div>
<div class="footer-right">
<button class="btn-cancel" @click="tryClose">Отмена</button>
<button
class="btn-save"
@click="handleSave"
:disabled="!canSave || isSaving"
>
<span v-if="isSaving" class="btn-loader"></span>
<span v-else>{{ isNew ? 'Создать' : 'Сохранить' }}</span>
</button>
</div>
</template>
</SlidePanel>
<!-- Диалог несохранённых изменений -->
<ConfirmDialog
:show="showUnsavedDialog"
title="Обнаружены изменения"
message="У вас есть несохранённые изменения.<br>Что вы хотите сделать?"
confirm-text="Сохранить"
:show-discard="true"
@confirm="confirmSave"
@cancel="cancelClose"
@discard="confirmDiscard"
/>
<!-- Диалог удаления задачи -->
<ConfirmDialog
:show="showDeleteDialog"
title="Удалить задачу?"
message="Это действие нельзя отменить.<br>Задача будет удалена навсегда."
confirm-text="Удалить"
variant="danger"
@confirm="confirmDelete"
@cancel="showDeleteDialog = false"
/>
<!-- Диалог архивации задачи -->
<ConfirmDialog
:show="showArchiveDialog"
title="Архивировать задачу?"
message="Задача будет перемещена в архив.<br>Вы сможете восстановить её позже."
confirm-text="В архив"
variant="warning"
@confirm="confirmArchive"
@cancel="showArchiveDialog = false"
/>
<!-- Диалог разархивации задачи -->
<ConfirmDialog
:show="showRestoreDialog"
title="Вернуть из архива?"
message="Задача будет возвращена на доску<br>в колонку «Готово»."
confirm-text="Вернуть"
variant="warning"
@confirm="confirmRestore"
@cancel="showRestoreDialog = false"
/>
<!-- Модальное окно просмотра изображения -->
<ImagePreview
:file="previewImage"
:get-full-url="getFullUrl"
:show-delete="previewImage?.source === 'comment' ? previewImage?.canDelete : true"
@close="closeImagePreview"
@delete="deleteFromPreview"
/>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUpdated, nextTick } from 'vue'
import SlidePanel from '../ui/SlidePanel.vue'
import TabsPanel from '../ui/TabsPanel.vue'
import IconButton from '../ui/IconButton.vue'
import ImagePreview from '../ui/ImagePreview.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import TaskEditTab from './TaskEditTab.vue'
import TaskCommentsTab from './TaskCommentsTab.vue'
import { taskImageApi, commentImageApi, getFullUrl } from '../../api'
const props = defineProps({
show: Boolean,
card: Object,
columnId: [String, Number],
doneColumnId: Number,
isArchived: {
type: Boolean,
default: false
},
departments: {
type: Array,
default: () => []
},
labels: {
type: Array,
default: () => []
},
users: {
type: Array,
default: () => []
},
currentUserId: {
type: Number,
default: null
},
isProjectAdmin: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close', 'save', 'delete', 'archive', 'restore'])
// State
const isNew = ref(true)
const isSaving = ref(false)
const activeTab = ref('edit')
const commentsCount = ref(0)
// Refs
const editTabRef = ref(null)
const commentsTabRef = ref(null)
// Dialogs
const showUnsavedDialog = ref(false)
const showDeleteDialog = ref(false)
const showArchiveDialog = ref(false)
const showRestoreDialog = ref(false)
// Image preview
const previewImage = ref(null)
// Tabs config
const tabsConfig = computed(() => [
{ id: 'edit', icon: 'pencil', title: 'Редактирование' },
{ id: 'comments', icon: 'message-circle', title: 'Комментарии', badge: commentsCount.value || null }
])
// Can save
const canSave = computed(() => {
const form = editTabRef.value?.form
return form?.title?.trim() && form?.departmentId
})
// Can archive (только если колонка "Готово")
const canArchive = computed(() => {
return props.doneColumnId && Number(props.columnId) === props.doneColumnId
})
// Format date
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const day = date.getDate()
const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
return `${day} ${months[date.getMonth()]} ${date.getFullYear()}`
}
// Close handling
const tryClose = () => {
if (editTabRef.value?.hasChanges) {
showUnsavedDialog.value = true
} else {
emit('close')
}
}
const confirmSave = () => {
showUnsavedDialog.value = false
handleSave()
}
const confirmDiscard = () => {
showUnsavedDialog.value = false
emit('close')
}
const cancelClose = () => {
showUnsavedDialog.value = false
}
// Save
const handleSave = async () => {
if (!canSave.value) return
isSaving.value = true
editTabRef.value.fileError = ''
if (props.card?.id) {
// Upload new files
const newFiles = editTabRef.value.getNewFiles()
for (const file of newFiles) {
const result = await taskImageApi.upload(props.card.id, file.preview, file.name)
if (result.success) {
file.isNew = false
file.name = result.file.name
file.preview = result.file.url
} else {
editTabRef.value.fileError = result.errors?.file || 'Ошибка загрузки файла'
isSaving.value = false
return
}
}
// Delete marked files
const filesToDelete = editTabRef.value.getFilesToDelete()
if (filesToDelete.length > 0) {
const fileNames = filesToDelete.map(f => f.name)
const result = await taskImageApi.delete(props.card.id, fileNames)
if (!result.success) {
editTabRef.value.fileError = result.errors?.file || 'Ошибка удаления файла'
isSaving.value = false
return
}
}
editTabRef.value.removeDeletedFiles()
}
const formData = editTabRef.value.getFormData()
emit('save', {
...formData,
id: props.card?.id
})
isSaving.value = false
}
// Delete
const handleDelete = () => {
showDeleteDialog.value = true
}
const confirmDelete = () => {
showDeleteDialog.value = false
emit('delete', props.card.id)
}
// Archive
const handleArchive = () => {
showArchiveDialog.value = true
}
const confirmArchive = () => {
showArchiveDialog.value = false
if (props.card?.id) {
emit('archive', props.card.id)
}
}
// Restore
const handleRestore = () => {
showRestoreDialog.value = true
}
const confirmRestore = () => {
showRestoreDialog.value = false
if (props.card?.id) {
emit('restore', props.card.id)
}
}
// Image preview
const openImagePreview = (file) => {
previewImage.value = file
nextTick(refreshIcons)
}
const closeImagePreview = () => {
previewImage.value = null
}
const deleteFromPreview = async () => {
if (!previewImage.value) return
// Удаление файла из комментария
if (previewImage.value.source === 'comment') {
const { commentId, name } = previewImage.value
try {
const result = await commentImageApi.delete(commentId, name)
if (result.success) {
closeImagePreview()
// Перезагружаем комментарии
commentsTabRef.value?.loadComments()
}
} catch (e) {
console.error('Ошибка удаления файла:', e)
}
return
}
// Удаление файла из задачи (старая логика)
const files = editTabRef.value?.attachedFiles || []
const index = files.findIndex(
f => f.name === previewImage.value.name && f.size === previewImage.value.size
)
if (index !== -1) {
closeImagePreview()
// Trigger file remove in edit tab
editTabRef.value?.handleFileRemove?.(index)
}
}
// Icons
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
// Watch show
watch(() => props.show, async (newVal) => {
if (newVal) {
isNew.value = !props.card
activeTab.value = 'edit'
commentsCount.value = props.card?.comments_count || 0
previewImage.value = null
// Reset comments tab
commentsTabRef.value?.reset()
// Load form data
await nextTick()
editTabRef.value?.loadFromCard(props.card)
editTabRef.value?.setDetailsContent(props.card?.details || '')
await nextTick()
editTabRef.value?.saveInitialForm()
editTabRef.value?.focusTitle()
refreshIcons()
}
})
onMounted(refreshIcons)
onUpdated(refreshIcons)
</script>
<style scoped>
.header-date {
font-size: 13px;
color: var(--text-muted);
}
.header-tabs {
margin-left: auto;
margin-right: 12px;
}
.footer-left {
display: flex;
gap: 8px;
}
.footer-right {
display: flex;
gap: 12px;
margin-left: auto;
}
.btn-cancel {
padding: 12px 24px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: var(--text-secondary);
font-family: inherit;
font-size: 14px;
cursor: pointer;
transition: all 0.15s;
}
.btn-cancel:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.btn-save {
padding: 12px 28px;
background: var(--accent);
border: none;
border-radius: 8px;
color: #000;
font-family: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-save:hover {
filter: brightness(1.1);
}
.btn-save:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-loader {
width: 16px;
height: 16px;
border: 2px solid rgba(0, 0, 0, 0.2);
border-top-color: #000;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>