1
0

Добавление в Архив + Фронт

1. Переписал модуль выпадающего слева меню
2. Добавил механику Архивации задач
3. Запоминания выбранного отдела
This commit is contained in:
2026-01-13 07:04:10 +07:00
parent 6688b8e37c
commit 44b6e636d4
17 changed files with 2434 additions and 1594 deletions

View 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>