814 lines
41 KiB
JavaScript
814 lines
41 KiB
JavaScript
// 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);
|