feat: 完成ocrTab的逻辑

This commit is contained in:
肖应宇 2026-03-10 09:56:19 +08:00
parent 95fa091565
commit b3003c4606
7 changed files with 482 additions and 81 deletions

View File

@ -877,23 +877,30 @@ body.immersive-active #immersive-main-content-area .container {
/* ==================== 16. 简单沉浸模式样式 ==================== */
/* 简单沉浸模式隐藏元素 */
body.simple-immersive-mode #fileName,
body.simple-immersive-mode .tabs-container,
body.simple-immersive-mode #fileMeta {
body.simple-immersive-pdf-mode #fileName,
body.simple-immersive-pdf-mode .tabs-container,
body.simple-immersive-pdf-mode #fileMeta {
display: none !important;
}
/* 简单沉浸模式下PDF阅读器高度为100vh */
body.simple-immersive-mode #pdf-viewer-iframe {
height: 100vh !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
z-index: 1000 !important;
/* 简单沉浸模式下 container 的 padding 设为 0 */
body.simple-immersive-pdf-mode .container {
padding: 0 !important;
margin: 0 auto !important;
}
/* 简单沉浸模式下 container 的 padding 设为 0 */
body.simple-immersive-pdf-mode #immersive-main-content-area .container {
padding: 0 !important;
margin: 0 auto !important;
}
/* 简单沉浸模式下 container 的 padding 设为 0 */
body.simple-immersive-pdf-mode .history-export-controls {
margin: 0 !important;
}
/* ==================== 17. 打印样式 ==================== */
@media print {
@ -902,9 +909,9 @@ body.simple-immersive-mode #pdf-viewer-iframe {
display: none !important;
}
body.simple-immersive-mode #fileName,
body.simple-immersive-mode .tabs-container,
body.simple-immersive-mode #fileMeta {
body.simple-immersive-pdf-mode #fileName,
body.simple-immersive-pdf-mode .tabs-container,
body.simple-immersive-pdf-mode #fileMeta {
display: none !important;
}

View File

