1
0
Files
TaskBoard/front_vue/src/components/ui/FileUploader.vue
Falknat 3258fa9137 Исправления фронта
Множество оптимизаций по фронту
2026-01-16 10:15:33 +07:00

448 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 } from 'vue'
import { useLucideIcons } from '../../composables/useLucideIcons'
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'])
useLucideIcons()
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')
}
}
// Экспортируем методы
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>