587 lines
17 KiB
JavaScript
587 lines
17 KiB
JavaScript
// history_detail_scripts.js - 从 history_detail.html 中提取的 JavaScript 代码
|
||
// 这个文件包含了历史详情页面的主要 JavaScript 逻辑
|
||
|
||
window.addEventListener('storage', function(e) {
|
||
if (e.key === 'paperBurnerSettings') {
|
||
// 重新加载设置并刷新 chatbot 配置
|
||
if (window.ChatbotCore && typeof window.ChatbotCore.getChatbotConfig === 'function') {
|
||
// 你可以强制刷新 Chatbot UI 或重载配置
|
||
window.ChatbotUI && window.ChatbotUI.updateChatbotUI && window.ChatbotUI.updateChatbotUI();
|
||
}
|
||
}
|
||
});
|
||
|
||
/**
|
||
* 将 exact 文本转为模糊正则,允许空格、换行模糊匹配,大小写不敏感
|
||
* @param {string} exact
|
||
* @returns {RegExp}
|
||
*/
|
||
function escapeRegExp(string) {
|
||
// 更安全地转义所有正则特殊字符
|
||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
function fuzzyRegFromExact(exact) {
|
||
// 先转义所有正则特殊字符
|
||
let pattern = escapeRegExp(exact);
|
||
// 将所有空白替换为 \s+,允许跨行、多个空格
|
||
pattern = pattern.replace(/\s+/g, '\\s+');
|
||
// 可选:忽略前后空白
|
||
pattern = '\\s*' + pattern + '\\s*';
|
||
return new RegExp(pattern, 'gi');
|
||
}
|
||
|
||
/**
|
||
* 检查是否有OCR数据
|
||
* @returns {boolean}
|
||
*/
|
||
function hasOcrData() {
|
||
return window.data && window.data.ocr && window.data.ocr.trim() !== '';
|
||
}
|
||
|
||
/**
|
||
* 检查是否有翻译数据
|
||
* @returns {boolean}
|
||
*/
|
||
function hasTranslationData() {
|
||
return window.data && window.data.translation && window.data.translation.trim() !== '';
|
||
}
|
||
|
||
/**
|
||
* 检查是否有原始PDF数据
|
||
* @returns {boolean}
|
||
*/
|
||
function hasOriginalPdfData() {
|
||
return window.data && window.data.metadata && window.data.metadata.originalPdfBase64;
|
||
}
|
||
|
||
/**
|
||
* 将 Base64 字符串转换为 File 对象
|
||
* @param {string} base64 - Base64 编码的字符串(可带或不带 data: 前缀)
|
||
* @param {string} filename - 文件名
|
||
* @returns {File} File 对象
|
||
*/
|
||
function base64ToFile(base64, filename) {
|
||
// 处理 data URL 格式
|
||
let dataUrl = base64;
|
||
let mimeString = 'application/pdf';
|
||
|
||
if (base64.startsWith('data:')) {
|
||
const matches = base64.match(/^data:([^;]+);base64,(.+)$/);
|
||
if (matches) {
|
||
mimeString = matches[1];
|
||
dataUrl = matches[2];
|
||
}
|
||
}
|
||
|
||
const byteString = atob(dataUrl);
|
||
const ab = new ArrayBuffer(byteString.length);
|
||
const ia = new Uint8Array(ab);
|
||
for (let i = 0; i < byteString.length; i++) {
|
||
ia[i] = byteString.charCodeAt(i);
|
||
}
|
||
|
||
return new File([ab], filename || 'document.pdf', { type: mimeString });
|
||
}
|
||
|
||
/**
|
||
* 显示 Toast 消息
|
||
* @param {string} message - 消息内容
|
||
* @param {string} type - 类型: 'info', 'success', 'error', 'warning'
|
||
* @param {number} duration - 显示时长(毫秒)
|
||
*/
|
||
function showToast(message, type = 'info', duration = 3000) {
|
||
// 检查是否已有 toast 容器
|
||
let toastContainer = document.getElementById('pbx-toast-container');
|
||
if (!toastContainer) {
|
||
toastContainer = document.createElement('div');
|
||
toastContainer.id = 'pbx-toast-container';
|
||
toastContainer.style.cssText = `
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
z-index: 10001;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
`;
|
||
document.body.appendChild(toastContainer);
|
||
}
|
||
|
||
// 创建 toast 元素
|
||
const toast = document.createElement('div');
|
||
const colors = {
|
||
info: '#3b82f6',
|
||
success: '#10b981',
|
||
error: '#ef4444',
|
||
warning: '#f59e0b'
|
||
};
|
||
|
||
toast.style.cssText = `
|
||
padding: 12px 20px;
|
||
background: ${colors[type] || colors.info};
|
||
color: white;
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
font-size: 14px;
|
||
max-width: 400px;
|
||
word-wrap: break-word;
|
||
animation: slideIn 0.3s ease-out;
|
||
`;
|
||
|
||
// 添加动画样式
|
||
if (!document.getElementById('pbx-toast-styles')) {
|
||
const style = document.createElement('style');
|
||
style.id = 'pbx-toast-styles';
|
||
style.textContent = `
|
||
@keyframes slideIn {
|
||
from { transform: translateX(100%); opacity: 0; }
|
||
to { transform: translateX(0); opacity: 1; }
|
||
}
|
||
@keyframes slideOut {
|
||
from { transform: translateX(0); opacity: 1; }
|
||
to { transform: translateX(100%); opacity: 0; }
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
toast.textContent = message;
|
||
toastContainer.appendChild(toast);
|
||
|
||
// 自动移除
|
||
setTimeout(() => {
|
||
toast.style.animation = 'slideOut 0.3s ease-out forwards';
|
||
setTimeout(() => {
|
||
if (toast.parentNode) {
|
||
toast.parentNode.removeChild(toast);
|
||
}
|
||
// 如果容器为空,移除容器
|
||
if (toastContainer.children.length === 0) {
|
||
toastContainer.parentNode.removeChild(toastContainer);
|
||
}
|
||
}, 300);
|
||
}, duration);
|
||
}
|
||
|
||
/**
|
||
* 执行 OCR 处理
|
||
* @param {File} file - 要处理的文件
|
||
* @param {Function} onProgress - 进度回调函数 (current, total, message)
|
||
* @returns {Promise<Object>} OCR 结果 { markdown, images, metadata }
|
||
*/
|
||
async function performOcr(file, onProgress) {
|
||
// 验证 OcrManager 可用
|
||
if (typeof OcrManager === 'undefined') {
|
||
throw new Error('OCR 模块未加载,请刷新页面重试');
|
||
}
|
||
|
||
// 创建 OcrManager 实例
|
||
const ocrManager = new OcrManager();
|
||
|
||
// 执行 OCR
|
||
const result = await ocrManager.processFile(file, onProgress);
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 执行翻译处理
|
||
* @param {string} markdown - OCR 后的 Markdown 文本
|
||
* @param {Function} onProgress - 进度回调函数
|
||
* @returns {Promise<string>} 翻译后的文本
|
||
*/
|
||
async function performTranslation(markdown, onProgress) {
|
||
// 获取翻译设置
|
||
const settings = typeof loadSettings === 'function' ? loadSettings() : {};
|
||
|
||
const targetLang = settings.targetLanguage || 'zh-CN';
|
||
const targetLangName = targetLang === 'custom'
|
||
? (settings.customTargetLanguageName || '中文')
|
||
: { 'zh-CN': '中文', 'en': 'English', 'ja': '日本語', 'ko': '한국어' }[targetLang] || targetLang;
|
||
|
||
const selectedModel = settings.selectedTranslationModel || 'mistral';
|
||
|
||
// 获取 API Key
|
||
let apiKey = '';
|
||
let modelConfig = null;
|
||
|
||
if (selectedModel === 'custom') {
|
||
// 自定义模型配置
|
||
modelConfig = settings.translationModelConfig || settings.customModelConfig || null;
|
||
if (!modelConfig) {
|
||
throw new Error('请先配置自定义翻译模型');
|
||
}
|
||
} else {
|
||
// 预设模型 - 从 Key 管理器获取 API Key
|
||
if (typeof loadModelKeys === 'function') {
|
||
const keys = loadModelKeys(selectedModel);
|
||
const validKey = keys.find(k => k && k.value && (k.status === 'valid' || k.status === 'untested'));
|
||
if (validKey) {
|
||
apiKey = validKey.value.trim();
|
||
}
|
||
}
|
||
|
||
// 回退到 localStorage
|
||
if (!apiKey) {
|
||
const legacyKey = localStorage.getItem(`${selectedModel}ApiKeys`) || localStorage.getItem('translationApiKeys');
|
||
if (legacyKey) {
|
||
apiKey = legacyKey.trim();
|
||
}
|
||
}
|
||
|
||
if (!apiKey) {
|
||
throw new Error(`请先配置 ${selectedModel} 模型的 API Key`);
|
||
}
|
||
}
|
||
|
||
// 分段翻译长文本
|
||
const chunks = splitMarkdownIntoChunks(markdown, 2000);
|
||
let translatedText = '';
|
||
const totalChunks = chunks.length;
|
||
|
||
onProgress && onProgress(0, totalChunks, '正在翻译...');
|
||
|
||
// 临时禁用 promptPoolUI,避免历史详情页访问不存在的 UI 元素
|
||
const originalPromptPoolUI = window.promptPoolUI;
|
||
window.promptPoolUI = undefined;
|
||
|
||
try {
|
||
for (let i = 0; i < chunks.length; i++) {
|
||
onProgress && onProgress(i + 1, totalChunks, `翻译中 (${i + 1}/${totalChunks})`);
|
||
|
||
try {
|
||
// 构建翻译选项
|
||
const translateOptions = {};
|
||
|
||
// 如果是自定义模型,需要传入 modelConfig
|
||
if (selectedModel === 'custom' && modelConfig) {
|
||
translateOptions.modelConfig = modelConfig;
|
||
}
|
||
|
||
// 调用 translateMarkdown 函数 - 不传 boundPrompt,让它使用内置提示词
|
||
const chunkResult = await translateMarkdown(
|
||
chunks[i],
|
||
targetLangName,
|
||
selectedModel,
|
||
apiKey,
|
||
'[历史详情页翻译]', // logContext
|
||
'', // defaultSystemPrompt - 空值会触发内置提示词
|
||
'', // defaultUserPromptTemplate - 空值会触发内置提示词
|
||
false, // useCustomPrompts
|
||
true, // processTablePlaceholders
|
||
translateOptions
|
||
);
|
||
|
||
translatedText += chunkResult + '\n\n';
|
||
} catch (error) {
|
||
console.error(`[performTranslation] 翻译第 ${i + 1} 块失败:`, error);
|
||
// 如果某块翻译失败,保留原文
|
||
translatedText += chunks[i] + '\n\n';
|
||
}
|
||
}
|
||
} finally {
|
||
// 恢复 promptPoolUI
|
||
window.promptPoolUI = originalPromptPoolUI;
|
||
}
|
||
|
||
return translatedText.trim();
|
||
}
|
||
|
||
/**
|
||
* 将 Markdown 分割成小块
|
||
* @param {string} markdown - Markdown 文本
|
||
* @param {number} maxChars - 每块最大字符数
|
||
* @returns {string[]} 分割后的块数组
|
||
*/
|
||
function splitMarkdownIntoChunks(markdown, maxChars = 2000) {
|
||
const chunks = [];
|
||
const lines = markdown.split('\n');
|
||
let currentChunk = '';
|
||
|
||
for (const line of lines) {
|
||
if (currentChunk.length + line.length + 1 > maxChars) {
|
||
if (currentChunk.trim()) {
|
||
chunks.push(currentChunk.trim());
|
||
}
|
||
currentChunk = line + '\n';
|
||
} else {
|
||
currentChunk += line + '\n';
|
||
}
|
||
}
|
||
|
||
if (currentChunk.trim()) {
|
||
chunks.push(currentChunk.trim());
|
||
}
|
||
|
||
return chunks.length > 0 ? chunks : [markdown];
|
||
}
|
||
|
||
/**
|
||
* 创建确认对话框
|
||
* @param {string} title - 对话框标题
|
||
* @param {string} message - 对话框消息
|
||
* @param {string} confirmText - 确认按钮文本
|
||
* @param {string} cancelText - 取消按钮文本
|
||
* @returns {Promise<boolean>} 用户是否确认
|
||
*/
|
||
function showConfirmDialog(title, message, confirmText = '确认', cancelText = '取消') {
|
||
return new Promise((resolve) => {
|
||
// 创建对话框容器
|
||
const dialogOverlay = document.createElement('div');
|
||
dialogOverlay.id = 'pbx-confirm-dialog-overlay';
|
||
dialogOverlay.style.cssText = `
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 10000;
|
||
`;
|
||
|
||
const dialogBox = document.createElement('div');
|
||
dialogBox.style.cssText = `
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
max-width: 400px;
|
||
width: 90%;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||
`;
|
||
|
||
dialogBox.innerHTML = `
|
||
<h3 style="margin: 0 0 16px 0; font-size: 18px; color: #1a1a1a;">${title}</h3>
|
||
<p style="margin: 0 0 24px 0; font-size: 14px; color: #666; line-height: 1.6;">${message}</p>
|
||
<div style="display: flex; gap: 12px; justify-content: flex-end;">
|
||
<button id="pbx-dialog-cancel" style="
|
||
padding: 10px 20px;
|
||
border: 1px solid #ddd;
|
||
background: white;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
color: #666;
|
||
">${cancelText}</button>
|
||
<button id="pbx-dialog-confirm" style="
|
||
padding: 10px 20px;
|
||
border: none;
|
||
background: #4f46e5;
|
||
color: white;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
">${confirmText}</button>
|
||
</div>
|
||
`;
|
||
|
||
dialogOverlay.appendChild(dialogBox);
|
||
document.body.appendChild(dialogOverlay);
|
||
|
||
// 按钮事件
|
||
const confirmBtn = dialogBox.querySelector('#pbx-dialog-confirm');
|
||
const cancelBtn = dialogBox.querySelector('#pbx-dialog-cancel');
|
||
|
||
const cleanup = () => {
|
||
document.body.removeChild(dialogOverlay);
|
||
};
|
||
|
||
confirmBtn.onclick = () => {
|
||
cleanup();
|
||
resolve(true);
|
||
};
|
||
|
||
cancelBtn.onclick = () => {
|
||
cleanup();
|
||
resolve(false);
|
||
};
|
||
|
||
// 点击遮罩层关闭
|
||
dialogOverlay.onclick = (e) => {
|
||
if (e.target === dialogOverlay) {
|
||
cleanup();
|
||
resolve(false);
|
||
}
|
||
};
|
||
|
||
// ESC 键关闭
|
||
const handleEsc = (e) => {
|
||
if (e.key === 'Escape') {
|
||
cleanup();
|
||
resolve(false);
|
||
document.removeEventListener('keydown', handleEsc);
|
||
}
|
||
};
|
||
document.addEventListener('keydown', handleEsc);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 触发重新处理文档(OCR和/或翻译)
|
||
* @param {boolean} includeTranslation - 是否包含翻译
|
||
*/
|
||
async function triggerReprocess(includeTranslation) {
|
||
const docId = window.docIdForLocalStorage;
|
||
const docName = window.data ? window.data.name : '未知文档';
|
||
|
||
if (!docId) {
|
||
showToast('无法获取文档ID,请刷新页面重试。', 'error');
|
||
return;
|
||
}
|
||
|
||
// 检查是否有原始 PDF 数据
|
||
const pdfBase64 = window.data?.metadata?.originalPdfBase64;
|
||
if (!pdfBase64) {
|
||
showToast('当前记录没有保存原始PDF数据,无法重新处理。', 'error');
|
||
return;
|
||
}
|
||
|
||
// 显示处理中 Toast
|
||
showToast('正在准备文档...', 'info');
|
||
|
||
try {
|
||
// 将 Base64 转为 File 对象
|
||
const file = base64ToFile(pdfBase64, docName);
|
||
|
||
// 执行 OCR
|
||
showToast('正在进行 OCR 识别...', 'info');
|
||
|
||
const ocrResult = await performOcr(file, (current, total, msg) => {
|
||
showToast(`${msg || 'OCR 处理中'} (${current}/${total})`, 'info', 5000);
|
||
});
|
||
|
||
// 更新 window.data
|
||
window.data.ocr = ocrResult.markdown;
|
||
if (ocrResult.images && ocrResult.images.length > 0) {
|
||
window.data.images = ocrResult.images;
|
||
}
|
||
if (ocrResult.metadata) {
|
||
window.data.metadata = window.data.metadata || {};
|
||
window.data.metadata.ocrEngine = ocrResult.metadata.engine;
|
||
window.data.metadata.pageCount = ocrResult.metadata.pageCount;
|
||
}
|
||
|
||
// 如果需要翻译,执行翻译
|
||
if (includeTranslation) {
|
||
showToast('正在进行翻译...', 'info');
|
||
|
||
try {
|
||
const translationResult = await performTranslation(ocrResult.markdown, (current, total, msg) => {
|
||
showToast(`${msg || '翻译中'} (${current}/${total})`, 'info', 5000);
|
||
});
|
||
window.data.translation = translationResult;
|
||
} catch (translationError) {
|
||
console.error('[triggerReprocess] 翻译失败:', translationError);
|
||
showToast(`翻译失败: ${translationError.message},但 OCR 已完成`, 'warning');
|
||
// 继续保存 OCR 结果,即使翻译失败
|
||
}
|
||
}
|
||
|
||
// 保存到 IndexedDB
|
||
showToast('正在保存...', 'info');
|
||
await saveResultToDB(window.data);
|
||
|
||
// 刷新页面显示
|
||
if (typeof renderDetail === 'function') {
|
||
renderDetail();
|
||
}
|
||
if (typeof showTab === 'function') {
|
||
showTab(includeTranslation ? 'translation' : 'ocr');
|
||
}
|
||
|
||
showToast('处理完成!', 'success');
|
||
|
||
} catch (error) {
|
||
console.error('[triggerReprocess] 处理失败:', error);
|
||
showToast(`处理失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理"Word文档"标签点击
|
||
* 如果没有OCR数据,询问用户是否需要生成
|
||
*/
|
||
async function handleOcrTabClick() {
|
||
// 如果已有OCR数据,直接显示
|
||
if (hasOcrData()) {
|
||
showTab('ocr');
|
||
return;
|
||
}
|
||
|
||
// 检查是否有原始PDF
|
||
if (!hasOriginalPdfData()) {
|
||
showToast('当前记录没有保存原始PDF数据,无法重新处理。', 'warning');
|
||
return;
|
||
}
|
||
|
||
// 弹出确认对话框
|
||
const confirmed = await showConfirmDialog(
|
||
'生成Word文档',
|
||
'当前文档尚未进行OCR处理。是否需要启动OCR处理生成Word文档?\n\n处理完成后可在本页面查看和导出。',
|
||
'启动OCR',
|
||
'取消'
|
||
);
|
||
|
||
if (confirmed) {
|
||
await triggerReprocess(false); // 仅OCR,不翻译
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理"仅翻译"标签点击
|
||
* 如果没有翻译数据,询问用户是否需要生成翻译对照文档
|
||
*/
|
||
async function handleTranslationTabClick() {
|
||
// 如果已有翻译数据,直接显示
|
||
if (hasTranslationData()) {
|
||
showTab('translation');
|
||
return;
|
||
}
|
||
|
||
// 检查是否有原始PDF
|
||
if (!hasOriginalPdfData()) {
|
||
showToast('当前记录没有保存原始PDF数据,无法重新处理。', 'warning');
|
||
return;
|
||
}
|
||
|
||
// 弹出确认对话框
|
||
const confirmed = await showConfirmDialog(
|
||
'生成翻译对照文档',
|
||
'当前文档尚未进行翻译处理。是否需要启动OCR+翻译处理?\n\n这将生成原文Word文档和翻译对照文档,处理完成后可在本页面查看和导出。',
|
||
'启动OCR+翻译',
|
||
'取消'
|
||
);
|
||
|
||
if (confirmed) {
|
||
await triggerReprocess(true); // OCR + 翻译
|
||
}
|
||
}
|
||
|
||
// 绑定 tab 按钮点击事件
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
if (document.getElementById('tab-ocr')) {
|
||
document.getElementById('tab-ocr').onclick = handleOcrTabClick;
|
||
}
|
||
if (document.getElementById('tab-translation')) {
|
||
document.getElementById('tab-translation').onclick = handleTranslationTabClick;
|
||
}
|
||
if (document.getElementById('tab-chunk-compare')) {
|
||
document.getElementById('tab-chunk-compare').onclick = function() { showTab('chunk-compare'); };
|
||
}
|
||
if (document.getElementById('tab-pdf-compare')) {
|
||
document.getElementById('tab-pdf-compare').onclick = function() { showTab('pdf-compare'); };
|
||
}
|
||
if (document.getElementById('tab-original-file')) {
|
||
document.getElementById('tab-original-file').onclick = function() { showTab('original-file'); };
|
||
}
|
||
|
||
// 页面加载后渲染详情
|
||
if (typeof renderDetail === 'function') {
|
||
renderDetail();
|
||
}
|
||
});
|