Добавление в Архив + Фронт
1. Переписал модуль выпадающего слева меню 2. Добавил механику Архивации задач 3. Запоминания выбранного отдела
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user