Files
Invoice/demo/templates/index2.html
ModelHub XC 529c996d3e 初始化项目,由ModelHub XC社区提供模型
Model: FLYFAI/Invoice
Source: Original Platform
2026-08-30 02:16:13 +08:00

1027 lines
56 KiB
HTML
Raw Permalink 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.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>AI发票识别系统 - Mint Edition</title>
<link href="/static/favicon.ico" rel="icon" type="image/x-icon"/>
<script src="/static/js/tailwindcss.js"></script>
<link href="/static/css/inter-fonts.css" rel="stylesheet"/>
<link href="/static/css/all.min.css" rel="stylesheet"/>
<script src="/static/js/xlsx.full.min.js"></script>
<script src="/static/js/jszip.min.js"></script>
<script src="/static/js/FileSaver.min.js"></script>
<style>
body { font-family: 'Inter', sans-serif; }
.glass-panel { background: rgba(255, 255, 255, 0.7); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border: 1px solid rgba(255, 255, 255, 0.6); }
.glass-card { background: rgba(255, 255, 255, 0.6); border: 1px solid rgba(255, 255, 255, 0.5); transition: all 0.2s ease; }
.glass-card:hover { background: rgba(255, 255, 255, 0.9); }
.scan-line { position: absolute; width: 100%; height: 4px; background: linear-gradient(to right, transparent, #2dd4bf, transparent); box-shadow: 0 0 15px rgba(45, 212, 191, 0.8); animation: scan 2s linear infinite; top: 0; z-index: 10; display: none; }
@keyframes scan { 0% { top: 0%; opacity: 0; } 10% { opacity: 1; } 90% { opacity: 1; } 100% { top: 100%; opacity: 0; } }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
.animate-fade-in-up { animation: fadeInUp 0.4s ease-out forwards; opacity: 0; transform: translateY(10px); }
@keyframes fadeInUp { to { opacity: 1; transform: translateY(0); } }
.file-item-active { border-color: #2dd4bf; background-color: rgba(240, 253, 250, 0.9); }
.btn-primary { background-color: #0d9488; color: white; transition: 0.3s; }
.btn-primary:hover { background-color: #0f766e; transform: translateY(-1px); }
.btn-success { background-color: #10b981; color: white; transition: 0.3s; }
.btn-success:hover { background-color: #059669; transform: translateY(-1px); }
.btn-action { background-color: #f59e0b; color: white; transition: 0.3s; }
.btn-action:hover { background-color: #d97706; }
.btn-copy { background-color: rgba(255, 255, 255, 0.1); backdrop-filter: blur(4px); border: 1px solid rgba(255, 255, 255, 0.2); transition: all 0.2s; }
.btn-copy:hover { background-color: rgba(45, 212, 191, 0.2); border-color: rgba(45, 212, 191, 0.5); }
/* Prompt Sidebar Styles */
.prompt-sidebar-item { @apply px-3 py-2 text-sm rounded-lg cursor-pointer transition-colors flex justify-between items-center; }
.prompt-sidebar-item:hover { @apply bg-slate-100; }
.prompt-sidebar-item.active { @apply bg-teal-50 text-teal-700 font-medium border border-teal-100; }
</style>
</head>
<body class="bg-slate-50 text-slate-800 h-screen overflow-hidden flex flex-col relative">
<div class="absolute top-0 left-0 w-full h-full -z-10 overflow-hidden pointer-events-none">
<div class="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-teal-200/40 rounded-full mix-blend-multiply filter blur-[100px] animate-blob"></div>
<div class="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-emerald-200/40 rounded-full mix-blend-multiply filter blur-[100px] animate-blob animation-delay-2000"></div>
</div>
<header class="w-full px-8 py-4 flex justify-between items-center z-50 bg-white/40 backdrop-blur-md border-b border-white/50">
<div class="flex items-center gap-3">
<div class="w-9 h-9 bg-teal-600 rounded-xl flex items-center justify-center shadow-teal-200/50 shadow-lg overflow-hidden">
<img src="/static/favicon.ico" alt="Logo" class="w-full h-full object-contain">
</div>
<span class="text-xl font-bold tracking-tight">AI<span class="text-teal-600">智能识别系统</span></span>
</div>
<div class="flex gap-3">
<button onclick="openPromptModal()" class="px-4 py-2 bg-white/60 hover:bg-white text-slate-600 rounded-lg text-sm font-medium transition-all shadow-sm border border-slate-200 flex items-center gap-2 group">
<i class="fa-solid fa-sliders text-teal-500 group-hover:rotate-45 transition-transform"></i>
<span id="current-prompt-label">系统预设:发票识别</span>
</button>
<div class="h-8 w-px bg-slate-300 mx-1 self-center"></div>
<button onclick="clearHistory()" class="px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-500 hover:text-slate-700 rounded-lg text-sm font-medium transition-colors">
<i class="fa-solid fa-trash-arrow-up mr-2"></i>清空
</button>
<button id="start-btn" onclick="startRecognition()" class="btn-action px-5 py-2 rounded-lg text-sm font-bold shadow-lg shadow-amber-100/50 flex items-center gap-2">
<i class="fa-solid fa-bolt"></i> 一键识别
</button>
</div>
</header>
<main class="flex-1 flex gap-6 p-6 h-[calc(100vh-80px)] max-w-[1600px] mx-auto w-full">
<section class="flex-1 glass-panel rounded-[2rem] shadow-[0_8px_30px_rgb(0,0,0,0.04)] flex flex-col overflow-hidden relative border-white/60">
<div class="p-4 border-b border-slate-100 flex justify-between items-center bg-white/40">
<h2 class="font-semibold text-lg flex items-center gap-2 text-slate-700">
<i class="fa-solid fa-folder-open text-teal-500"></i> 文件列表
<span class="text-xs bg-teal-50 text-teal-600 px-2 py-0.5 rounded-full border border-teal-100" id="file-count-badge">0</span>
</h2>
<div class="flex gap-2">
<button onclick="document.getElementById('file-input').click()" class="btn-primary px-3 py-1.5 text-sm font-medium rounded-lg flex items-center gap-2 shadow-lg shadow-teal-100/50">
<i class="fa-solid fa-plus"></i> 添加文件
</button>
</div>
<input type="file" id="file-input" class="hidden" accept="image/jpeg,image/png,application/pdf" multiple/>
</div>
<div class="flex-1 flex flex-col h-full overflow-hidden relative bg-slate-50/20">
<div id="empty-state" class="absolute inset-0 flex flex-col items-center justify-center text-center p-8 z-20 bg-white/60 backdrop-blur-sm transition-all duration-300">
<div class="w-24 h-24 bg-teal-50 rounded-3xl flex items-center justify-center mb-4 border-2 border-dashed border-teal-200">
<i class="fa-solid fa-cloud-arrow-up text-4xl text-teal-300"></i>
</div>
<h3 class="text-lg font-semibold text-slate-700">拖拽或添加文件</h3>
<p class="text-sm text-slate-400 mt-2">支持 JPG, PNG, PDF</p>
</div>
<div id="main-preview-area" class="flex-[3] relative border-b border-white/50 flex items-center justify-center p-4 overflow-hidden hidden bg-slate-100/30">
<img id="active-image-preview" class="max-h-full max-w-full object-contain rounded-lg shadow-sm hidden" src="" alt="">
<div id="active-pdf-preview" class="hidden flex-col items-center animate-fade-in-up">
<div class="w-28 h-36 bg-gradient-to-tr from-red-50 to-white border border-red-100 rounded-xl shadow flex items-center justify-center relative mb-3">
<i class="fa-solid fa-file-pdf text-4xl text-red-400/80"></i>
</div>
<p class="text-sm font-medium text-slate-700 max-w-[200px] truncate" id="active-pdf-name"></p>
</div>
<div id="scan-line" class="scan-line"></div>
</div>
<div class="flex-[2] overflow-y-auto p-4 space-y-3 relative z-10" id="file-list-container"></div>
</div>
</section>
<section class="w-[450px] glass-panel rounded-[2rem] shadow-[0_8px_30px_rgb(0,0,0,0.04)] flex flex-col overflow-hidden transition-all border-white/60">
<div class="p-4 border-b border-slate-100 bg-white/40 flex justify-between items-center">
<h2 class="font-semibold text-lg flex items-center gap-2 text-slate-700">
<i class="fa-solid fa-wand-magic-sparkles text-emerald-400"></i> 识别结果
</h2>
<button id="download-single-btn" onclick="openExportDialog('single')" class="hidden text-xs bg-teal-50 text-teal-600 px-2 py-1 rounded border border-teal-200 hover:bg-teal-100 flex items-center gap-1 transition-colors">
<i class="fa-solid fa-download"></i> <span id="download-btn-text">导出</span>
</button>
</div>
<div class="flex-1 overflow-y-auto p-6 relative bg-slate-50/20" id="results-container">
<div id="result-placeholder" class="h-full flex flex-col items-center justify-center text-slate-300 opacity-80">
<i class="fa-regular fa-hand-pointer text-4xl mb-4 text-slate-300"></i>
<p class="text-slate-400">选择文件并点击<br/>"闪电"图标或一键识别</p>
</div>
<div id="result-loading" class="hidden h-full flex flex-col items-center justify-center">
<div class="w-10 h-10 border-4 border-teal-100 border-t-teal-500 rounded-full animate-spin mb-4"></div>
<p class="text-teal-600 font-medium text-sm">AI 正在处理...</p>
</div>
<div id="result-content" class="hidden flex flex-col h-full">
<div id="pagination-controls" class="hidden mb-4 bg-white/80 p-2 rounded-xl flex justify-between items-center shadow-sm border border-slate-100">
<button onclick="changePage(-1)" class="w-8 h-8 rounded-lg bg-white hover:bg-teal-50 text-teal-600 border border-slate-200 flex items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed" id="btn-prev"><i class="fa-solid fa-chevron-left"></i></button>
<span class="text-xs font-mono font-medium text-slate-600">Page <span id="current-page-num" class="text-teal-600 font-bold">1</span> / <span id="total-pages">1</span></span>
<button onclick="changePage(1)" class="w-8 h-8 rounded-lg bg-white hover:bg-teal-50 text-teal-600 border border-slate-200 flex items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed" id="btn-next"><i class="fa-solid fa-chevron-right"></i></button>
</div>
<div class="space-y-6 animate-fade-in-up flex-1">
<div class="bg-teal-50/60 p-5 rounded-2xl border border-teal-100/80 shadow-sm relative overflow-hidden transition-all" id="main-result-card">
<div class="absolute -right-4 -top-4 w-20 h-20 bg-teal-100 rounded-full opacity-20 blur-xl"></div>
<p class="text-xs text-teal-500 font-bold uppercase tracking-wide mb-2 flex items-center gap-1">
<i id="res-main-icon" class="fa-solid fa-coins"></i>
<span id="res-main-label">价税合计 (Total)</span>
</p>
<p class="text-2xl font-bold text-slate-700 tracking-tight break-words line-clamp-3" id="res-main-value" style="font-size: 1.5rem; line-height: 2rem;">--</p>
</div>
<div class="space-y-3">
<div class="group" id="field-group-1">
<label class="text-xs text-slate-400 ml-1 font-medium" id="res-label-1">发票代码/号码</label>
<div class="bg-white/80 p-3 rounded-xl border border-slate-200 shadow-sm font-mono text-sm text-slate-600 truncate" id="res-value-1">--</div>
</div>
<div class="group" id="field-group-2">
<label class="text-xs text-slate-400 ml-1 font-medium" id="res-label-2">开票日期</label>
<div class="bg-white/80 p-3 rounded-xl border border-slate-200 shadow-sm text-sm text-slate-600 truncate" id="res-value-2">--</div>
</div>
<div class="group flex-1 flex flex-col relative">
<label class="text-xs text-slate-400 ml-1 font-medium mb-1">完整内容 / JSON</label>
<div class="relative">
<button onclick="copyResult()" id="copy-btn" class="btn-copy absolute top-2 right-2 text-teal-100 hover:text-white px-2 py-1 rounded-md text-xs flex items-center gap-1 z-10" title="复制内容">
<i class="fa-regular fa-copy"></i> <span id="copy-btn-text">复制</span>
</button>
<pre id="res-raw-json" class="bg-slate-800 text-teal-200 p-3 pt-4 rounded-xl text-xs overflow-x-auto h-48 whitespace-pre-wrap font-mono select-all border border-slate-700 scrollbar-thin scrollbar-thumb-slate-600 scrollbar-track-transparent"></pre>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="p-4 border-t border-slate-100 bg-white/60">
<button class="w-full btn-success py-2.5 rounded-xl shadow-lg shadow-emerald-100/50 flex items-center justify-center gap-2 text-sm font-bold" onclick="openExportDialog('batch')">
<i class="fa-solid fa-file-export"></i> 导出所有结果 (ZIP)
</button>
</div>
</section>
</main>
<div id="prompt-config-modal" class="fixed inset-0 z-[100] hidden flex items-center justify-center">
<div class="absolute inset-0 bg-slate-900/20 backdrop-blur-sm transition-opacity" onclick="closePromptModal()"></div>
<div class="glass-panel w-[800px] h-[500px] flex rounded-2xl shadow-2xl transform transition-all scale-100 relative z-10 mx-4 overflow-hidden">
<div class="w-1/3 bg-slate-50/80 border-r border-slate-200 flex flex-col">
<div class="p-4 border-b border-slate-200">
<h3 class="font-bold text-slate-700 flex items-center gap-2">
<i class="fa-solid fa-book-bookmark text-teal-500"></i> 配置选择
</h3>
</div>
<div class="p-3 flex-1 overflow-y-auto space-y-1" id="prompt-sidebar-list">
<p class="text-xs font-bold text-slate-400 uppercase px-2 mt-2 mb-1">系统预设 (System)</p>
<div onclick="selectSystemPrompt('invoice')" id="nav-sys-invoice" class="prompt-sidebar-item active">
<span><i class="fa-solid fa-file-invoice mr-2 text-slate-400"></i>发票识别</span>
<i class="fa-solid fa-lock text-[10px] text-slate-300"></i>
</div>
<div onclick="selectSystemPrompt('ocr')" id="nav-sys-ocr" class="prompt-sidebar-item">
<span><i class="fa-solid fa-font mr-2 text-slate-400"></i>通用 Markdown</span>
<i class="fa-solid fa-lock text-[10px] text-slate-300"></i>
</div>
<div class="flex justify-between items-center px-2 mt-6 mb-1">
<p class="text-xs font-bold text-slate-400 uppercase">我的提示词库</p>
<button onclick="createNewPrompt()" class="text-xs bg-teal-50 text-teal-600 px-2 py-0.5 rounded border border-teal-100 hover:bg-teal-100 transition"><i class="fa-solid fa-plus"></i> 新建</button>
</div>
<div id="custom-prompt-list-container" class="space-y-1">
</div>
</div>
</div>
<div class="flex-1 flex flex-col bg-white/60">
<div class="p-4 border-b border-slate-200 flex justify-between items-center bg-slate-50/50">
<div class="flex-1 mr-4 flex items-center gap-2">
<span class="text-xs font-bold text-slate-400 whitespace-nowrap">名称:</span>
<input type="text" id="prompt-name-input"
class="w-full bg-white border border-slate-300 text-slate-700 font-bold text-sm rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-teal-500/50 focus:border-teal-500 transition disabled:bg-transparent disabled:border-transparent disabled:text-slate-500"
placeholder="请输入提示词名称..." disabled>
</div>
<div class="flex gap-2" id="prompt-actions">
<button onclick="deleteCurrentPrompt()" id="btn-delete-prompt" class="text-red-400 hover:bg-red-50 hover:text-red-500 px-3 py-1.5 rounded-lg transition hidden text-xs font-medium border border-transparent hover:border-red-100">
<i class="fa-solid fa-trash-can mr-1"></i> 删除
</button>
<button onclick="saveCurrentPrompt()" id="btn-save-prompt" class="btn-primary px-4 py-1.5 rounded-lg text-xs font-bold hidden transition-all shadow-md shadow-teal-100">
<i class="fa-solid fa-floppy-disk mr-1"></i> 保存配置
</button>
<button onclick="applySelection()" class="btn-success px-4 py-1.5 rounded-lg text-xs font-bold shadow-md shadow-emerald-100 hover:shadow-lg transition-all">
<i class="fa-solid fa-check mr-1"></i> 立即使用
</button>
</div>
</div>
<div class="flex-1 p-4 relative flex flex-col">
<textarea id="prompt-editor" class="w-full h-full bg-slate-50 border border-slate-200 rounded-xl p-4 text-xs font-mono text-slate-600 resize-none focus:ring-2 focus:ring-teal-500/50 outline-none scrollbar-thin transition disabled:bg-slate-100 disabled:text-slate-400" placeholder="在此输入提示词内容..." disabled></textarea>
<div id="system-prompt-overlay" class="absolute inset-0 flex items-center justify-center bg-slate-50/50 backdrop-blur-[1px] z-10">
<div class="bg-white/90 p-6 rounded-2xl shadow-lg border border-slate-100 text-center max-w-xs">
<div class="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-3 text-slate-400">
<i class="fa-solid fa-shield-halved text-xl"></i>
</div>
<h4 class="font-bold text-slate-700 mb-1">系统内置提示词</h4>
<p class="text-xs text-slate-500">为了保证核心功能的稳定性,内置提示词由服务器管理,不可查看或编辑。</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="export-config-modal" class="fixed inset-0 z-[100] hidden flex items-center justify-center">
<div class="absolute inset-0 bg-slate-900/20 backdrop-blur-sm transition-opacity" onclick="closeExportModal()"></div>
<div class="glass-panel w-[550px] max-h-[85vh] flex flex-col rounded-2xl shadow-2xl transform transition-all scale-100 relative z-10 mx-4">
<div class="p-4 border-b border-white/50 flex justify-between items-center bg-white/40">
<h3 class="font-semibold text-lg text-slate-700 flex items-center gap-2"><i class="fa-solid fa-file-export text-teal-500"></i> <span id="export-modal-title">导出配置</span></h3>
<button onclick="closeExportModal()" class="text-slate-400 hover:text-red-400 transition"><i class="fa-solid fa-xmark text-xl"></i></button>
</div>
<div class="p-5 overflow-y-auto flex-1">
<div class="mb-5">
<p class="text-sm font-bold text-slate-700 mb-3 flex items-center gap-2">
<i class="fa-solid fa-layer-group text-teal-500"></i> 导出格式
</p>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer bg-white/50 px-4 py-2 rounded-lg border border-slate-200 hover:border-teal-300 transition">
<input type="radio" name="export_format" value="excel" checked onchange="toggleExportFormat()" class="w-4 h-4 text-teal-600 border-slate-300 focus:ring-teal-500">
<span class="text-sm text-slate-700"><i class="fa-solid fa-file-excel text-emerald-500 mr-1"></i> Excel 表格 (.xlsx)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer bg-white/50 px-4 py-2 rounded-lg border border-slate-200 hover:border-teal-300 transition">
<input type="radio" name="export_format" value="json" onchange="toggleExportFormat()" class="w-4 h-4 text-teal-600 border-slate-300 focus:ring-teal-500">
<span class="text-sm text-slate-700"><i class="fa-solid fa-file-code text-amber-500 mr-1"></i> 原始数据 (.json)</span>
</label>
</div>
</div>
<div id="field-selection-area" class="transition-all duration-300">
<div class="flex justify-between items-center mb-2">
<p class="text-sm font-bold text-slate-700 flex items-center gap-2"><i class="fa-solid fa-table-columns text-teal-500"></i> 字段选择</p>
<div class="text-xs space-x-2 text-teal-600"><button onclick="toggleAllKeys(true)" class="hover:underline">全选</button><span>/</span><button onclick="toggleAllKeys(false)" class="hover:underline">全不选</button></div>
</div>
<div class="bg-slate-50/50 border border-slate-200 rounded-xl p-4 h-64 overflow-y-auto scrollbar-thin scrollbar-thumb-slate-300">
<div id="export-keys-container" class="grid grid-cols-2 gap-2"></div>
</div>
</div>
</div>
<div class="p-4 border-t border-white/50 bg-white/40 flex justify-end gap-3">
<button onclick="closeExportModal()" class="px-4 py-2 rounded-xl text-sm font-medium text-slate-500 hover:bg-slate-100 transition">取消</button>
<button onclick="executeExport()" class="btn-primary px-6 py-2 rounded-xl text-sm font-bold shadow-lg shadow-teal-100/50"><i class="fa-solid fa-download mr-2"></i> 确认导出</button>
</div>
</div>
</div>
<script>
// 全局状态
let filesStore = [];
let activeFileIndex = -1;
let currentViewPageIndex = 0;
// 导出状态
let exportMode = 'batch';
let allAvailableKeys = [];
// Prompt 管理状态
let userPrompts = []; // [{id, name, content}, ...]
let selectedPromptId = 'sys-invoice'; // 'sys-invoice', 'sys-ocr', or UUID
let editingPromptId = null; // 当前正在编辑的 Custom Prompt ID
// DOM Elements
const promptConfigModal = document.getElementById('prompt-config-modal');
const promptNameInput = document.getElementById('prompt-name-input');
const promptEditor = document.getElementById('prompt-editor');
const btnSavePrompt = document.getElementById('btn-save-prompt');
const btnDeletePrompt = document.getElementById('btn-delete-prompt');
const systemPromptOverlay = document.getElementById('system-prompt-overlay');
const customPromptListContainer = document.getElementById('custom-prompt-list-container');
const currentPromptLabel = document.getElementById('current-prompt-label');
// 其他 UI Elements
const fileInput = document.getElementById('file-input');
const fileListContainer = document.getElementById('file-list-container');
const emptyState = document.getElementById('empty-state');
const fileCountBadge = document.getElementById('file-count-badge');
const mainPreviewArea = document.getElementById('main-preview-area');
const activeImagePreview = document.getElementById('active-image-preview');
const activePdfPreview = document.getElementById('active-pdf-preview');
const activePdfName = document.getElementById('active-pdf-name');
const scanLine = document.getElementById('scan-line');
const resultPlaceholder = document.getElementById('result-placeholder');
const resultLoading = document.getElementById('result-loading');
const resultContent = document.getElementById('result-content');
const downloadSingleBtn = document.getElementById('download-single-btn');
const downloadBtnText = document.getElementById('download-btn-text');
const paginationControls = document.getElementById('pagination-controls');
const currentPageNum = document.getElementById('current-page-num');
const totalPages = document.getElementById('total-pages');
const btnPrev = document.getElementById('btn-prev');
const btnNext = document.getElementById('btn-next');
const copyBtn = document.getElementById('copy-btn');
const exportConfigModal = document.getElementById('export-config-modal');
const exportKeysContainer = document.getElementById('export-keys-container');
const fieldSelectionArea = document.getElementById('field-selection-area');
function init() {
const history = localStorage.getItem('ocr_history');
if (history) console.log("History loaded");
// Load User Prompts
const savedPrompts = localStorage.getItem('user_prompts');
if (savedPrompts) {
userPrompts = JSON.parse(savedPrompts);
} else {
userPrompts = [];
}
// Restore last selection if possible
const lastUsedPrompt = localStorage.getItem('last_used_prompt_id');
if (lastUsedPrompt) {
// Check if it still exists (if custom)
if (lastUsedPrompt.startsWith('sys-') || userPrompts.find(p => p.id === lastUsedPrompt)) {
selectedPromptId = lastUsedPrompt;
updatePromptLabel();
}
}
}
init();
// --- Prompt Manager Logic ---
function openPromptModal() {
promptConfigModal.classList.remove('hidden');
renderPromptSidebar();
// 选中当前使用的 Prompt
if (selectedPromptId.startsWith('sys-')) {
selectSystemPrompt(selectedPromptId.replace('sys-', ''));
} else {
const p = userPrompts.find(item => item.id === selectedPromptId);
if (p) selectCustomPrompt(p.id);
else selectSystemPrompt('invoice'); // Fallback
}
}
function closePromptModal() {
promptConfigModal.classList.add('hidden');
}
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
function renderPromptSidebar() {
customPromptListContainer.innerHTML = '';
userPrompts.forEach(p => {
const div = document.createElement('div');
div.className = `prompt-sidebar-item ${p.id === editingPromptId ? 'active' : ''}`;
div.onclick = () => selectCustomPrompt(p.id);
div.innerHTML = `
<span class="truncate pr-2"><i class="fa-solid fa-code text-slate-400 mr-2"></i>${p.name}</span>
`;
customPromptListContainer.appendChild(div);
});
// Update System UI classes
document.getElementById('nav-sys-invoice').className = `prompt-sidebar-item ${editingPromptId === 'sys-invoice' ? 'active' : ''}`;
document.getElementById('nav-sys-ocr').className = `prompt-sidebar-item ${editingPromptId === 'sys-ocr' ? 'active' : ''}`;
}
function selectSystemPrompt(type) {
editingPromptId = `sys-${type}`;
renderPromptSidebar();
// UI Updates
promptNameInput.value = type === 'invoice' ? '系统预设:发票识别' : '系统预设:Markdown OCR';
promptNameInput.disabled = true; // disabled makes it transparent via CSS
promptEditor.value = ''; // Don't show content
promptEditor.disabled = true;
systemPromptOverlay.classList.remove('hidden');
btnDeletePrompt.classList.add('hidden');
btnSavePrompt.classList.add('hidden');
}
function selectCustomPrompt(id) {
const p = userPrompts.find(item => item.id === id);
if (!p) return;
editingPromptId = id;
renderPromptSidebar();
// UI Updates
promptNameInput.value = p.name;
promptNameInput.disabled = false;
promptEditor.value = p.content;
promptEditor.disabled = false;
systemPromptOverlay.classList.add('hidden');
btnDeletePrompt.classList.remove('hidden');
btnSavePrompt.classList.remove('hidden');
}
function createNewPrompt() {
const newId = uuidv4();
const newPrompt = {
id: newId,
name: "未命名提示词",
content: ""
};
userPrompts.push(newPrompt);
savePromptsToLocal();
selectCustomPrompt(newId);
// 自动聚焦并清空,方便用户直接命名
const nameInput = document.getElementById('prompt-name-input');
nameInput.value = "";
nameInput.placeholder = "请输入新名称...";
nameInput.focus();
}
function saveCurrentPrompt() {
if (!editingPromptId || editingPromptId.startsWith('sys-')) return;
const p = userPrompts.find(item => item.id === editingPromptId);
if (p) {
p.name = promptNameInput.value.trim() || "未命名";
p.content = promptEditor.value;
savePromptsToLocal();
renderPromptSidebar(); // Refresh name in list
// Visual feedback
const originalText = btnSavePrompt.innerHTML;
btnSavePrompt.innerHTML = '<i class="fa-solid fa-check"></i> 已保存';
setTimeout(() => btnSavePrompt.innerHTML = originalText, 1000);
}
}
function deleteCurrentPrompt() {
if (!editingPromptId || editingPromptId.startsWith('sys-')) return;
if (confirm("确定要删除这个提示词吗?此操作无法撤销。")) {
userPrompts = userPrompts.filter(p => p.id !== editingPromptId);
savePromptsToLocal();
// Switch back to default
if (selectedPromptId === editingPromptId) {
selectedPromptId = 'sys-invoice';
localStorage.setItem('last_used_prompt_id', selectedPromptId);
updatePromptLabel();
}
selectSystemPrompt('invoice');
}
}
function savePromptsToLocal() {
localStorage.setItem('user_prompts', JSON.stringify(userPrompts));
}
function applySelection() {
// If editing a custom prompt, auto-save before applying
if (!editingPromptId.startsWith('sys-')) {
saveCurrentPrompt();
}
selectedPromptId = editingPromptId;
localStorage.setItem('last_used_prompt_id', selectedPromptId);
updatePromptLabel();
closePromptModal();
}
function updatePromptLabel() {
if (selectedPromptId === 'sys-invoice') {
currentPromptLabel.innerText = "系统预设:发票识别";
} else if (selectedPromptId === 'sys-ocr') {
currentPromptLabel.innerText = "系统预设:Markdown OCR";
} else {
const p = userPrompts.find(item => item.id === selectedPromptId);
currentPromptLabel.innerText = p ? `自定义:${p.name}` : "未知提示词";
}
}
function getRequestPayload() {
const formData = new FormData();
if (selectedPromptId === 'sys-invoice') {
formData.append('mode', 'invoice');
} else if (selectedPromptId === 'sys-ocr') {
formData.append('mode', 'ocr');
} else {
const p = userPrompts.find(item => item.id === selectedPromptId);
if (p) {
formData.append('mode', 'custom');
formData.append('custom_prompt', p.content);
} else {
// Fallback
formData.append('mode', 'invoice');
}
}
return formData;
}
// --- Core File Handling ---
function flattenObject(obj, prefix = '', res = {}) { for (const key in obj) { if (!obj.hasOwnProperty(key)) continue; const val = obj[key]; const newKey = prefix ? `${prefix}_${key}` : key; if (val !== null && typeof val === 'object' && !Array.isArray(val)) { flattenObject(val, newKey, res); } else { res[newKey] = Array.isArray(val) ? JSON.stringify(val) : val; } } return res; }
function getUniqueFileName(filesStore, fileName) { let baseName = fileName.substring(0, fileName.lastIndexOf('.')); let ext = fileName.substring(fileName.lastIndexOf('.')); let newName = fileName; let counter = 1; while (filesStore.some(f => f.file.name === newName)) { newName = `${baseName}(${counter})${ext}`; counter++; } return newName; }
function cleanAndParseJSON(str) { if (typeof str !== 'string') return null; try { let cleaned = str.replace(/```json\n/g, '').replace(/```/g, '').trim(); return JSON.parse(cleaned); } catch (e) { return null; } }
function getPageNum(resData) { const name = resData.image_name || resData.file_name; const match = name.match(/_(\d+)\.(png|jpg|jpeg)$/i); return match ? parseInt(match[1]) : 0; }
function handleFiles(files) {
if (files.length === 0) return;
const newFiles = Array.from(files);
newFiles.forEach(f => {
const uniqueName = getUniqueFileName(filesStore, f.name);
let finalFile = f;
if (uniqueName !== f.name) {
finalFile = new File([f], uniqueName, {type: f.type});
}
filesStore.push({file: finalFile, status: 'pending', result: []});
});
updateUI();
if (activeFileIndex === -1 && filesStore.length > 0) {
selectFile(filesStore.length - newFiles.length);
}
}
fileInput.addEventListener('change', (e) => {
handleFiles(e.target.files);
fileInput.value = '';
});
function updateUI() {
fileCountBadge.innerText = filesStore.length;
if (filesStore.length > 0) {
emptyState.classList.add('opacity-0', 'pointer-events-none');
mainPreviewArea.classList.remove('hidden');
} else {
emptyState.classList.remove('opacity-0', 'pointer-events-none');
mainPreviewArea.classList.add('hidden');
resetRightPanel();
}
renderFileList();
}
function renderFileList() {
fileListContainer.innerHTML = '';
filesStore.forEach((item, index) => {
const file = item.file;
const activeClass = index === activeFileIndex ? 'file-item-active shadow-md' : 'shadow-sm border-transparent hover:border-teal-200 bg-white/80';
let statusIcon = '';
if (item.status === 'done') statusIcon = '<i class="fa-solid fa-circle-check text-emerald-500 ml-2"></i>';
else if (item.status === 'error') statusIcon = '<i class="fa-solid fa-circle-exclamation text-red-400 ml-2"></i>';
else if (item.status === 'loading') statusIcon = '<i class="fa-solid fa-spinner animate-spin text-teal-500 ml-2"></i>';
let pageCountBadge = '';
if (item.result && item.result.length > 1) {
pageCountBadge = `<span class="text-[10px] bg-slate-100 px-1.5 py-0.5 rounded text-slate-500 ml-1 border border-slate-200">${item.result.length}页</span>`;
}
const fileItem = document.createElement('div');
fileItem.className = `glass-card rounded-xl p-3 flex items-center justify-between cursor-pointer animate-fade-in-up border ${activeClass}`;
fileItem.style.animationDelay = `${index * 30}ms`;
let thumbnailHtml = '';
if (file.type.includes('image')) {
thumbnailHtml = `<div class="w-10 h-10 bg-slate-100 rounded-lg overflow-hidden flex-shrink-0 border border-slate-200"><img src="${URL.createObjectURL(file)}" class="w-full h-full object-cover"></div>`;
} else {
thumbnailHtml = `<div class="w-10 h-10 bg-red-50 rounded-lg flex items-center justify-center text-red-400 flex-shrink-0 border border-red-100"><i class="fa-solid fa-file-pdf"></i></div>`;
}
fileItem.innerHTML = `
<div class="flex items-center gap-3 overflow-hidden flex-1" onclick="selectFile(${index})">
${thumbnailHtml}
<div class="truncate">
<p class="font-medium text-sm text-slate-700 truncate flex items-center">${file.name} ${statusIcon}</p>
<div class="flex items-center gap-1">
<p class="text-xs text-slate-400">${(file.size / 1024).toFixed(1)} KB</p>
${pageCountBadge}
</div>
</div>
</div>
<div class="flex items-center gap-1">
<button onclick="recognizeSingleFile(event, ${index})" class="w-8 h-8 rounded-full flex items-center justify-center text-slate-400 hover:bg-teal-50 hover:text-teal-600 transition shrink-0 z-10" title="识别此文件"><i class="fa-solid fa-bolt text-sm"></i></button>
<button onclick="deleteFile(event, ${index})" class="w-8 h-8 rounded-full flex items-center justify-center text-slate-400 hover:bg-red-50 hover:text-red-500 transition shrink-0 z-10" title="删除"><i class="fa-solid fa-trash-can text-sm"></i></button>
</div>`;
fileListContainer.appendChild(fileItem);
});
}
function selectFile(index) {
if (index < 0 || index >= filesStore.length) return;
activeFileIndex = index;
currentViewPageIndex = 0;
const item = filesStore[index];
const file = item.file;
renderFileList();
scanLine.style.display = 'none';
void scanLine.offsetWidth;
if (file.type.includes('image')) {
activeImagePreview.src = URL.createObjectURL(file);
activeImagePreview.classList.remove('hidden');
activePdfPreview.classList.add('hidden');
if (item.status === 'loading') scanLine.style.display = 'block';
} else {
activePdfName.innerText = file.name;
activePdfPreview.classList.remove('hidden');
activeImagePreview.classList.add('hidden');
}
displayResult(item);
}
function displayResult(item) {
resetCopyButton();
// DOM Elements
const resMainIcon = document.getElementById('res-main-icon');
const resMainLabel = document.getElementById('res-main-label');
const resMainValue = document.getElementById('res-main-value');
const fieldGroup1 = document.getElementById('field-group-1');
const resLabel1 = document.getElementById('res-label-1');
const resValue1 = document.getElementById('res-value-1');
const fieldGroup2 = document.getElementById('field-group-2');
const resLabel2 = document.getElementById('res-label-2');
const resValue2 = document.getElementById('res-value-2');
const rawJsonPre = document.getElementById('res-raw-json');
if (item.status === 'loading') {
resultPlaceholder.classList.add('hidden');
resultContent.classList.add('hidden');
resultLoading.classList.remove('hidden');
downloadSingleBtn.classList.add('hidden');
return;
} else if (item.result && item.result.length > 0) {
resultPlaceholder.classList.add('hidden');
resultLoading.classList.add('hidden');
resultContent.classList.remove('hidden');
downloadSingleBtn.classList.remove('hidden');
downloadBtnText.innerText = "导出";
// 分页处理
if (item.result.length > 1) {
paginationControls.classList.remove('hidden');
paginationControls.classList.add('flex');
totalPages.innerText = item.result.length;
currentPageNum.innerText = currentViewPageIndex + 1;
btnPrev.disabled = currentViewPageIndex === 0;
btnNext.disabled = currentViewPageIndex === item.result.length - 1;
} else {
paginationControls.classList.add('hidden');
paginationControls.classList.remove('flex');
}
// --- 核心:自适应内容展示逻辑 ---
if (currentViewPageIndex >= item.result.length) currentViewPageIndex = 0;
const rawData = item.result[currentViewPageIndex];
let rawString = rawData.result;
let finalJsonObj = cleanAndParseJSON(rawString);
// 1. 填充底部的 raw preview (始终显示完整内容)
rawJsonPre.innerText = finalJsonObj ? JSON.stringify(finalJsonObj, null, 2) : rawString;
// 2. 智能判断展示模式
let isInvoice = false;
if (finalJsonObj) {
if (finalJsonObj["发票信息"] || finalJsonObj["价税合计"] || finalJsonObj["发票号码"] || (finalJsonObj["合计"] && typeof finalJsonObj["合计"] === 'object')) {
isInvoice = true;
}
}
if (isInvoice) {
// --- 模式 A: 标准发票展示 ---
resMainIcon.className = "fa-solid fa-coins";
resMainLabel.innerText = "价税合计 (Total)";
const info = finalJsonObj["发票信息"] || {};
const total = info["价税合计"] || finalJsonObj["合计"] || {};
const amount = total["小写"] || total["金额合计"] || total || "0.00";
resMainValue.innerText = typeof amount === 'object' ? JSON.stringify(amount) : amount;
resMainValue.className = "text-3xl font-bold text-slate-700 tracking-tight break-words";
resLabel1.innerText = "发票代码/号码";
resValue1.innerText = (info["发票代码"] || "") + " " + (info["发票号码"] || "--");
fieldGroup1.classList.remove('hidden');
resLabel2.innerText = "开票日期";
resValue2.innerText = info["开票日期"] || "--";
fieldGroup2.classList.remove('hidden');
} else if (finalJsonObj) {
// --- 模式 B: 通用 JSON 展示 (提取前两个 Key) ---
resMainIcon.className = "fa-solid fa-code";
resMainLabel.innerText = "识别结果摘要 (Summary)";
const keys = Object.keys(finalJsonObj);
const firstKey = keys[0] || "Result";
const firstValue = finalJsonObj[firstKey];
resMainValue.innerText = typeof firstValue === 'object' ? JSON.stringify(firstValue).substring(0, 50) : firstValue;
resMainValue.className = "text-xl font-bold text-slate-700 tracking-tight break-all line-clamp-3";
if (keys.length > 1) {
resLabel1.innerText = keys[1];
let val2 = finalJsonObj[keys[1]];
resValue1.innerText = typeof val2 === 'object' ? "[Object/Array]" : val2;
fieldGroup1.classList.remove('hidden');
} else {
fieldGroup1.classList.add('hidden');
}
resLabel2.innerText = "字段统计";
resValue2.innerText = `共检测到 ${keys.length} 个字段`;
fieldGroup2.classList.remove('hidden');
} else {
// --- 模式 C: 纯文本/Markdown 预览 (适用自定义 Prompt) ---
resMainIcon.className = "fa-solid fa-align-left";
resMainLabel.innerText = "内容预览 (Preview)";
let cleanText = rawString.replace(/[#*`]/g, '');
if (cleanText.length > 100) cleanText = cleanText.substring(0, 100) + "...";
resMainValue.innerText = cleanText || "无内容";
resMainValue.className = "text-sm font-medium text-slate-600 leading-relaxed break-words line-clamp-4";
resLabel1.innerText = "字数统计";
resValue1.innerText = `${rawString.length} 字符`;
fieldGroup1.classList.remove('hidden');
resLabel2.innerText = "行数统计";
resValue2.innerText = `${rawString.split('\n').length} 行`;
fieldGroup2.classList.remove('hidden');
}
} else {
resultPlaceholder.classList.remove('hidden');
resultContent.classList.add('hidden');
resultLoading.classList.add('hidden');
downloadSingleBtn.classList.add('hidden');
}
}
function copyResult() {
const text = document.getElementById('res-raw-json').innerText;
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
const originalIcon = copyBtn.innerHTML;
copyBtn.innerHTML = '<i class="fa-solid fa-check text-emerald-400"></i> <span class="text-emerald-400">已复制</span>';
copyBtn.classList.add('border-emerald-500/50', 'bg-emerald-500/10');
setTimeout(() => resetCopyButton(), 2000);
}).catch(err => console.error('Copy failed', err));
}
function resetCopyButton() {
copyBtn.innerHTML = '<i class="fa-regular fa-copy"></i> <span id="copy-btn-text">复制</span>';
copyBtn.classList.remove('border-emerald-500/50', 'bg-emerald-500/10');
}
function changePage(delta) {
if (activeFileIndex === -1) return;
const item = filesStore[activeFileIndex];
if (!item.result) return;
const newIndex = currentViewPageIndex + delta;
if (newIndex >= 0 && newIndex < item.result.length) {
currentViewPageIndex = newIndex;
displayResult(item);
}
}
async function recognizeSingleFile(event, index) {
event.stopPropagation();
const item = filesStore[index];
item.status = 'loading';
item.result = [];
if (index === activeFileIndex) selectFile(index);
renderFileList();
const formData = getRequestPayload();
formData.append('files', item.file);
await sendRecognitionRequest(formData, [index]);
}
async function startRecognition() {
const pendingIndices = [];
const formData = getRequestPayload();
filesStore.forEach((item, index) => {
if (item.status === 'done') return;
item.status = 'loading';
item.result = [];
formData.append('files', item.file);
pendingIndices.push(index);
});
if (pendingIndices.length === 0) {
if (filesStore.length === 0) return alert("请先添加文件");
return alert("所有文件已识别完成");
}
updateUI();
if (activeFileIndex !== -1) selectFile(activeFileIndex);
await sendRecognitionRequest(formData, pendingIndices);
}
async function sendRecognitionRequest(formData, indices) {
try {
const response = await fetch('/api/recognize', {method: 'POST', body: formData});
const jsonResp = await response.json();
const resultsArray = jsonResp.data.flat(Infinity);
resultsArray.forEach(resData => {
const resName = resData.image_name || resData.file_name;
let targetItem = filesStore.find(f => f.file.name === resName);
if (!targetItem) {
targetItem = filesStore.find(f => resName.startsWith(f.file.name));
}
if (targetItem) {
if (resData.state === 'error') {
targetItem.status = 'error';
const exists = targetItem.result.some(r => r.image_name === resData.image_name);
if (!exists) targetItem.result.push(resData);
} else {
targetItem.status = 'done';
const exists = targetItem.result.some(r => r.image_name === resData.image_name);
if (!exists) targetItem.result.push(resData);
targetItem.result.sort((a, b) => getPageNum(a) - getPageNum(b));
}
}
});
saveHistoryToLocal();
} catch (error) {
console.error(error);
indices.forEach(i => filesStore[i].status = 'error');
alert("请求失败: " + error.message);
} finally {
indices.forEach(i => {
if (filesStore[i].status === 'loading') filesStore[i].status = 'error';
});
updateUI();
if (activeFileIndex !== -1) selectFile(activeFileIndex);
}
}
function deleteFile(event, index) {
event.stopPropagation();
filesStore.splice(index, 1);
if (index === activeFileIndex) {
activeFileIndex = -1;
resetRightPanel();
if (filesStore.length > 0) selectFile(0);
} else if (index < activeFileIndex) {
activeFileIndex--;
}
updateUI();
}
function resetRightPanel() {
activeImagePreview.src = '';
activeImagePreview.classList.add('hidden');
activePdfPreview.classList.add('hidden');
scanLine.style.display = 'none';
resultPlaceholder.classList.remove('hidden');
resultContent.classList.add('hidden');
resultLoading.classList.add('hidden');
downloadSingleBtn.classList.add('hidden');
}
function saveHistoryToLocal() {
const historyMap = {};
filesStore.forEach(f => {
if (f.result && f.result.length > 0) {
historyMap[f.file.name] = f.result;
}
});
localStorage.setItem('ocr_history', JSON.stringify(historyMap));
}
function clearHistory() {
localStorage.removeItem('ocr_history');
filesStore = [];
activeFileIndex = -1;
updateUI();
}
// --- Export Logic ---
function openExportDialog(mode) {
exportMode = mode;
let targetFiles = [];
if (mode === 'single') {
if (activeFileIndex === -1) return;
const item = filesStore[activeFileIndex];
if (!item.result || item.result.length === 0) return alert("当前文件无识别数据");
targetFiles = [item];
document.getElementById('export-modal-title').innerText = "导出配置 - " + item.file.name;
} else {
targetFiles = filesStore.filter(f => f.result && f.result.length > 0);
if (targetFiles.length === 0) return alert("暂无已完成的识别数据");
document.getElementById('export-modal-title').innerText = "批量导出配置 (ZIP)";
}
const allKeysSet = new Set();
targetFiles.forEach(item => {
item.result.forEach(res => {
const parsed = cleanAndParseJSON(res.result);
if (parsed) {
const flat = flattenObject(parsed);
Object.keys(flat).forEach(k => allKeysSet.add(k));
}
});
});
allAvailableKeys = Array.from(allKeysSet).sort();
if(allAvailableKeys.length === 0 && mode === 'batch') return alert("没有提取到有效字段,无法导出表格");
exportKeysContainer.innerHTML = '';
allAvailableKeys.forEach(key => {
const label = document.createElement('label');
label.className = 'flex items-center gap-2 cursor-pointer hover:bg-teal-50/50 p-2 rounded-lg transition';
label.innerHTML = `<input type="checkbox" value="${key}" checked class="form-checkbox w-4 h-4 text-teal-600 rounded border-slate-300 focus:ring-teal-500"><span class="text-xs text-slate-700 truncate font-mono" title="${key}">${key}</span>`;
exportKeysContainer.appendChild(label);
});
document.querySelector('input[name="export_format"][value="excel"]').checked = true;
toggleExportFormat();
exportConfigModal.classList.remove('hidden');
}
function closeExportModal() { exportConfigModal.classList.add('hidden'); }
function toggleExportFormat() { const format = document.querySelector('input[name="export_format"]:checked').value; if (format === 'excel') { fieldSelectionArea.classList.remove('hidden'); } else { fieldSelectionArea.classList.add('hidden'); } }
function toggleAllKeys(checked) { const checkboxes = exportKeysContainer.querySelectorAll('input[type="checkbox"]'); checkboxes.forEach(cb => cb.checked = checked); }
function executeExport() {
const format = document.querySelector('input[name="export_format"]:checked').value;
let targetItems = [];
if (exportMode === 'single') targetItems = [filesStore[activeFileIndex]];
else targetItems = filesStore.filter(f => f.result && f.result.length > 0);
const zip = new JSZip();
try {
if (format === 'json') {
targetItems.forEach(item => {
const nameNoExt = item.file.name.substring(0, item.file.name.lastIndexOf('.'));
let exportObj = [];
item.result.forEach(res => { const parsed = cleanAndParseJSON(res.result); if(parsed) exportObj.push(parsed); else exportObj.push({raw: res.result}); });
if (exportObj.length === 1) zip.file(`${nameNoExt}.json`, JSON.stringify(exportObj[0], null, 2));
else if (exportObj.length > 1) zip.file(`${nameNoExt}_combined.json`, JSON.stringify(exportObj, null, 2));
});
} else {
const checkboxes = exportKeysContainer.querySelectorAll('input[type="checkbox"]:checked');
const selectedKeys = Array.from(checkboxes).map(cb => cb.value);
if (selectedKeys.length === 0) return alert("请至少选择一个导出字段");
targetItems.forEach(item => {
try {
const nameNoExt = item.file.name.substring(0, item.file.name.lastIndexOf('.'));
let rows = [];
item.result.forEach((res, idx) => {
const parsed = cleanAndParseJSON(res.result);
if (parsed) {
const flat = flattenObject(parsed);
const row = {};
selectedKeys.forEach(key => { row[key] = flat[key] !== undefined ? flat[key] : ""; });
rows.push(row);
}
});
if (rows.length > 0) {
const worksheet = XLSX.utils.json_to_sheet(rows);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Sheet1");
const excelBuffer = XLSX.write(workbook, {bookType: 'xlsx', type: 'array'});
zip.file(`${nameNoExt}.xlsx`, excelBuffer);
}
} catch (err) { console.error(`Export error`, err); zip.file(`${item.file.name}_error.txt`, err.message); }
});
}
const zipName = exportMode === 'single' ? `${filesStore[activeFileIndex].file.name}_export.zip` : `批量识别结果_${format.toUpperCase()}.zip`;
zip.generateAsync({type: "blob"}).then(content => { saveAs(content, zipName); closeExportModal(); });
} catch (e) { console.error("Export process failed", e); alert("导出错误: " + e.message); }
}
const dropArea = document.querySelector('section');
dropArea.addEventListener('dragover', (e) => { e.preventDefault(); });
dropArea.addEventListener('drop', (e) => { e.preventDefault(); handleFiles(e.dataTransfer.files); });
</script>
<style>
@keyframes blob { 0% { transform: translate(0px, 0px) scale(1); } 33% { transform: translate(50px, -50px) scale(1.2); } 66% { transform: translate(-30px, 40px) scale(0.8); } 100% { transform: translate(0px, 0px) scale(1); } }
.animate-blob { animation: blob 10s infinite ease-in-out; }
.animation-delay-2000 { animation-delay: 3s; }
</style>
</body>
</html>