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