EDMS/components/dms/FileUploadManager.vue
2025-06-05 14:57:08 +08:00

510 lines
15 KiB
Vue

<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useDmsStore } from '~/stores/dms';
const props = defineProps({
currentPath: {
type: String,
default: '/'
},
allowMultiple: {
type: Boolean,
default: true
},
maxFileSize: {
type: Number,
default: 100 * 1024 * 1024 // 100MB
},
allowedTypes: {
type: Array,
default: () => ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'jpg', 'png']
}
});
const emit = defineEmits(['upload-complete', 'upload-error', 'upload-progress']);
// Store
const dmsStore = useDmsStore();
// Upload state
const uploadQueue = ref([]);
const isUploading = ref(false);
const dragCounter = ref(0);
const isDragOver = ref(false);
const fileInput = ref(null);
// Upload progress tracking
const uploadProgress = ref({});
const overallProgress = computed(() => {
const files = Object.keys(uploadProgress.value);
if (files.length === 0) return 0;
const total = files.reduce((sum, fileId) => sum + uploadProgress.value[fileId], 0);
return Math.round(total / files.length);
});
// File validation
const validateFile = (file) => {
const errors = [];
// Check file size
if (file.size > props.maxFileSize) {
errors.push(`File size exceeds limit of ${formatFileSize(props.maxFileSize)}`);
}
// Check file type
const extension = file.name.split('.').pop().toLowerCase();
if (!props.allowedTypes.includes(extension)) {
errors.push(`File type .${extension} is not allowed`);
}
// Check user quota
if (dmsStore.userQuotas.used + file.size > dmsStore.userQuotas.limit) {
errors.push('Upload would exceed user storage quota');
}
return {
isValid: errors.length === 0,
errors
};
};
// Format file size
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Generate unique file ID
const generateFileId = () => {
return `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};
// Add files to upload queue
const addFilesToQueue = (files) => {
const newFiles = [];
for (const file of files) {
const validation = validateFile(file);
const fileId = generateFileId();
const uploadFile = {
id: fileId,
file,
name: file.name,
size: file.size,
type: file.type,
extension: file.name.split('.').pop().toLowerCase(),
status: validation.isValid ? 'pending' : 'error',
errors: validation.errors,
progress: 0,
metadata: {
title: file.name.split('.')[0],
description: '',
department: dmsStore.currentUser.department || '',
author: dmsStore.currentUser.name || '',
tags: [],
priority: 'Medium',
projectCode: '',
accessType: 'private'
}
};
newFiles.push(uploadFile);
uploadProgress.value[fileId] = 0;
}
uploadQueue.value = [...uploadQueue.value, ...newFiles];
return newFiles;
};
// Remove file from queue
const removeFromQueue = (fileId) => {
const index = uploadQueue.value.findIndex(f => f.id === fileId);
if (index > -1) {
uploadQueue.value.splice(index, 1);
delete uploadProgress.value[fileId];
}
};
// Clear completed uploads
const clearCompleted = () => {
uploadQueue.value = uploadQueue.value.filter(f =>
f.status !== 'completed' && f.status !== 'error'
);
};
// Upload single file
const uploadSingleFile = async (uploadFile) => {
try {
uploadFile.status = 'uploading';
// Simulate chunked upload with progress
const chunkSize = 1024 * 1024; // 1MB chunks
const totalChunks = Math.ceil(uploadFile.file.size / chunkSize);
for (let chunk = 0; chunk < totalChunks; chunk++) {
// Simulate chunk upload delay
await new Promise(resolve => setTimeout(resolve, 100));
const progress = Math.round(((chunk + 1) / totalChunks) * 100);
uploadProgress.value[uploadFile.id] = progress;
uploadFile.progress = progress;
emit('upload-progress', {
fileId: uploadFile.id,
progress,
overall: overallProgress.value
});
}
// Create document in store
const document = await dmsStore.uploadFileWithMetadata(
uploadFile.file,
uploadFile.metadata,
props.currentPath
);
uploadFile.status = 'completed';
uploadFile.documentId = document.id;
return document;
} catch (error) {
uploadFile.status = 'error';
uploadFile.errors = [error.message];
throw error;
}
};
// Upload all files in queue
const uploadAllFiles = async () => {
if (isUploading.value) return;
isUploading.value = true;
const pendingFiles = uploadQueue.value.filter(f => f.status === 'pending');
try {
const uploadPromises = pendingFiles.map(uploadSingleFile);
const results = await Promise.allSettled(uploadPromises);
const completed = results.filter(r => r.status === 'fulfilled').map(r => r.value);
const failed = results.filter(r => r.status === 'rejected');
emit('upload-complete', {
completed,
failed: failed.length,
total: pendingFiles.length
});
if (failed.length > 0) {
emit('upload-error', {
message: `${failed.length} file(s) failed to upload`,
failures: failed
});
}
} catch (error) {
emit('upload-error', { message: error.message });
} finally {
isUploading.value = false;
}
};
// Drag and drop handlers
const handleDragEnter = (e) => {
e.preventDefault();
dragCounter.value++;
isDragOver.value = true;
};
const handleDragLeave = (e) => {
e.preventDefault();
dragCounter.value--;
if (dragCounter.value === 0) {
isDragOver.value = false;
}
};
const handleDragOver = (e) => {
e.preventDefault();
};
const handleDrop = (e) => {
e.preventDefault();
dragCounter.value = 0;
isDragOver.value = false;
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
addFilesToQueue(files);
}
};
// File input handler
const handleFileInput = (e) => {
const files = Array.from(e.target.files);
if (files.length > 0) {
addFilesToQueue(files);
}
// Reset input
if (fileInput.value) {
fileInput.value.value = '';
}
};
// Open file dialog
const openFileDialog = () => {
if (fileInput.value) {
fileInput.value.click();
}
};
// Update metadata for a file
const updateFileMetadata = (fileId, metadata) => {
const file = uploadQueue.value.find(f => f.id === fileId);
if (file) {
file.metadata = { ...file.metadata, ...metadata };
}
};
// Get status color
const getStatusColor = (status) => {
switch (status) {
case 'pending': return 'blue';
case 'uploading': return 'yellow';
case 'completed': return 'green';
case 'error': return 'red';
default: return 'gray';
}
};
// Get status icon
const getStatusIcon = (status) => {
switch (status) {
case 'pending': return 'mdi:clock-outline';
case 'uploading': return 'mdi:upload';
case 'completed': return 'mdi:check-circle';
case 'error': return 'mdi:alert-circle';
default: return 'mdi:file';
}
};
// Watch for auto-upload
watch(() => uploadQueue.value.length, (newLength, oldLength) => {
if (newLength > oldLength && !isUploading.value) {
// Auto-start upload if there are pending files
const pendingFiles = uploadQueue.value.filter(f => f.status === 'pending');
if (pendingFiles.length > 0) {
uploadAllFiles();
}
}
});
</script>
<template>
<div class="file-upload-manager">
<!-- Hidden file input -->
<input
ref="fileInput"
type="file"
class="hidden"
:multiple="allowMultiple"
:accept="allowedTypes.map(ext => `.${ext}`).join(',')"
@change="handleFileInput"
/>
<!-- Drop zone -->
<div
class="drop-zone border-2 border-dashed rounded-lg p-8 text-center transition-colors"
:class="{
'border-blue-500 bg-blue-50 dark:bg-blue-900/10': isDragOver,
'border-gray-300 dark:border-gray-600': !isDragOver,
'hover:border-gray-400 dark:hover:border-gray-500': !isDragOver
}"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@dragover="handleDragOver"
@drop="handleDrop"
>
<div class="space-y-4">
<Icon
name="mdi:cloud-upload"
class="w-16 h-16 mx-auto text-gray-400"
:class="{ 'text-blue-500': isDragOver }"
/>
<div>
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">
Drop files here to upload
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
or
<button
@click="openFileDialog"
class="text-blue-600 hover:text-blue-700 underline"
>
browse to choose files
</button>
</p>
</div>
<div class="text-xs text-gray-400 dark:text-gray-500">
<p>Supported formats: {{ allowedTypes.join(', ').toUpperCase() }}</p>
<p>Maximum file size: {{ formatFileSize(maxFileSize) }}</p>
</div>
</div>
</div>
<!-- Upload queue -->
<div v-if="uploadQueue.length > 0" class="mt-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Upload Queue ({{ uploadQueue.length }})
</h3>
<div class="flex space-x-2">
<button
v-if="!isUploading"
@click="uploadAllFiles"
class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm"
:disabled="uploadQueue.filter(f => f.status === 'pending').length === 0"
>
Upload All
</button>
<button
@click="clearCompleted"
class="px-3 py-1 bg-gray-600 text-white rounded hover:bg-gray-700 text-sm"
>
Clear Completed
</button>
</div>
</div>
<!-- Overall progress -->
<div v-if="isUploading" class="mb-4">
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 mb-1">
<span>Overall Progress</span>
<span>{{ overallProgress }}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: `${overallProgress}%` }"
></div>
</div>
</div>
<!-- File list -->
<div class="space-y-3 max-h-96 overflow-y-auto">
<div
v-for="uploadFile in uploadQueue"
:key="uploadFile.id"
class="border border-gray-200 dark:border-gray-600 rounded-lg p-4"
>
<div class="flex items-start justify-between">
<div class="flex items-center space-x-3 flex-1">
<Icon
:name="getStatusIcon(uploadFile.status)"
class="w-5 h-5 flex-shrink-0"
:class="`text-${getStatusColor(uploadFile.status)}-500`"
/>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{{ uploadFile.name }}
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ formatFileSize(uploadFile.size) }} {{ uploadFile.extension.toUpperCase() }}
</p>
<!-- Progress bar -->
<div v-if="uploadFile.status === 'uploading'" class="mt-2">
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1">
<div
class="bg-blue-600 h-1 rounded-full transition-all duration-300"
:style="{ width: `${uploadFile.progress}%` }"
></div>
</div>
</div>
<!-- Errors -->
<div v-if="uploadFile.errors.length > 0" class="mt-2">
<p class="text-xs text-red-600 dark:text-red-400">
{{ uploadFile.errors.join(', ') }}
</p>
</div>
</div>
</div>
<button
v-if="uploadFile.status !== 'uploading'"
@click="removeFromQueue(uploadFile.id)"
class="ml-2 p-1 text-gray-400 hover:text-red-600"
>
<Icon name="mdi:close" class="w-4 h-4" />
</button>
</div>
<!-- Metadata form for pending files -->
<div v-if="uploadFile.status === 'pending'" class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Title
</label>
<input
v-model="uploadFile.metadata.title"
type="text"
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Department
</label>
<select
v-model="uploadFile.metadata.department"
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
>
<option value="">Select Department</option>
<option value="HR">HR Department</option>
<option value="Finance">Finance</option>
<option value="IT">IT</option>
<option value="Legal">Legal</option>
</select>
</div>
<div class="md:col-span-2">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea
v-model="uploadFile.metadata.description"
rows="2"
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
placeholder="Brief description of the document..."
></textarea>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.drop-zone {
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
</style>