paper-burner/js/history/history_detail_scripts.js

587 lines
17 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.

// 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();
}
});