EDMS/components/dms/viewers/DMSDocumentViewer.vue
2025-05-30 17:45:37 +08:00

369 lines
17 KiB
Vue

<script setup>
import { ref, computed, onMounted, watch } from 'vue';
const props = defineProps({
document: {
type: Object,
required: true
},
visible: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['close', 'download', 'print', 'edit']);
// Local state
const isLoading = ref(true);
const zoomLevel = ref(100);
const currentPage = ref(1);
const totalPages = ref(1);
const isFullscreen = ref(false);
const previewMode = ref('fit-width'); // fit-width, fit-page, actual-size
// Computed properties
const zoomOptions = computed(() => [
{ label: '50%', value: 50 },
{ label: '75%', value: 75 },
{ label: '100%', value: 100 },
{ label: '125%', value: 125 },
{ label: '150%', value: 150 },
{ label: '200%', value: 200 },
{ label: 'Fit Width', value: 'fit-width' },
{ label: 'Fit Page', value: 'fit-page' }
]);
const canPreview = computed(() => {
if (!props.document) return false;
const ext = props.document.extension?.toLowerCase();
return ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'txt', 'doc', 'docx', 'xls', 'xlsx'].includes(ext);
});
const documentIcon = computed(() => {
if (!props.document) return 'file';
const ext = props.document.extension?.toLowerCase();
switch (ext) {
case 'pdf': return 'pdf';
case 'doc':
case 'docx': return 'doc';
case 'xls':
case 'xlsx': return 'excel';
case 'ppt':
case 'pptx': return 'powerpoint';
case 'jpg':
case 'jpeg':
case 'png':
case 'gif': return 'image';
case 'txt': return 'text';
default: return 'file';
}
});
// Methods
const closeViewer = () => {
emit('close');
};
const downloadDocument = () => {
emit('download', props.document);
};
const printDocument = () => {
emit('print', props.document);
};
const editDocument = () => {
emit('edit', props.document);
};
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value;
if (isFullscreen.value) {
document.documentElement.requestFullscreen?.();
} else {
document.exitFullscreen?.();
}
};
const zoomIn = () => {
if (zoomLevel.value < 400) {
zoomLevel.value += 25;
}
};
const zoomOut = () => {
if (zoomLevel.value > 25) {
zoomLevel.value -= 25;
}
};
const setZoom = (value) => {
if (typeof value === 'string') {
previewMode.value = value;
} else {
zoomLevel.value = value;
previewMode.value = 'custom';
}
};
const previousPage = () => {
if (currentPage.value > 1) {
currentPage.value--;
}
};
const nextPage = () => {
if (currentPage.value < totalPages.value) {
currentPage.value++;
}
};
const getSvgIcon = (iconType, size = 24) => {
const icons = {
pdf: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><path d="M16 13a2 2 0 0 1-2 2h-2v4"></path><path d="M12 17v-4h2a2 2 0 0 1 2 2z"></path></svg>`,
doc: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><line x1="10" y1="9" x2="8" y2="9"></line></svg>`,
excel: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><rect x="8" y="12" width="8" height="6"></rect><line x1="8" y1="16" x2="16" y2="16"></line><line x1="12" y1="12" x2="12" y2="18"></line></svg>`,
powerpoint: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><rect x="9" y="12" width="6" height="6"></rect><circle cx="12" cy="15" r="1"></circle></svg>`,
image: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>`,
text: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>`,
file: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
close: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
download: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
print: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>`,
edit: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>`,
fullscreen: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path></svg>`,
zoomIn: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><path d="M21 21l-4.35-4.35"></path><line x1="11" y1="8" x2="11" y2="14"></line><line x1="8" y1="11" x2="14" y2="11"></line></svg>`,
zoomOut: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><path d="M21 21l-4.35-4.35"></path><line x1="8" y1="11" x2="14" y2="11"></line></svg>`,
chevronLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>`,
chevronRight: `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>`
};
return icons[iconType] || icons.file;
};
// Simulate loading
watch(() => props.document, (newDoc) => {
if (newDoc) {
isLoading.value = true;
setTimeout(() => {
isLoading.value = false;
totalPages.value = Math.floor(Math.random() * 20) + 1; // Random page count for demo
}, 1000);
}
}, { immediate: true });
onMounted(() => {
// Set up document loading simulation
if (props.document) {
setTimeout(() => {
isLoading.value = false;
}, 1000);
}
});
</script>
<template>
<div v-if="visible" class="document-viewer fixed inset-0 z-50 bg-black bg-opacity-75 flex items-center justify-center">
<div class="viewer-container bg-white dark:bg-gray-900 rounded-lg shadow-2xl max-w-7xl max-h-[95vh] w-full mx-4 flex flex-col">
<!-- Header with toolbar -->
<div class="viewer-header border-b border-gray-200 dark:border-gray-700 p-3">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<span v-html="getSvgIcon(documentIcon, 24)" class="text-blue-600"></span>
<div>
<h3 class="text-lg font-semibold">{{ document?.name }}</h3>
<p class="text-sm text-gray-500">{{ document?.extension?.toUpperCase() }} {{ document?.size }}</p>
</div>
</div>
<div class="flex items-center space-x-2">
<!-- Zoom Controls -->
<div class="flex items-center space-x-1 border border-gray-300 dark:border-gray-600 rounded">
<button
@click="zoomOut"
class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-l"
:disabled="zoomLevel <= 25"
>
<span v-html="getSvgIcon('zoomOut', 16)"></span>
</button>
<select
v-model="zoomLevel"
@change="setZoom($event.target.value)"
class="px-2 py-1 text-sm border-0 focus:ring-0 bg-transparent"
>
<option v-for="option in zoomOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<button
@click="zoomIn"
class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-r"
:disabled="zoomLevel >= 400"
>
<span v-html="getSvgIcon('zoomIn', 16)"></span>
</button>
</div>
<!-- Page Navigation (for PDFs) -->
<div v-if="document?.extension === 'pdf'" class="flex items-center space-x-1 border border-gray-300 dark:border-gray-600 rounded">
<button
@click="previousPage"
class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-l"
:disabled="currentPage <= 1"
>
<span v-html="getSvgIcon('chevronLeft', 16)"></span>
</button>
<span class="px-2 py-1 text-sm">{{ currentPage }} / {{ totalPages }}</span>
<button
@click="nextPage"
class="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-r"
:disabled="currentPage >= totalPages"
>
<span v-html="getSvgIcon('chevronRight', 16)"></span>
</button>
</div>
<!-- Action Buttons -->
<div class="flex items-center space-x-1">
<rs-button size="sm" variant="ghost" @click="downloadDocument">
<span v-html="getSvgIcon('download', 16)" class="mr-1"></span>
Download
</rs-button>
<rs-button size="sm" variant="ghost" @click="printDocument">
<span v-html="getSvgIcon('print', 16)" class="mr-1"></span>
Print
</rs-button>
<rs-button size="sm" variant="ghost" @click="editDocument">
<span v-html="getSvgIcon('edit', 16)" class="mr-1"></span>
Edit
</rs-button>
<rs-button size="sm" variant="ghost" @click="toggleFullscreen">
<span v-html="getSvgIcon('fullscreen', 16)"></span>
</rs-button>
</div>
<button @click="closeViewer" class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">
<span v-html="getSvgIcon('close', 20)"></span>
</button>
</div>
</div>
</div>
<!-- Content Area -->
<div class="viewer-content flex-1 p-4 overflow-auto bg-gray-50 dark:bg-gray-800">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center h-full">
<div class="flex flex-col items-center space-y-4">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p class="text-gray-500">Loading document...</p>
</div>
</div>
<!-- Document Preview -->
<div v-else-if="canPreview" class="document-preview flex justify-center">
<div class="preview-area bg-white shadow-lg rounded border max-w-full"
:style="{
transform: `scale(${zoomLevel / 100})`,
transformOrigin: 'top center'
}">
<!-- PDF Preview -->
<div v-if="document?.extension === 'pdf'" class="pdf-preview w-[210mm] h-[297mm] p-8 border">
<div class="flex items-center justify-center h-full text-gray-400">
<div class="text-center">
<span v-html="getSvgIcon('pdf', 48)" class="mb-4 block text-red-500"></span>
<p class="text-lg font-semibold">{{ document.name }}</p>
<p class="text-sm">PDF Document Preview</p>
<p class="text-xs mt-2">Page {{ currentPage }} of {{ totalPages }}</p>
</div>
</div>
</div>
<!-- Image Preview -->
<div v-else-if="['jpg', 'jpeg', 'png', 'gif'].includes(document?.extension?.toLowerCase())" class="image-preview">
<img
:src="`/api/documents/${document.id}/preview`"
:alt="document.name"
class="max-w-full h-auto rounded shadow"
@error="$event.target.style.display = 'none'"
/>
</div>
<!-- Office Document Preview -->
<div v-else class="office-preview w-[210mm] h-[297mm] p-8 border bg-white">
<div class="flex items-center justify-center h-full text-gray-400">
<div class="text-center">
<span v-html="getSvgIcon(documentIcon, 48)" class="mb-4 block text-blue-500"></span>
<p class="text-lg font-semibold">{{ document.name }}</p>
<p class="text-sm">{{ document.extension?.toUpperCase() }} Document Preview</p>
<p class="text-xs mt-2">Preview not available for this file type</p>
</div>
</div>
</div>
</div>
</div>
<!-- No Preview Available -->
<div v-else class="no-preview flex items-center justify-center h-full">
<div class="text-center text-gray-500">
<span v-html="getSvgIcon(documentIcon, 64)" class="mb-4 block mx-auto"></span>
<p class="text-lg font-semibold">Preview not available</p>
<p class="text-sm">This file type cannot be previewed</p>
<rs-button @click="downloadDocument" class="mt-4">
<span v-html="getSvgIcon('download', 16)" class="mr-2"></span>
Download to view
</rs-button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.document-viewer {
backdrop-filter: blur(4px);
}
.viewer-container {
height: 95vh;
}
.viewer-content {
min-height: 0;
}
.preview-area {
transition: transform 0.2s ease;
}
select:focus {
outline: none;
box-shadow: none;
}
/* Custom scrollbar */
.viewer-content::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.viewer-content::-webkit-scrollbar-track {
background: transparent;
}
.viewer-content::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 4px;
}
.viewer-content::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.7);
}
</style>