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

543 lines
20 KiB
Vue

<script setup>
import { ref, computed, watch } from 'vue';
import { useDmsStore } from '~/stores/dms';
const props = defineProps({
visible: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['close', 'search']);
// Store
const dmsStore = useDmsStore();
// Search state
const searchQuery = ref('');
const isLoading = ref(false);
const searchResults = ref([]);
const hasSearched = ref(false);
// Advanced filters
const filters = ref({
fileType: [],
department: [],
tags: [],
priority: [],
author: [],
dateRange: { start: null, end: null },
hasVersions: false,
accessType: []
});
// Filter options
const fileTypeOptions = computed(() => {
const types = new Set();
dmsStore.items.filter(item => item.type === 'file').forEach(item => {
if (item.extension) types.add(item.extension);
});
return Array.from(types).map(type => ({ value: type, label: type.toUpperCase() }));
});
const departmentOptions = computed(() => {
const departments = new Set();
dmsStore.items.forEach(item => {
if (item.department) departments.add(item.department);
});
return Array.from(departments).map(dept => ({ value: dept, label: dept }));
});
const priorityOptions = computed(() => [
{ value: 'Low', label: 'Low' },
{ value: 'Medium', label: 'Medium' },
{ value: 'High', label: 'High' },
{ value: 'Critical', label: 'Critical' }
]);
const authorOptions = computed(() => {
const authors = new Set();
dmsStore.items.forEach(item => {
if (item.author) authors.add(item.author);
});
return Array.from(authors).map(author => ({ value: author, label: author }));
});
const accessTypeOptions = computed(() => [
{ value: 'public', label: 'Public' },
{ value: 'private', label: 'Private' },
{ value: 'personal', label: 'Personal' }
]);
const availableTags = computed(() => {
const tags = new Set();
dmsStore.items.forEach(item => {
if (item.tags) {
item.tags.forEach(tag => tags.add(tag));
}
});
return Array.from(tags);
});
// Methods
const performSearch = async () => {
isLoading.value = true;
hasSearched.value = true;
try {
const results = await dmsStore.advancedSearch(searchQuery.value, filters.value);
searchResults.value = results;
emit('search', { query: searchQuery.value, filters: filters.value, results });
} catch (error) {
console.error('Search failed:', error);
searchResults.value = [];
} finally {
isLoading.value = false;
}
};
const clearSearch = () => {
searchQuery.value = '';
filters.value = {
fileType: [],
department: [],
tags: [],
priority: [],
author: [],
dateRange: { start: null, end: null },
hasVersions: false,
accessType: []
};
searchResults.value = [];
hasSearched.value = false;
dmsStore.clearSearch();
};
const closeDialog = () => {
emit('close');
};
const selectDocument = (document) => {
dmsStore.selectItem(document);
closeDialog();
};
const formatFileSize = (size) => {
return dmsStore.formatFileSize(size);
};
const getFileIcon = (extension) => {
const iconMap = {
pdf: 'text-red-500',
doc: 'text-blue-500',
docx: 'text-blue-500',
xls: 'text-green-500',
xlsx: 'text-green-500',
ppt: 'text-orange-500',
pptx: 'text-orange-500',
jpg: 'text-purple-500',
jpeg: 'text-purple-500',
png: 'text-purple-500'
};
return iconMap[extension] || 'text-gray-500';
};
// Tag input handling
const tagInput = ref('');
const addTag = () => {
const tag = tagInput.value.trim();
if (tag && !filters.value.tags.includes(tag)) {
filters.value.tags.push(tag);
tagInput.value = '';
}
};
const removeTag = (index) => {
filters.value.tags.splice(index, 1);
};
// Quick search presets
const applyQuickFilter = (type) => {
clearSearch();
switch (type) {
case 'recent':
// Documents modified in last 7 days
const lastWeek = new Date();
lastWeek.setDate(lastWeek.getDate() - 7);
filters.value.dateRange.start = lastWeek.toISOString().split('T')[0];
break;
case 'confidential':
filters.value.tags = ['confidential'];
break;
case 'contracts':
filters.value.tags = ['contract'];
filters.value.department = ['Legal'];
break;
case 'versioned':
filters.value.hasVersions = true;
break;
}
performSearch();
};
// Auto-search when Enter is pressed
const handleKeyup = (event) => {
if (event.key === 'Enter') {
performSearch();
}
};
// File size conversion helper
const fileSizeToNumber = (sizeStr) => {
if (typeof sizeStr === 'number') return sizeStr;
const match = sizeStr.match(/^([\d.]+)\s*(B|KB|MB|GB)$/i);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = match[2].toUpperCase();
switch (unit) {
case 'GB': return value * 1024 * 1024 * 1024;
case 'MB': return value * 1024 * 1024;
case 'KB': return value * 1024;
default: return value;
}
};
</script>
<template>
<rs-modal :visible="visible" @close="closeDialog" size="5xl">
<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">
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2"/>
<path d="M21 21l-4.35-4.35" stroke="currentColor" stroke-width="2"/>
</svg>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Advanced Search</h3>
</div>
</template>
<template #body>
<div class="space-y-6">
<!-- Search Input -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Search Query
</label>
<div class="relative">
<input
v-model="searchQuery"
@keyup="handleKeyup"
type="text"
class="w-full pl-10 pr-4 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"
placeholder="Search documents, descriptions, authors..."
/>
<svg class="absolute left-3 top-2.5 h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
<!-- Quick Filters -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Quick Filters
</label>
<div class="flex flex-wrap gap-2">
<button @click="applyQuickFilter('recent')"
class="px-3 py-1 text-sm bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full hover:bg-blue-200 dark:hover:bg-blue-900/50">
Recent (7 days)
</button>
<button @click="applyQuickFilter('confidential')"
class="px-3 py-1 text-sm bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full hover:bg-red-200 dark:hover:bg-red-900/50">
Confidential
</button>
<button @click="applyQuickFilter('contracts')"
class="px-3 py-1 text-sm bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full hover:bg-green-200 dark:hover:bg-green-900/50">
Contracts
</button>
<button @click="applyQuickFilter('versioned')"
class="px-3 py-1 text-sm bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full hover:bg-purple-200 dark:hover:bg-purple-900/50">
Versioned Documents
</button>
</div>
</div>
<!-- Advanced Filters -->
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 space-y-4">
<h4 class="text-md font-semibold text-gray-900 dark:text-gray-100">Advanced Filters</h4>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- File Type Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
File Type
</label>
<select v-model="filters.fileType" multiple
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 v-for="option in fileTypeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<!-- Department Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Department
</label>
<select v-model="filters.department" multiple
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 v-for="option in departmentOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<!-- Priority Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Priority
</label>
<select v-model="filters.priority" multiple
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 v-for="option in priorityOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<!-- Author Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Author
</label>
<select v-model="filters.author" multiple
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 v-for="option in authorOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<!-- Access Type Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Access Type
</label>
<select v-model="filters.accessType" multiple
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 v-for="option in accessTypeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<!-- Has Versions Filter -->
<div class="flex items-center">
<label class="flex items-center space-x-2 text-sm font-medium text-gray-700 dark:text-gray-300">
<input type="checkbox" v-model="filters.hasVersions"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span>Has Multiple Versions</span>
</label>
</div>
</div>
<!-- Date Range Filter -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
From Date
</label>
<input v-model="filters.dateRange.start" type="date"
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-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
To Date
</label>
<input v-model="filters.dateRange.end" type="date"
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>
<!-- Tags Filter -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tags
</label>
<div class="space-y-2">
<!-- Tag Input -->
<div class="flex space-x-2">
<input v-model="tagInput"
@keyup.enter="addTag"
type="text"
class="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700"
placeholder="Type tag and press Enter">
<button @click="addTag"
class="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700">
Add
</button>
</div>
<!-- Selected Tags -->
<div v-if="filters.tags.length > 0" class="flex flex-wrap gap-2">
<span v-for="(tag, index) in filters.tags" :key="index"
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(index)"
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>
<!-- Available Tags -->
<div class="text-xs text-gray-500 dark:text-gray-400">
Available tags:
<button v-for="tag in availableTags.slice(0, 10)" :key="tag"
@click="filters.tags.includes(tag) ? null : filters.tags.push(tag)"
class="mx-1 px-1 hover:text-blue-600 dark:hover:text-blue-400"
:class="filters.tags.includes(tag) ? 'text-blue-600 dark:text-blue-400' : ''">
{{ tag }}
</button>
</div>
</div>
</div>
</div>
<!-- Search Results -->
<div v-if="hasSearched" class="space-y-4">
<div class="flex justify-between items-center">
<h4 class="text-md font-semibold text-gray-900 dark:text-gray-100">
Search Results ({{ searchResults.length }})
</h4>
<button @click="clearSearch"
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
Clear Results
</button>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<div class="flex items-center space-x-2">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<span class="text-gray-500">Searching...</span>
</div>
</div>
<!-- Results List -->
<div v-else-if="searchResults.length > 0" class="max-h-96 overflow-y-auto space-y-2">
<div v-for="document in searchResults" :key="document.id"
@click="selectDocument(document)"
class="p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer">
<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"
:class="getFileIcon(document.extension)">
<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 class="flex-1 min-w-0">
<h5 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{{ document.name }}
</h5>
<div class="flex items-center space-x-4 text-xs text-gray-500 dark:text-gray-400">
<span>{{ document.size }}</span>
<span v-if="document.department">{{ document.department }}</span>
<span v-if="document.author">{{ document.author }}</span>
<span>{{ document.modified }}</span>
<span v-if="document.version > 1" class="bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 px-1 rounded">
v{{ document.version }}
</span>
</div>
<div v-if="document.description" class="text-xs text-gray-600 dark:text-gray-400 mt-1 truncate">
{{ document.description }}
</div>
<div v-if="document.tags && document.tags.length > 0" class="flex flex-wrap gap-1 mt-1">
<span v-for="tag in document.tags.slice(0, 3)" :key="tag"
class="inline-block px-1 py-0.5 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded">
{{ tag }}
</span>
<span v-if="document.tags.length > 3" class="text-xs text-gray-500">
+{{ document.tags.length - 3 }} more
</span>
</div>
</div>
</div>
</div>
</div>
<!-- No Results -->
<div v-else class="text-center py-8">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">No documents found</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Try adjusting your search criteria</p>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-between items-center">
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ dmsStore.items.filter(item => item.type === 'file').length }} total documents
</div>
<div class="flex space-x-3">
<rs-button @click="clearSearch" variant="secondary">
Clear
</rs-button>
<rs-button @click="performSearch" :disabled="isLoading">
<svg v-if="isLoading" 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>
{{ isLoading ? 'Searching...' : 'Search' }}
</rs-button>
</div>
</div>
</template>
</rs-modal>
</template>
<style scoped>
/* Multi-select styling */
select[multiple] {
height: auto;
min-height: 2.5rem;
}
select[multiple] option {
padding: 0.25rem 0.5rem;
}
select[multiple] option:checked {
background: linear-gradient(#3b82f6, #3b82f6);
color: white;
}
/* 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>