EDMS/components/dms/dialogs/DMSUploadDialog.vue
2025-05-30 21:08:11 +08:00

479 lines
19 KiB
Vue

<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useDmsStore } from '~/stores/dms';
const props = defineProps({
visible: {
type: Boolean,
default: false
},
currentPath: {
type: String,
default: '/'
}
});
const emit = defineEmits(['close', 'upload']);
// Store
const dmsStore = useDmsStore();
// Local state
const isUploading = ref(false);
const uploadProgress = ref(0);
const selectedFiles = ref([]);
const dragCounter = ref(0);
const isDragOver = ref(false);
const metadataTemplate = ref('standard');
const validationErrors = ref({});
// File metadata - using enhanced system
const fileMetadata = ref({});
// Get metadata template options
const templateOptions = computed(() => [
{ value: 'standard', label: 'Standard Document' },
{ value: 'contract', label: 'Contract/Agreement' },
{ value: 'report', label: 'Report/Analysis' }
]);
// Get custom fields from settings
const customFields = computed(() => {
return dmsStore.systemSettings.metadata.customFields || [];
});
// Get tag suggestions
const tagSuggestions = ref([]);
// Methods
const openFileDialog = () => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = dmsStore.systemSettings.upload.allowedFileTypes.map(ext => `.${ext}`).join(',');
input.onchange = (e) => handleFiles(Array.from(e.target.files));
input.click();
};
const handleFiles = (files) => {
const validFiles = [];
const errors = {};
files.forEach(file => {
const validation = dmsStore.validateFile(file);
if (validation.isValid) {
validFiles.push(file);
// Initialize metadata for each file
const template = dmsStore.metadataTemplates[metadataTemplate.value];
fileMetadata.value[file.name] = {
...template,
title: file.name.split('.')[0],
author: 'Current User' // Get from auth store
};
} else {
errors[file.name] = validation.errors;
}
});
selectedFiles.value = [...selectedFiles.value, ...validFiles];
validationErrors.value = { ...validationErrors.value, ...errors };
};
const removeFile = (index) => {
const file = selectedFiles.value[index];
delete fileMetadata.value[file.name];
delete validationErrors.value[file.name];
selectedFiles.value.splice(index, 1);
};
const validateMetadata = () => {
const errors = {};
selectedFiles.value.forEach(file => {
const metadata = fileMetadata.value[file.name];
const validation = dmsStore.validateMetadata(metadata, metadataTemplate.value);
if (!validation.isValid) {
errors[file.name] = validation.errors;
}
});
validationErrors.value = { ...validationErrors.value, ...errors };
return Object.keys(errors).length === 0;
};
const uploadFiles = async () => {
if (!validateMetadata()) {
return;
}
isUploading.value = true;
uploadProgress.value = 0;
try {
for (let i = 0; i < selectedFiles.value.length; i++) {
const file = selectedFiles.value[i];
const metadata = fileMetadata.value[file.name];
// Upload with enhanced metadata
await dmsStore.uploadFileWithMetadata(file, metadata, props.currentPath);
uploadProgress.value = Math.round(((i + 1) / selectedFiles.value.length) * 100);
}
emit('upload', selectedFiles.value);
closeDialog();
} catch (error) {
console.error('Upload failed:', error);
// Show error to user
} finally {
isUploading.value = false;
}
};
const closeDialog = () => {
selectedFiles.value = [];
fileMetadata.value = {};
validationErrors.value = {};
uploadProgress.value = 0;
emit('close');
};
// 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);
handleFiles(files);
};
// Tag input handling
const handleTagInput = (fileName, input) => {
if (input.length > 2) {
tagSuggestions.value = dmsStore.getTagSuggestions(input);
} else {
tagSuggestions.value = [];
}
};
const addTag = (fileName, tag) => {
if (!fileMetadata.value[fileName].tags.includes(tag)) {
fileMetadata.value[fileName].tags.push(tag);
}
tagSuggestions.value = [];
};
const removeTag = (fileName, tagIndex) => {
fileMetadata.value[fileName].tags.splice(tagIndex, 1);
};
// Watch for template changes
watch(metadataTemplate, (newTemplate) => {
selectedFiles.value.forEach(file => {
const currentMetadata = fileMetadata.value[file.name] || {};
const template = dmsStore.metadataTemplates[newTemplate];
// Merge current values with new template
fileMetadata.value[file.name] = {
...template,
...currentMetadata,
title: currentMetadata.title || file.name.split('.')[0]
};
});
});
// Format file size
const formatFileSize = (bytes) => {
return dmsStore.formatFileSize(bytes);
};
// Get field component type
const getFieldComponent = (fieldType) => {
switch (fieldType) {
case 'dropdown':
case 'select':
return 'select';
case 'date':
return 'date';
case 'number':
return 'number';
default:
return 'text';
}
};
</script>
<template>
<rs-modal :visible="visible" @close="closeDialog" size="4xl">
<template #header>
<div class="flex items-center space-x-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="10,9 9,9 8,9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Upload Documents</h3>
</div>
</template>
<template #body>
<div class="space-y-6">
<!-- Template Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Document Template
</label>
<select v-model="metadataTemplate"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
<option v-for="template in templateOptions" :key="template.value" :value="template.value">
{{ template.label }}
</option>
</select>
</div>
<!-- File Upload Area -->
<div class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-8 text-center"
:class="{
'border-blue-500 bg-blue-50 dark:bg-blue-900/10': isDragOver,
'hover:border-gray-400': !isDragOver
}"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@dragover="handleDragOver"
@drop="handleDrop">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div v-if="isDragOver" class="text-blue-600 dark:text-blue-400">
<p class="text-lg font-medium">Drop files here</p>
</div>
<div v-else>
<p class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
Drag and drop files here, or
<button @click="openFileDialog" class="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300">
browse
</button>
</p>
<p class="text-sm text-gray-500">
Supported: {{ dmsStore.systemSettings.upload.allowedFileTypes.join(', ').toUpperCase() }}
</p>
<p class="text-sm text-gray-500">
Max size: {{ Math.round(dmsStore.systemSettings.upload.maxFileSize / (1024 * 1024)) }}MB per file
</p>
</div>
</div>
<!-- Selected Files -->
<div v-if="selectedFiles.length > 0" class="space-y-4">
<h4 class="text-md font-semibold text-gray-900 dark:text-gray-100">
Selected Files ({{ selectedFiles.length }})
</h4>
<div class="space-y-4 max-h-96 overflow-y-auto">
<div v-for="(file, index) in selectedFiles" :key="file.name"
class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<!-- File Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-3">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z" stroke="currentColor" stroke-width="2"/>
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2"/>
</svg>
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">{{ file.name }}</p>
<p class="text-sm text-gray-500">{{ formatFileSize(file.size) }}</p>
</div>
</div>
<button @click="removeFile(index)"
class="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<!-- Validation Errors -->
<div v-if="validationErrors[file.name]" class="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
<p class="text-sm font-medium text-red-800 dark:text-red-200 mb-1">Validation Errors:</p>
<ul class="text-sm text-red-700 dark:text-red-300 list-disc list-inside">
<li v-for="error in validationErrors[file.name]" :key="error">{{ error }}</li>
</ul>
</div>
<!-- Metadata Form -->
<div v-if="fileMetadata[file.name]" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Dynamic Custom Fields -->
<div v-for="field in customFields" :key="field.name"
:class="field.type === 'text' && field.name === 'Description' ? 'md:col-span-2' : ''">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ field.name }}
<span v-if="field.required" class="text-red-500">*</span>
</label>
<!-- Dropdown/Select Field -->
<select v-if="field.type === 'dropdown' || field.type === 'select'"
v-model="fileMetadata[file.name][field.name]"
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-1 focus:ring-blue-500 dark:bg-gray-700">
<option value="">Select {{ field.name }}</option>
<option v-for="option in field.options" :key="option" :value="option">
{{ option }}
</option>
</select>
<!-- Date Field -->
<input v-else-if="field.type === 'date'"
v-model="fileMetadata[file.name][field.name]"
type="date"
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-1 focus:ring-blue-500 dark:bg-gray-700">
<!-- Number Field -->
<input v-else-if="field.type === 'number'"
v-model="fileMetadata[file.name][field.name]"
type="number"
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-1 focus:ring-blue-500 dark:bg-gray-700">
<!-- Text Field -->
<input v-else
v-model="fileMetadata[file.name][field.name]"
type="text"
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-1 focus:ring-blue-500 dark:bg-gray-700"
:placeholder="`Enter ${field.name.toLowerCase()}`">
</div>
<!-- Description -->
<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="fileMetadata[file.name].description"
rows="2"
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-1 focus:ring-blue-500 dark:bg-gray-700"
placeholder="Brief description of the document"></textarea>
</div>
<!-- Tags -->
<div class="md:col-span-2">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Tags</label>
<div class="space-y-2">
<!-- Tag Input -->
<div class="relative">
<input type="text"
@input="handleTagInput(file.name, $event.target.value)"
@keyup.enter="addTag(file.name, $event.target.value); $event.target.value = ''"
class="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:ring-1 focus:ring-blue-500 dark:bg-gray-700"
placeholder="Type and press Enter to add tags">
<!-- Tag Suggestions -->
<div v-if="tagSuggestions.length > 0"
class="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md shadow-lg max-h-40 overflow-y-auto">
<button v-for="suggestion in tagSuggestions" :key="suggestion"
@click="addTag(file.name, suggestion)"
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 focus:bg-gray-100 dark:focus:bg-gray-700">
{{ suggestion }}
</button>
</div>
</div>
<!-- Selected Tags -->
<div v-if="fileMetadata[file.name].tags && fileMetadata[file.name].tags.length > 0"
class="flex flex-wrap gap-2">
<span v-for="(tag, tagIndex) in fileMetadata[file.name].tags" :key="tagIndex"
class="inline-flex items-center px-2 py-1 text-xs bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">
{{ tag }}
<button @click="removeTag(file.name, tagIndex)"
class="ml-1 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Upload Progress -->
<div v-if="isUploading" class="space-y-2">
<div class="flex justify-between text-sm text-gray-600 dark:text-gray-400">
<span>Uploading...</span>
<span>{{ uploadProgress }}%</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: `${uploadProgress}%` }"></div>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-between items-center">
<div class="text-sm text-gray-500">
Storage: {{ Math.round(dmsStore.userQuotas.used / (1024 * 1024)) }}MB /
{{ Math.round(dmsStore.userQuotas.limit / (1024 * 1024)) }}MB used
</div>
<div class="flex space-x-3">
<rs-button @click="closeDialog" variant="secondary" :disabled="isUploading">
Cancel
</rs-button>
<rs-button @click="uploadFiles"
:disabled="selectedFiles.length === 0 || isUploading || Object.keys(validationErrors).length > 0">
<svg v-if="isUploading" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ isUploading ? 'Uploading...' : `Upload ${selectedFiles.length} File${selectedFiles.length !== 1 ? 's' : ''}` }}
</rs-button>
</div>
</div>
</template>
</rs-modal>
</template>
<style scoped>
/* Enhanced scrollbar styling */
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
</style>