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,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>

View 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>

View 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>

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>

View File

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

View 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'