448 lines
10 KiB
Vue
448 lines
10 KiB
Vue
<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>
|