Add preview modal docs

This commit is contained in:
Vladimir 2026-07-07 12:16:10 +03:00
parent 80cded72bb
commit 97ec3aaf39
3 changed files with 520 additions and 2 deletions

View File

@ -1,14 +1,14 @@
<template>
<div class="w-full sm:w-1/2 md:w-1/3 lg:w-1/4 pr-4 mb-4">
<div class="document-card p-4 rounded flex">
<div class="deal-document-thumbnail w-16 h-16 shrink-0 rounded-lg overflow-hidden">
<a href="javascript:void(0)" @click.prevent="openPreview" class="deal-document-thumbnail w-16 h-16 shrink-0 rounded-lg overflow-hidden cursor-pointer">
<img v-if="hasThumbnail" :src="thumbnailUrl" alt="Миниатюра документа" class="w-full h-full object-cover" />
<img v-else-if="isImage" :src="fileUrl" alt="Превью документа" class="w-full h-full object-cover" />
<img v-else
:src="defaultIconUrl"
:alt="fileExtension + ' документ'"
class="w-full h-full object-contain bg-gray-100" />
</div>
</a>
<!-- Название документа -->
<div class="ml-4 flex flex-col justify-between grow min-w-0">
@ -35,6 +35,8 @@
import defaultIconUrl from '@/shared/assets/images/deal/deal-doc-icon.png';
import {useConfirm} from "primevue/useconfirm";
import {useToast} from "primevue/usetoast";
import {useDialog} from "primevue/usedialog";
import DocumentPreviewModal from './DocumentPreviewModal.vue';
const props = defineProps({
doc: {
@ -62,6 +64,10 @@ const thumbnailUrl = computed(() => {
return `${props.urlLaravel}/storage/${props.doc.thumbnail}`;
});
const proxyUrl = computed(() => {
return `/deal/index.php?path=documents/${props.doc.id}/preview`;
});
// // Проверка, является ли файл изображением
const isImage = computed(() => {
if (!props.doc.file) return false;
@ -78,6 +84,23 @@ const getFileName = (fullPath) => {
return fullPath.split('/').pop();
}
const dialog = useDialog();
const openPreview = () => {
dialog.open(DocumentPreviewModal, {
props: {
modal: true,
style: { width: '80vw', maxHeight: '90vh' },
contentStyle: { padding: 0, overflow: 'auto', maxHeight: '75vh' },
},
data: {
fileUrl: fileUrl.value,
proxyUrl: proxyUrl.value,
fileName: props.doc.name || 'Документ',
extension: fileExtension.value,
},
});
};
const emit = defineEmits(['remove']);

View File

@ -0,0 +1,237 @@
<template>
<div class="doc-preview-modal">
<div v-if="loading" class="flex items-center justify-center p-8" style="min-height:200px;">
<span>Загрузка...</span>
</div>
<div v-else-if="error" class="p-4 text-center text-red-500">{{ error }}</div>
<div v-else-if="imageUrl" class="flex justify-center p-2">
<img :src="imageUrl" :alt="fileName" style="max-width:100%;max-height:70vh;object-fit:contain;" />
</div>
<div v-else-if="htmlContent" v-html="htmlContent" class="p-4"></div>
<div v-else-if="isPdf" id="pdf-container" class="pdf-container"></div>
<div v-else class="p-4 text-center text-gray-500">Нет предпросмотра</div>
</div>
</template>
<script setup>
import { ref, computed, nextTick, inject, onMounted } from 'vue';
const dialogRef = inject('dialogRef');
const dialogData = dialogRef.value.data;
const fileUrl = dialogData.fileUrl || '';
const proxyUrl = dialogData.proxyUrl || '';
const fileName = dialogData.fileName || 'Документ';
const extension = dialogData.extension || '';
const loading = ref(true);
const error = ref('');
const imageUrl = ref('');
const htmlContent = ref('');
const isPdf = ref(false);
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
const fetchUrl = computed(() => proxyUrl || fileUrl);
onMounted(() => {
loadPreview();
});
const loadPreview = async () => {
console.log('[DocPreview] loadPreview called, ext:', extension, 'fetchUrl:', fetchUrl.value);
loading.value = true;
const ext = (extension || '').toLowerCase();
try {
if (imageExts.includes(ext)) {
imageUrl.value = fetchUrl.value;
loading.value = false;
return;
}
if (ext === 'pdf') {
isPdf.value = true;
loading.value = false;
await nextTick();
await loadPdf();
return;
}
if (ext === 'docx' || ext === 'doc') {
await loadDocx();
loading.value = false;
return;
}
if (ext === 'xlsx' || ext === 'xls') {
await loadXlsx();
loading.value = false;
return;
}
error.value = 'Предпросмотр не поддерживается для этого формата';
loading.value = false;
} catch (e) {
console.error('[DocPreview] error:', e);
error.value = 'Ошибка загрузки: ' + e.message;
loading.value = false;
}
};
const loadPdf = async () => {
const pdfjsLib = await import('pdfjs-dist');
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).href;
const response = await fetch(fetchUrl.value);
if (!response.ok) throw new Error('HTTP ' + response.status);
const data = new Uint8Array(await response.arrayBuffer());
const pdfDoc = await pdfjsLib.getDocument({ data }).promise;
const container = document.getElementById('pdf-container');
if (!container) return;
container.innerHTML = '';
for (let i = 1; i <= pdfDoc.numPages; i++) {
const page = await pdfDoc.getPage(i);
const scale = 1.5;
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.display = 'block';
canvas.style.margin = '0 auto 10px';
canvas.style.maxWidth = '100%';
container.appendChild(canvas);
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
}
};
const loadDocx = async () => {
const mammoth = await import('mammoth');
const response = await fetch(fetchUrl.value);
if (!response.ok) throw new Error('HTTP ' + response.status);
const arrayBuffer = await response.arrayBuffer();
const result = await mammoth.convertToHtml({
arrayBuffer,
convertImage: mammoth.images.inline(function(element) {
return { src: 'data:' + element.contentType + ';base64,' + element.read('base64') };
})
});
const warnings = result.messages.filter(m => m.type === 'warning');
if (warnings.length) console.warn('[DocPreview] mammoth warnings:', warnings);
htmlContent.value = '<div class="doc-preview-content">' + (result.value || '<p>Документ пуст</p>') + '</div>';
};
const loadXlsx = async () => {
const XLSX = await import('xlsx');
const response = await fetch(fetchUrl.value);
if (!response.ok) throw new Error('HTTP ' + response.status);
const data = await response.arrayBuffer();
const workbook = XLSX.read(data, { type: 'array' });
let html = '';
workbook.SheetNames.forEach(name => {
html += '<h3 style="margin:10px 0 5px;">' + name + '</h3>';
html += XLSX.utils.sheet_to_html(workbook.Sheets[name]);
});
htmlContent.value = html || '<p>Таблица пуста</p>';
};
</script>
<style>
.doc-preview-content {
font-family: 'Times New Roman', 'Liberation Serif', serif;
font-size: 14px;
line-height: 1.5;
color: #333;
padding: 30px 40px;
background: white;
max-width: 800px;
margin: 0 auto;
}
.doc-preview-content h1 {
font-size: 22px;
font-weight: bold;
margin: 16px 0 12px;
}
.doc-preview-content h2 {
font-size: 18px;
font-weight: bold;
margin: 14px 0 10px;
}
.doc-preview-content h3 {
font-size: 16px;
font-weight: bold;
margin: 12px 0 8px;
}
.doc-preview-content p {
margin: 6px 0;
text-align: justify;
}
.doc-preview-content table {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
}
.doc-preview-content td, .doc-preview-content th {
border: 1px solid #999;
padding: 6px 10px;
text-align: left;
vertical-align: top;
}
.doc-preview-content th {
background: #f0f0f0;
font-weight: bold;
}
.doc-preview-content ul {
list-style-type: disc;
margin: 8px 0 8px 20px;
padding-left: 20px;
}
.doc-preview-content ol {
list-style-type: decimal;
margin: 8px 0 8px 20px;
padding-left: 24px;
}
.doc-preview-content ul ul {
list-style-type: circle;
margin: 4px 0 4px 16px;
}
.doc-preview-content ul ul ul {
list-style-type: square;
}
.doc-preview-content li {
margin: 4px 0;
line-height: 1.5;
}
.doc-preview-content li::marker {
color: #555;
}
.doc-preview-content img {
max-width: 100%;
height: auto;
margin: 8px 0;
}
.doc-preview-content blockquote {
border-left: 3px solid #ccc;
margin: 10px 0;
padding: 4px 12px;
color: #666;
}
.doc-preview-content a {
color: #1a73e8;
text-decoration: underline;
}
.doc-preview-content br {
display: block;
content: '';
margin-top: 2px;
}
.pdf-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 10px;
}
</style>

View File

@ -0,0 +1,258 @@
/**
* Генерация миниатюр документов в браузере
* PDF первая страница через pdf.js
* DOCX HTML через mammoth рендерим как страницу
* XLSX данные через SheetJS таблица-превью
* Изображения resize через Canvas
*/
const THUMBNAIL_WIDTH = 200;
const THUMBNAIL_HEIGHT = 280;
function getFileExtension(fileName) {
return fileName.split('.').pop().toLowerCase();
}
function isImage(fileName) {
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(getFileExtension(fileName));
}
function createPageContainer() {
const el = document.createElement('div');
el.style.cssText = `
position: absolute; left: -9999px; top: 0;
width: ${THUMBNAIL_WIDTH}px;
background: #fff;
padding: 16px;
font-family: 'Times New Roman', serif;
font-size: 10px;
line-height: 1.5;
color: #000;
word-wrap: break-word;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
`;
document.body.appendChild(el);
return el;
}
/**
* Изображение уменьшенная копия с отступами (как превью файла)
*/
async function generateImageThumbnail(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = THUMBNAIL_WIDTH;
canvas.height = THUMBNAIL_HEIGHT;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#f8f8f8';
ctx.fillRect(0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT);
const pad = 12;
const maxW = THUMBNAIL_WIDTH - pad * 2;
const maxH = THUMBNAIL_HEIGHT - pad * 2;
const scale = Math.min(maxW / img.width, maxH / img.height, 1);
const w = img.width * scale;
const h = img.height * scale;
const x = (THUMBNAIL_WIDTH - w) / 2;
const y = (THUMBNAIL_HEIGHT - h) / 2;
ctx.shadowColor = 'rgba(0,0,0,0.1)';
ctx.shadowBlur = 4;
ctx.fillStyle = '#fff';
ctx.fillRect(x - 2, y - 2, w + 4, h + 4);
ctx.shadowBlur = 0;
ctx.drawImage(img, x, y, w, h);
canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.85);
};
img.onerror = reject;
img.src = e.target.result;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/**
* PDF первая страница через pdf.js
*/
async function generatePdfThumbnail(file) {
const pdfjsLib = await import('pdfjs-dist');
pdfjsLib.GlobalWorkerOptions.workerSrc =
`https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.mjs`;
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const page = await pdf.getPage(1);
const rawViewport = page.getViewport({ scale: 1 });
const scale = Math.min(THUMBNAIL_WIDTH / rawViewport.width, THUMBNAIL_HEIGHT / rawViewport.height);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = THUMBNAIL_WIDTH;
canvas.height = THUMBNAIL_HEIGHT;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT);
const offsetX = (THUMBNAIL_WIDTH - viewport.width) / 2;
const offsetY = (THUMBNAIL_HEIGHT - viewport.height) / 2;
ctx.translate(offsetX, offsetY);
await page.render({ canvasContext: ctx, viewport }).promise;
return new Promise((resolve) => canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.85));
}
/**
* DOCX извлекаем текст, рендерим на canvas как страницу документа
*/
async function generateDocxThumbnail(file) {
const mammoth = await import('mammoth');
const arrayBuffer = await file.arrayBuffer();
const result = await mammoth.extractRawText({ arrayBuffer });
const text = result.value;
const canvas = document.createElement('canvas');
canvas.width = THUMBNAIL_WIDTH;
canvas.height = THUMBNAIL_HEIGHT;
const ctx = canvas.getContext('2d');
// Белый фон
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT);
// Тонкая тень по краям
const grad = ctx.createLinearGradient(0, 0, THUMBNAIL_WIDTH, 0);
grad.addColorStop(0, 'rgba(0,0,0,0.03)');
grad.addColorStop(0.05, 'rgba(0,0,0,0)');
grad.addColorStop(0.95, 'rgba(0,0,0,0)');
grad.addColorStop(1, 'rgba(0,0,0,0.03)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT);
// Рендерим текст как строки
ctx.fillStyle = '#222';
ctx.font = '10px "Times New Roman", Times, serif';
const maxWidth = THUMBNAIL_WIDTH - 24;
const lineHeight = 14;
const startY = 20;
const lines = text.split(/\n/).filter(l => l.trim().length > 0);
let y = startY;
for (let i = 0; i < lines.length && y < THUMBNAIL_HEIGHT - 10; i++) {
const line = lines[i];
// Перенос длинных строк
const words = line.split(/\s+/);
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? currentLine + ' ' + word : word;
if (ctx.measureText(testLine).width > maxWidth) {
ctx.fillText(currentLine, 12, y, maxWidth);
y += lineHeight;
currentLine = word;
if (y >= THUMBNAIL_HEIGHT - 10) break;
} else {
currentLine = testLine;
}
}
if (currentLine) {
ctx.fillText(currentLine, 12, y, maxWidth);
y += lineHeight;
}
}
return new Promise((resolve) => canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.85));
}
/**
* XLSX таблица данных
*/
async function generateXlsxThumbnail(file) {
const XLSX = await import('xlsx');
const arrayBuffer = await file.arrayBuffer();
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const data = XLSX.utils.sheet_to_json(sheet, { header: 1 }).slice(0, 15);
const canvas = document.createElement('canvas');
canvas.width = THUMBNAIL_WIDTH;
canvas.height = THUMBNAIL_HEIGHT;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT);
// Заголовок листа
ctx.fillStyle = '#444';
ctx.font = 'bold 10px Arial, sans-serif';
ctx.fillText(workbook.SheetNames[0], 8, 16);
// Линия-разделитель
ctx.strokeStyle = '#ddd';
ctx.beginPath();
ctx.moveTo(8, 22);
ctx.lineTo(THUMBNAIL_WIDTH - 8, 22);
ctx.stroke();
// Данные таблицы
ctx.font = '8px Arial, sans-serif';
const startY = 34;
const lineHeight = 12;
const colWidth = Math.floor((THUMBNAIL_WIDTH - 16) / 5);
for (let i = 0; i < data.length && startY + i * lineHeight < THUMBNAIL_HEIGHT - 8; i++) {
const row = data[i] || [];
const y = startY + i * lineHeight;
if (i === 0) {
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(8, y - 9, THUMBNAIL_WIDTH - 16, lineHeight);
}
ctx.fillStyle = i === 0 ? '#333' : '#555';
const cells = row.slice(0, 5);
for (let j = 0; j < cells.length; j++) {
const x = 8 + j * colWidth;
const text = String(cells[j] ?? '').substring(0, 20);
ctx.fillText(text, x, y, colWidth - 4);
}
}
return new Promise((resolve) => canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.85));
}
/**
* Главная функция генерирует thumbnail для любого типа файла
*/
export async function generateThumbnail(file) {
const ext = getFileExtension(file.name);
try {
if (isImage(file.name)) {
return { blob: await generateImageThumbnail(file), extension: 'jpg' };
}
if (ext === 'pdf') {
return { blob: await generatePdfThumbnail(file), extension: 'jpg' };
}
if (['doc', 'docx'].includes(ext)) {
return { blob: await generateDocxThumbnail(file), extension: 'jpg' };
}
if (['xls', 'xlsx'].includes(ext)) {
return { blob: await generateXlsxThumbnail(file), extension: 'jpg' };
}
} catch (e) {
console.error('[generateThumbnail] Ошибка для', file.name, e);
}
return null;
}