1
0
Files
TaskBoard/front_vue/src/components/TaskPanel/TaskPanel.vue
Falknat 7e1482f515 Исправление ошибок фронта
Правим фронт от ошибок
2026-01-15 15:27:39 +07:00

525 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<SlidePanel
:show="show"
@close="tryClose"
>
<template #header>
<div class="header-title-block">
<h2>{{ panelTitle }}</h2>
<span v-if="!isNew && card?.dateCreate" class="header-date">
Создано: {{ formatDate(card.dateCreate) }}
</span>
</div>
<!-- Вкладки (только для существующих задач) -->
<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>
<!-- Footer: скрываем на вкладке комментариев -->
<template #footer v-if="activeTab !== 'comments'">
<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'
import { useMobile } from '../../composables/useMobile'
const { isMobile } = useMobile()
// Состояние загрузки для кнопки сохранения
const isSaving = ref(false)
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
},
// Callback для сохранения (возвращает Promise)
onSave: {
type: Function,
default: null
}
})
const emit = defineEmits(['close', 'save', 'delete', 'archive', 'restore'])
// State
const isNew = ref(true)
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)
// Panel title
const panelTitle = computed(() => {
if (isNew.value) return 'Новая задача'
if (activeTab.value === 'comments') return 'Комментарии'
return 'Редактирование'
})
// 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 || isSaving.value) return
isSaving.value = true
editTabRef.value.fileError = ''
try {
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 || 'Ошибка загрузки файла'
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 || 'Ошибка удаления файла'
return
}
}
editTabRef.value.removeDeletedFiles()
}
const formData = editTabRef.value.getFormData()
const taskData = {
...formData,
id: props.card?.id
}
// Вызываем callback и ждём завершения
if (props.onSave) {
await props.onSave(taskData)
} else {
emit('save', taskData)
}
} finally {
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
isSaving.value = false // Сброс состояния кнопки сохранения
// 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()
// Не фокусируем поле автоматически, чтобы не открывалась клавиатура
refreshIcons()
}
})
onMounted(refreshIcons)
onUpdated(refreshIcons)
</script>
<style scoped>
.header-title-block {
display: flex;
flex-direction: column;
gap: 4px;
}
.header-title-block h2 {
margin: 0;
}
.header-date {
font-size: 12px;
color: var(--text-muted);
}
.header-tabs {
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 {
display: flex;
align-items: center;
justify-content: center;
min-width: 120px;
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 {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid rgba(0, 0, 0, 0.3);
border-top-color: #000;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>