Добавление в Архив + Фронт
1. Переписал модуль выпадающего слева меню 2. Добавил механику Архивации задач 3. Запоминания выбранного отдела
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,4 +9,5 @@ backend/public/*
|
|||||||
|
|
||||||
# Личные файлы
|
# Личные файлы
|
||||||
deploy.js
|
deploy.js
|
||||||
deploy.php
|
deploy.php
|
||||||
|
.vscode
|
||||||
@@ -95,6 +95,14 @@ if ($method === 'POST') {
|
|||||||
RestApi::response($result);
|
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) {
|
if (!$action) {
|
||||||
RestApi::response(['success' => false, 'error' => 'Укажите метод'], 400);
|
RestApi::response(['success' => false, 'error' => 'Укажите метод'], 400);
|
||||||
@@ -103,8 +111,16 @@ if ($method === 'POST') {
|
|||||||
|
|
||||||
if ($method === 'GET') {
|
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();
|
$task = new Task();
|
||||||
$tasks = $task->getAll();
|
$tasks = $task->getAll($archive);
|
||||||
|
|
||||||
RestApi::response(['success' => true, 'data' => $tasks]);
|
RestApi::response(['success' => true, 'data' => $tasks]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class Task extends BaseEntity {
|
|||||||
public $title;
|
public $title;
|
||||||
public $descript;
|
public $descript;
|
||||||
public $descript_full;
|
public $descript_full;
|
||||||
|
public $archive;
|
||||||
|
|
||||||
// Валидация данных
|
// Валидация данных
|
||||||
protected function validate() {
|
protected function validate() {
|
||||||
@@ -65,6 +66,7 @@ class Task extends BaseEntity {
|
|||||||
'title' => $this->title,
|
'title' => $this->title,
|
||||||
'descript' => $this->descript ?: null,
|
'descript' => $this->descript ?: null,
|
||||||
'descript_full' => $this->descript_full ?: null,
|
'descript_full' => $this->descript_full ?: null,
|
||||||
|
'archive' => 0,
|
||||||
'date_create' => date('Y-m-d H:i:s'),
|
'date_create' => date('Y-m-d H:i:s'),
|
||||||
'file_img' => '[]'
|
'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, [
|
$tasks = Database::select($this->db_name, [
|
||||||
'id',
|
'id',
|
||||||
'id_department',
|
'id_department',
|
||||||
@@ -188,8 +196,9 @@ class Task extends BaseEntity {
|
|||||||
'file_img',
|
'file_img',
|
||||||
'title',
|
'title',
|
||||||
'descript',
|
'descript',
|
||||||
'descript_full'
|
'descript_full',
|
||||||
]);
|
'archive'
|
||||||
|
], $where);
|
||||||
|
|
||||||
// Декодируем JSON и получаем avatar_url из accounts
|
// Декодируем JSON и получаем avatar_url из accounts
|
||||||
return array_map(function($task) {
|
return array_map(function($task) {
|
||||||
@@ -243,6 +252,29 @@ class Task extends BaseEntity {
|
|||||||
}
|
}
|
||||||
return $task;
|
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 ====================
|
// ==================== CARDS ====================
|
||||||
export const cardsApi = {
|
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', {
|
updateOrder: (id, column_id, to_index) => request('/api/task', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
@@ -94,6 +95,12 @@ export const cardsApi = {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ action: 'delete', id })
|
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"
|
@drop-card="handleDropCard"
|
||||||
@open-task="(card) => emit('open-task', { card, columnId: column.id })"
|
@open-task="(card) => emit('open-task', { card, columnId: column.id })"
|
||||||
@create-task="emit('create-task', column.id)"
|
@create-task="emit('create-task', column.id)"
|
||||||
|
@archive-task="archiveTask"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -19,6 +19,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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 + ' изображений'">
|
<span v-if="card.files && card.files.length" class="files-indicator" :title="card.files.length + ' изображений'">
|
||||||
<i data-lucide="image-plus"></i>
|
<i data-lucide="image-plus"></i>
|
||||||
</span>
|
</span>
|
||||||
@@ -69,7 +77,7 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// defineEmits(['delete']) - будет при раскрытии карточки
|
const emit = defineEmits(['archive'])
|
||||||
|
|
||||||
const refreshIcons = () => {
|
const refreshIcons = () => {
|
||||||
if (window.lucide) {
|
if (window.lucide) {
|
||||||
@@ -146,6 +154,15 @@ const daysLeftText = computed(() => {
|
|||||||
const isAvatarUrl = (value) => {
|
const isAvatarUrl = (value) => {
|
||||||
return value && (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/'))
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -279,4 +296,33 @@ const isAvatarUrl = (value) => {
|
|||||||
.due-date.overdue {
|
.due-date.overdue {
|
||||||
color: var(--red);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
:departments="departments"
|
:departments="departments"
|
||||||
:labels="labels"
|
:labels="labels"
|
||||||
@click="emit('open-task', card)"
|
@click="emit('open-task', card)"
|
||||||
|
@archive="emit('archive-task', $event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</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 = () => {
|
const refreshIcons = () => {
|
||||||
if (window.lucide) {
|
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"
|
@close="closePanel"
|
||||||
@save="handleSaveTask"
|
@save="handleSaveTask"
|
||||||
@delete="handleDeleteTask"
|
@delete="handleDeleteTask"
|
||||||
|
@archive="handleArchiveTask"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, watch, onMounted } from 'vue'
|
||||||
import Sidebar from '../components/Sidebar.vue'
|
import Sidebar from '../components/Sidebar.vue'
|
||||||
import Header from '../components/Header.vue'
|
import Header from '../components/Header.vue'
|
||||||
import Board from '../components/Board.vue'
|
import Board from '../components/Board.vue'
|
||||||
@@ -86,7 +87,18 @@ import TaskPanel from '../components/TaskPanel.vue'
|
|||||||
import { departmentsApi, labelsApi, columnsApi, cardsApi } from '../api'
|
import { departmentsApi, labelsApi, columnsApi, cardsApi } from '../api'
|
||||||
|
|
||||||
// Активный фильтр по отделу (null = все)
|
// Активный фильтр по отделу (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 })
|
const stats = ref({ total: 0, inProgress: 0, done: 0 })
|
||||||
// Данные из API
|
// Данные из API
|
||||||
@@ -157,6 +169,12 @@ const handleDeleteTask = async (cardId) => {
|
|||||||
closePanel()
|
closePanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Архивировать задачу через Board компонент
|
||||||
|
const handleArchiveTask = async (cardId) => {
|
||||||
|
await boardRef.value?.archiveTask(cardId)
|
||||||
|
closePanel()
|
||||||
|
}
|
||||||
|
|
||||||
// Инициализация при монтировании
|
// Инициализация при монтировании
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
|
|||||||
Reference in New Issue
Block a user