Исправления фронта
Множество оптимизаций по фронту
This commit is contained in:
@@ -1,769 +0,0 @@
|
||||
<template>
|
||||
<div class="comment-form">
|
||||
<!-- Desktop: Inline форма -->
|
||||
<template v-if="!isMobile">
|
||||
<!-- Индикатор "ответ на" -->
|
||||
<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>
|
||||
|
||||
<!-- Превью прикреплённых файлов -->
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<!-- Mobile: Кнопка открытия формы -->
|
||||
<template v-else>
|
||||
<button class="btn-open-form" @click="openMobileForm">
|
||||
<i data-lucide="message-square-plus"></i>
|
||||
{{ replyingTo ? 'Написать ответ' : 'Написать комментарий' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Скрытый 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"
|
||||
>
|
||||
|
||||
<!-- Mobile: Fullscreen форма -->
|
||||
<Teleport to="body">
|
||||
<Transition name="mobile-form">
|
||||
<div v-if="isMobile && mobileFormOpen" class="mobile-form-overlay">
|
||||
<div class="mobile-form-panel">
|
||||
<div class="mobile-form-header">
|
||||
<button class="btn-close" @click="closeMobileForm">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
<h3 class="panel-title">{{ replyingTo ? 'Ответ' : 'Новый комментарий' }}</h3>
|
||||
<div class="header-spacer"></div>
|
||||
</div>
|
||||
|
||||
<!-- Индикатор "ответ на" -->
|
||||
<div v-if="replyingTo" class="mobile-reply-indicator">
|
||||
<i data-lucide="corner-down-right"></i>
|
||||
<span>Ответ для <strong>{{ replyingTo.author_name }}</strong></span>
|
||||
</div>
|
||||
|
||||
<div class="mobile-form-body">
|
||||
<RichTextEditor
|
||||
:modelValue="modelValue"
|
||||
@update:modelValue="$emit('update:modelValue', $event)"
|
||||
:placeholder="replyingTo ? 'Напишите ответ...' : 'Напишите комментарий...'"
|
||||
:show-toolbar="false"
|
||||
ref="mobileEditorRef"
|
||||
class="mobile-editor"
|
||||
/>
|
||||
|
||||
<!-- Превью прикреплённых файлов -->
|
||||
<div v-if="files.length > 0" class="attached-files mobile">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="mobile-form-footer">
|
||||
<div class="mobile-format-buttons">
|
||||
<button class="btn-format" @click="applyMobileFormat('bold')" title="Жирный">
|
||||
<i data-lucide="bold"></i>
|
||||
</button>
|
||||
<button class="btn-format" @click="applyMobileFormat('italic')" title="Курсив">
|
||||
<i data-lucide="italic"></i>
|
||||
</button>
|
||||
<button class="btn-format" @click="applyMobileFormat('underline')" title="Подчёркивание">
|
||||
<i data-lucide="underline"></i>
|
||||
</button>
|
||||
<button class="btn-format" @click="triggerFileInput" title="Прикрепить файл">
|
||||
<i data-lucide="paperclip"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn-send"
|
||||
@click="handleMobileSend"
|
||||
:disabled="!canSend || isSending"
|
||||
>
|
||||
<span v-if="isSending" class="btn-loader"></span>
|
||||
<template v-else>
|
||||
<i data-lucide="send"></i>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUpdated, watch, nextTick } from 'vue'
|
||||
import FormField from '../ui/FormField.vue'
|
||||
import RichTextEditor from '../ui/RichTextEditor.vue'
|
||||
import { useMobile } from '../../composables/useMobile'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
replyingTo: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
isSending: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'send', 'cancel-reply'])
|
||||
|
||||
const editorRef = ref(null)
|
||||
const mobileEditorRef = ref(null)
|
||||
const fileInputRef = ref(null)
|
||||
const files = ref([])
|
||||
const mobileFormOpen = ref(false)
|
||||
|
||||
// Открытие мобильной формы
|
||||
const openMobileForm = async () => {
|
||||
mobileFormOpen.value = true
|
||||
await nextTick()
|
||||
refreshIcons()
|
||||
}
|
||||
|
||||
// Закрытие мобильной формы
|
||||
const closeMobileForm = () => {
|
||||
mobileFormOpen.value = false
|
||||
emit('cancel-reply')
|
||||
}
|
||||
|
||||
// Отправка из мобильной формы
|
||||
const handleMobileSend = () => {
|
||||
emit('send')
|
||||
// Форма закроется после успешной отправки через watch
|
||||
}
|
||||
|
||||
// Закрытие формы когда isSending становится false после отправки
|
||||
watch(() => props.isSending, (newVal, oldVal) => {
|
||||
if (oldVal === true && newVal === false && mobileFormOpen.value) {
|
||||
// Отправка завершена, закрываем форму
|
||||
closeMobileForm()
|
||||
}
|
||||
})
|
||||
|
||||
// Открытие формы при выборе ответа
|
||||
watch(() => props.replyingTo, (newVal) => {
|
||||
if (newVal && isMobile.value) {
|
||||
openMobileForm()
|
||||
}
|
||||
})
|
||||
|
||||
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 applyMobileFormat = (command) => {
|
||||
mobileEditorRef.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;
|
||||
padding-bottom: calc(var(--safe-area-bottom, 0px));
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/* Индикатор ответа */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ========== MOBILE: Кнопка открытия формы ========== */
|
||||
.btn-open-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #000;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-open-form i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.btn-open-form:active {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
/* ========== MOBILE: Fullscreen Form ========== */
|
||||
.mobile-form-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #18181b;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.mobile-form-panel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobile-form-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-form-header .panel-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mobile-form-header .btn-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-form-header .btn-close i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.mobile-form-header .header-spacer {
|
||||
width: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-reply-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 212, 170, 0.08);
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mobile-reply-indicator i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mobile-reply-indicator strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mobile-form-body {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-editor {
|
||||
flex: 1;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.mobile-editor :deep(.editor-content) {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.attached-files.mobile {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.mobile-form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
padding-bottom: calc(12px + var(--safe-area-bottom, 0px));
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-format-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-format {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-format i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.btn-format:active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #000;
|
||||
font-family: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-send i {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.btn-send:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-send:not(:disabled):active {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
/* Mobile form transition */
|
||||
.mobile-form-enter-active,
|
||||
.mobile-form-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-form-enter-from,
|
||||
.mobile-form-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -82,12 +82,16 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUpdated } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import IconButton from '../ui/IconButton.vue'
|
||||
import { serverSettings } from '../../api'
|
||||
import { useMobile } from '../../composables/useMobile'
|
||||
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||
import { useDateFormat } from '../../composables/useDateFormat'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const { formatTimeAgo } = useDateFormat()
|
||||
|
||||
useLucideIcons()
|
||||
|
||||
const props = defineProps({
|
||||
comment: {
|
||||
@@ -131,36 +135,8 @@ 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}`
|
||||
})
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
// Форматирование даты комментария (таймзона сервера учитывается автоматически)
|
||||
const formattedDate = computed(() => formatTimeAgo(props.comment.date_create))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
392
front_vue/src/components/TaskPanel/ContentEditorPanel.vue
Normal file
392
front_vue/src/components/TaskPanel/ContentEditorPanel.vue
Normal file
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<SlidePanel
|
||||
:show="show"
|
||||
:width="500"
|
||||
:min-width="400"
|
||||
:max-width="700"
|
||||
@close="handleClose"
|
||||
>
|
||||
<template #header>
|
||||
<div class="editor-panel-header">
|
||||
<div v-if="avatarUrl" class="editor-author-avatar">
|
||||
<img :src="avatarUrl" alt="">
|
||||
</div>
|
||||
<div class="editor-header-text">
|
||||
<h2>{{ title }}</h2>
|
||||
<span v-if="subtitle" class="editor-author-name">{{ subtitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="editor-panel-content">
|
||||
<!-- Редактор текста -->
|
||||
<div class="editor-form-section">
|
||||
<div class="editor-form-label">
|
||||
<span>{{ textLabel }}</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<RichTextEditor
|
||||
:modelValue="localText"
|
||||
@update:modelValue="localText = $event"
|
||||
:placeholder="placeholder"
|
||||
:show-toolbar="false"
|
||||
ref="editorRef"
|
||||
class="editor-textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Файлы -->
|
||||
<div class="editor-form-section">
|
||||
<div class="editor-form-label">
|
||||
<span>Файлы</span>
|
||||
</div>
|
||||
<FileUploader
|
||||
:files="allFiles"
|
||||
:get-full-url="getFileFullUrl"
|
||||
dropzone-text="Перетащите файлы сюда"
|
||||
dropzone-subtext="или нажмите для выбора"
|
||||
@add="handleFileAdd"
|
||||
@remove="handleFileRemove"
|
||||
@preview="handleFilePreview"
|
||||
@error="handleFileError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<ActionButtons
|
||||
:save-text="saveButtonText"
|
||||
save-icon="check"
|
||||
:loading="isSaving"
|
||||
:disabled="!canSave"
|
||||
@save="handleSave"
|
||||
@cancel="handleClose"
|
||||
/>
|
||||
</template>
|
||||
</SlidePanel>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import SlidePanel from '../ui/SlidePanel.vue'
|
||||
import RichTextEditor from '../ui/RichTextEditor.vue'
|
||||
import FileUploader from '../ui/FileUploader.vue'
|
||||
import ActionButtons from '../ui/ActionButtons.vue'
|
||||
import { getFullUrl } from '../../api'
|
||||
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||
|
||||
const props = defineProps({
|
||||
// Управление видимостью
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// Заголовок панели
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Редактирование'
|
||||
},
|
||||
// Подзаголовок (например, имя автора)
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// URL аватара (опционально)
|
||||
avatarUrl: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// Начальный текст
|
||||
text: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// Placeholder для редактора
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Введите текст...'
|
||||
},
|
||||
// Лейбл для поля текста
|
||||
textLabel: {
|
||||
type: String,
|
||||
default: 'Текст'
|
||||
},
|
||||
// Существующие файлы (для редактирования)
|
||||
existingFiles: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// Функция получения URL файла
|
||||
fileUrlGetter: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
// Текст кнопки сохранения
|
||||
saveButtonText: {
|
||||
type: String,
|
||||
default: 'Сохранить'
|
||||
},
|
||||
// Состояние сохранения
|
||||
isSaving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save', 'close', 'update:show'])
|
||||
|
||||
const { refreshIcons } = useLucideIcons()
|
||||
|
||||
// Refs
|
||||
const editorRef = ref(null)
|
||||
|
||||
// Local state
|
||||
const localText = ref('')
|
||||
const allFiles = ref([])
|
||||
|
||||
// Computed
|
||||
const canSave = computed(() => {
|
||||
const hasText = localText.value.trim()
|
||||
const hasNewFiles = allFiles.value.some(f => f.isNew)
|
||||
return hasText || hasNewFiles
|
||||
})
|
||||
|
||||
// Получить полный URL файла
|
||||
const getFileFullUrl = (url) => {
|
||||
if (props.fileUrlGetter) {
|
||||
return props.fileUrlGetter(url)
|
||||
}
|
||||
return getFullUrl(url)
|
||||
}
|
||||
|
||||
// File handlers
|
||||
const handleFileAdd = (file) => {
|
||||
allFiles.value.push(file)
|
||||
refreshIcons()
|
||||
}
|
||||
|
||||
const handleFileRemove = (index) => {
|
||||
const file = allFiles.value[index]
|
||||
if (file.isNew) {
|
||||
// Новый файл - удаляем сразу
|
||||
allFiles.value.splice(index, 1)
|
||||
} else {
|
||||
// Существующий файл - помечаем на удаление
|
||||
allFiles.value[index] = { ...file, toDelete: true }
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilePreview = (file) => {
|
||||
// Можно добавить emit для превью если нужно
|
||||
console.log('Preview file:', file.name)
|
||||
}
|
||||
|
||||
const handleFileError = (message) => {
|
||||
console.warn(message)
|
||||
}
|
||||
|
||||
// Format
|
||||
const applyFormat = (command) => {
|
||||
editorRef.value?.applyFormat(command)
|
||||
}
|
||||
|
||||
// Actions
|
||||
const handleSave = () => {
|
||||
// Собираем новые файлы
|
||||
const newFiles = allFiles.value
|
||||
.filter(f => f.isNew)
|
||||
.map(f => ({
|
||||
name: f.name,
|
||||
data: f.preview
|
||||
}))
|
||||
|
||||
// Собираем файлы на удаление
|
||||
const filesToDelete = allFiles.value
|
||||
.filter(f => f.toDelete)
|
||||
.map(f => f.name)
|
||||
|
||||
emit('save', {
|
||||
text: localText.value,
|
||||
newFiles,
|
||||
filesToDelete
|
||||
})
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
// Reset state when panel opens
|
||||
const resetState = () => {
|
||||
localText.value = props.text || ''
|
||||
|
||||
// Преобразуем существующие файлы в формат FileUploader
|
||||
allFiles.value = (props.existingFiles || []).map(file => ({
|
||||
name: file.name,
|
||||
size: file.size || 0,
|
||||
preview: file.url,
|
||||
isNew: false,
|
||||
toDelete: false
|
||||
}))
|
||||
|
||||
nextTick(() => {
|
||||
editorRef.value?.setContent(localText.value)
|
||||
refreshIcons()
|
||||
})
|
||||
}
|
||||
|
||||
// Watch show prop
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
resetState()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch text/existingFiles for external updates
|
||||
watch(() => props.text, (newVal) => {
|
||||
if (props.show) {
|
||||
localText.value = newVal || ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.existingFiles, (newVal) => {
|
||||
if (props.show) {
|
||||
allFiles.value = (newVal || []).map(file => ({
|
||||
name: file.name,
|
||||
size: file.size || 0,
|
||||
preview: file.url,
|
||||
isNew: false,
|
||||
toDelete: false
|
||||
}))
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Expose
|
||||
defineExpose({
|
||||
reset: resetState
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ========== Panel Header ========== */
|
||||
.editor-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.editor-author-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-author-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.editor-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.editor-author-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ========== Content ========== */
|
||||
.editor-panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.editor-form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.editor-form-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.format-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.format-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
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: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.editor-textarea {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.editor-textarea :deep(.editor-content) {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -85,10 +85,7 @@
|
||||
<!-- Диалог удаления файла -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteFileDialog"
|
||||
title="Удалить изображение?"
|
||||
message="Изображение будет удалено из задачи."
|
||||
confirm-text="Удалить"
|
||||
variant="danger"
|
||||
type="deleteFile"
|
||||
@confirm="confirmDeleteFile"
|
||||
@cancel="showDeleteFileDialog = false"
|
||||
/>
|
||||
@@ -96,7 +93,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, onMounted, onUpdated, nextTick } from 'vue'
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||||
import FormField from '../ui/FormField.vue'
|
||||
import TextInput from '../ui/TextInput.vue'
|
||||
import RichTextEditor from '../ui/RichTextEditor.vue'
|
||||
@@ -107,9 +104,12 @@ import DatePicker from '../DatePicker.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
import { getFullUrl } from '../../api'
|
||||
import { useMobile } from '../../composables/useMobile'
|
||||
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
|
||||
const { refreshIcons } = useLucideIcons()
|
||||
|
||||
const props = defineProps({
|
||||
card: {
|
||||
type: Object,
|
||||
@@ -286,12 +286,6 @@ const confirmDeleteFile = () => {
|
||||
fileToDeleteIndex.value = null
|
||||
}
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
// Get form data for saving
|
||||
const getFormData = () => {
|
||||
return {
|
||||
@@ -335,9 +329,6 @@ const setDetailsContent = (content) => {
|
||||
detailsEditorRef.value?.setContent(content)
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
|
||||
// Expose for parent
|
||||
defineExpose({
|
||||
form,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="header-title-block">
|
||||
<h2>{{ panelTitle }}</h2>
|
||||
<span v-if="!isNew && card?.dateCreate" class="header-date">
|
||||
Создано: {{ formatDate(card.dateCreate) }}
|
||||
Создано: {{ formatFull(card.dateCreate) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -26,9 +26,9 @@
|
||||
v-show="activeTab === 'edit' || isNew"
|
||||
ref="editTabRef"
|
||||
:card="card"
|
||||
:departments="departments"
|
||||
:labels="labels"
|
||||
:users="users"
|
||||
:departments="store.departments"
|
||||
:labels="store.labels"
|
||||
:users="store.users"
|
||||
@preview-image="openImagePreview"
|
||||
/>
|
||||
|
||||
@@ -37,8 +37,10 @@
|
||||
v-show="activeTab === 'comments'"
|
||||
ref="commentsTabRef"
|
||||
:task-id="card?.id"
|
||||
:current-user-id="currentUserId"
|
||||
:is-project-admin="isProjectAdmin"
|
||||
:current-user-id="store.currentUserId"
|
||||
:current-user-name="store.currentUserName"
|
||||
:current-user-avatar="store.currentUserAvatar"
|
||||
:is-project-admin="store.isProjectAdmin"
|
||||
:active="activeTab === 'comments'"
|
||||
@comments-loaded="commentsCount = $event"
|
||||
@preview-file="openImagePreview"
|
||||
@@ -70,27 +72,20 @@
|
||||
@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>
|
||||
<ActionButtons
|
||||
:save-text="isNew ? 'Создать' : 'Сохранить'"
|
||||
:loading="isSaving"
|
||||
:disabled="!canSave"
|
||||
@save="handleSave"
|
||||
@cancel="tryClose"
|
||||
/>
|
||||
</template>
|
||||
</SlidePanel>
|
||||
|
||||
<!-- Диалог несохранённых изменений -->
|
||||
<ConfirmDialog
|
||||
:show="showUnsavedDialog"
|
||||
title="Обнаружены изменения"
|
||||
message="У вас есть несохранённые изменения.<br>Что вы хотите сделать?"
|
||||
confirm-text="Сохранить"
|
||||
:show-discard="true"
|
||||
type="unsavedChanges"
|
||||
@confirm="confirmSave"
|
||||
@cancel="cancelClose"
|
||||
@discard="confirmDiscard"
|
||||
@@ -99,33 +94,27 @@
|
||||
<!-- Диалог удаления задачи -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
title="Удалить задачу?"
|
||||
message="Это действие нельзя отменить.<br>Задача будет удалена навсегда."
|
||||
confirm-text="Удалить"
|
||||
variant="danger"
|
||||
@confirm="confirmDelete"
|
||||
type="deleteTask"
|
||||
:action="confirmDelete"
|
||||
@confirm="showDeleteDialog = false"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Диалог архивации задачи -->
|
||||
<ConfirmDialog
|
||||
:show="showArchiveDialog"
|
||||
title="Архивировать задачу?"
|
||||
message="Задача будет перемещена в архив.<br>Вы сможете восстановить её позже."
|
||||
confirm-text="В архив"
|
||||
variant="warning"
|
||||
@confirm="confirmArchive"
|
||||
type="archive"
|
||||
:action="confirmArchive"
|
||||
@confirm="showArchiveDialog = false"
|
||||
@cancel="showArchiveDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Диалог разархивации задачи -->
|
||||
<ConfirmDialog
|
||||
:show="showRestoreDialog"
|
||||
title="Вернуть из архива?"
|
||||
message="Задача будет возвращена на доску<br>в колонку «Готово»."
|
||||
confirm-text="Вернуть"
|
||||
variant="warning"
|
||||
@confirm="confirmRestore"
|
||||
type="restore"
|
||||
:action="confirmRestore"
|
||||
@confirm="showRestoreDialog = false"
|
||||
@cancel="showRestoreDialog = false"
|
||||
/>
|
||||
|
||||
@@ -140,18 +129,25 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUpdated, nextTick } from 'vue'
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import SlidePanel from '../ui/SlidePanel.vue'
|
||||
import TabsPanel from '../ui/TabsPanel.vue'
|
||||
import IconButton from '../ui/IconButton.vue'
|
||||
import ActionButtons from '../ui/ActionButtons.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'
|
||||
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||
import { useDateFormat } from '../../composables/useDateFormat'
|
||||
import { useProjectsStore } from '../../stores/projects'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const { refreshIcons } = useLucideIcons()
|
||||
const { formatFull } = useDateFormat()
|
||||
const store = useProjectsStore()
|
||||
|
||||
// Состояние загрузки для кнопки сохранения
|
||||
const isSaving = ref(false)
|
||||
@@ -160,33 +156,24 @@ 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,
|
||||
// Callbacks (возвращают Promise)
|
||||
onSave: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
isProjectAdmin: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
onDelete: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
// Callback для сохранения (возвращает Promise)
|
||||
onSave: {
|
||||
onArchive: {
|
||||
type: Function,
|
||||
default: null
|
||||
},
|
||||
onRestore: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
@@ -233,18 +220,9 @@ const canSave = computed(() => {
|
||||
|
||||
// Can archive (только если колонка "Готово")
|
||||
const canArchive = computed(() => {
|
||||
return props.doneColumnId && Number(props.columnId) === props.doneColumnId
|
||||
return store.doneColumnId && Number(props.columnId) === store.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) {
|
||||
@@ -327,9 +305,16 @@ const handleDelete = () => {
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
showDeleteDialog.value = false
|
||||
emit('delete', props.card.id)
|
||||
const confirmDelete = async () => {
|
||||
if (!props.card?.id) {
|
||||
throw new Error('Задача не выбрана')
|
||||
}
|
||||
|
||||
if (props.onDelete) {
|
||||
await props.onDelete(props.card.id)
|
||||
} else {
|
||||
emit('delete', props.card.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Archive
|
||||
@@ -337,9 +322,14 @@ const handleArchive = () => {
|
||||
showArchiveDialog.value = true
|
||||
}
|
||||
|
||||
const confirmArchive = () => {
|
||||
showArchiveDialog.value = false
|
||||
if (props.card?.id) {
|
||||
const confirmArchive = async () => {
|
||||
if (!props.card?.id) {
|
||||
throw new Error('Задача не выбрана')
|
||||
}
|
||||
|
||||
if (props.onArchive) {
|
||||
await props.onArchive(props.card.id)
|
||||
} else {
|
||||
emit('archive', props.card.id)
|
||||
}
|
||||
}
|
||||
@@ -349,9 +339,14 @@ const handleRestore = () => {
|
||||
showRestoreDialog.value = true
|
||||
}
|
||||
|
||||
const confirmRestore = () => {
|
||||
showRestoreDialog.value = false
|
||||
if (props.card?.id) {
|
||||
const confirmRestore = async () => {
|
||||
if (!props.card?.id) {
|
||||
throw new Error('Задача не выбрана')
|
||||
}
|
||||
|
||||
if (props.onRestore) {
|
||||
await props.onRestore(props.card.id)
|
||||
} else {
|
||||
emit('restore', props.card.id)
|
||||
}
|
||||
}
|
||||
@@ -397,13 +392,6 @@ const deleteFromPreview = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Icons
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
// Watch show
|
||||
watch(() => props.show, async (newVal) => {
|
||||
if (newVal) {
|
||||
@@ -427,9 +415,6 @@ watch(() => props.show, async (newVal) => {
|
||||
refreshIcons()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -457,68 +442,4 @@ onUpdated(refreshIcons)
|
||||
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>
|
||||
|
||||
@@ -2,7 +2,7 @@ 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'
|
||||
export { default as ContentEditorPanel } from './ContentEditorPanel.vue'
|
||||
|
||||
// Default export
|
||||
export { default } from './TaskPanel.vue'
|
||||
|
||||
Reference in New Issue
Block a user