paper-burner/js/ui/ui-processing.js

814 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// js/ui/ui-processing.js
// 文件列表展示与处理进度相关的 UI 逻辑。
(function(global) {
'use strict';
const fileListContainer = document.getElementById('fileListContainer');
const fileList = document.getElementById('fileList');
const downloadAllBtn = document.getElementById('downloadAllBtn');
const processBtn = document.getElementById('processBtn');
const resultsSection = document.getElementById('resultsSection');
const resultsSummary = document.getElementById('resultsSummary');
const progressSection = document.getElementById('progressSection');
const batchProgressText = document.getElementById('batchProgressText');
const concurrentProgressText = document.getElementById('concurrentProgressText');
const progressStep = document.getElementById('progressStep');
const progressPercentage = document.getElementById('progressPercentage');
const progressBar = document.getElementById('progressBar');
const progressLog = document.getElementById('progressLog');
// 使用事件委托绑定验证刷新按钮
document.addEventListener('click', (e) => {
const refreshBtn = e.target.closest('#validationRefreshBtn');
if (refreshBtn) {
console.log('[Validation] Refresh button clicked');
e.preventDefault();
e.stopPropagation();
if (typeof window.refreshValidationState === 'function') {
window.refreshValidationState();
} else {
console.warn('[Validation] window.refreshValidationState not available');
}
}
});
function updateFileListUI(pdfFiles, isProcessing, onRemoveFile) {
if (!fileList || !fileListContainer) return;
fileList.innerHTML = '';
if (pdfFiles.length > 0) {
fileListContainer.classList.remove('hidden');
pdfFiles.forEach((file, index) => {
const displayPath = global.getFileDisplayPath(file);
const displayName = (displayPath.split('/').pop() || file.name || '').trim() || file.name;
const listItem = document.createElement('div');
listItem.className = 'file-list-item';
let virtualBadge = '';
const vType = (file && file.virtualType) ? String(file.virtualType) : '';
const nameLower = (file && file.name) ? file.name.toLowerCase() : '';
const isRetranslate = vType === 'retranslate' || /-retranslate-/.test(nameLower);
const isRetryFailed = vType === 'retry-failed' || /-retry-failed-/.test(nameLower);
if (isRetranslate) {
virtualBadge = '<span class="ml-2 inline-block text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 flex-shrink-0">重译</span>';
} else if (isRetryFailed) {
virtualBadge = '<span class="ml-2 inline-block text-[10px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 flex-shrink-0">失败重试</span>';
}
const extSource = displayName || file.name || '';
const ext = (extSource.split('.').pop() || '').toLowerCase();
const icon = ext === 'pdf' ? 'carbon:document-pdf' : 'carbon:document';
const iconColor = ext === 'pdf' ? 'text-red-500' : 'text-gray-500';
const isExcluded = typeof global.isExtensionExcluded === 'function' ? global.isExtensionExcluded(ext) : false;
listItem.innerHTML = `
<div class="flex items-center overflow-hidden mr-2">
<iconify-icon icon="${icon}" class="${iconColor} mr-2 flex-shrink-0" width="20"></iconify-icon>
<span class="flex flex-col overflow-hidden">
<span class="text-sm text-gray-800 truncate" title="${displayName}">${displayName}</span>
${displayPath && displayPath !== displayName ? `<span class="text-[11px] text-gray-500 truncate" title="${displayPath}">${displayPath}</span>` : ''}
</span>
${virtualBadge}
${isExcluded ? '<span class="ml-2 inline-block text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-600 flex-shrink-0">已排除</span>' : ''}
<span class="text-xs text-gray-500 ml-2 flex-shrink-0">(${global.formatFileSize(file.size)})</span>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<button data-index="${index}" class="preview-file-btn text-gray-400 hover:text-blue-600 flex-shrink-0" title="预览">
<iconify-icon icon="carbon:search" width="16"></iconify-icon>
</button>
<button data-index="${index}" class="remove-file-btn text-gray-400 hover:text-red-600 flex-shrink-0" title="移除">
<iconify-icon icon="carbon:close" width="16"></iconify-icon>
</button>
</div>
`;
if (isExcluded) {
listItem.classList.add('opacity-60');
}
fileList.appendChild(listItem);
});
document.querySelectorAll('.remove-file-btn').forEach(button => {
button.addEventListener('click', (e) => {
if (isProcessing) return;
const indexToRemove = parseInt(e.currentTarget.getAttribute('data-index'));
onRemoveFile(indexToRemove);
});
});
// 添加预览按钮事件监听器
document.querySelectorAll('.preview-file-btn').forEach(button => {
button.addEventListener('click', (e) => {
const fileIndex = parseInt(e.currentTarget.getAttribute('data-index'));
const fileToPreview = pdfFiles[fileIndex];
if (fileToPreview) {
previewFile(fileToPreview);
}
});
});
if (pdfFiles.length === 1) {
global.data = { name: pdfFiles[0].name, ocr: '', translation: '', images: [], summaries: {} };
} else if (pdfFiles.length === 0) {
global.data = {};
} else {
global.data = { summaries: {} };
}
console.log('刷新文件列表:', pdfFiles.map(f => f.name));
} else {
fileListContainer.classList.add('hidden');
global.data = {};
}
if (typeof global.syncBatchModeControls === 'function') {
global.syncBatchModeControls(pdfFiles.length);
}
}
function updateProcessButtonState(pdfFiles, isProcessing) {
if (!processBtn) return;
const getActiveFiles = typeof global.getActiveFiles === 'function' ? global.getActiveFiles : null;
const effectiveFiles = getActiveFiles ? getActiveFiles() : pdfFiles;
let ocrConfigAvailable = true; // 重命名:更清晰地表示 OCR 配置是否可用
let translationKeysAvailable = true;
const hasPdfFiles = effectiveFiles.some(file => file.name.toLowerCase().endsWith('.pdf'));
// 检查 OCR 引擎与所需配置
let ocrEngine = 'mineru';
let ocrConfigValid = true;
let ocrConfigMessage = '';
try {
if (window.ocrSettingsManager && typeof window.ocrSettingsManager.getCurrentConfig === 'function') {
ocrEngine = window.ocrSettingsManager.getCurrentConfig().engine || (localStorage.getItem('ocrEngine') || 'mineru');
// 使用 validateConfig 检查配置是否完整
const validation = window.ocrSettingsManager.validateConfig();
ocrConfigValid = validation.valid;
ocrConfigMessage = validation.message;
} else {
ocrEngine = localStorage.getItem('ocrEngine') || 'mineru';
}
} catch {}
if (hasPdfFiles) {
if (ocrEngine === 'none') {
// 选择了"不需要 OCR"但有 PDF 文件,标记为无效
ocrConfigAvailable = false;
} else {
// OCR 配置不论是否有效(允许后端使用环境变量代填)
ocrConfigAvailable = true;
}
}
// 检查翻译 Keys如果需要翻译- 直接使用 app.js 的逻辑
const settings = typeof global.loadSettings === 'function' ? global.loadSettings() : {};
const selectedTranslationModelName = settings.selectedTranslationModel || 'none';
let translationModelDisplayName = selectedTranslationModelName;
let currentTranslationModelForProvider = null;
let translationModelConfigForProcess = null;
if (selectedTranslationModelName !== 'none') {
if (selectedTranslationModelName === 'custom') {
const selectedCustomSourceId = settings.selectedCustomSourceSiteId;
if (!selectedCustomSourceId) {
translationKeysAvailable = false;
} else {
const allSourceSites = typeof global.loadAllCustomSourceSites === 'function' ? global.loadAllCustomSourceSites() : {};
const siteConfig = allSourceSites[selectedCustomSourceId];
if (!siteConfig) {
translationKeysAvailable = false;
} else {
currentTranslationModelForProvider = `custom_source_${selectedCustomSourceId}`;
translationModelConfigForProcess = siteConfig;
translationModelDisplayName = siteConfig.displayName || siteConfig.name || selectedCustomSourceId;
}
}
} else {
// 预设模型
currentTranslationModelForProvider = selectedTranslationModelName;
translationModelConfigForProcess = typeof global.loadModelConfig === 'function' ? global.loadModelConfig(selectedTranslationModelName) : {};
translationModelDisplayName = translationModelConfigForProcess?.displayName || selectedTranslationModelName;
}
// 使用 KeyProvider 检查
if (currentTranslationModelForProvider) {
const translationKeyProvider = new KeyProvider(currentTranslationModelForProvider);
translationKeysAvailable = translationKeyProvider.hasAvailableKeys();
}
}
// 更新按钮禁用状态和验证提示
const hasValidationIssues = (hasPdfFiles && !ocrConfigAvailable) || (!translationKeysAvailable && selectedTranslationModelName !== 'none');
processBtn.disabled = effectiveFiles.length === 0 || isProcessing || hasValidationIssues;
// 更新验证状态提示(传递 OCR 配置信息)
updateValidationAlert(effectiveFiles, hasPdfFiles, ocrConfigAvailable, translationKeysAvailable, selectedTranslationModelName, translationModelDisplayName, isProcessing, ocrEngine, ocrConfigValid, ocrConfigMessage);
if (isProcessing) {
processBtn.innerHTML = `<iconify-icon icon="carbon:hourglass" class="mr-1 animate-spin"></iconify-icon>处理中...`;
} else {
processBtn.innerHTML = `<iconify-icon icon="carbon:play" class="mr-1"></iconify-icon>开始处理`;
}
}
function updateValidationAlert(effectiveFiles, hasPdfFiles, ocrConfigAvailable, translationKeysAvailable, selectedTranslationModel, translationModelDisplayName, isProcessing, ocrEngine, ocrConfigValid, ocrConfigMessage) {
const validationAlert = document.getElementById('validationAlert');
const validationIcon = document.getElementById('validationIcon');
const validationTitle = document.getElementById('validationTitle');
const validationMessage = document.getElementById('validationMessage');
const validationActions = document.getElementById('validationActions');
if (!validationAlert || !validationIcon || !validationTitle || !validationMessage || !validationActions) return;
// 如果正在处理或没有文件,隐藏提示
if (isProcessing || effectiveFiles.length === 0) {
validationAlert.classList.add('hidden');
return;
}
// 检查各种验证条件
const issues = [];
if (hasPdfFiles && !ocrConfigAvailable) {
if (ocrEngine === 'none') {
issues.push({
type: 'ocr-none-with-pdf',
title: 'PDF 文件需要 OCR 引擎',
message: '您上传了 PDF 文件,但当前选择了"不需要 OCR"。请在上方 OCR 设置中选择 Mistral OCR、MinerU 或 Doc2X。',
icon: 'carbon:warning-alt',
actions: []
});
} else if (!ocrConfigValid) {
// 仅提示但不拦截
const engineNames = { mistral: 'Mistral OCR', mineru: 'MinerU', doc2x: 'Doc2X' };
const engineName = engineNames[ocrEngine] || ocrEngine;
issues.push({
type: 'ocr-config-incomplete',
title: `${engineName} 提示`,
message: ocrConfigMessage ? ocrConfigMessage + ' (如果没有前端Key代理服务器将尝试使用后端环境变量补充)' : `未完全配置 ${engineName} (如果代理服已配置环境变量则可忽略此提示)。`,
icon: 'carbon:information',
actions: [
{ text: '去配置', onClick: () => {
const btn = document.getElementById('modelKeyManagerBtn');
if (btn) btn.click();
}}
]
});
}
}
if (selectedTranslationModel && selectedTranslationModel !== 'none' && !translationKeysAvailable) {
const isCustomSource = selectedTranslationModel === 'custom';
const message = isCustomSource
? `源站 "${translationModelDisplayName}" 没有可用的 API Key。请添加 Key 后再处理,配置后可点击右上角刷新按钮更新状态。`
: `模型 "${translationModelDisplayName}" 没有可用的 API Key。请添加 Key 后再处理,配置后可点击右上角刷新按钮更新状态。`;
issues.push({
type: isCustomSource ? 'no-custom-translation-key' : 'no-translation-key',
title: isCustomSource ? `缺少源站 Key` : `缺少翻译模型 Key`,
message: message,
icon: 'carbon:warning-alt',
actions: [
{ text: isCustomSource ? '去配置该源站 Key' : '去配置 Key', onClick: () => {
const btn = document.getElementById('modelKeyManagerBtn');
if (btn) btn.click();
}},
{ text: '关闭翻译', onClick: () => {
const select = document.getElementById('translationModel');
if (select) {
select.value = 'none';
select.dispatchEvent(new Event('change'));
}
}}
]
});
}
// 如果没有问题,隐藏提示
if (issues.length === 0) {
validationAlert.classList.add('hidden');
return;
}
// 显示问题
if (issues.length > 1) {
// 多个问题:合并显示
validationIcon.setAttribute('icon', 'carbon:warning-alt');
validationTitle.textContent = '配置检查';
validationMessage.innerHTML = issues.map(issue => `${issue.message}`).join('<br>');
validationActions.innerHTML = '';
const addedButtons = new Set();
issues.forEach(issue => {
issue.actions.forEach(action => {
if (!addedButtons.has(action.text)) {
addedButtons.add(action.text);
const btn = document.createElement('button');
btn.className = 'text-xs px-3 py-1.5 bg-white/80 hover:bg-white border border-current/20 hover:border-current/40 rounded-md transition-all font-medium shadow-sm';
btn.textContent = action.text;
if (action.onClick) btn.onclick = action.onClick;
validationActions.appendChild(btn);
}
});
});
} else {
// 单个问题:正常显示
const issue = issues[0];
validationIcon.setAttribute('icon', issue.icon);
validationTitle.textContent = issue.title;
validationMessage.textContent = issue.message;
validationActions.innerHTML = '';
issue.actions.forEach(action => {
const btn = document.createElement('button');
btn.className = 'text-xs px-3 py-1.5 bg-white/80 hover:bg-white border border-current/20 hover:border-current/40 rounded-md transition-all font-medium shadow-sm';
btn.textContent = action.text;
if (action.onClick) btn.onclick = action.onClick;
validationActions.appendChild(btn);
});
}
// 设置样式
validationAlert.className = 'mt-6 mb-4 p-3.5 rounded-lg border transition-all shadow-sm border-amber-400 bg-amber-50/50 text-amber-900';
validationAlert.classList.remove('hidden');
}
function showResultsSection(successCount, skippedCount, errorCount, pdfFilesLength) {
if (!progressSection || !resultsSection || !resultsSummary || !downloadAllBtn || !concurrentProgressText) return;
progressSection.classList.add('hidden');
resultsSection.classList.remove('hidden');
concurrentProgressText.textContent = '';
const totalAttempted = successCount + skippedCount + errorCount;
resultsSummary.innerHTML = `
<p><strong>处理总结:</strong></p>
<ul class="list-disc list-inside ml-4">
<li>成功处理: ${successCount} 文件</li>
<li>跳过 (已处理): ${skippedCount} 文件</li>
<li>处理失败 (含重试): ${errorCount} 文件</li>
</ul>
<p class="mt-2">在 ${pdfFilesLength} 个选定文件中,尝试处理了 ${totalAttempted} 个。</p>
`;
downloadAllBtn.disabled = successCount === 0;
window.scrollTo({
top: resultsSection.offsetTop - 20,
behavior: 'smooth'
});
}
function showProgressSection() {
if (!resultsSection || !progressSection || !progressLog || !batchProgressText || !concurrentProgressText) return;
resultsSection.classList.add('hidden');
progressSection.classList.remove('hidden');
progressLog.innerHTML = '';
batchProgressText.textContent = '';
concurrentProgressText.textContent = '';
updateProgress('初始化...', 0);
window.scrollTo({
top: progressSection.offsetTop - 20,
behavior: 'smooth'
});
}
function updateConcurrentProgress(count) {
if (concurrentProgressText) {
concurrentProgressText.textContent = `当前并发任务数: ${count}`;
}
}
function updateOverallProgress(success, skipped, errors, totalFiles) {
if (!batchProgressText || !progressPercentage || !progressBar) return;
const completedCount = success + skipped + errors;
if (totalFiles > 0) {
const percentage = totalFiles > 0 ? Math.round((completedCount / totalFiles) * 100) : 0;
batchProgressText.textContent = `整体进度: ${completedCount} / ${totalFiles} 完成`;
progressPercentage.textContent = `${percentage}%`;
progressBar.style.width = `${percentage}%`;
} else {
batchProgressText.textContent = '';
progressPercentage.textContent = '0%';
progressBar.style.width = '0%';
}
}
function updateProgress(stepText, percentage) {
if (progressStep) {
progressStep.textContent = stepText;
}
}
function addProgressLog(text) {
if (!progressLog) return;
const timestamp = new Date().toLocaleTimeString();
const logLine = document.createElement('div');
logLine.textContent = `[${timestamp}] ${text}`;
progressLog.appendChild(logLine);
progressLog.scrollTop = progressLog.scrollHeight;
}
/**
* 文件预览功能
* @param {File} file - 要预览的文件对象
*/
function previewFile(file) {
const ext = file.name.split('.').pop().toLowerCase();
// 创建预览模态框
const modal = document.createElement('div');
modal.className = 'fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm';
modal.style.animation = 'fadeIn 0.2s ease-out';
const modalContent = document.createElement('div');
modalContent.className = 'relative bg-white rounded-lg shadow-2xl w-[90vw] h-[90vh] flex flex-col';
modalContent.style.animation = 'slideUp 0.3s ease-out';
// 头部
const header = document.createElement('div');
header.className = 'flex items-center justify-between px-6 py-4 border-b border-gray-200';
header.innerHTML = `
<div class="flex items-center gap-3">
<iconify-icon icon="carbon:view" class="text-blue-500" width="24"></iconify-icon>
<h3 class="text-lg font-semibold text-gray-800">文件预览</h3>
</div>
<button class="preview-close-btn text-gray-400 hover:text-gray-600 transition-colors">
<iconify-icon icon="carbon:close" width="24"></iconify-icon>
</button>
`;
// 文件信息和控件整合到一行(仅针对 PDF
let fileInfoAndControls = null;
if (ext === 'pdf') {
fileInfoAndControls = document.createElement('div');
fileInfoAndControls.className = 'px-6 py-3 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-blue-100 flex items-center justify-between';
fileInfoAndControls.innerHTML = `
<div class="flex items-center gap-6 text-sm text-gray-700">
<div class="flex items-center gap-2">
<iconify-icon icon="carbon:document-pdf" class="text-red-500" width="18"></iconify-icon>
<span class="font-medium">${file.name}</span>
</div>
<div class="flex items-center gap-1 text-gray-600">
<iconify-icon icon="carbon:data-1" width="14"></iconify-icon>
<span>${global.formatFileSize ? global.formatFileSize(file.size) : (file.size / 1024).toFixed(2) + ' KB'}</span>
</div>
<div class="flex items-center gap-1 text-gray-500 text-xs">
<iconify-icon icon="carbon:information" width="14"></iconify-icon>
<span>按住 Ctrl 滚动鼠标滚轮可缩放</span>
</div>
</div>
<div class="flex items-center gap-3">
<button class="pdf-prev-page px-3 py-1.5 bg-white hover:bg-blue-50 border border-gray-300 hover:border-blue-400 rounded transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-sm" title="上一页">
<iconify-icon icon="carbon:chevron-left" width="16"></iconify-icon>
</button>
<span class="text-sm text-gray-700 px-2">
第 <span class="pdf-page-num font-semibold text-blue-600">1</span> / <span class="pdf-page-count font-semibold">-</span> 页
</span>
<button class="pdf-next-page px-3 py-1.5 bg-white hover:bg-blue-50 border border-gray-300 hover:border-blue-400 rounded transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-sm" title="下一页">
<iconify-icon icon="carbon:chevron-right" width="16"></iconify-icon>
</button>
<div class="w-px h-5 bg-gray-300 mx-1"></div>
<button class="pdf-zoom-out px-3 py-1.5 bg-white hover:bg-blue-50 border border-gray-300 hover:border-blue-400 rounded transition-all shadow-sm" title="缩小">
<iconify-icon icon="carbon:zoom-out" width="16"></iconify-icon>
</button>
<span class="pdf-zoom-level text-sm text-gray-700 font-semibold min-w-[50px] text-center">120%</span>
<button class="pdf-zoom-in px-3 py-1.5 bg-white hover:bg-blue-50 border border-gray-300 hover:border-blue-400 rounded transition-all shadow-sm" title="放大">
<iconify-icon icon="carbon:zoom-in" width="16"></iconify-icon>
</button>
</div>
`;
} else {
// 非 PDF 文件,保持原有的文件信息显示
fileInfoAndControls = document.createElement('div');
fileInfoAndControls.className = 'px-6 py-3 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-blue-100';
fileInfoAndControls.innerHTML = `
<div class="flex items-center gap-6 text-sm text-gray-700">
<div class="flex items-center gap-2">
<iconify-icon icon="carbon:document" class="text-gray-500" width="18"></iconify-icon>
<span class="font-medium">${file.name}</span>
</div>
<div class="flex items-center gap-1 text-gray-600">
<iconify-icon icon="carbon:data-1" width="14"></iconify-icon>
<span>${global.formatFileSize ? global.formatFileSize(file.size) : (file.size / 1024).toFixed(2) + ' KB'}</span>
</div>
<span class="text-gray-600">${ext.toUpperCase()}</span>
</div>
`;
}
// 内容区域
const contentArea = document.createElement('div');
contentArea.className = 'flex-1 overflow-auto p-6';
// 根据文件类型生成预览内容
if (ext === 'pdf') {
// PDF 预览 - 使用 PDF.js 渲染,添加左侧缩略图
contentArea.className = 'flex-1 flex overflow-hidden';
contentArea.innerHTML = `
<div class="w-48 bg-gray-50 border-r border-gray-200 overflow-y-auto p-3">
<div class="text-xs font-semibold text-gray-600 mb-3 px-2">页面缩略图</div>
<div class="pdf-thumbnails space-y-2"></div>
</div>
<div class="flex-1 overflow-auto bg-gray-100 flex items-start justify-center p-6">
<canvas class="pdf-canvas shadow-2xl bg-white"></canvas>
</div>
`;
// 使用 PDF.js 渲染
const canvas = contentArea.querySelector('.pdf-canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: false });
const thumbnailsContainer = contentArea.querySelector('.pdf-thumbnails');
const pageNumSpan = fileInfoAndControls.querySelector('.pdf-page-num');
const pageCountSpan = fileInfoAndControls.querySelector('.pdf-page-count');
const zoomLevelSpan = fileInfoAndControls.querySelector('.pdf-zoom-level');
const prevBtn = fileInfoAndControls.querySelector('.pdf-prev-page');
const nextBtn = fileInfoAndControls.querySelector('.pdf-next-page');
const zoomInBtn = fileInfoAndControls.querySelector('.pdf-zoom-in');
const zoomOutBtn = fileInfoAndControls.querySelector('.pdf-zoom-out');
let pdfDoc = null;
let currentPage = 1;
let currentScale = 1.2; // 调整为更合适的默认缩放比例
const renderPage = async (num) => {
const page = await pdfDoc.getPage(num);
const viewport = page.getViewport({ scale: currentScale });
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({
canvasContext: ctx,
viewport: viewport
}).promise;
pageNumSpan.textContent = num;
prevBtn.disabled = num <= 1;
nextBtn.disabled = num >= pdfDoc.numPages;
// 更新缩略图高亮
document.querySelectorAll('.pdf-thumbnail').forEach((thumb, idx) => {
if (idx + 1 === num) {
thumb.classList.add('ring-2', 'ring-blue-500', 'bg-blue-50');
} else {
thumb.classList.remove('ring-2', 'ring-blue-500', 'bg-blue-50');
}
});
};
// 渲染缩略图
const renderThumbnails = async () => {
for (let i = 1; i <= pdfDoc.numPages; i++) {
const page = await pdfDoc.getPage(i);
const viewport = page.getViewport({ scale: 0.2 }); // 缩略图小比例
const thumbCanvas = document.createElement('canvas');
thumbCanvas.width = viewport.width;
thumbCanvas.height = viewport.height;
const thumbCtx = thumbCanvas.getContext('2d', { willReadFrequently: false });
await page.render({
canvasContext: thumbCtx,
viewport: viewport
}).promise;
const thumbDiv = document.createElement('div');
thumbDiv.className = 'pdf-thumbnail cursor-pointer p-2 rounded border-2 border-transparent hover:border-blue-300 transition-all';
thumbDiv.innerHTML = `
<div class="relative">
<img src="${thumbCanvas.toDataURL()}" class="w-full rounded shadow-sm" />
<div class="text-center text-xs text-gray-600 mt-1">第 ${i} 页</div>
</div>
`;
thumbDiv.addEventListener('click', async () => {
currentPage = i;
await renderPage(currentPage);
});
thumbnailsContainer.appendChild(thumbDiv);
}
};
// 加载 PDF
const fileReader = new FileReader();
fileReader.onload = async (e) => {
try {
const typedArray = new Uint8Array(e.target.result);
pdfDoc = await pdfjsLib.getDocument({ data: typedArray }).promise;
pageCountSpan.textContent = pdfDoc.numPages;
// 渲染第一页
await renderPage(currentPage);
// 渲染所有缩略图(异步进行,不阻塞)
renderThumbnails();
} catch (error) {
console.error('[PDF Preview] Error loading PDF:', error);
contentArea.innerHTML = `
<div class="flex flex-col items-center justify-center h-full text-gray-500">
<iconify-icon icon="carbon:warning-alt" width="64" class="mb-4 text-red-500"></iconify-icon>
<p class="text-lg">PDF 加载失败</p>
<p class="text-sm mt-2">${error.message}</p>
</div>
`;
}
};
fileReader.readAsArrayBuffer(file);
// 翻页事件
prevBtn.addEventListener('click', async () => {
if (currentPage > 1) {
currentPage--;
await renderPage(currentPage);
}
});
nextBtn.addEventListener('click', async () => {
if (currentPage < pdfDoc.numPages) {
currentPage++;
await renderPage(currentPage);
}
});
// 缩放事件
zoomInBtn.addEventListener('click', async () => {
currentScale += 0.25;
zoomLevelSpan.textContent = Math.round(currentScale * 100) + '%';
await renderPage(currentPage);
});
zoomOutBtn.addEventListener('click', async () => {
if (currentScale > 0.5) {
currentScale -= 0.25;
zoomLevelSpan.textContent = Math.round(currentScale * 100) + '%';
await renderPage(currentPage);
}
});
// 滚轮缩放功能Ctrl/Cmd + 滚轮)
const pdfContainer = contentArea.querySelector('.flex-1.overflow-auto');
let zoomTimeout = null;
pdfContainer.addEventListener('wheel', async (e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY;
const zoomStep = 0.1; // 更细腻的缩放步进
if (delta < 0) {
// 向上滚动 - 放大
currentScale = Math.min(currentScale + zoomStep, 5.0); // 最大 500%
} else {
// 向下滚动 - 缩小
currentScale = Math.max(currentScale - zoomStep, 0.5); // 最小 50%
}
// 更新显示
zoomLevelSpan.textContent = Math.round(currentScale * 100) + '%';
// 防抖渲染:等待滚轮停止后再渲染
clearTimeout(zoomTimeout);
zoomTimeout = setTimeout(async () => {
await renderPage(currentPage);
}, 150);
}
}, { passive: false });
} else if (['docx', 'doc'].includes(ext)) {
// Word 文件预览 - 使用 docx-preview.js
contentArea.className = 'flex-1 overflow-auto bg-gray-50 p-6';
contentArea.innerHTML = `
<div class="docx-preview-container bg-white shadow-lg mx-auto" style="max-width: 21cm; min-height: 29.7cm;"></div>
`;
const docxContainer = contentArea.querySelector('.docx-preview-container');
// 检查 docx-preview 库是否加载
if (typeof docx === 'undefined') {
contentArea.innerHTML = `
<div class="flex flex-col items-center justify-center h-full text-gray-500">
<iconify-icon icon="carbon:warning-alt" width="64" class="mb-4 text-red-500"></iconify-icon>
<p class="text-lg">Word 预览库未加载</p>
<p class="text-sm mt-2">请刷新页面重试</p>
</div>
`;
return;
}
// 使用 docx-preview 渲染
const reader = new FileReader();
reader.onload = async (e) => {
try {
await docx.renderAsync(e.target.result, docxContainer, null, {
className: "docx-preview",
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
ignoreFonts: false,
breakPages: true,
ignoreLastRenderedPageBreak: true,
experimental: false,
trimXmlDeclaration: true,
useBase64URL: false,
useMathMLPolyfill: false,
renderHeaders: true,
renderFooters: true,
renderFootnotes: true,
renderEndnotes: true
});
} catch (error) {
console.error('[Word Preview] Error rendering Word:', error);
contentArea.innerHTML = `
<div class="flex flex-col items-center justify-center h-full text-gray-500">
<iconify-icon icon="carbon:warning-alt" width="64" class="mb-4 text-red-500"></iconify-icon>
<p class="text-lg">Word 文件加载失败</p>
<p class="text-sm mt-2">${error.message}</p>
</div>
`;
}
};
reader.readAsArrayBuffer(file);
} else if (['pptx', 'ppt'].includes(ext)) {
// PPT 文件预览
contentArea.className = 'flex-1 overflow-hidden bg-gray-100';
contentArea.innerHTML = `
<div class="flex flex-col items-center justify-center h-full text-gray-500 px-6">
<iconify-icon icon="carbon:presentation-file" width="64" class="mb-4 text-orange-500"></iconify-icon>
<p class="text-lg font-semibold mb-2">PowerPoint 文件预览</p>
<p class="text-sm text-gray-600 text-center max-w-md">
建议使用 Microsoft PowerPoint 或其他本地应用打开此文件。
</p>
<div class="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200 max-w-md">
<p class="text-xs text-gray-600">
<strong>文件信息:</strong><br>
名称: ${file.name}<br>
大小: ${global.formatFileSize ? global.formatFileSize(file.size) : (file.size / 1024).toFixed(2) + ' KB'}
</p>
</div>
</div>
`;
} else if (['txt', 'md', 'yaml', 'yml', 'html', 'htm'].includes(ext)) {
// 文本文件预览
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target.result;
contentArea.innerHTML = `<pre class="text-sm text-gray-800 whitespace-pre-wrap break-words bg-gray-50 p-4 rounded-lg border border-gray-200 font-mono">${escapeHtml(text)}</pre>`;
};
reader.readAsText(file);
} else {
// 不支持的类型
contentArea.innerHTML = `
<div class="flex flex-col items-center justify-center h-full text-gray-500">
<iconify-icon icon="carbon:document-unknown" width="64" class="mb-4"></iconify-icon>
<p class="text-lg">暂不支持预览此文件类型</p>
<p class="text-sm mt-2">支持的类型: PDF, Word (DOCX), TXT, MD, YAML, HTML</p>
</div>
`;
}
// 组装模态框
modalContent.appendChild(header);
modalContent.appendChild(fileInfoAndControls);
modalContent.appendChild(contentArea);
modal.appendChild(modalContent);
document.body.appendChild(modal);
// 关闭事件
const closeModal = () => {
modal.style.animation = 'fadeOut 0.2s ease-out';
setTimeout(() => modal.remove(), 200);
};
header.querySelector('.preview-close-btn').addEventListener('click', closeModal);
modal.addEventListener('click', (e) => {
if (e.target === modal) closeModal();
});
// ESC 键关闭
const handleEsc = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', handleEsc);
}
};
document.addEventListener('keydown', handleEsc);
}
/**
* HTML 转义函数
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
global.updateFileListUI = updateFileListUI;
global.updateProcessButtonState = updateProcessButtonState;
global.showResultsSection = showResultsSection;
global.showProgressSection = showProgressSection;
global.updateConcurrentProgress = updateConcurrentProgress;
global.updateOverallProgress = updateOverallProgress;
global.updateProgress = updateProgress;
global.addProgressLog = addProgressLog;
})(window);