Комментарии, файлы и права проекта
- Система комментариев к задачам с вложенными ответами - Редактирование и удаление комментариев - Прикрепление файлов к задачам и комментариям (картинки, архивы до 10 МБ) - Система прав проекта: админ проекта может удалять чужие комментарии и файлы - Универсальный класс FileUpload для загрузки файлов - Защита загрузки: только автор комментария может добавлять файлы - Каскадное удаление: задача → комментарии → файлы - Автообновление комментариев в реальном времени
This commit is contained in:
@@ -113,7 +113,94 @@ export const taskImageApi = {
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== COMMENT IMAGES ====================
|
||||
export const commentImageApi = {
|
||||
upload: (comment_id, file_data, file_name) => request('/api/comment', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'upload_image', comment_id, file_data, file_name })
|
||||
}),
|
||||
// Принимает строку (один файл) или массив (несколько файлов)
|
||||
delete: (comment_id, file_names) => request('/api/comment', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'delete_image', comment_id, file_names })
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== USERS ====================
|
||||
export const usersApi = {
|
||||
getAll: () => request('/api/user', { credentials: 'include' })
|
||||
}
|
||||
|
||||
// ==================== SERVER ====================
|
||||
export const serverApi = {
|
||||
// Получение настроек сервера (timezone и т.д.) — публичный action
|
||||
getSettings: () => request('/api/server', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'get_settings' })
|
||||
})
|
||||
}
|
||||
|
||||
// Хранилище серверных настроек
|
||||
export const serverSettings = {
|
||||
timezoneOffset: '+03:00', // дефолт, обновится при загрузке
|
||||
|
||||
// Инициализация — вызвать один раз при старте приложения
|
||||
async init() {
|
||||
try {
|
||||
const result = await serverApi.getSettings()
|
||||
if (result.success) {
|
||||
this.timezoneOffset = result.data.timezone_offset
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Не удалось получить настройки сервера:', e)
|
||||
}
|
||||
},
|
||||
|
||||
// Парсинг даты с сервера с учётом таймзоны
|
||||
parseDate(dateStr) {
|
||||
if (!dateStr) return null
|
||||
// Добавляем таймзону сервера для корректного парсинга
|
||||
const normalized = dateStr.replace(' ', 'T')
|
||||
// Если уже есть таймзона — не добавляем
|
||||
if (normalized.includes('+') || normalized.includes('Z')) {
|
||||
return new Date(normalized)
|
||||
}
|
||||
return new Date(normalized + this.timezoneOffset)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== COMMENTS ====================
|
||||
export const commentsApi = {
|
||||
// Получение комментариев задачи
|
||||
getByTask: (id_task) => request(`/api/comment?id_task=${id_task}`, { credentials: 'include' }),
|
||||
|
||||
// Создание комментария (id_answer — опционально, для ответа на комментарий)
|
||||
create: (id_task, text, id_answer = null) => request('/api/comment', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'create', id_task, text, id_answer })
|
||||
}),
|
||||
|
||||
// Обновление комментария
|
||||
update: (id, text) => request('/api/comment', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'update', id, text })
|
||||
}),
|
||||
|
||||
// Удаление комментария
|
||||
delete: (id) => request('/api/comment', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'delete', id })
|
||||
})
|
||||
}
|
||||
@@ -87,6 +87,7 @@ const columnsWithCards = computed(() => {
|
||||
dueDate: card.date,
|
||||
dateCreate: card.date_create,
|
||||
dateClosed: card.date_closed,
|
||||
comments_count: card.comments_count || 0,
|
||||
files: card.files || (card.file_img || []).map(f => ({
|
||||
name: f.name,
|
||||
url: f.url,
|
||||
|
||||
@@ -1,723 +0,0 @@
|
||||
<template>
|
||||
<SlidePanel
|
||||
:show="show"
|
||||
@close="tryClose"
|
||||
>
|
||||
<template #header>
|
||||
<h2>{{ isNew ? 'Новая задача' : 'Редактирование' }}</h2>
|
||||
<span v-if="!isNew && card?.dateCreate" class="header-date">
|
||||
Создано: {{ formatDate(card.dateCreate) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<FormField label="Название">
|
||||
<TextInput
|
||||
v-model="form.title"
|
||||
placeholder="Введите название задачи"
|
||||
ref="titleInput"
|
||||
/>
|
||||
</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="detailsEditor"
|
||||
/>
|
||||
</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="openImagePreview"
|
||||
@error="fileError = $event"
|
||||
/>
|
||||
</FormField>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="footer-left">
|
||||
<button v-if="!isNew" class="btn-icon btn-delete" @click="handleDelete" title="Удалить">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
<button v-if="!isNew && canArchive && !isArchived" class="btn-icon btn-archive" @click="handleArchive" title="В архив">
|
||||
<i data-lucide="archive"></i>
|
||||
</button>
|
||||
<button v-if="!isNew && isArchived" class="btn-icon btn-restore" @click="handleRestore" title="Из архива">
|
||||
<i data-lucide="archive-restore"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<button class="btn-cancel" @click="tryClose">Отмена</button>
|
||||
<button class="btn-save" @click="handleSave" :disabled="!form.title.trim() || !form.departmentId || 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="showDeleteFileDialog"
|
||||
title="Удалить изображение?"
|
||||
message="Изображение будет удалено из задачи."
|
||||
confirm-text="Удалить"
|
||||
variant="danger"
|
||||
@confirm="confirmDeleteFile"
|
||||
@cancel="showDeleteFileDialog = 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"
|
||||
@close="closeImagePreview"
|
||||
@delete="deleteFromPreview"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, onMounted, onUpdated, nextTick } from 'vue'
|
||||
import DatePicker from './DatePicker.vue'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import SlidePanel from './ui/SlidePanel.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 ImagePreview from './ui/ImagePreview.vue'
|
||||
import { taskImageApi, 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: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'save', 'delete', 'archive', 'restore'])
|
||||
|
||||
const isNew = ref(true)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
details: '',
|
||||
departmentId: null,
|
||||
labelId: null,
|
||||
dueDate: '',
|
||||
userId: null
|
||||
})
|
||||
|
||||
const titleInput = ref(null)
|
||||
const detailsEditor = ref(null)
|
||||
|
||||
// Преобразование departments в формат для TagsSelect
|
||||
const departmentOptions = computed(() => {
|
||||
return props.departments.map(dept => ({
|
||||
value: dept.id,
|
||||
label: dept.name_departments,
|
||||
color: dept.color
|
||||
}))
|
||||
})
|
||||
|
||||
// Преобразование labels в формат для TagsSelect
|
||||
const labelOptions = computed(() => {
|
||||
return props.labels.map(label => ({
|
||||
value: label.id,
|
||||
label: label.name_labels,
|
||||
icon: label.icon
|
||||
}))
|
||||
})
|
||||
|
||||
// Преобразование users в формат для SelectDropdown
|
||||
const userOptions = computed(() => {
|
||||
return props.users.map(user => ({
|
||||
value: user.id,
|
||||
label: user.name,
|
||||
subtitle: user.telegram,
|
||||
avatar: getFullUrl(user.avatar_url)
|
||||
}))
|
||||
})
|
||||
|
||||
// Прикреплённые файлы
|
||||
const attachedFiles = ref([])
|
||||
const fileError = ref('')
|
||||
|
||||
// Просмотр изображения
|
||||
const previewImage = ref(null)
|
||||
|
||||
// Диалоги
|
||||
const showUnsavedDialog = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showDeleteFileDialog = ref(false)
|
||||
const showArchiveDialog = ref(false)
|
||||
const showRestoreDialog = ref(false)
|
||||
const fileToDeleteIndex = ref(null)
|
||||
|
||||
// Начальные значения для отслеживания изменений
|
||||
const initialForm = ref({})
|
||||
const initialFilesCount = ref(0)
|
||||
|
||||
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 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
|
||||
})
|
||||
|
||||
const tryClose = () => {
|
||||
if (hasChanges.value) {
|
||||
showUnsavedDialog.value = true
|
||||
} else {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmSave = () => {
|
||||
showUnsavedDialog.value = false
|
||||
handleSave()
|
||||
}
|
||||
|
||||
const confirmDiscard = () => {
|
||||
showUnsavedDialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const cancelClose = () => {
|
||||
showUnsavedDialog.value = false
|
||||
}
|
||||
|
||||
const getAvatarByUserId = (userId) => {
|
||||
if (!userId) return null
|
||||
const user = props.users.find(u => u.id === userId)
|
||||
return user ? user.avatar_url : null
|
||||
}
|
||||
|
||||
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 = ''
|
||||
previewImage.value = null
|
||||
}
|
||||
|
||||
// Применить форматирование через редактор
|
||||
const applyFormat = (command) => {
|
||||
detailsEditor.value?.applyFormat(command)
|
||||
}
|
||||
|
||||
watch(() => props.show, async (newVal) => {
|
||||
if (newVal) {
|
||||
isNew.value = !props.card
|
||||
clearFiles()
|
||||
|
||||
if (props.card) {
|
||||
form.title = props.card.title || ''
|
||||
form.description = props.card.description || ''
|
||||
form.details = props.card.details || ''
|
||||
form.departmentId = props.card.departmentId || null
|
||||
form.labelId = props.card.labelId || null
|
||||
form.dueDate = props.card.dueDate || ''
|
||||
form.userId = props.card.accountId || null
|
||||
|
||||
if (props.card.files && props.card.files.length > 0) {
|
||||
attachedFiles.value = props.card.files.map(f => ({
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type,
|
||||
preview: f.data || f.url,
|
||||
isNew: false
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
saveInitialForm()
|
||||
|
||||
// Установка содержимого редактора
|
||||
detailsEditor.value?.setContent(form.details)
|
||||
|
||||
await nextTick()
|
||||
titleInput.value?.$el?.focus()
|
||||
refreshIcons()
|
||||
}
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.title.trim()) return
|
||||
|
||||
isSaving.value = true
|
||||
fileError.value = ''
|
||||
|
||||
if (props.card?.id) {
|
||||
// Загружаем новые файлы
|
||||
const newFiles = attachedFiles.value.filter(f => f.isNew && !f.toDelete)
|
||||
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 {
|
||||
fileError.value = result.errors?.file || 'Ошибка загрузки файла'
|
||||
isSaving.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем помеченные файлы
|
||||
const filesToDelete = attachedFiles.value.filter(f => f.toDelete && !f.isNew)
|
||||
if (filesToDelete.length > 0) {
|
||||
const fileNames = filesToDelete.map(f => f.name)
|
||||
const result = await taskImageApi.delete(props.card.id, fileNames)
|
||||
if (!result.success) {
|
||||
fileError.value = result.errors?.file || 'Ошибка удаления файла'
|
||||
isSaving.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
attachedFiles.value = attachedFiles.value.filter(f => !f.toDelete)
|
||||
}
|
||||
|
||||
emit('save', {
|
||||
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),
|
||||
id: props.card?.id,
|
||||
files: attachedFiles.value
|
||||
.filter(f => !f.toDelete)
|
||||
.map(f => ({
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
type: f.type,
|
||||
data: f.preview
|
||||
}))
|
||||
})
|
||||
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
// Можно ли архивировать (только если колонка "Готово")
|
||||
const canArchive = computed(() => {
|
||||
return props.doneColumnId && Number(props.columnId) === props.doneColumnId
|
||||
})
|
||||
|
||||
const handleArchive = () => {
|
||||
showArchiveDialog.value = true
|
||||
}
|
||||
|
||||
const confirmArchive = () => {
|
||||
showArchiveDialog.value = false
|
||||
if (props.card?.id) {
|
||||
emit('archive', props.card.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = () => {
|
||||
showRestoreDialog.value = true
|
||||
}
|
||||
|
||||
const confirmRestore = () => {
|
||||
showRestoreDialog.value = false
|
||||
if (props.card?.id) {
|
||||
emit('restore', props.card.id)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
showDeleteDialog.value = false
|
||||
emit('delete', props.card.id)
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
const day = date.getDate()
|
||||
const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
|
||||
return `${day} ${months[date.getMonth()]} ${date.getFullYear()}`
|
||||
}
|
||||
|
||||
// Работа с файлами
|
||||
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 openImagePreview = (file) => {
|
||||
previewImage.value = file
|
||||
nextTick(refreshIcons)
|
||||
}
|
||||
|
||||
const closeImagePreview = () => {
|
||||
previewImage.value = null
|
||||
}
|
||||
|
||||
const deleteFromPreview = () => {
|
||||
if (previewImage.value) {
|
||||
const index = attachedFiles.value.findIndex(
|
||||
f => f.name === previewImage.value.name && f.size === previewImage.value.size
|
||||
)
|
||||
if (index !== -1) {
|
||||
closeImagePreview()
|
||||
handleFileRemove(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-date {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
.btn-icon.btn-delete {
|
||||
border: 1px solid var(--red);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.btn-icon.btn-delete:hover {
|
||||
background: var(--red);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.footer-left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-icon i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.btn-icon.btn-archive {
|
||||
border: 1px solid var(--orange);
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.btn-icon.btn-archive:hover {
|
||||
background: var(--orange);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-icon.btn-restore {
|
||||
border: 1px solid var(--orange);
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.btn-icon.btn-restore:hover {
|
||||
background: var(--orange);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.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(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
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'
|
||||
182
front_vue/src/components/ui/IconButton.vue
Normal file
182
front_vue/src/components/ui/IconButton.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<button
|
||||
class="icon-btn"
|
||||
:class="[`icon-btn--${variant}`, { 'icon-btn--small': small }]"
|
||||
:title="title"
|
||||
:disabled="disabled"
|
||||
@click="$emit('click', $event)"
|
||||
>
|
||||
<span v-if="loading" class="icon-btn-loader"></span>
|
||||
<i v-else :data-lucide="icon"></i>
|
||||
<span v-if="label" class="icon-btn-label">{{ label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUpdated } from 'vue'
|
||||
|
||||
defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (v) => ['default', 'danger', 'warning', 'primary', 'ghost'].includes(v)
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['click'])
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.icon-btn i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.icon-btn-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Если есть лейбл — растягиваем кнопку */
|
||||
.icon-btn:has(.icon-btn-label) {
|
||||
width: auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
/* Small */
|
||||
.icon-btn--small {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.icon-btn--small i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.icon-btn--small:has(.icon-btn-label) {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.icon-btn--default {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.icon-btn--default:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icon-btn--danger {
|
||||
border: 1px solid var(--red);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.icon-btn--danger:hover {
|
||||
background: var(--red);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.icon-btn--warning {
|
||||
border: 1px solid var(--orange);
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.icon-btn--warning:hover {
|
||||
background: var(--orange);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.icon-btn--primary {
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.icon-btn--primary:hover {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.icon-btn--ghost {
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.icon-btn--ghost:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Disabled */
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Loader */
|
||||
.icon-btn-loader {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
108
front_vue/src/components/ui/TabsPanel.vue
Normal file
108
front_vue/src/components/ui/TabsPanel.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="tabs-panel">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="tab"
|
||||
:class="{ active: modelValue === tab.id }"
|
||||
@click="$emit('update:modelValue', tab.id)"
|
||||
:title="tab.title || tab.label"
|
||||
>
|
||||
<i v-if="tab.icon" :data-lucide="tab.icon"></i>
|
||||
<span v-if="tab.label && showLabels" class="tab-label">{{ tab.label }}</span>
|
||||
<span v-if="tab.badge" class="tab-badge">{{ tab.badge }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUpdated } from 'vue'
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
},
|
||||
tabs: {
|
||||
type: Array,
|
||||
required: true
|
||||
// { id, icon?, label?, title?, badge? }
|
||||
},
|
||||
showLabels: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tabs-panel {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
padding: 3px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab i,
|
||||
.tab :deep(svg) {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
pointer-events: none;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,10 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { serverSettings } from './api'
|
||||
|
||||
// Инициализация серверных настроек (timezone и т.д.)
|
||||
serverSettings.init()
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { projectsApi, usersApi } from '../api'
|
||||
import { projectsApi, usersApi, authApi } from '../api'
|
||||
|
||||
export const useProjectsStore = defineStore('projects', () => {
|
||||
// ==================== СОСТОЯНИЕ ====================
|
||||
@@ -11,6 +11,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const initialized = ref(false)
|
||||
const currentUser = ref(null) // Текущий авторизованный пользователь
|
||||
|
||||
// Текущий проект (из localStorage)
|
||||
const savedProjectId = localStorage.getItem('currentProjectId')
|
||||
@@ -30,6 +31,16 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
return project ? Number(project.id_ready) : null
|
||||
})
|
||||
|
||||
// ID текущего пользователя
|
||||
const currentUserId = computed(() => currentUser.value?.id || null)
|
||||
|
||||
// Является ли текущий пользователь админом проекта
|
||||
// Сервер возвращает id_admin: true только если текущий пользователь — админ
|
||||
const isProjectAdmin = computed(() => {
|
||||
const project = projects.value.find(p => p.id === currentProjectId.value)
|
||||
return project?.id_admin === true
|
||||
})
|
||||
|
||||
// ==================== ДЕЙСТВИЯ ====================
|
||||
// Инициализация (загрузка проектов + данных активного)
|
||||
const init = async () => {
|
||||
@@ -74,6 +85,14 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
const usersData = await usersApi.getAll()
|
||||
if (usersData.success) users.value = usersData.data
|
||||
|
||||
// Загружаем текущего пользователя
|
||||
const authData = await authApi.check()
|
||||
if (authData.success && authData.user) {
|
||||
// Находим полные данные пользователя (с id) из списка users
|
||||
const fullUser = users.value.find(u => u.username === authData.user.username)
|
||||
currentUser.value = fullUser || authData.user
|
||||
}
|
||||
|
||||
initialized.value = true
|
||||
} catch (error) {
|
||||
console.error('Ошибка инициализации:', error)
|
||||
@@ -108,6 +127,12 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
columns.value = projectData.data.columns
|
||||
departments.value = projectData.data.departments
|
||||
labels.value = projectData.data.labels
|
||||
|
||||
// Обновляем id_admin в списке проектов (сервер возвращает true если текущий пользователь админ)
|
||||
const project = projects.value.find(p => p.id === currentProjectId.value)
|
||||
if (project && projectData.data.project?.id_admin === true) {
|
||||
project.id_admin = true
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки данных проекта:', error)
|
||||
@@ -122,6 +147,7 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
columns.value = []
|
||||
users.value = []
|
||||
currentProjectId.value = null
|
||||
currentUser.value = null
|
||||
initialized.value = false
|
||||
localStorage.removeItem('currentProjectId')
|
||||
localStorage.removeItem('currentProjectName')
|
||||
@@ -137,9 +163,12 @@ export const useProjectsStore = defineStore('projects', () => {
|
||||
loading,
|
||||
initialized,
|
||||
currentProjectId,
|
||||
currentUser,
|
||||
// Геттеры
|
||||
currentProject,
|
||||
doneColumnId,
|
||||
currentUserId,
|
||||
isProjectAdmin,
|
||||
// Действия
|
||||
init,
|
||||
selectProject,
|
||||
|
||||
@@ -82,6 +82,8 @@
|
||||
:departments="store.departments"
|
||||
:labels="store.labels"
|
||||
:users="store.users"
|
||||
:current-user-id="store.currentUserId"
|
||||
:is-project-admin="store.isProjectAdmin"
|
||||
@close="closePanel"
|
||||
@save="handleSaveTask"
|
||||
@delete="handleDeleteTask"
|
||||
@@ -105,7 +107,7 @@ import { ref, computed, watch, onMounted } from 'vue'
|
||||
import Sidebar from '../components/Sidebar.vue'
|
||||
import Header from '../components/Header.vue'
|
||||
import ArchiveCard from '../components/ArchiveCard.vue'
|
||||
import TaskPanel from '../components/TaskPanel.vue'
|
||||
import TaskPanel from '../components/TaskPanel'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import ProjectSelector from '../components/ProjectSelector.vue'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
@@ -156,6 +158,7 @@ const fetchCards = async () => {
|
||||
dateClosed: card.date_closed,
|
||||
columnId: card.column_id,
|
||||
order: card.order ?? 0,
|
||||
comments_count: card.comments_count || 0,
|
||||
files: card.files || (card.file_img || []).map(f => ({
|
||||
name: f.name,
|
||||
url: f.url,
|
||||
|
||||
@@ -79,6 +79,8 @@
|
||||
:departments="store.departments"
|
||||
:labels="store.labels"
|
||||
:users="store.users"
|
||||
:current-user-id="store.currentUserId"
|
||||
:is-project-admin="store.isProjectAdmin"
|
||||
@close="closePanel"
|
||||
@save="handleSaveTask"
|
||||
@delete="handleDeleteTask"
|
||||
@@ -92,7 +94,7 @@ import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import Sidebar from '../components/Sidebar.vue'
|
||||
import Header from '../components/Header.vue'
|
||||
import Board from '../components/Board.vue'
|
||||
import TaskPanel from '../components/TaskPanel.vue'
|
||||
import TaskPanel from '../components/TaskPanel'
|
||||
import ProjectSelector from '../components/ProjectSelector.vue'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { cardsApi } from '../api'
|
||||
|
||||
Reference in New Issue
Block a user