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,408 @@
<template>
<div class="edit-tab">
<FormField label="Название">
<TextInput
v-model="form.title"
placeholder="Введите название задачи"
ref="titleInputRef"
/>
</FormField>
<FormField label="Краткое описание">
<TextInput
v-model="form.description"
placeholder="Краткое описание в одну строку..."
/>
</FormField>
<FormField label="Подробное описание">
<template #actions>
<div class="format-buttons">
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный (Ctrl+B)">
<i data-lucide="bold"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('italic')" title="Курсив (Ctrl+I)">
<i data-lucide="italic"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('underline')" title="Подчёркивание (Ctrl+U)">
<i data-lucide="underline"></i>
</button>
</div>
</template>
<RichTextEditor
v-model="form.details"
placeholder="Подробное описание задачи, заметки, ссылки..."
:show-toolbar="false"
ref="detailsEditorRef"
/>
</FormField>
<FormField label="Отдел">
<TagsSelect
v-model="form.departmentId"
:options="departmentOptions"
/>
</FormField>
<FormField label="Приоритет">
<TagsSelect
v-model="form.labelId"
:options="labelOptions"
/>
</FormField>
<div class="field-row">
<FormField label="Срок выполнения">
<DatePicker v-model="form.dueDate" />
</FormField>
<FormField label="Исполнитель">
<SelectDropdown
v-model="form.userId"
:options="userOptions"
searchable
placeholder="Без исполнителя"
empty-label="Без исполнителя"
/>
</FormField>
</div>
<FormField
label="Прикреплённые файлы"
hint="Разрешены: PNG, JPEG, JPG, ZIP, RAR (до 10 МБ)"
:error="fileError"
>
<FileUploader
:files="attachedFiles"
:get-full-url="getFullUrl"
@add="handleFileAdd"
@remove="handleFileRemove"
@preview="$emit('preview-image', $event)"
@error="fileError = $event"
/>
</FormField>
<!-- Диалог удаления файла -->
<ConfirmDialog
:show="showDeleteFileDialog"
title="Удалить изображение?"
message="Изображение будет удалено из задачи."
confirm-text="Удалить"
variant="danger"
@confirm="confirmDeleteFile"
@cancel="showDeleteFileDialog = false"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted, onUpdated, nextTick } from 'vue'
import FormField from '../ui/FormField.vue'
import TextInput from '../ui/TextInput.vue'
import RichTextEditor from '../ui/RichTextEditor.vue'
import SelectDropdown from '../ui/SelectDropdown.vue'
import TagsSelect from '../ui/TagsSelect.vue'
import FileUploader from '../ui/FileUploader.vue'
import DatePicker from '../DatePicker.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import { getFullUrl } from '../../api'
const props = defineProps({
card: {
type: Object,
default: null
},
departments: {
type: Array,
default: () => []
},
labels: {
type: Array,
default: () => []
},
users: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['preview-image'])
// Refs
const titleInputRef = ref(null)
const detailsEditorRef = ref(null)
// Form
const form = reactive({
title: '',
description: '',
details: '',
departmentId: null,
labelId: null,
dueDate: '',
userId: null
})
// Files
const attachedFiles = ref([])
const fileError = ref('')
const showDeleteFileDialog = ref(false)
const fileToDeleteIndex = ref(null)
// Initial state for change tracking
const initialForm = ref({})
const initialFilesCount = ref(0)
// Options for selects
const departmentOptions = computed(() => {
return props.departments.map(dept => ({
value: dept.id,
label: dept.name_departments,
color: dept.color
}))
})
const labelOptions = computed(() => {
return props.labels.map(label => ({
value: label.id,
label: label.name_labels,
icon: label.icon
}))
})
const userOptions = computed(() => {
return props.users.map(user => ({
value: user.id,
label: user.name,
subtitle: user.telegram,
avatar: getFullUrl(user.avatar_url)
}))
})
// Change tracking
const hasChanges = computed(() => {
return form.title !== initialForm.value.title ||
form.description !== initialForm.value.description ||
form.details !== initialForm.value.details ||
form.departmentId !== initialForm.value.departmentId ||
form.labelId !== initialForm.value.labelId ||
form.dueDate !== initialForm.value.dueDate ||
form.userId !== initialForm.value.userId ||
attachedFiles.value.length !== initialFilesCount.value
})
// Methods
const saveInitialForm = () => {
initialForm.value = {
title: form.title,
description: form.description,
details: form.details,
departmentId: form.departmentId,
labelId: form.labelId,
dueDate: form.dueDate,
userId: form.userId
}
initialFilesCount.value = attachedFiles.value.length
}
const resetForm = () => {
form.title = ''
form.description = ''
form.details = ''
form.departmentId = null
form.labelId = 2 // Нормально по умолчанию
form.dueDate = new Date().toISOString().split('T')[0]
form.userId = null
clearFiles()
}
const clearFiles = () => {
attachedFiles.value = []
fileError.value = ''
}
const loadFromCard = (card) => {
if (card) {
form.title = card.title || ''
form.description = card.description || ''
form.details = card.details || ''
form.departmentId = card.departmentId || null
form.labelId = card.labelId || null
form.dueDate = card.dueDate || ''
form.userId = card.accountId || null
if (card.files && card.files.length > 0) {
attachedFiles.value = card.files.map(f => ({
name: f.name,
size: f.size,
type: f.type,
preview: f.data || f.url,
isNew: false
}))
} else {
attachedFiles.value = []
}
} else {
resetForm()
}
}
const applyFormat = (command) => {
detailsEditorRef.value?.applyFormat(command)
}
const getAvatarByUserId = (userId) => {
if (!userId) return null
const user = props.users.find(u => u.id === userId)
return user ? user.avatar_url : null
}
// File handlers
const handleFileAdd = async (file) => {
attachedFiles.value.push(file)
await nextTick()
refreshIcons()
}
const handleFileRemove = (index) => {
fileToDeleteIndex.value = index
showDeleteFileDialog.value = true
}
const confirmDeleteFile = () => {
if (fileToDeleteIndex.value !== null) {
const file = attachedFiles.value[fileToDeleteIndex.value]
if (file.isNew) {
attachedFiles.value.splice(fileToDeleteIndex.value, 1)
} else {
file.toDelete = true
}
}
showDeleteFileDialog.value = false
fileToDeleteIndex.value = null
}
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
}
}
// Get form data for saving
const getFormData = () => {
return {
title: form.title,
description: form.description,
details: form.details,
departmentId: form.departmentId,
labelId: form.labelId,
dueDate: form.dueDate,
accountId: form.userId,
assignee: getAvatarByUserId(form.userId),
files: attachedFiles.value
.filter(f => !f.toDelete)
.map(f => ({
name: f.name,
size: f.size,
type: f.type,
data: f.preview
}))
}
}
const getNewFiles = () => {
return attachedFiles.value.filter(f => f.isNew && !f.toDelete)
}
const getFilesToDelete = () => {
return attachedFiles.value.filter(f => f.toDelete && !f.isNew)
}
const removeDeletedFiles = () => {
attachedFiles.value = attachedFiles.value.filter(f => !f.toDelete)
}
const focusTitle = async () => {
await nextTick()
titleInputRef.value?.$el?.focus()
}
const setDetailsContent = (content) => {
detailsEditorRef.value?.setContent(content)
}
onMounted(refreshIcons)
onUpdated(refreshIcons)
// Expose for parent
defineExpose({
form,
attachedFiles,
fileError,
hasChanges,
loadFromCard,
resetForm,
clearFiles,
saveInitialForm,
getFormData,
getNewFiles,
getFilesToDelete,
removeDeletedFiles,
focusTitle,
setDetailsContent
})
</script>
<style scoped>
.edit-tab {
display: flex;
flex-direction: column;
gap: 16px;
}
.field-row {
display: flex;
gap: 20px;
}
.field-row > :deep(.field) {
flex: 1;
}
.format-buttons {
display: flex;
gap: 3px;
}
.format-btn {
width: 24px;
height: 24px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 5px;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.format-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
}
.format-btn:active {
background: var(--accent);
border-color: var(--accent);
color: #000;
}
.format-btn i {
width: 10px;
height: 10px;
}
</style>