@ -14,6 +14,7 @@ body {
.container {
max-width: 1200px;
margin: 40px auto;
height: 100vh;
background: var(--color-bg-base);
border-radius: var(--radius-xl);
/* 使用变量定义的轻量化阴影 */
@ -86,6 +87,7 @@ body {
.tab-content {
background: transparent;
padding: 8px 0;
height: 100vh;
min-height: 300px;
margin-top: 0;
/* 优化阅读体验的字体设置 */

View File

@ -532,30 +532,7 @@
</style>
</head>
<body class="bg-slate-50 min-h-screen">
<script>
// 可选:每天一次跳转到落地页(仅在非后端模式且未强制 backend 时启用)
(function() {
try {
var currentPath = window.location.pathname;
// 如果明确进入后端模式(通过查询参数或全局变量),则不跳转落地页,避免影响使用
var params = new URLSearchParams(window.location.search);
var forcedMode = (params.get('mode') || '').toLowerCase();
var envMode = (window.ENV_DEPLOYMENT_MODE || '').toLowerCase();
var shouldSkip = (forcedMode === 'backend') || (envMode === 'backend');
if (shouldSkip) return;
if (currentPath.indexOf('landing-page.html') !== -1) return;
var today = new Date().toDateString();
var lastShownDate = localStorage.getItem('paperBurnerLandingLastShown');
if (lastShownDate !== today) {
localStorage.setItem('paperBurnerLandingLastShown', today);
var basePath = currentPath.substring(0, currentPath.lastIndexOf('/') + 1);
window.location.href = basePath + 'views/landing/landing-page.html';
}
} catch (e) { /* ignore */ }
})();
</script>
<!-- Mobile Sidebar Overlay -->
<div id="sidebarOverlay" class="sidebar-overlay md:hidden"></div>
@ -566,9 +543,11 @@
<!-- ===================== -->
<aside id="appSidebar" class="app-sidebar">
<div class="px-6 py-5 flex items-center justify-between transition-all" id="sidebarHeader">
<a href="views/landing/landing-page.html" class="hover:opacity-80 transition-opacity" title="返回落地页">
<!-- <a href="views/landing/landing-page.html" class="hover:opacity-80 transition-opacity" title="返回落地页">
<img id="sidebarLogo" src="public/h_with_name.svg" class="h-8 transition-all" alt="Paper Burner X">
</a>
</a> -->
<!-- 占位元素 -->
<span></span>
<!-- Desktop Collapse Button -->
<button id="sidebarToggleBtn" class="hidden md:flex text-slate-400 hover:text-slate-600 p-1.5 hover:bg-slate-100 rounded-md transition-colors" title="切换侧边栏">
<iconify-icon id="sidebarToggleIcon" icon="carbon:side-panel-close" width="20"></iconify-icon>
@ -605,34 +584,22 @@
</div>
<div class="my-4 border-t border-slate-100 mx-3 transition-all" id="sidebarDivider"></div>
<div class="nav-section-title px-3 mb-2 text-[11px] font-bold text-slate-400 uppercase tracking-wider transition-opacity">
<!-- <div class="nav-section-title px-3 mb-2 text-[11px] font-bold text-slate-400 uppercase tracking-wider transition-opacity">
系统
</div>
<div id="sidebarSettingsBtn" class="nav-item cursor-pointer" title="全局设置">
<iconify-icon icon="carbon:settings" class="nav-icon"></iconify-icon>
<span class="nav-text transition-opacity">全局设置</span>
</div>
</div> -->
</nav>
<!-- 删除界面会加载不出来 -->
<div class="p-3 transition-all" id="sidebarFooter">
<div class="p-4 bg-slate-50 rounded-2xl border border-slate-100 overflow-hidden">
<a href="https://github.com/Feather-2/paper-burner" target="_blank" class="flex items-start gap-3 hover:opacity-80 transition-opacity" title="GitHub 仓库">
<div class="w-10 h-10 rounded-xl bg-white border border-slate-200 flex items-center justify-center text-slate-400 shadow-sm shrink-0">
<iconify-icon icon="carbon:logo-github" width="24"></iconify-icon>
</div>
<div class="flex-1 min-w-0 nav-text transition-opacity">
<div class="flex items-center gap-1.5 mb-1">
<span id="githubStars" class="inline-flex items-center gap-1 text-xs text-slate-500 bg-slate-100 px-2 py-0.5 rounded-full">
<iconify-icon icon="carbon:star" width="10"></iconify-icon>
<span>加载中...</span>
</span>
</div>
<p class="text-xs text-slate-500">支持自部署,欢迎星标</p>
</div>
</a>
</div>
<div class="text-center py-1 mt-1 nav-text transition-opacity">
<span id="showCopyrightModal" class="text-[11px] text-slate-400 hover:text-slate-600 transition-colors cursor-pointer select-none">
Paper Burner X 丨 关于
</span>
</div>
</div>

View File

@ -55,6 +55,268 @@ 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 - 对话框标题
@ -167,22 +429,76 @@ async function triggerReprocess(includeTranslation) {
const docName = window.data ? window.data.name : '未知文档';
if (!docId) {
alert('无法获取文档ID请刷新页面重试。');
showToast('无法获取文档ID请刷新页面重试。', 'error');
return;
}
// 保存处理模式到 localStorage以便主页面读取
const reprocessConfig = {
docId: docId,
docName: docName,
includeTranslation: includeTranslation,
timestamp: Date.now()
};
localStorage.setItem('pbx_reprocess_config', JSON.stringify(reprocessConfig));
// 检查是否有原始 PDF 数据
const pdfBase64 = window.data?.metadata?.originalPdfBase64;
if (!pdfBase64) {
showToast('当前记录没有保存原始PDF数据无法重新处理。', 'error');
return;
}
// 跳转到主页面
const baseUrl = window.location.pathname.replace('/views/history/history_detail.html', '/');
window.location.href = baseUrl + '?reprocess=1';
// 显示处理中 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');
}
}
/**
@ -198,7 +514,7 @@ async function handleOcrTabClick() {
// 检查是否有原始PDF
if (!hasOriginalPdfData()) {
alert('当前记录没有保存原始PDF数据无法重新处理。');
showToast('当前记录没有保存原始PDF数据无法重新处理。', 'warning');
return;
}
@ -228,7 +544,7 @@ async function handleTranslationTabClick() {
// 检查是否有原始PDF
if (!hasOriginalPdfData()) {
alert('当前记录没有保存原始PDF数据无法重新处理。');
showToast('当前记录没有保存原始PDF数据无法重新处理。', 'warning');
return;
}

View File

@ -213,19 +213,17 @@ function showTabImmediate(tab) {
// 构建 iframe 容器
document.getElementById('tabContent').innerHTML = `
<div id="pdf-iframe-wrapper" style="
width: 100%; height: calc(100vh - 180px);
width: 100%; height: 100%;
min-height: 500px; position: relative;
background: #525659;
">
<div id="pdf-viewer-loading" style="
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
color:#ccc;font-size:16px;text-align:center;
">
<i class="fa fa-spinner fa-spin" style="font-size:32px;display:block;margin-bottom:12px;"></i>
正在加载 PDF 查看器...
</div>
<iframe id="pdf-viewer-iframe" style="
width:100%; height:100%; border:none; display:none;
width:100%; height:100%; min-height:500px; border:none; display:none;
" allowfullscreen></iframe>
</div>`;

View File

@ -632,13 +632,110 @@
if (simpleToggleBtn) {
simpleToggleBtn.addEventListener('click', () => {
isSimpleImmersiveActive = !isSimpleImmersiveActive;
// 检查是否在"原始文件"标签页PDF查看器
const isOriginalFileTab = window.currentVisibleTabId === 'original-file';
const pdfIframeWrapper = document.getElementById('pdf-iframe-wrapper');
const pdfIframe = document.getElementById('pdf-viewer-iframe');
const mainContainer = document.querySelector('.container');
if (isSimpleImmersiveActive) {
document.body.classList.add('simple-immersive-mode');
// 如果是PDF查看器标签页进行特殊处理
if (isOriginalFileTab && pdfIframeWrapper && mainContainer) {
// 添加特殊类用于PDF全屏模式
document.body.classList.add('simple-immersive-pdf-mode');
// 移除container的padding
mainContainer.dataset.originalPadding = mainContainer.style.padding || '';
mainContainer.style.padding = '0 !important';
// 让iframe wrapper充满container接近全屏
pdfIframeWrapper.style.cssText = `
width: 100%;
height: calc(100vh - 20px);
min-height: calc(100vh - 20px);
position: relative;
background: #525659;
`;
if (pdfIframe) {
pdfIframe.style.cssText = `
width: 100%;
height: 100%;
border: none;
`;
}
// 隐藏其他UI元素
const elementsToHide = [
{ selector: '.tabs-container', el: document.querySelector('.tabs-container') },
{ selector: '.history-export-controls', el: document.querySelector('.history-export-controls') },
{ selector: '#bottom-left-dock', el: document.getElementById('bottom-left-dock') },
{ selector: '#toc-popup', el: document.getElementById('toc-popup') },
{ selector: '#fileName', el: document.getElementById('fileName') },
{ selector: '#fileMeta', el: document.getElementById('fileMeta') }
];
elementsToHide.forEach(item => {
if (item.el) {
item.el.dataset.originalDisplay = item.el.style.display || '';
item.el.style.display = 'none';
}
});
// 隐藏loading指示器
const loading = document.getElementById('pdf-viewer-loading');
if (loading) loading.style.display = 'none';
// 确保iframe显示
if (pdfIframe) pdfIframe.style.display = 'block';
} else {
// 非PDF查看器标签页使用原有的简单沉浸模式
document.body.classList.add('simple-immersive-pdf-mode');
}
simpleToggleBtn.innerHTML = '<i class="fas fa-eye-slash"></i>';
simpleToggleBtn.title = '退出简单沉浸模式';
localStorage.setItem(LS_SIMPLE_IMMERSIVE_KEY, 'true');
} else {
document.body.classList.remove('simple-immersive-mode');
// 退出简单沉浸模式
document.body.classList.remove('simple-immersive-pdf-mode');
document.body.classList.remove('simple-immersive-pdf-mode');
// 恢复container的padding
if (mainContainer && mainContainer.dataset.originalPadding !== undefined) {
mainContainer.style.padding = mainContainer.dataset.originalPadding;
delete mainContainer.dataset.originalPadding;
}
// 恢复所有被隐藏的元素
const elementsToRestore = [
{ selector: '.tabs-container', el: document.querySelector('.tabs-container') },
{ selector: '.history-export-controls', el: document.querySelector('.history-export-controls') },
{ selector: '#bottom-left-dock', el: document.getElementById('bottom-left-dock') },
{ selector: '#toc-popup', el: document.getElementById('toc-popup') },
{ selector: '#fileName', el: document.getElementById('fileName') },
{ selector: '#fileMeta', el: document.getElementById('fileMeta') }
];
elementsToRestore.forEach(item => {
if (item.el && item.el.dataset.originalDisplay !== undefined) {
item.el.style.display = item.el.dataset.originalDisplay;
delete item.el.dataset.originalDisplay;
}
});
// 恢复iframe wrapper的原始样式
if (pdfIframeWrapper) {
pdfIframeWrapper.style.cssText = `
width: 100%;
height: 100%;
min-height: 500px;
position: relative;
background: #525659;
`;
}
simpleToggleBtn.innerHTML = '<i class="fas fa-eye"></i>';
simpleToggleBtn.title = '进入简单沉浸模式';
localStorage.setItem(LS_SIMPLE_IMMERSIVE_KEY, 'false');
@ -690,7 +787,7 @@
if (!isImmersiveActive) {
setTimeout(() => {
if (!isSimpleImmersiveActive) {
document.body.classList.add('simple-immersive-mode');
document.body.classList.add('simple-immersive-pdf-mode');
isSimpleImmersiveActive = true;
if (toggleBtn) {
toggleBtn.innerHTML = '<i class="fas fa-compress-alt"></i>';

View File

@ -173,12 +173,14 @@
<aside class="app-sidebar" id="appSidebar">
<!-- 侧边栏头部 -->
<div class="sidebar-header">
<img
<!-- <img
src="../../public/h_with_name.svg"
alt="Paper Burner X"
class="sidebar-logo"
id="sidebarLogo"
/>
/> -->
<!-- 占位元素 -->
<span></span>
<!-- 桌面端收起按钮 -->
<button
class="sidebar-toggle-btn"
@ -812,6 +814,18 @@
<script src="../../js/api/api.js"></script>
<script src="../../js/process/translation.js"></script>
<script src="../../js/process/mineru-structured-translation.js"></script>
<!-- OCR Manager 及适配器 -->
<script src="../../js/process/ocr-manager.js"></script>
<script src="../../js/process/ocr-adapters/base-adapter.js"></script>
<script src="../../js/process/ocr-adapters/mistral-adapter.js"></script>
<script src="../../js/process/ocr-adapters/mineru-adapter.js"></script>
<script src="../../js/process/ocr-adapters/doc2x-adapter.js"></script>
<script src="../../js/process/ocr-adapters/local-adapter.js"></script>
<script src="../../js/process/ocr.js"></script>
<!-- OCR 设置管理器 -->
<script src="../../js/ui/ocr-settings.js"></script>
<script src="../../js/annotations/annotation_logic.js"></script>
<!-- Defines helpers like checkIfTextIsHighlighted -->
<script src="../../js/annotations/custom_markdown_renderer.js"></script>