Добавление в Архив + Фронт
1. Переписал модуль выпадающего слева меню 2. Добавил механику Архивации задач 3. Запоминания выбранного отдела
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,4 +9,5 @@ backend/public/*
|
||||
|
||||
# Личные файлы
|
||||
deploy.js
|
||||
deploy.php
|
||||
deploy.php
|
||||
.vscode
|
||||
@@ -95,6 +95,14 @@ if ($method === 'POST') {
|
||||
RestApi::response($result);
|
||||
}
|
||||
|
||||
// Установка статуса архивации задачи
|
||||
if ($action === 'set_archive') {
|
||||
$id = $data['id'] ?? null;
|
||||
$archive = $data['archive'] ?? 1;
|
||||
$result = Task::setArchive($id, $archive);
|
||||
RestApi::response($result);
|
||||
}
|
||||
|
||||
// Метод не указан
|
||||
if (!$action) {
|
||||
RestApi::response(['success' => false, 'error' => 'Укажите метод'], 400);
|
||||
@@ -103,8 +111,16 @@ if ($method === 'POST') {
|
||||
|
||||
if ($method === 'GET') {
|
||||
// Получение всех задач
|
||||
// ?archive=0 (неархивные, по умолчанию), ?archive=1 (архивные), ?archive=all (все)
|
||||
$archive = $_GET['archive'] ?? 0;
|
||||
if ($archive === 'all') {
|
||||
$archive = null;
|
||||
} else {
|
||||
$archive = (int)$archive;
|
||||
}
|
||||
|
||||
$task = new Task();
|
||||
$tasks = $task->getAll();
|
||||
$tasks = $task->getAll($archive);
|
||||
|
||||
RestApi::response(['success' => true, 'data' => $tasks]);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ class Task extends BaseEntity {
|
||||
public $title;
|
||||
public $descript;
|
||||
public $descript_full;
|
||||
public $archive;
|
||||
|
||||
// Валидация данных
|
||||
protected function validate() {
|
||||
@@ -65,6 +66,7 @@ class Task extends BaseEntity {
|
||||
'title' => $this->title,
|
||||
'descript' => $this->descript ?: null,
|
||||
'descript_full' => $this->descript_full ?: null,
|
||||
'archive' => 0,
|
||||
'date_create' => date('Y-m-d H:i:s'),
|
||||
'file_img' => '[]'
|
||||
]);
|
||||
@@ -175,7 +177,13 @@ class Task extends BaseEntity {
|
||||
}
|
||||
|
||||
// Получение всех задач
|
||||
public function getAll() {
|
||||
// $archive: 0 = неархивные, 1 = архивные, null = все
|
||||
public function getAll($archive = 0) {
|
||||
$where = [];
|
||||
if ($archive !== null) {
|
||||
$where['archive'] = $archive ? 1 : 0;
|
||||
}
|
||||
|
||||
$tasks = Database::select($this->db_name, [
|
||||
'id',
|
||||
'id_department',
|
||||
@@ -188,8 +196,9 @@ class Task extends BaseEntity {
|
||||
'file_img',
|
||||
'title',
|
||||
'descript',
|
||||
'descript_full'
|
||||
]);
|
||||
'descript_full',
|
||||
'archive'
|
||||
], $where);
|
||||
|
||||
// Декодируем JSON и получаем avatar_url из accounts
|
||||
return array_map(function($task) {
|
||||
@@ -243,6 +252,29 @@ class Task extends BaseEntity {
|
||||
}
|
||||
return $task;
|
||||
}
|
||||
|
||||
// Установка статуса архивации задачи (только для задач в колонке 4)
|
||||
public static function setArchive($id, $archive = 1) {
|
||||
// Проверка что задача существует
|
||||
$task = self::check_task($id);
|
||||
|
||||
// Архивировать можно только задачи в колонке 4
|
||||
if ($archive && $task['column_id'] != 4) {
|
||||
RestApi::response([
|
||||
'success' => false,
|
||||
'errors' => ['column' => 'Архивировать можно только задачи из колонки "Готово"']
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Обновляем archive
|
||||
Database::update('cards_task', [
|
||||
'archive' => $archive ? 1 : 0
|
||||
], [
|
||||
'id' => $id
|
||||
]);
|
||||
|
||||
return ['success' => true, 'archive' => $archive ? 1 : 0];
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -70,7 +70,8 @@ export const columnsApi = {
|
||||
|
||||
// ==================== CARDS ====================
|
||||
export const cardsApi = {
|
||||
getAll: () => request('/api/task', { credentials: 'include' }),
|
||||
// archive: 0 = неархивные (по умолчанию), 1 = архивные, 'all' = все
|
||||
getAll: (archive = 0) => request(`/api/task${archive !== 0 ? `?archive=${archive}` : ''}`, { credentials: 'include' }),
|
||||
updateOrder: (id, column_id, to_index) => request('/api/task', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
@@ -94,6 +95,12 @@ export const cardsApi = {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'delete', id })
|
||||
}),
|
||||
setArchive: (id, archive = 1) => request('/api/task', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'set_archive', id, archive })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
@drop-card="handleDropCard"
|
||||
@open-task="(card) => emit('open-task', { card, columnId: column.id })"
|
||||
@create-task="emit('create-task', column.id)"
|
||||
@archive-task="archiveTask"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -238,7 +239,20 @@ const deleteTask = async (cardId, columnId) => {
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ saveTask, deleteTask })
|
||||
const archiveTask = async (cardId) => {
|
||||
// Архивируем на сервере
|
||||
const result = await cardsApi.setArchive(cardId, 1)
|
||||
|
||||
if (result.success) {
|
||||
// Удаляем из локального списка (задача уходит в архив)
|
||||
const index = localCards.value.findIndex(c => c.id === cardId)
|
||||
if (index !== -1) {
|
||||
localCards.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ saveTask, deleteTask, archiveTask })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button
|
||||
v-if="canArchive"
|
||||
class="btn-archive-card"
|
||||
@click.stop="handleArchive"
|
||||
title="В архив"
|
||||
>
|
||||
<i data-lucide="archive"></i>
|
||||
</button>
|
||||
<span v-if="card.files && card.files.length" class="files-indicator" :title="card.files.length + ' изображений'">
|
||||
<i data-lucide="image-plus"></i>
|
||||
</span>
|
||||
@@ -69,7 +77,7 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
// defineEmits(['delete']) - будет при раскрытии карточки
|
||||
const emit = defineEmits(['archive'])
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
@@ -146,6 +154,15 @@ const daysLeftText = computed(() => {
|
||||
const isAvatarUrl = (value) => {
|
||||
return value && (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/'))
|
||||
}
|
||||
|
||||
// Можно ли архивировать (только если колонка 4)
|
||||
const canArchive = computed(() => {
|
||||
return Number(props.columnId) === 4
|
||||
})
|
||||
|
||||
const handleArchive = () => {
|
||||
emit('archive', props.card.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -279,4 +296,33 @@ const isAvatarUrl = (value) => {
|
||||
.due-date.overdue {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.btn-archive-card {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-archive-card i,
|
||||
.btn-archive-card svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.card:hover .btn-archive-card {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.btn-archive-card:hover {
|
||||
background: var(--orange);
|
||||
color: #000;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
:departments="departments"
|
||||
:labels="labels"
|
||||
@click="emit('open-task', card)"
|
||||
@archive="emit('archive-task', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -56,7 +57,7 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['drop-card', 'open-task', 'create-task'])
|
||||
const emit = defineEmits(['drop-card', 'open-task', 'create-task', 'archive-task'])
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
454
front_vue/src/components/ui/FileUploader.vue
Normal file
454
front_vue/src/components/ui/FileUploader.vue
Normal file
@@ -0,0 +1,454 @@
|
||||
<template>
|
||||
<div class="file-uploader">
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInputRef"
|
||||
:accept="acceptString"
|
||||
:multiple="multiple"
|
||||
@change="handleFileSelect"
|
||||
style="display: none"
|
||||
>
|
||||
|
||||
<!-- Пустая зона drag & drop -->
|
||||
<div
|
||||
v-if="files.length === 0"
|
||||
class="file-dropzone"
|
||||
:class="{ 'dragover': isDragging }"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleFileDrop"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<i data-lucide="image-plus" class="dropzone-icon"></i>
|
||||
<span class="dropzone-text">{{ dropzoneText }}</span>
|
||||
<span class="dropzone-subtext">{{ dropzoneSubtext }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Сетка с файлами -->
|
||||
<div
|
||||
v-else
|
||||
class="files-container"
|
||||
:class="{ 'dragover': isDragging }"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleFileDrop"
|
||||
>
|
||||
<!-- Превью файлов -->
|
||||
<div
|
||||
v-for="(file, index) in visibleFiles"
|
||||
:key="file.name + '-' + file.size"
|
||||
class="file-preview-item"
|
||||
>
|
||||
<div class="file-actions">
|
||||
<button class="btn-download-file" @click.stop="downloadFile(file)" title="Скачать">
|
||||
<i data-lucide="download"></i>
|
||||
</button>
|
||||
<button class="btn-remove-file" @click.stop="$emit('remove', index)" title="Удалить">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="file-thumbnail" @click="!isArchive(file) && $emit('preview', file)">
|
||||
<template v-if="isArchive(file)">
|
||||
<div class="archive-icon">
|
||||
<i data-lucide="archive"></i>
|
||||
<span class="archive-ext">.{{ getFileExt(file) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<img v-else :src="getPreviewUrl(file)" :alt="file.name">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка добавить ещё -->
|
||||
<div class="file-add-btn" @click="triggerFileInput">
|
||||
<i data-lucide="plus"></i>
|
||||
<span>Добавить</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUpdated } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
files: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
// Формат: [{ name, size, type, preview, isNew, toDelete }]
|
||||
},
|
||||
allowedExtensions: {
|
||||
type: Array,
|
||||
default: () => ['png', 'jpg', 'jpeg', 'zip', 'rar']
|
||||
},
|
||||
archiveExtensions: {
|
||||
type: Array,
|
||||
default: () => ['zip', 'rar']
|
||||
},
|
||||
maxFileSize: {
|
||||
type: Number,
|
||||
default: 10 * 1024 * 1024 // 10 MB
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
dropzoneText: {
|
||||
type: String,
|
||||
default: 'Перетащите файлы сюда'
|
||||
},
|
||||
dropzoneSubtext: {
|
||||
type: String,
|
||||
default: 'или нажмите для выбора'
|
||||
},
|
||||
getFullUrl: {
|
||||
type: Function,
|
||||
default: (url) => url
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['add', 'remove', 'preview', 'error'])
|
||||
|
||||
const fileInputRef = ref(null)
|
||||
const isDragging = ref(false)
|
||||
|
||||
// Видимые файлы (без помеченных на удаление)
|
||||
const visibleFiles = computed(() => props.files.filter(f => !f.toDelete))
|
||||
|
||||
// Accept строка для input
|
||||
const acceptString = computed(() => {
|
||||
const mimeTypes = {
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'zip': 'application/zip',
|
||||
'rar': 'application/x-rar-compressed'
|
||||
}
|
||||
const exts = props.allowedExtensions.map(e => `.${e}`)
|
||||
const mimes = props.allowedExtensions.map(e => mimeTypes[e]).filter(Boolean)
|
||||
return [...exts, ...mimes].join(',')
|
||||
})
|
||||
|
||||
// Получить расширение файла
|
||||
const getFileExt = (file) => {
|
||||
return file.name?.split('.').pop()?.toLowerCase() || ''
|
||||
}
|
||||
|
||||
// Проверка разрешён ли файл
|
||||
const isAllowedFile = (file) => {
|
||||
return props.allowedExtensions.includes(getFileExt(file))
|
||||
}
|
||||
|
||||
// Проверка является ли файл архивом
|
||||
const isArchive = (file) => {
|
||||
return props.archiveExtensions.includes(getFileExt(file))
|
||||
}
|
||||
|
||||
// Получить URL превью
|
||||
const getPreviewUrl = (file) => {
|
||||
return props.getFullUrl(file.preview)
|
||||
}
|
||||
|
||||
// Открыть диалог выбора файлов
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
// Обработка выбора файлов
|
||||
const handleFileSelect = (event) => {
|
||||
const files = event.target.files
|
||||
if (files) {
|
||||
processFiles(Array.from(files))
|
||||
}
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
// Обработка drop
|
||||
const handleFileDrop = (event) => {
|
||||
isDragging.value = false
|
||||
const files = event.dataTransfer.files
|
||||
if (files) {
|
||||
processFiles(Array.from(files))
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка файлов
|
||||
const processFiles = (fileList) => {
|
||||
for (const file of fileList) {
|
||||
// Проверка типа
|
||||
if (!isAllowedFile(file)) {
|
||||
emit('error', `Файл "${file.name}" не поддерживается.`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Проверка размера
|
||||
if (file.size > props.maxFileSize) {
|
||||
emit('error', `Файл "${file.name}" слишком большой.`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Проверяем дубликат
|
||||
const isDuplicate = props.files.some(
|
||||
f => f.name === file.name && f.size === file.size
|
||||
)
|
||||
if (isDuplicate) continue
|
||||
|
||||
// Создаём превью
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
emit('add', {
|
||||
file: file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
preview: e.target.result,
|
||||
isNew: true
|
||||
})
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}
|
||||
|
||||
// Скачивание файла
|
||||
const downloadFile = async (file) => {
|
||||
const url = getPreviewUrl(file)
|
||||
try {
|
||||
if (file.preview?.startsWith('data:')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = file.preview
|
||||
link.download = file.name
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
} else {
|
||||
const response = await fetch(url)
|
||||
const blob = await response.blob()
|
||||
const blobUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = file.name
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка скачивания:', error)
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление иконок
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
|
||||
// Экспортируем методы
|
||||
defineExpose({
|
||||
triggerFileInput
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px 24px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 2px dashed rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-dropzone:hover {
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.file-dropzone.dragover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(0, 212, 170, 0.08);
|
||||
}
|
||||
|
||||
.dropzone-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.file-dropzone:hover .dropzone-icon,
|
||||
.file-dropzone.dragover .dropzone-icon {
|
||||
color: var(--accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dropzone-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropzone-subtext {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.files-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 2px dashed rgba(255, 255, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.files-container.dragover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(0, 212, 170, 0.08);
|
||||
}
|
||||
|
||||
.file-preview-item {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-thumbnail {
|
||||
width: 100%;
|
||||
padding-bottom: 100%;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-thumbnail img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.archive-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.archive-icon i {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.archive-ext {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-top: 4px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.file-preview-item:hover .file-thumbnail {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.file-preview-item:hover .file-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-remove-file,
|
||||
.btn-download-file {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-remove-file i,
|
||||
.btn-download-file i {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.btn-remove-file:hover {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.btn-download-file:hover {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.file-add-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
aspect-ratio: 1;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 2px dashed rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-add-btn:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(0, 212, 170, 0.08);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.file-add-btn i {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.file-add-btn span {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
88
front_vue/src/components/ui/FormField.vue
Normal file
88
front_vue/src/components/ui/FormField.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="field">
|
||||
<div v-if="label" class="field-header">
|
||||
<label>{{ label }}</label>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
<div v-if="hint" class="field-hint">{{ hint }}</div>
|
||||
<slot></slot>
|
||||
<div v-if="error" class="field-error">
|
||||
<i data-lucide="alert-circle"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUpdated } from 'vue'
|
||||
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
hint: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
error: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #ef4444;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field-error i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
256
front_vue/src/components/ui/ImagePreview.vue
Normal file
256
front_vue/src/components/ui/ImagePreview.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<Transition name="dialog">
|
||||
<div v-if="file" class="image-preview-overlay" @click="$emit('close')">
|
||||
<div class="image-preview-container" @click.stop>
|
||||
<div class="preview-actions">
|
||||
<button class="btn-download-preview" @click="downloadFile" title="Скачать">
|
||||
<i data-lucide="download"></i>
|
||||
</button>
|
||||
<button v-if="showDelete" class="btn-delete-preview" @click="$emit('delete')" title="Удалить">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
<button class="btn-close-preview" @click="$emit('close')" title="Закрыть">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Архив -->
|
||||
<template v-if="isArchive">
|
||||
<div class="archive-preview-icon">
|
||||
<i data-lucide="archive"></i>
|
||||
<span>{{ file.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Изображение -->
|
||||
<img v-else :src="previewUrl" :alt="file.name" class="image-preview-full">
|
||||
|
||||
<div class="image-preview-info">
|
||||
<span class="preview-name">{{ file.name }}</span>
|
||||
<span class="preview-size">{{ formatFileSize(file.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUpdated } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
file: {
|
||||
type: Object,
|
||||
default: null
|
||||
// Формат: { name, size, type, preview }
|
||||
},
|
||||
showDelete: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
archiveExtensions: {
|
||||
type: Array,
|
||||
default: () => ['zip', 'rar']
|
||||
},
|
||||
getFullUrl: {
|
||||
type: Function,
|
||||
default: (url) => url
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'delete'])
|
||||
|
||||
// URL превью
|
||||
const previewUrl = computed(() => {
|
||||
if (!props.file) return ''
|
||||
return props.getFullUrl(props.file.preview)
|
||||
})
|
||||
|
||||
// Проверка архива
|
||||
const isArchive = computed(() => {
|
||||
if (!props.file) return false
|
||||
const ext = props.file.name?.split('.').pop()?.toLowerCase() || ''
|
||||
return props.archiveExtensions.includes(ext)
|
||||
})
|
||||
|
||||
// Форматирование размера
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return bytes + ' Б'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' КБ'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' МБ'
|
||||
}
|
||||
|
||||
// Скачивание
|
||||
const downloadFile = async () => {
|
||||
if (!props.file) return
|
||||
|
||||
const url = previewUrl.value
|
||||
try {
|
||||
if (props.file.preview?.startsWith('data:')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = props.file.preview
|
||||
link.download = props.file.name
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
} else {
|
||||
const response = await fetch(url)
|
||||
const blob = await response.blob()
|
||||
const blobUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = props.file.name
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка скачивания:', error)
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление иконок
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.image-preview-container {
|
||||
position: relative;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-close-preview,
|
||||
.btn-download-preview,
|
||||
.btn-delete-preview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-close-preview:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.btn-download-preview:hover {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.btn-delete-preview:hover {
|
||||
background: #ef4444;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.btn-close-preview i,
|
||||
.btn-download-preview i,
|
||||
.btn-delete-preview i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.image-preview-full {
|
||||
max-width: 100%;
|
||||
max-height: calc(90vh - 60px);
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.archive-preview-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 60px;
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.archive-preview-icon i {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.archive-preview-icon span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.image-preview-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.preview-name {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.preview-size {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.dialog-enter-active,
|
||||
.dialog-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-enter-from,
|
||||
.dialog-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
324
front_vue/src/components/ui/RichTextEditor.vue
Normal file
324
front_vue/src/components/ui/RichTextEditor.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<div class="rich-editor-wrapper">
|
||||
<!-- Кнопки форматирования -->
|
||||
<div v-if="showToolbar" 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>
|
||||
|
||||
<!-- Редактируемое поле -->
|
||||
<div
|
||||
class="rich-editor"
|
||||
:class="{ 'is-empty': !modelValue }"
|
||||
contenteditable="true"
|
||||
ref="editorRef"
|
||||
@input="onInput"
|
||||
@paste="onPaste"
|
||||
@keydown="onKeydown"
|
||||
@click="onClick"
|
||||
@focus="$emit('focus', $event)"
|
||||
@blur="$emit('blur', $event)"
|
||||
:data-placeholder="placeholder"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onUpdated } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Введите текст...'
|
||||
},
|
||||
showToolbar: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
autoLinkify: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'focus', 'blur'])
|
||||
|
||||
const editorRef = ref(null)
|
||||
|
||||
// Применить форматирование
|
||||
const applyFormat = (command) => {
|
||||
editorRef.value?.focus()
|
||||
document.execCommand(command, false, null)
|
||||
syncContent()
|
||||
}
|
||||
|
||||
// Синхронизация содержимого
|
||||
const syncContent = () => {
|
||||
if (!editorRef.value) return
|
||||
let html = editorRef.value.innerHTML
|
||||
// Нормализуем переносы строк
|
||||
html = html.replace(/<\/div><div>/gi, '<br>')
|
||||
html = html.replace(/<div>/gi, '')
|
||||
html = html.replace(/<\/div>/gi, '')
|
||||
emit('update:modelValue', html)
|
||||
}
|
||||
|
||||
// Обработка ввода
|
||||
const onInput = () => {
|
||||
syncContent()
|
||||
}
|
||||
|
||||
// Обработка клика (открытие ссылок)
|
||||
const onClick = (e) => {
|
||||
if (e.target.tagName === 'A') {
|
||||
e.preventDefault()
|
||||
window.open(e.target.href, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка вставки
|
||||
const onPaste = (e) => {
|
||||
e.preventDefault()
|
||||
const text = e.clipboardData.getData('text/plain')
|
||||
document.execCommand('insertText', false, text)
|
||||
if (props.autoLinkify) {
|
||||
setTimeout(linkifyContent, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Горячие клавиши
|
||||
const onKeydown = (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'b') {
|
||||
e.preventDefault()
|
||||
applyFormat('bold')
|
||||
} else if (e.key === 'i') {
|
||||
e.preventDefault()
|
||||
applyFormat('italic')
|
||||
} else if (e.key === 'u') {
|
||||
e.preventDefault()
|
||||
applyFormat('underline')
|
||||
}
|
||||
}
|
||||
|
||||
// После пробела или Enter — подсвечиваем ссылки
|
||||
if (props.autoLinkify && (e.key === ' ' || e.key === 'Enter')) {
|
||||
setTimeout(linkifyContent, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Автоматическое преобразование URL в ссылки
|
||||
const linkifyContent = () => {
|
||||
if (!editorRef.value) return
|
||||
|
||||
const html = editorRef.value.innerHTML
|
||||
const parts = html.split(/(<a [^>]*>.*?<\/a>)/gi)
|
||||
|
||||
let changed = false
|
||||
const newParts = parts.map(part => {
|
||||
if (part.startsWith('<a ')) return part
|
||||
|
||||
const newPart = part.replace(
|
||||
/(https?:\/\/[^\s<>"]+)/g,
|
||||
'<a href="$1" target="_blank" rel="noopener">$1</a>'
|
||||
)
|
||||
if (newPart !== part) changed = true
|
||||
return newPart
|
||||
})
|
||||
|
||||
if (changed) {
|
||||
editorRef.value.innerHTML = newParts.join('')
|
||||
|
||||
// Ставим курсор в конец
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()
|
||||
range.selectNodeContents(editorRef.value)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
syncContent()
|
||||
}
|
||||
}
|
||||
|
||||
// Преобразование URL в ссылки
|
||||
const linkifyHtml = (html) => {
|
||||
if (!html) return ''
|
||||
if (html.includes('<a ')) return html
|
||||
return html.replace(
|
||||
/(https?:\/\/[^\s<>"]+)/g,
|
||||
'<a href="$1" target="_blank" rel="noopener">$1</a>'
|
||||
)
|
||||
}
|
||||
|
||||
// Установка содержимого
|
||||
const setContent = (html) => {
|
||||
if (editorRef.value) {
|
||||
editorRef.value.innerHTML = linkifyHtml(html || '')
|
||||
}
|
||||
}
|
||||
|
||||
// Следим за внешними изменениями modelValue
|
||||
watch(() => props.modelValue, (newVal, oldVal) => {
|
||||
// Обновляем только если изменение пришло извне
|
||||
if (editorRef.value && editorRef.value.innerHTML !== newVal) {
|
||||
setContent(newVal)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Обновление иконок Lucide
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshIcons()
|
||||
setContent(props.modelValue)
|
||||
})
|
||||
|
||||
onUpdated(refreshIcons)
|
||||
|
||||
// Экспортируем методы для использования извне
|
||||
defineExpose({
|
||||
setContent,
|
||||
applyFormat,
|
||||
focus: () => editorRef.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rich-editor-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.rich-editor {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
min-height: 120px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
word-break: break-word;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.rich-editor:focus {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.rich-editor:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rich-editor::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.rich-editor::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.rich-editor::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(a) {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rich-editor :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rich-editor :deep(b),
|
||||
.rich-editor :deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rich-editor :deep(i),
|
||||
.rich-editor :deep(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rich-editor :deep(u) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
369
front_vue/src/components/ui/SelectDropdown.vue
Normal file
369
front_vue/src/components/ui/SelectDropdown.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<div class="select-dropdown" ref="dropdownRef">
|
||||
<!-- Триггер -->
|
||||
<button class="dropdown-trigger" @click="toggleDropdown" :disabled="disabled">
|
||||
<slot name="selected" :selected="selectedOption">
|
||||
<img
|
||||
v-if="selectedOption?.avatar"
|
||||
:src="selectedOption.avatar"
|
||||
:alt="selectedOption.label"
|
||||
class="option-avatar"
|
||||
>
|
||||
<span v-else-if="!selectedOption" class="no-selection-icon">—</span>
|
||||
<span class="selected-label">{{ selectedOption?.label || placeholder }}</span>
|
||||
</slot>
|
||||
<i data-lucide="chevron-down" class="dropdown-arrow"></i>
|
||||
</button>
|
||||
|
||||
<!-- Выпадающий список -->
|
||||
<Transition name="dropdown">
|
||||
<div v-if="isOpen" class="dropdown-menu">
|
||||
<input
|
||||
v-if="searchable"
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
:placeholder="searchPlaceholder"
|
||||
class="dropdown-search"
|
||||
ref="searchInputRef"
|
||||
@click.stop
|
||||
>
|
||||
<div class="dropdown-list">
|
||||
<!-- Опция "не выбрано" -->
|
||||
<button
|
||||
v-if="allowEmpty"
|
||||
class="dropdown-item"
|
||||
:class="{ active: !modelValue }"
|
||||
@click="selectOption(null)"
|
||||
>
|
||||
<span class="no-selection-icon">—</span>
|
||||
<span>{{ emptyLabel }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Опции -->
|
||||
<button
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
class="dropdown-item"
|
||||
:class="{ active: modelValue === option.value }"
|
||||
@click="selectOption(option.value)"
|
||||
>
|
||||
<slot name="option" :option="option">
|
||||
<img
|
||||
v-if="option.avatar"
|
||||
:src="option.avatar"
|
||||
:alt="option.label"
|
||||
class="option-avatar"
|
||||
>
|
||||
<div class="option-content">
|
||||
<span class="option-label">{{ option.label }}</span>
|
||||
<span v-if="option.subtitle" class="option-subtitle">{{ option.subtitle }}</span>
|
||||
</div>
|
||||
</slot>
|
||||
</button>
|
||||
|
||||
<!-- Пустой результат поиска -->
|
||||
<div v-if="searchable && filteredOptions.length === 0" class="dropdown-empty">
|
||||
Ничего не найдено
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted, onUpdated, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number, null],
|
||||
default: null
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
// Формат: [{ value: 1, label: 'Name', subtitle: '@telegram', avatar: 'url' }]
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Выберите...'
|
||||
},
|
||||
searchable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: 'Поиск...'
|
||||
},
|
||||
allowEmpty: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
emptyLabel: {
|
||||
type: String,
|
||||
default: 'Не выбрано'
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const dropdownRef = ref(null)
|
||||
const searchInputRef = ref(null)
|
||||
const isOpen = ref(false)
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Выбранная опция
|
||||
const selectedOption = computed(() => {
|
||||
if (!props.modelValue) return null
|
||||
return props.options.find(opt => opt.value === props.modelValue)
|
||||
})
|
||||
|
||||
// Отфильтрованные опции
|
||||
const filteredOptions = computed(() => {
|
||||
if (!searchQuery.value.trim()) return props.options
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.options.filter(opt =>
|
||||
opt.label?.toLowerCase().includes(query) ||
|
||||
opt.subtitle?.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
// Переключение dropdown
|
||||
const toggleDropdown = async () => {
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
await nextTick()
|
||||
searchInputRef.value?.focus()
|
||||
refreshIcons()
|
||||
}
|
||||
}
|
||||
|
||||
// Выбор опции
|
||||
const selectOption = (value) => {
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
// Закрытие при клике вне
|
||||
const handleClickOutside = (e) => {
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(e.target)) {
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление иконок
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
refreshIcons()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUpdated(refreshIcons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.select-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dropdown-trigger:hover:not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.dropdown-trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1f1f23;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-search {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dropdown-search::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dropdown-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.dropdown-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.dropdown-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dropdown-list::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dropdown-item.active {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.option-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.selected-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.option-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dropdown-item.active .option-subtitle {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.no-selection-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropdown-item.active .no-selection-icon {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dropdown-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
282
front_vue/src/components/ui/SlidePanel.vue
Normal file
282
front_vue/src/components/ui/SlidePanel.vue
Normal file
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<Transition name="panel">
|
||||
<div
|
||||
v-if="show"
|
||||
class="panel-overlay"
|
||||
@mousedown.self="onOverlayMouseDown"
|
||||
@mouseup.self="onOverlayMouseUp"
|
||||
>
|
||||
<div
|
||||
class="panel"
|
||||
:style="{ width: panelWidth + 'px' }"
|
||||
ref="panelRef"
|
||||
@mousedown="overlayMouseDownTarget = false"
|
||||
>
|
||||
<!-- Ручка для изменения ширины -->
|
||||
<div
|
||||
class="resize-handle"
|
||||
@mousedown="startResize"
|
||||
></div>
|
||||
|
||||
<div class="panel-header">
|
||||
<div class="header-content">
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
<button class="btn-close" @click="handleClose">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.footer" class="panel-footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, onUpdated, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 600
|
||||
},
|
||||
minWidth: {
|
||||
type: Number,
|
||||
default: 500
|
||||
},
|
||||
maxWidth: {
|
||||
type: Number,
|
||||
default: 1000
|
||||
},
|
||||
resizable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'update:show'])
|
||||
|
||||
const panelRef = ref(null)
|
||||
const panelWidth = ref(props.width)
|
||||
const isResizing = ref(false)
|
||||
const overlayMouseDownTarget = ref(false)
|
||||
|
||||
// Resize логика
|
||||
const startResize = (e) => {
|
||||
if (!props.resizable) return
|
||||
isResizing.value = true
|
||||
document.addEventListener('mousemove', onResize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
document.body.style.cursor = 'ew-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}
|
||||
|
||||
const onResize = (e) => {
|
||||
if (!isResizing.value) return
|
||||
const newWidth = window.innerWidth - e.clientX
|
||||
panelWidth.value = Math.min(props.maxWidth, Math.max(props.minWidth, newWidth))
|
||||
}
|
||||
|
||||
const stopResize = () => {
|
||||
setTimeout(() => {
|
||||
isResizing.value = false
|
||||
}, 100)
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
|
||||
// Закрытие по клику на overlay
|
||||
const onOverlayMouseDown = () => {
|
||||
overlayMouseDownTarget.value = true
|
||||
}
|
||||
|
||||
const onOverlayMouseUp = () => {
|
||||
if (overlayMouseDownTarget.value && !isResizing.value) {
|
||||
handleClose()
|
||||
}
|
||||
overlayMouseDownTarget.value = false
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
// Обновление иконок Lucide
|
||||
const refreshIcons = () => {
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
// Сброс ширины при открытии
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
panelWidth.value = props.width
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(refreshIcons)
|
||||
onUpdated(refreshIcons)
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.panel-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
background: #18181b;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -10px 0 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle:hover,
|
||||
.resize-handle:active {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 32px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-content :deep(h2) {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-content :deep(.header-date) {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-close i {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.panel-body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.panel-body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.panel-body::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.panel-body::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 32px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.panel-enter-active,
|
||||
.panel-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.panel-enter-active .panel,
|
||||
.panel-leave-active .panel {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.panel-enter-from,
|
||||
.panel-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.panel-enter-from .panel,
|
||||
.panel-leave-to .panel {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
</style>
|
||||
121
front_vue/src/components/ui/TagsSelect.vue
Normal file
121
front_vue/src/components/ui/TagsSelect.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div class="tags-select">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="tag-option"
|
||||
:class="{ active: isSelected(option.value) }"
|
||||
:style="{ '--tag-color': option.color || defaultColor }"
|
||||
@click="toggleOption(option.value)"
|
||||
>
|
||||
<span v-if="option.icon" class="tag-icon">{{ option.icon }}</span>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number, Array, null],
|
||||
default: null
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
// Формат: [{ value: 1, label: 'Name', color: '#00d4aa', icon: '🔥' }]
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
allowDeselect: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
defaultColor: {
|
||||
type: String,
|
||||
default: 'var(--accent)'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
// Проверка выбрана ли опция
|
||||
const isSelected = (value) => {
|
||||
if (props.multiple) {
|
||||
return Array.isArray(props.modelValue) && props.modelValue.includes(value)
|
||||
}
|
||||
return props.modelValue === value
|
||||
}
|
||||
|
||||
// Переключение опции
|
||||
const toggleOption = (value) => {
|
||||
let newValue
|
||||
|
||||
if (props.multiple) {
|
||||
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||
const index = current.indexOf(value)
|
||||
|
||||
if (index > -1) {
|
||||
if (props.allowDeselect) {
|
||||
current.splice(index, 1)
|
||||
}
|
||||
} else {
|
||||
current.push(value)
|
||||
}
|
||||
newValue = current
|
||||
} else {
|
||||
// Single select
|
||||
if (props.modelValue === value && props.allowDeselect) {
|
||||
newValue = null
|
||||
} else {
|
||||
newValue = value
|
||||
}
|
||||
}
|
||||
|
||||
emit('update:modelValue', newValue)
|
||||
emit('change', newValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tags-select {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tag-option {
|
||||
padding: 8px 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tag-option:hover {
|
||||
border-color: var(--tag-color);
|
||||
color: var(--tag-color);
|
||||
}
|
||||
|
||||
.tag-option.active {
|
||||
background: var(--tag-color);
|
||||
border-color: var(--tag-color);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.tag-icon {
|
||||
font-size: 14px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
68
front_vue/src/components/ui/TextInput.vue
Normal file
68
front_vue/src/components/ui/TextInput.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<input
|
||||
type="text"
|
||||
class="text-input"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
@focus="$emit('focus', $event)"
|
||||
@blur="$emit('blur', $event)"
|
||||
@keydown="$emit('keydown', $event)"
|
||||
>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue', 'focus', 'blur', 'keydown'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-input {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
height: 48px;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.15s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.text-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.text-input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -73,12 +73,13 @@
|
||||
@close="closePanel"
|
||||
@save="handleSaveTask"
|
||||
@delete="handleDeleteTask"
|
||||
@archive="handleArchiveTask"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import Sidebar from '../components/Sidebar.vue'
|
||||
import Header from '../components/Header.vue'
|
||||
import Board from '../components/Board.vue'
|
||||
@@ -86,7 +87,18 @@ import TaskPanel from '../components/TaskPanel.vue'
|
||||
import { departmentsApi, labelsApi, columnsApi, cardsApi } from '../api'
|
||||
|
||||
// Активный фильтр по отделу (null = все)
|
||||
const activeDepartment = ref(null)
|
||||
// Восстанавливаем из localStorage
|
||||
const savedDepartment = localStorage.getItem('activeDepartment')
|
||||
const activeDepartment = ref(savedDepartment ? parseInt(savedDepartment) : null)
|
||||
|
||||
// Сохраняем в localStorage при изменении
|
||||
watch(activeDepartment, (newVal) => {
|
||||
if (newVal === null) {
|
||||
localStorage.removeItem('activeDepartment')
|
||||
} else {
|
||||
localStorage.setItem('activeDepartment', newVal.toString())
|
||||
}
|
||||
})
|
||||
// Статистика для шапки
|
||||
const stats = ref({ total: 0, inProgress: 0, done: 0 })
|
||||
// Данные из API
|
||||
@@ -157,6 +169,12 @@ const handleDeleteTask = async (cardId) => {
|
||||
closePanel()
|
||||
}
|
||||
|
||||
// Архивировать задачу через Board компонент
|
||||
const handleArchiveTask = async (cardId) => {
|
||||
await boardRef.value?.archiveTask(cardId)
|
||||
closePanel()
|
||||
}
|
||||
|
||||
// Инициализация при монтировании
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
|
||||
Reference in New Issue
Block a user