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