// DEPRECATED: this file is not served by current web entry. // Active frontend entry is /opt/xibao-web/app/static/app.js. const state = { lastResult: null, keyFields: ["branch", "amount", "type"], lastGeneratedImages: [], activeProgressToken: "", progressTimer: null, correctionContext: null, }; const SKIP_REASON_MAP = { skip_line_rule: "说明/标题行", branch_not_found: "未识别网点", type_not_found: "未识别产品类型或期限", amount_not_found: "未识别金额", demand_deposit_not_generate: "活期类信息且未写明确期限,默认不生成", }; const DUP_REASON_MAP = { history_duplicate: "历史重复", input_duplicate: "本次重复", }; function setMsg(text, isError = false) { const el = document.getElementById("msg"); el.textContent = text || ""; el.style.color = isError ? "#9f2f2f" : "#5b6f69"; } function setProgressVisible(visible) { const wrap = document.getElementById("progress-wrap"); if (!wrap) { return; } wrap.classList.toggle("hidden", !visible); } function renderProgress(progress) { const stageEl = document.getElementById("progress-stage"); const pctEl = document.getElementById("progress-percent"); const detailEl = document.getElementById("progress-detail"); const fillEl = document.getElementById("progress-fill"); if (!stageEl || !pctEl || !detailEl || !fillEl) { return; } const stage = progress?.stage || "处理中"; const percent = Math.max(0, Math.min(100, Number(progress?.percent ?? 0))); const detail = progress?.detail || ""; const status = progress?.status || ""; const error = progress?.error || ""; stageEl.textContent = stage; pctEl.textContent = `${percent}%`; fillEl.style.width = `${percent}%`; if (status === "error" && error) { detailEl.textContent = `${detail ? `${detail} - ` : ""}${error}`; } else { detailEl.textContent = detail; } } function stopProgressPolling() { if (state.progressTimer) { clearInterval(state.progressTimer); state.progressTimer = null; } } async function fetchProgressOnce(token) { if (!token) { return; } const res = await fetch(`/api/progress/${encodeURIComponent(token)}`); let data = {}; try { data = await res.json(); } catch (e) { return; } if (!data.ok || !data.progress) { return; } renderProgress(data.progress); if (data.progress.status === "done" || data.progress.status === "error") { stopProgressPolling(); } } function startProgressPolling(token) { stopProgressPolling(); state.activeProgressToken = token || ""; if (!state.activeProgressToken) { return; } state.progressTimer = setInterval(() => { void fetchProgressOnce(state.activeProgressToken); }, 700); } function getRawText() { return document.getElementById("raw-text").value.trim(); } function getTemplateFile() { return document.getElementById("template-file").value.trim(); } function getOutputDir() { return document.getElementById("output-dir").value.trim(); } function translateSkipReason(reason) { if (!reason) { return "未知原因"; } if (reason.startsWith("branch_not_allowed:")) { const branch = reason.split(":", 2)[1] || ""; return `网点不在白名单: ${branch}`; } return SKIP_REASON_MAP[reason] || reason; } function translateDupReason(reason) { return DUP_REASON_MAP[reason] || reason || "未知原因"; } function normalizeSkipLineText(line) { return String(line || "").replace(/\s+/g, "").trim(); } function isSameSkippedItem(a, b) { const lineA = normalizeSkipLineText(a?.line || ""); const lineB = normalizeSkipLineText(b?.line || ""); const reasonA = String(a?.reason || "").trim(); const reasonB = String(b?.reason || "").trim(); return lineA && lineA === lineB && reasonA === reasonB; } function toInlineUrl(url) { if (!url) { return ""; } return url.includes("?") ? `${url}&inline=1` : `${url}?inline=1`; } async function postJson(url, body) { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}), }); let data = {}; try { data = await res.json(); } catch (e) { throw new Error(`接口返回非JSON: ${url}`); } return { status: res.status, data }; } function normalizeAmountInput(raw) { const s = String(raw || "").trim(); if (!s) { return ""; } const m = s.match(/\d+(?:\.\d+)?/); if (!m) { return s; } const n = Math.max(0, Math.floor(Number(m[0]) || 0)); return `${n}万`; } function toEditableAmount(raw) { const s = String(raw || "").trim(); if (!s) { return ""; } return s.replace(/万元/g, "").replace(/万/g, ""); } function closeCorrectionModal() { const modal = document.getElementById("correction-modal"); modal.classList.add("hidden"); state.correctionContext = null; } function openCorrectionModal(record) { const modal = document.getElementById("correction-modal"); const branchEl = document.getElementById("corr-branch"); const amountEl = document.getElementById("corr-amount"); const typeEl = document.getElementById("corr-type"); const pageEl = document.getElementById("corr-page"); const statusEl = document.getElementById("corr-status"); const noteEl = document.getElementById("corr-note"); const rememberEl = document.getElementById("corr-remember"); const keywordEl = document.getElementById("corr-keyword"); const keywordWrap = document.getElementById("corr-keyword-wrap"); state.correctionContext = { record: { ...(record || {}) }, }; branchEl.value = record?.branch || ""; amountEl.value = toEditableAmount(record?.amount || ""); typeEl.value = record?.type || ""; pageEl.value = record?.page || ""; statusEl.value = record?.status || ""; noteEl.value = ""; rememberEl.checked = false; keywordEl.value = ""; keywordWrap.classList.add("hidden"); modal.classList.remove("hidden"); } function buildCorrectionOverrides() { const ctx = state.correctionContext || {}; const base = ctx.record || {}; const branch = document.getElementById("corr-branch").value.trim(); const amount = normalizeAmountInput(document.getElementById("corr-amount").value); const type = document.getElementById("corr-type").value.trim(); const page = document.getElementById("corr-page").value.trim(); const status = document.getElementById("corr-status").value.trim(); const out = {}; if (branch && branch !== (base.branch || "")) { out.branch = branch; } if (amount && amount !== (base.amount || "")) { out.amount = amount; } if (type && type !== (base.type || "")) { out.type = type; } if (page && page !== (base.page || "")) { out.page = page; } if (status && status !== (base.status || "")) { out.status = status; } return out; } async function applyCorrection() { const ctx = state.correctionContext || {}; const record = ctx.record; if (!record) { setMsg("缺少待修正记录", true); return; } const overrides = buildCorrectionOverrides(); const rememberRule = Boolean(document.getElementById("corr-remember").checked); const ruleKeyword = document.getElementById("corr-keyword").value.trim(); const note = document.getElementById("corr-note").value.trim(); setLoading(true); setMsg("修正生成中..."); try { const { data } = await postJson("/api/correction/apply", { record, overrides, remember_rule: rememberRule, rule_keyword: ruleKeyword || undefined, note, template_file: getTemplateFile() || undefined, output_dir: getOutputDir() || undefined, }); if (!data.ok) { throw new Error(data.error || "修正失败"); } const images = Array.isArray(data.download_images) ? data.download_images : []; if (images.length > 0) { state.lastGeneratedImages = [...images, ...(state.lastGeneratedImages || [])]; renderPreview(state.lastGeneratedImages); updateDownloadButtonState(false); } closeCorrectionModal(); await loadHistoryView(); await loadConfig(); if (data.rule) { setMsg("修正成功并已记住规则。"); } else { setMsg("修正成功,已生成新图片。"); } } finally { setLoading(false); } } function appendTextCell(tr, text) { const td = document.createElement("td"); td.textContent = text ?? ""; tr.appendChild(td); return td; } function appendEmptyRow(tbody, colSpan) { const tr = document.createElement("tr"); const td = document.createElement("td"); td.colSpan = colSpan; td.textContent = "暂无数据"; td.className = "muted"; tr.appendChild(td); tbody.appendChild(tr); } function makeMiniButton(label, className, onClick) { const btn = document.createElement("button"); btn.type = "button"; btn.className = `mini-btn ${className || ""}`.trim(); btn.textContent = label; btn.addEventListener("click", onClick); return btn; } async function markIssue(markType, sourceLine, record = {}, note = "") { const line = (sourceLine || "").trim(); if (!line) { setMsg("缺少原始行,无法标记日志", true); return; } const typeText = markType === "generation_error" ? "生成错误" : "识别错误"; try { const { data } = await postJson("/api/log/mark", { mark_type: markType, source_line: line, record, note, }); if (!data.ok) { throw new Error(data.error || "标记失败"); } setMsg(`已标记${typeText}:${line}`); } catch (err) { setMsg(err.message || String(err), true); } } async function suppressSkippedItem(item) { const line = String(item?.line || "").trim(); if (!line) { setMsg("跳过项缺少原始行,无法屏蔽", true); return; } try { const { data } = await postJson("/api/skipped/suppress", { line, reason: item?.reason || "", }); if (!data.ok) { throw new Error(data.error || "屏蔽失败"); } if (state.lastResult && Array.isArray(state.lastResult.skipped)) { const remain = state.lastResult.skipped.filter((x) => !isSameSkippedItem(x, item)); state.lastResult.skipped = remain; if (state.lastResult.summary && typeof state.lastResult.summary === "object") { state.lastResult.summary.skipped = remain.length; renderSummary(state.lastResult.summary); } renderSkipped(remain); document.getElementById("skip-panel").open = remain.length > 0; } setMsg("已屏蔽该跳过项,清空历史后会恢复。"); } catch (err) { setMsg(err.message || String(err), true); } } function renderSummary(summary) { document.getElementById("m-input").textContent = summary?.input_lines ?? 0; document.getElementById("m-parsed").textContent = summary?.parsed ?? 0; document.getElementById("m-new").textContent = summary?.new ?? 0; document.getElementById("m-dup").textContent = summary?.duplicate ?? 0; document.getElementById("m-skip").textContent = summary?.skipped ?? 0; } function renderNewRecords(records) { const tbody = document.getElementById("new-body"); tbody.innerHTML = ""; if (!records || records.length === 0) { appendEmptyRow(tbody, 7); return; } records.forEach((row) => { const tr = document.createElement("tr"); appendTextCell(tr, row.branch); appendTextCell(tr, row.amount); appendTextCell(tr, row.type); appendTextCell(tr, row.page); appendTextCell(tr, row.status); appendTextCell(tr, row.output_file); const actionTd = document.createElement("td"); actionTd.className = "cell-actions"; const actions = document.createElement("div"); actions.className = "actions-inline"; const sourceLine = row.source_line || row.raw_text || ""; const baseRecord = { branch: row.branch, amount: row.amount, type: row.type, page: row.page, status: row.status, source_line: sourceLine, raw_text: row.raw_text || "", output_file: row.output_file || "", }; actions.appendChild( makeMiniButton("修正", "", () => { openCorrectionModal(baseRecord); }) ); actions.appendChild( makeMiniButton("识别错", "secondary", () => { void markIssue("recognition_error", sourceLine, baseRecord); }) ); actions.appendChild( makeMiniButton("生成错", "danger", () => { void markIssue("generation_error", sourceLine, baseRecord); }) ); actionTd.appendChild(actions); tr.appendChild(actionTd); tbody.appendChild(tr); }); } function triggerDownload(url) { const a = document.createElement("a"); a.href = url; a.style.display = "none"; document.body.appendChild(a); a.click(); a.remove(); } function isMobileClient() { const ua = navigator.userAgent || ""; return /Android|iPhone|iPad|iPod|Mobile|HarmonyOS/i.test(ua); } function getSingleImageActionLabel() { return isMobileClient() ? "下载" : "复制"; } async function copyImageToClipboard(downloadUrl) { if (!downloadUrl) { throw new Error("缺少图片地址"); } if (!navigator?.clipboard?.write || typeof window.ClipboardItem === "undefined") { throw new Error("当前浏览器不支持图片复制"); } const res = await fetch(toInlineUrl(downloadUrl), { cache: "no-store" }); if (!res.ok) { throw new Error("获取图片失败"); } const blob = await res.blob(); const contentType = blob.type || "image/png"; const item = new window.ClipboardItem({ [contentType]: blob }); await navigator.clipboard.write([item]); } async function handleSingleImageAction(downloadUrl) { if (isMobileClient()) { triggerDownload(downloadUrl); return; } try { await copyImageToClipboard(downloadUrl); setMsg("复制成功"); } catch (err) { setMsg(`复制失败:${err?.message || "请检查浏览器权限"}`, true); } } function renderDuplicateRecords(records) { const tbody = document.getElementById("dup-body"); tbody.innerHTML = ""; if (!records || records.length === 0) { appendEmptyRow(tbody, 6); return; } records.forEach((row) => { const tr = document.createElement("tr"); appendTextCell(tr, row.branch); appendTextCell(tr, row.amount); appendTextCell(tr, row.type); appendTextCell(tr, row.status); appendTextCell(tr, translateDupReason(row.duplicate_reason)); const actionTd = document.createElement("td"); actionTd.className = "cell-actions"; const actions = document.createElement("div"); actions.className = "actions-inline"; if (row.download_url) { actions.appendChild( makeMiniButton("预览", "secondary", () => { window.open(toInlineUrl(row.download_url), "_blank", "noopener,noreferrer"); }) ); actions.appendChild( makeMiniButton(getSingleImageActionLabel(), "secondary", () => { void handleSingleImageAction(row.download_url); }) ); } else { const muted = document.createElement("span"); muted.className = "muted"; muted.textContent = "暂无图片"; actions.appendChild(muted); } const sourceLine = row.source_line || row.raw_text || ""; const baseRecord = { branch: row.branch, amount: row.amount, type: row.type, page: row.page, status: row.status, source_line: sourceLine, raw_text: row.raw_text || "", output_file: row.output_file || "", duplicate_reason: row.duplicate_reason, }; actions.appendChild( makeMiniButton("修正", "", () => { openCorrectionModal(baseRecord); }) ); actions.appendChild( makeMiniButton("识别错", "secondary", () => { void markIssue("recognition_error", sourceLine, baseRecord); }) ); actions.appendChild( makeMiniButton("生成错", "danger", () => { void markIssue("generation_error", sourceLine, baseRecord); }) ); actionTd.appendChild(actions); tr.appendChild(actionTd); tbody.appendChild(tr); }); } function renderHistoryRecords(items, totalCount = 0) { const tbody = document.getElementById("history-body"); const note = document.getElementById("history-note"); if (!tbody || !note) { return; } tbody.innerHTML = ""; const rows = Array.isArray(items) ? items : []; note.textContent = `总计 ${totalCount} 条,当前显示 ${rows.length} 条`; if (rows.length === 0) { appendEmptyRow(tbody, 7); return; } rows.forEach((row) => { const tr = document.createElement("tr"); appendTextCell(tr, row.created_at || ""); appendTextCell(tr, row.branch || ""); appendTextCell(tr, row.amount || ""); appendTextCell(tr, row.type || ""); appendTextCell(tr, row.status || ""); appendTextCell(tr, row.source_line || row.raw_text || ""); const actionTd = document.createElement("td"); actionTd.className = "cell-actions"; const actions = document.createElement("div"); actions.className = "actions-inline"; if (row.download_url) { actions.appendChild( makeMiniButton("预览", "secondary", () => { window.open(toInlineUrl(row.download_url), "_blank", "noopener,noreferrer"); }) ); actions.appendChild( makeMiniButton(getSingleImageActionLabel(), "secondary", () => { void handleSingleImageAction(row.download_url); }) ); } else { const muted = document.createElement("span"); muted.className = "muted"; muted.textContent = "图片已清理"; actions.appendChild(muted); } const sourceLine = row.source_line || row.raw_text || ""; const baseRecord = { branch: row.branch, amount: row.amount, type: row.type, page: row.page, status: row.status, source_line: sourceLine, raw_text: row.raw_text || "", output_file: row.output_file || "", created_at: row.created_at, }; actions.appendChild( makeMiniButton("修正", "", () => { openCorrectionModal(baseRecord); }) ); actions.appendChild( makeMiniButton("识别错", "secondary", () => { void markIssue("recognition_error", sourceLine, baseRecord); }) ); actions.appendChild( makeMiniButton("生成错", "danger", () => { void markIssue("generation_error", sourceLine, baseRecord); }) ); actionTd.appendChild(actions); tr.appendChild(actionTd); tbody.appendChild(tr); }); } async function loadHistoryView(showMsg = false) { const res = await fetch("/api/history/view?limit=500"); let data = {}; try { data = await res.json(); } catch (e) { throw new Error("历史接口返回异常"); } if (!data.ok) { throw new Error(data.error || "加载历史失败"); } renderHistoryRecords(data.items || [], data.count || 0); if (showMsg) { setMsg(`历史已刷新:共 ${data.count || 0} 条。`); } } function renderSkipped(skipped) { const ul = document.getElementById("skip-list"); ul.innerHTML = ""; if (!skipped || skipped.length === 0) { const li = document.createElement("li"); li.textContent = "无跳过项"; li.className = "muted"; ul.appendChild(li); return; } skipped.forEach((item) => { const li = document.createElement("li"); const main = document.createElement("span"); const reason = document.createElement("strong"); reason.textContent = translateSkipReason(item.reason); main.appendChild(reason); main.appendChild(document.createTextNode(`:${item.line || ""}`)); const actions = document.createElement("span"); actions.className = "skip-actions"; actions.appendChild( makeMiniButton("屏蔽", "danger", () => { void suppressSkippedItem(item); }) ); actions.appendChild( makeMiniButton("标记识别错", "secondary", () => { void markIssue("recognition_error", item.line || "", { reason: item.reason, stage: "skipped", }); }) ); li.appendChild(main); li.appendChild(actions); ul.appendChild(li); }); } function renderPreview(items) { const grid = document.getElementById("preview-grid"); const note = document.getElementById("preview-note"); grid.innerHTML = ""; if (!items || items.length === 0) { note.textContent = "生成后显示"; const empty = document.createElement("div"); empty.className = "preview-empty muted"; empty.textContent = "暂无预览图"; grid.appendChild(empty); return; } note.textContent = `共 ${items.length} 张`; items.forEach((item) => { const card = document.createElement("article"); card.className = "preview-item"; const img = document.createElement("img"); img.className = "preview-image"; img.loading = "lazy"; img.alt = item.name || "预览图"; img.src = toInlineUrl(item.download_url); img.addEventListener("click", () => { window.open(toInlineUrl(item.download_url), "_blank", "noopener,noreferrer"); }); const bar = document.createElement("div"); bar.className = "preview-bar"; const name = document.createElement("span"); name.className = "preview-name"; name.textContent = item.name || "未命名图片"; const btn = document.createElement("button"); btn.className = "preview-btn secondary"; btn.type = "button"; btn.textContent = getSingleImageActionLabel(); btn.addEventListener("click", () => { void handleSingleImageAction(item.download_url); }); bar.appendChild(name); bar.appendChild(btn); card.appendChild(img); card.appendChild(bar); grid.appendChild(card); }); } function renderResult(result) { state.lastResult = result; state.keyFields = result?.dedup_key_fields || ["branch", "amount", "type"]; renderSummary(result.summary || {}); renderNewRecords(result.new_records || []); renderDuplicateRecords(result.duplicate_records || []); renderSkipped(result.skipped || []); document.getElementById("dup-panel").open = (result.duplicate_records || []).length > 0; document.getElementById("skip-panel").open = (result.skipped || []).length > 0; } function updateDownloadButtonState(loading = false) { const btn = document.getElementById("download-btn"); if (!btn) { return; } const hasImages = Array.isArray(state.lastGeneratedImages) && state.lastGeneratedImages.length > 0; btn.disabled = loading || !hasImages; } function setLoading(loading) { document.getElementById("generate-btn").disabled = loading; document.getElementById("parse-btn").disabled = loading; document.getElementById("force-clear-btn").disabled = loading; document.getElementById("history-refresh-btn").disabled = loading; document.getElementById("clear-btn").disabled = loading; const corrSubmit = document.getElementById("corr-submit"); const corrCancel = document.getElementById("corr-cancel"); if (corrSubmit) { corrSubmit.disabled = loading; } if (corrCancel) { corrCancel.disabled = loading; } updateDownloadButtonState(loading); } async function triggerMultiDownloads(items) { if (!Array.isArray(items)) { return { total: 0, started: 0, blocked: 0, mobile: false }; } const validItems = items.filter((x) => x && x.download_url); if (validItems.length === 0) { return { total: 0, started: 0, blocked: 0, mobile: isMobileClient() }; } if (isMobileClient()) { let started = 0; let blocked = 0; validItems.forEach((it) => { const win = window.open(it.download_url, "_blank", "noopener,noreferrer"); if (win) { started += 1; } else { blocked += 1; } }); return { total: validItems.length, started, blocked, mobile: true }; } let started = 0; for (let i = 0; i < validItems.length; i += 1) { triggerDownload(validItems[i].download_url); started += 1; await new Promise((resolve) => setTimeout(resolve, 180)); } return { total: validItems.length, started, blocked: 0, mobile: false }; } function askInsuranceYear() { return new Promise((resolve) => { const modal = document.getElementById("insurance-modal"); const btn3 = document.getElementById("insurance-3"); const btn5 = document.getElementById("insurance-5"); const btnCancel = document.getElementById("insurance-cancel"); const cleanup = () => { modal.classList.add("hidden"); btn3.onclick = null; btn5.onclick = null; btnCancel.onclick = null; }; btn3.onclick = () => { cleanup(); resolve("3"); }; btn5.onclick = () => { cleanup(); resolve("5"); }; btnCancel.onclick = () => { cleanup(); resolve(null); }; modal.classList.remove("hidden"); }); } async function loadConfig() { const res = await fetch("/api/config"); const data = await res.json(); if (!data.ok) { throw new Error(data.error || "加载配置失败"); } const c = data.config; const div = document.getElementById("config-summary"); div.innerHTML = [ `