Комментарии, файлы и права проекта
- Система комментариев к задачам с вложенными ответами - Редактирование и удаление комментариев - Прикрепление файлов к задачам и комментариям (картинки, архивы до 10 МБ) - Система прав проекта: админ проекта может удалять чужие комментарии и файлы - Универсальный класс FileUpload для загрузки файлов - Защита загрузки: только автор комментария может добавлять файлы - Каскадное удаление: задача → комментарии → файлы - Автообновление комментариев в реальном времени
This commit is contained in:
423
front_vue/src/components/TaskPanel/CommentForm.vue
Normal file
423
front_vue/src/components/TaskPanel/CommentForm.vue
Normal file
@@ -0,0 +1,423 @@
|
||||
<template>
|
||||
<div class="comment-form">
|
||||
<!-- Индикатор "ответ на" -->
|
||||
<div v-if="replyingTo" class="reply-indicator">
|
||||
<i data-lucide="corner-down-right"></i>
|
||||
<span>Ответ для <strong>{{ replyingTo.author_name }}</strong></span>
|
||||
<button class="reply-cancel" @click="$emit('cancel-reply')" title="Отменить">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FormField :label="replyingTo ? 'Ваш ответ' : 'Новый комментарий'">
|
||||
<template #actions>
|
||||
<div class="format-buttons">
|
||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный">
|
||||
<i data-lucide="bold"></i>
|
||||
</button>
|
||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('italic')" title="Курсив">
|
||||
<i data-lucide="italic"></i>
|
||||
</button>
|
||||
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('underline')" title="Подчёркивание">
|
||||
<i data-lucide="underline"></i>
|
||||
</button>
|
||||
<button type="button" class="format-btn" @mousedown.prevent="triggerFileInput" title="Прикрепить файл">
|
||||
<i data-lucide="paperclip"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<RichTextEditor
|
||||
:modelValue="modelValue"
|
||||
@update:modelValue="$emit('update:modelValue', $event)"
|
||||
:placeholder="replyingTo ? 'Напишите ответ...' : 'Напишите комментарий...'"
|
||||
:show-toolbar="false"
|
||||
ref="editorRef"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<!-- Скрытый input для файлов -->
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInputRef"
|
||||
accept=".png,.jpg,.jpeg,.zip,.rar,image/png,image/jpeg,application/zip,application/x-rar-compressed"
|
||||
multiple
|
||||
@change="handleFileSelect"
|
||||
style="display: none"
|
||||
>
|
||||
|
||||
<!-- Превью прикреплённых файлов -->
|
||||
<div v-if="files.length > 0" class="attached-files">
|
||||
<div
|
||||
v-for="(file, index) in files"
|
||||
:key="file.name + '-' + index"
|
||||
class="attached-file"
|
||||
>
|
||||
<div class="attached-file-icon">
|
||||
<i v-if="isArchive(file)" data-lucide="archive"></i>
|
||||
<i v-else data-lucide="image"></i>
|
||||
</div>
|
||||
<span class="attached-file-name" :title="file.name">{{ file.name }}</span>
|
||||
<button class="attached-file-remove" @click="removeFile(index)" title="Удалить">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn-send-comment"
|
||||
@click="$emit('send')"
|
||||
:disabled="!canSend || isSending"
|
||||
>
|
||||
<span v-if="isSending" class="btn-loader"></span>
|
||||
<template v-else>
|
||||
<i data-lucide="send"></i>
|
||||
Отправить
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUpdated } from 'vue'
|
||||
import FormField from '../ui/FormField.vue'
|
||||
import RichTextEditor from '../ui/RichTextEditor.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
replyingTo: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
isSending: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue', 'send', 'cancel-reply'])
|
||||
|
||||
const editorRef = ref(null)
|
||||
const fileInputRef = ref(null)
|
||||
const files = ref([])
|
||||
|
||||
const allowedExtensions = ['png', 'jpg', 'jpeg', 'zip', 'rar']
|
||||
const archiveExtensions = ['zip', 'rar']
|
||||
const maxFileSize = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
// Можно отправить если есть текст или файлы
|
||||
const canSend = computed(() => {
|
||||
return props.modelValue.trim() || files.value.length > 0
|
||||
})
|
||||
|
||||
// Проверка расширения файла
|
||||
const getFileExt = (file) => {
|
||||
return file.name?.split('.').pop()?.toLowerCase() || ''
|
||||
}
|
||||
|
||||
const isArchive = (file) => {
|
||||
return archiveExtensions.includes(getFileExt(file))
|
||||
}
|
||||
|
||||
const isAllowedFile = (file) => {
|
||||
return allowedExtensions.includes(getFileExt(file))
|
||||
}
|
||||
|
||||
// Открыть диалог выбора файлов
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
// Обработка выбора файлов
|
||||
const handleFileSelect = (event) => {
|
||||
const selectedFiles = event.target.files
|
||||
if (selectedFiles) {
|
||||
processFiles(Array.from(selectedFiles))
|
||||
}
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
// Обработка файлов
|
||||
const processFiles = (fileList) => {
|
||||
for (const file of fileList) {
|
||||
// Проверка типа
|
||||
if (!isAllowedFile(file)) {
|
||||
console.warn(`Файл "${file.name}" не поддерживается.`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Проверка размера
|
||||
if (file.size > maxFileSize) {
|
||||
console.warn(`Файл "${file.name}" слишком большой.`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Проверяем дубликат
|
||||
const isDuplicate = files.value.some(
|
||||
f => f.name === file.name && f.size === file.size
|
||||
)
|
||||
if (isDuplicate) continue
|
||||
|
||||
// Читаем файл как base64
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
files.value.push({
|
||||
file: file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
data: e.target.result
|
||||
})
|
||||
refreshIcons()
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
// Удаление файла из списка
|
||||
const removeFile = (index) => {
|
||||
files.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// Получить файлы для отправки
|
||||
const getFiles = () => {
|
||||
return files.value.map(f => ({
|
||||
name: f.name,
|
||||
data: f.data
|
||||
}))
|
||||
}
|
||||
|
||||
// Очистить файлы
|
||||
const clearFiles = () => {
|
||||
files.value = []
|
||||
}
|
||||
|
||||
const applyFormat = (command) => {
|
||||
editorRef.value?.applyFormat(command)
|
||||
}
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
|
||||
// Expose для внешнего доступа
|
||||
defineExpose({
|
||||
setContent: (text) => editorRef.value?.setContent(text),
|
||||
focus: () => editorRef.value?.$el?.focus(),
|
||||
applyFormat,
|
||||
getFiles,
|
||||
clearFiles,
|
||||
hasFiles: computed(() => files.value.length > 0)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comment-form {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
padding-top: 16px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn-send-comment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
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;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.btn-send-comment:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-send-comment:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-send-comment i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
/* Индикатор ответа */
|
||||
.reply-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(0, 212, 170, 0.08);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.reply-indicator i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.reply-indicator strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.reply-cancel {
|
||||
margin-left: auto;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.reply-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.reply-cancel i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Прикреплённые файлы */
|
||||
.attached-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.attached-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.attached-file-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.attached-file-icon i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.attached-file-name {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attached-file-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.attached-file-remove:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.attached-file-remove i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
</style>
|
||||
466
front_vue/src/components/TaskPanel/CommentItem.vue
Normal file
466
front_vue/src/components/TaskPanel/CommentItem.vue
Normal file
@@ -0,0 +1,466 @@
|
||||
<template>
|
||||
<div
|
||||
class="comment"
|
||||
:class="{
|
||||
'comment--own': isOwn,
|
||||
'comment--editing': isEditing,
|
||||
'comment--reply': level > 0
|
||||
}"
|
||||
:style="{ marginLeft: level * 24 + 'px' }"
|
||||
>
|
||||
<div class="comment-avatar" :class="{ 'comment-avatar--small': level > 0 }">
|
||||
<img v-if="comment.author_avatar" :src="getFullUrl(comment.author_avatar)" alt="">
|
||||
<i v-else data-lucide="user"></i>
|
||||
</div>
|
||||
|
||||
<div class="comment-body">
|
||||
<div class="comment-header">
|
||||
<span class="comment-author">{{ comment.author_name }}</span>
|
||||
<span class="comment-date">{{ formattedDate }}</span>
|
||||
|
||||
<div v-if="!isEditing" class="comment-actions">
|
||||
<IconButton
|
||||
icon="reply"
|
||||
variant="ghost"
|
||||
small
|
||||
title="Ответить"
|
||||
@click="$emit('reply', comment)"
|
||||
/>
|
||||
<IconButton
|
||||
v-if="canEdit"
|
||||
icon="pencil"
|
||||
variant="ghost"
|
||||
small
|
||||
title="Редактировать"
|
||||
@click="$emit('edit', comment)"
|
||||
/>
|
||||
<IconButton
|
||||
v-if="canEdit"
|
||||
icon="trash-2"
|
||||
variant="ghost"
|
||||
small
|
||||
title="Удалить"
|
||||
class="comment-btn-delete"
|
||||
@click="$emit('delete', comment)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Режим редактирования -->
|
||||
<div v-if="isEditing" class="comment-edit-form">
|
||||
<RichTextEditor
|
||||
:modelValue="editText"
|
||||
@update:modelValue="$emit('update:editText', $event)"
|
||||
placeholder="Редактирование комментария..."
|
||||
:show-toolbar="false"
|
||||
ref="editEditor"
|
||||
/>
|
||||
<div class="comment-edit-actions">
|
||||
<button class="btn-comment-cancel" @click="$emit('cancel-edit')">Отмена</button>
|
||||
<button
|
||||
class="btn-comment-save"
|
||||
@click="$emit('save-edit')"
|
||||
:disabled="!editText.trim()"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Обычный вид -->
|
||||
<div v-else>
|
||||
<div class="comment-text" v-html="comment.text"></div>
|
||||
|
||||
<!-- Прикреплённые файлы -->
|
||||
<div v-if="comment.file_img && comment.file_img.length > 0" class="comment-files">
|
||||
<div
|
||||
v-for="(file, index) in comment.file_img"
|
||||
:key="file.name + '-' + index"
|
||||
class="comment-file"
|
||||
@click="!isArchiveFile(file) && openPreview(file)"
|
||||
>
|
||||
<template v-if="isArchiveFile(file)">
|
||||
<div class="comment-file-archive">
|
||||
<i data-lucide="archive"></i>
|
||||
<span class="comment-file-ext">.{{ getFileExt(file) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<img v-else :src="getFullUrl(file.url)" :alt="file.name">
|
||||
<a
|
||||
class="comment-file-download"
|
||||
:href="getFullUrl(file.url)"
|
||||
:download="file.name"
|
||||
@click.stop
|
||||
title="Скачать"
|
||||
>
|
||||
<i data-lucide="download"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onUpdated, watch, nextTick } from 'vue'
|
||||
import IconButton from '../ui/IconButton.vue'
|
||||
import RichTextEditor from '../ui/RichTextEditor.vue'
|
||||
import { serverSettings } from '../../api'
|
||||
|
||||
const props = defineProps({
|
||||
comment: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
isOwn: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
canEdit: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isEditing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
editText: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
getFullUrl: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['reply', 'edit', 'delete', 'cancel-edit', 'save-edit', 'update:editText', 'preview-file'])
|
||||
|
||||
const editEditor = ref(null)
|
||||
|
||||
const archiveExtensions = ['zip', 'rar']
|
||||
|
||||
// Получить расширение файла
|
||||
const getFileExt = (file) => {
|
||||
return file.name?.split('.').pop()?.toLowerCase() || ''
|
||||
}
|
||||
|
||||
// Проверка является ли файл архивом
|
||||
const isArchiveFile = (file) => {
|
||||
return archiveExtensions.includes(getFileExt(file))
|
||||
}
|
||||
|
||||
// Открыть превью файла
|
||||
const openPreview = (file) => {
|
||||
emit('preview-file', file)
|
||||
}
|
||||
|
||||
// Форматирование даты комментария
|
||||
const formattedDate = computed(() => {
|
||||
if (!props.comment.date_create) return ''
|
||||
// Используем таймзону сервера из настроек
|
||||
const date = serverSettings.parseDate(props.comment.date_create)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
const days = Math.floor(diff / 86400000)
|
||||
|
||||
if (minutes < 1) return 'только что'
|
||||
if (minutes < 60) return `${minutes} мин. назад`
|
||||
if (hours < 24) return `${hours} ч. назад`
|
||||
if (days < 7) return `${days} дн. назад`
|
||||
|
||||
const day = date.getDate()
|
||||
const months = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
|
||||
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
|
||||
return `${day} ${months[date.getMonth()]} ${time}`
|
||||
})
|
||||
|
||||
// Установка контента редактора при начале редактирования
|
||||
watch(() => props.isEditing, async (newVal) => {
|
||||
if (newVal) {
|
||||
await nextTick()
|
||||
editEditor.value?.setContent(props.editText)
|
||||
}
|
||||
})
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
|
||||
// Expose для установки контента редактора
|
||||
defineExpose({
|
||||
setEditContent: (text) => editEditor.value?.setContent(text)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comment {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.comment:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.comment--own {
|
||||
background: rgba(0, 212, 170, 0.05);
|
||||
}
|
||||
|
||||
.comment--own:hover {
|
||||
background: rgba(0, 212, 170, 0.08);
|
||||
}
|
||||
|
||||
.comment--editing {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
outline: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.comment--reply {
|
||||
border-left: 2px solid rgba(255, 255, 255, 0.1);
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.comment-avatar {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comment-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.comment-avatar i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.comment-avatar--small {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.comment-avatar--small i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.comment-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.comment-header .comment-date {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.comment-date {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.comment-text :deep(b),
|
||||
.comment-text :deep(strong) {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.comment:hover .comment-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.comment-btn-delete:hover {
|
||||
background: rgba(239, 68, 68, 0.15) !important;
|
||||
border-color: rgba(239, 68, 68, 0.3) !important;
|
||||
color: var(--red) !important;
|
||||
}
|
||||
|
||||
/* Редактирование комментария */
|
||||
.comment-edit-form {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.comment-edit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn-comment-cancel {
|
||||
padding: 8px 14px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-comment-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-comment-save {
|
||||
padding: 8px 14px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #000;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-comment-save:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-comment-save:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Прикреплённые файлы в комментарии */
|
||||
.comment-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.comment-file {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.comment-file:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.comment-file img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.comment-file-archive {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.comment-file-archive i {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.comment-file-ext {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-top: 2px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.comment-file-download {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
transition: all 0.15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.comment-file:hover .comment-file-download {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.comment-file-download:hover {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.comment-file-download i {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
8
front_vue/src/components/TaskPanel/index.js
Normal file
8
front_vue/src/components/TaskPanel/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as TaskPanel } from './TaskPanel.vue'
|
||||
export { default as TaskEditTab } from './TaskEditTab.vue'
|
||||
export { default as TaskCommentsTab } from './TaskCommentsTab.vue'
|
||||
export { default as CommentItem } from './CommentItem.vue'
|
||||
export { default as CommentForm } from './CommentForm.vue'
|
||||
|
||||
// Default export
|
||||
export { default } from './TaskPanel.vue'
|
||||
Reference in New Issue
Block a user