Files
Invoice/demo/templates/index2.html

1027 lines
56 KiB
HTML
Raw Normal View History

<!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>