const core = window.XIBAO_CORE; if (!core) { throw new Error("XIBAO_CORE is not initialized"); } const { state, SKIP_REASON_MAP, DUP_REASON_MAP, MARK_TYPE_MAP, DUP_AUTO_OPEN_LIMIT, } = core; window.__XIBAO_MAIN_READY__ = true; const RETRYABLE_STATUS_DEFAULTS = new Set([408, 425, 429, 500, 502, 503, 504]); const RETRYABLE_STATUS_NO_429 = new Set([408, 425, 500, 502, 503, 504]); const APP_BASE_PATH = (() => { const raw = String(window.__XIBAO_BASE_PATH__ || "").trim(); if (!raw || raw === "/") { return ""; } return raw.endsWith("/") ? raw.slice(0, -1) : raw; })(); function withAppBase(url) { const text = String(url || ""); if (!text) { return text; } if (!APP_BASE_PATH) { return text; } if ( text.startsWith("http://") || text.startsWith("https://") || text.startsWith("//") || text.startsWith("data:") || text.startsWith("blob:") ) { return text; } if (text.startsWith("/")) { if (text === APP_BASE_PATH || text.startsWith(`${APP_BASE_PATH}/`)) { return text; } return `${APP_BASE_PATH}${text}`; } return text; } function sleepMs(ms) { return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))); } function nextRetryDelay(baseMs, attempt) { const base = Math.max(120, Number(baseMs) || 450); return Math.min(2800, base * (attempt + 1)); } function isLikelyNetworkError(err) { if (!err) { return false; } const name = String(err.name || ""); if (name === "AbortError") { return true; } const msg = String(err.message || err).toLowerCase(); return ( msg.includes("failed to fetch") || msg.includes("network") || msg.includes("timeout") || msg.includes("timed out") || msg.includes("load failed") ); } function asStatusSet(value, fallbackSet) { if (!Array.isArray(value)) { return fallbackSet; } const out = new Set(); value.forEach((x) => { const n = Number(x); if (Number.isFinite(n)) { out.add(n); } }); return out.size > 0 ? out : fallbackSet; } async function fetchJsonWithRetry(url, init = {}, opts = {}) { const retries = Math.max(0, Math.floor(Number(opts.retries) || 0)); const timeoutMs = Math.max(2000, Math.floor(Number(opts.timeoutMs) || 20000)); const retryDelayMs = Math.max(120, Math.floor(Number(opts.retryDelayMs) || 450)); const retryStatuses = asStatusSet(opts.retryStatuses, RETRYABLE_STATUS_DEFAULTS); const scopedUrl = withAppBase(url); let lastErr = null; for (let attempt = 0; attempt <= retries; attempt += 1) { const controller = typeof AbortController === "function" ? new AbortController() : null; const fetchInit = { ...(init || {}) }; if (controller) { fetchInit.signal = controller.signal; } let timer = null; if (controller && timeoutMs > 0) { timer = setTimeout(() => controller.abort(), timeoutMs); } try { const res = await fetch(scopedUrl, fetchInit); const text = await res.text(); let data = {}; if (text) { try { data = JSON.parse(text); } catch (err) { if (attempt < retries) { await sleepMs(nextRetryDelay(retryDelayMs, attempt)); continue; } throw new Error(`接口返回非JSON: ${scopedUrl}`); } } if (!res.ok && retryStatuses.has(Number(res.status)) && attempt < retries) { await sleepMs(nextRetryDelay(retryDelayMs, attempt)); continue; } return { status: Number(res.status), ok: Boolean(res.ok), data }; } catch (err) { lastErr = err; if (attempt < retries && isLikelyNetworkError(err)) { await sleepMs(nextRetryDelay(retryDelayMs, attempt)); continue; } throw err; } finally { if (timer) { clearTimeout(timer); } } } throw lastErr || new Error(`请求失败: ${scopedUrl}`); } async function fetchBlobWithRetry(url, opts = {}) { const retries = Math.max(0, Math.floor(Number(opts.retries) || 0)); const timeoutMs = Math.max(2000, Math.floor(Number(opts.timeoutMs) || 15000)); const retryDelayMs = Math.max(120, Math.floor(Number(opts.retryDelayMs) || 450)); const retryStatuses = asStatusSet(opts.retryStatuses, RETRYABLE_STATUS_DEFAULTS); const scopedUrl = withAppBase(url); const firstCache = String(opts.cache || "force-cache"); let lastErr = null; for (let attempt = 0; attempt <= retries; attempt += 1) { const controller = typeof AbortController === "function" ? new AbortController() : null; const fetchInit = { cache: attempt === 0 ? firstCache : "no-store", }; if (controller) { fetchInit.signal = controller.signal; } let timer = null; if (controller && timeoutMs > 0) { timer = setTimeout(() => controller.abort(), timeoutMs); } try { const res = await fetch(scopedUrl, fetchInit); if (!res.ok) { const status = Number(res.status); if (retryStatuses.has(status) && attempt < retries) { await sleepMs(nextRetryDelay(retryDelayMs, attempt)); continue; } throw new Error("获取图片失败"); } return res.blob(); } catch (err) { lastErr = err; if (attempt < retries && isLikelyNetworkError(err)) { await sleepMs(nextRetryDelay(retryDelayMs, attempt)); continue; } throw err; } finally { if (timer) { clearTimeout(timer); } } } throw lastErr || new Error("获取图片失败"); } 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 null; } try { const { status, data } = await fetchJsonWithRetry(`/api/progress/${encodeURIComponent(token)}`, {}, { retries: 2, timeoutMs: 6000, retryDelayMs: 400, retryStatuses: [408, 425, 429, 500, 502, 503, 504], }); if (status === 404 || !data.ok || !data.progress) { return null; } renderProgress(data.progress); if (data.progress.status === "done" || data.progress.status === "error") { stopProgressPolling(); } return data.progress; } catch (err) { return null; } } 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 setRawText(value) { const el = document.getElementById("raw-text"); if (!el) { return; } el.value = String(value || ""); } 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 translateMarkType(markType) { return MARK_TYPE_MAP[markType] || markType || "未知类型"; } 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) { const scoped = withAppBase(url); if (!scoped) { return ""; } return scoped.includes("?") ? `${scoped}&inline=1` : `${scoped}?inline=1`; } function showToast(text, isError = false) { let el = document.getElementById("toast-msg"); if (!el) { el = document.createElement("div"); el.id = "toast-msg"; el.className = "toast-msg"; document.body.appendChild(el); } el.textContent = text || ""; el.classList.toggle("error", Boolean(isError)); el.classList.add("show"); if (state.toastTimer) { clearTimeout(state.toastTimer); } state.toastTimer = setTimeout(() => { el.classList.remove("show"); }, 1200); } window.showToast = showToast; function imageCacheKey(downloadUrl) { return toInlineUrl(downloadUrl || ""); } async function fetchImageBlobByUrl(downloadUrl, opts = {}) { const url = imageCacheKey(downloadUrl); if (!url) { throw new Error("缺少图片地址"); } return fetchBlobWithRetry(url, { retries: Number.isFinite(Number(opts.retries)) ? Number(opts.retries) : 2, timeoutMs: Number.isFinite(Number(opts.timeoutMs)) ? Number(opts.timeoutMs) : 15000, retryDelayMs: 420, retryStatuses: [408, 425, 429, 500, 502, 503, 504], cache: opts.cache || "force-cache", }); } function warmImageBlobCache(downloadUrl) { if (!downloadUrl || isMobileClient()) { return; } const key = imageCacheKey(downloadUrl); if (!key || state.imageBlobCache.has(key) || state.imageBlobPromises.has(key)) { return; } const task = fetchImageBlobByUrl(downloadUrl) .then((blob) => { state.imageBlobCache.set(key, blob); return blob; }) .catch(() => null) .finally(() => { state.imageBlobPromises.delete(key); }); state.imageBlobPromises.set(key, task); } function warmGeneratedImages(items) { // Preview now loads images strictly in-order to avoid burst traffic. // Keep generated prewarm disabled to prevent parallel image pulls. void items; } function setupCopyIntentPrefetch(button, downloadUrl) { if (!button || !downloadUrl || isMobileClient()) { return; } const warmOnce = () => { warmImageBlobCache(downloadUrl); }; button.addEventListener("mouseenter", warmOnce, { once: true }); button.addEventListener("focus", warmOnce, { once: true }); button.addEventListener("touchstart", warmOnce, { once: true }); } async function getImageBlob(downloadUrl) { const key = imageCacheKey(downloadUrl); if (!key) { throw new Error("缺少图片地址"); } if (state.imageBlobCache.has(key)) { return state.imageBlobCache.get(key); } if (state.imageBlobPromises.has(key)) { const blob = await state.imageBlobPromises.get(key); if (blob) { return blob; } } const blob = await fetchImageBlobByUrl(downloadUrl); state.imageBlobCache.set(key, blob); return blob; } async function postJson(url, body, opts = {}) { const { status, data } = await fetchJsonWithRetry( url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}), }, { retries: Number.isFinite(Number(opts.retries)) ? Number(opts.retries) : 0, timeoutMs: Number.isFinite(Number(opts.timeoutMs)) ? Number(opts.timeoutMs) : 30000, retryDelayMs: Number.isFinite(Number(opts.retryDelayMs)) ? Number(opts.retryDelayMs) : 500, retryStatuses: opts.retryStatuses || [408, 425, 500, 502, 503, 504], } ); return { 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, }, { retries: 2, timeoutMs: 60000, retryDelayMs: 650, retryStatuses: [408, 425, 500, 502, 503, 504], }); if (!data.ok) { throw new Error(data.error || "修正失败"); } const images = Array.isArray(data.download_images) ? data.download_images : []; if (images.length > 0) { warmGeneratedImages(images); state.lastGeneratedImages = [...images, ...(state.lastGeneratedImages || [])]; renderPreview(state.lastGeneratedImages); updateDownloadButtonState(false); } closeCorrectionModal(); await loadHistoryView(); await loadIssueMarks(); await loadConfig(); const resolvedCount = Number(data?.resolved_issue_count || 0); let msg = data.rule ? "修正成功并已记住规则。" : "修正成功,已生成新图片。"; if (resolvedCount > 0) { msg += ` 已自动清除标识 ${resolvedCount} 条。`; } setMsg(msg); } 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" ? "生成错误" : "识别错误"; let finalNote = typeof note === "string" ? note.trim() : ""; if (note === undefined || note === null) { const input = window.prompt(`标记${typeText}备注(可选,留空可直接保存)`, ""); if (input === null) { setMsg("已取消标记"); return; } finalNote = input.trim(); } try { const { data } = await postJson("/api/log/mark", { mark_type: markType, source_line: line, record, note: finalNote, }); if (!data.ok) { throw new Error(data.error || "标记失败"); } setMsg(`已标记${typeText}:${line}`); await loadIssueMarks(); } 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 = withAppBase(url); a.style.display = "none"; document.body.appendChild(a); a.click(); a.remove(); } function normalizeDownloadFilename(name, fallback = "喜报.png") { const raw = String(name || "").trim(); const base = raw || fallback; const cleaned = base.replace(/[\\/:*?"<>|]+/g, "_").trim(); if (!cleaned) { return fallback; } return cleaned.toLowerCase().endsWith(".png") ? cleaned : `${cleaned}.png`; } function findImageNameByDownloadUrl(downloadUrl) { const target = String(downloadUrl || "").trim(); if (!target) { return ""; } const images = Array.isArray(state.lastGeneratedImages) ? state.lastGeneratedImages : []; const found = images.find((it) => String(it?.download_url || "").trim() === target); return String(found?.name || "").trim(); } function triggerBlobDownload(blob, filename = "喜报.png") { const fileName = normalizeDownloadFilename(filename); const objectUrl = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = objectUrl; a.download = fileName; a.style.display = "none"; document.body.appendChild(a); a.click(); a.remove(); window.setTimeout(() => { URL.revokeObjectURL(objectUrl); }, 30000); } function isMobileClient() { const ua = navigator.userAgent || ""; return /Android|iPhone|iPad|iPod|Mobile|HarmonyOS/i.test(ua); } function getSingleImageActionLabel() { return isMobileClient() ? "下载" : "复制"; } async function copyBlobToClipboard(blob) { if (!navigator?.clipboard?.write || typeof window.ClipboardItem === "undefined") { throw new Error("当前浏览器不支持图片复制"); } const contentType = blob.type || "image/png"; const item = new window.ClipboardItem({ [contentType]: blob }); await navigator.clipboard.write([item]); } async function handleSingleImageAction(downloadUrl, preferredName = "") { if (!downloadUrl) { setMsg("缺少图片地址", true); return; } if (isMobileClient()) { try { const blob = await getImageBlob(downloadUrl); const filename = normalizeDownloadFilename( preferredName || findImageNameByDownloadUrl(downloadUrl), `喜报_${Date.now()}.png` ); triggerBlobDownload(blob, filename); setMsg("下载已开始"); showToast("下载已开始"); return; } catch (err) { triggerDownload(downloadUrl); return; } } try { const blob = await getImageBlob(downloadUrl); await copyBlobToClipboard(blob); setMsg("复制成功"); showToast("复制成功"); } catch (err) { setMsg(`复制失败:${err?.message || "请检查浏览器权限"}`, true); showToast("复制失败", 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"); }) ); const copyBtn = makeMiniButton(getSingleImageActionLabel(), "secondary", () => { void handleSingleImageAction(row.download_url, row.image_name || row.output_file || ""); }); setupCopyIntentPrefetch(copyBtn, row.download_url); actions.appendChild(copyBtn); } 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"); }) ); const copyBtn = makeMiniButton(getSingleImageActionLabel(), "secondary", () => { void handleSingleImageAction(row.download_url, row.image_name || row.output_file || ""); }); setupCopyIntentPrefetch(copyBtn, row.download_url); actions.appendChild(copyBtn); } 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 { data } = await fetchJsonWithRetry("/api/history/view?limit=500", {}, { retries: 2, timeoutMs: 10000, retryDelayMs: 420, retryStatuses: [408, 425, 500, 502, 503, 504], }); if (!data.ok) { throw new Error(data.error || "加载历史失败"); } renderHistoryRecords(data.items || [], data.count || 0); if (showMsg) { setMsg(`历史已刷新:共 ${data.count || 0} 条。`); } return data; } function buildCorrectionRecordFromIssue(issue) { const rec = issue && typeof issue.record === "object" ? issue.record : {}; const sourceLine = String(issue?.source_line || rec.source_line || rec.raw_text || "").trim(); return { branch: rec.branch || "", amount: rec.amount || "", type: rec.type || "", page: rec.page || "", status: rec.status || "", source_line: sourceLine, raw_text: rec.raw_text || sourceLine, output_file: rec.output_file || "", }; } async function editIssue(issue) { if (!issue?.id) { setMsg("缺少标识ID,无法编辑", true); return; } let markType = window.prompt("标识类型(recognition_error 或 generation_error)", issue.mark_type || ""); if (markType === null) { return; } markType = String(markType).trim(); if (!["recognition_error", "generation_error"].includes(markType)) { setMsg("标识类型不合法", true); return; } let sourceLine = window.prompt("原始行", issue.source_line || ""); if (sourceLine === null) { return; } sourceLine = String(sourceLine).trim(); if (!sourceLine) { setMsg("原始行不能为空", true); return; } const noteInput = window.prompt("备注(可空)", issue.note || ""); if (noteInput === null) { return; } const note = String(noteInput).trim(); try { const { data } = await postJson("/api/issues/update", { id: issue.id, mark_type: markType, source_line: sourceLine, note, }); if (!data.ok) { throw new Error(data.error || "更新标识失败"); } await loadIssueMarks(); setMsg("标识已更新。"); } catch (err) { setMsg(err.message || String(err), true); } } async function deleteIssue(issue) { if (!issue?.id) { setMsg("缺少标识ID,无法删除", true); return; } const ok = window.confirm("确认删除该标识?删除后不可恢复。"); if (!ok) { return; } try { const { data } = await postJson("/api/issues/delete", { id: issue.id }); if (!data.ok) { throw new Error(data.error || "删除标识失败"); } await loadIssueMarks(); setMsg("标识已删除。"); } catch (err) { setMsg(err.message || String(err), true); } } function renderIssueMarks(items, totalCount = 0) { const tbody = document.getElementById("issue-body"); const note = document.getElementById("issue-note"); if (!tbody || !note) { return; } tbody.innerHTML = ""; const rows = Array.isArray(items) ? items : []; note.textContent = `活跃标识 ${totalCount} 条,当前显示 ${rows.length} 条`; if (rows.length === 0) { appendEmptyRow(tbody, 5); return; } rows.forEach((row) => { const tr = document.createElement("tr"); appendTextCell(tr, row.updated_at || row.created_at || ""); appendTextCell(tr, translateMarkType(row.mark_type)); appendTextCell(tr, row.source_line || ""); appendTextCell(tr, row.note || ""); const actionTd = document.createElement("td"); actionTd.className = "cell-actions"; const actions = document.createElement("div"); actions.className = "actions-inline"; actions.appendChild( makeMiniButton("修正", "", () => { openCorrectionModal(buildCorrectionRecordFromIssue(row)); }) ); actions.appendChild( makeMiniButton("编辑", "secondary", () => { void editIssue(row); }) ); actions.appendChild( makeMiniButton("删除", "danger", () => { void deleteIssue(row); }) ); actionTd.appendChild(actions); tr.appendChild(actionTd); tbody.appendChild(tr); }); } async function loadIssueMarks(showMsg = false) { const { data } = await fetchJsonWithRetry("/api/issues?status=active&limit=500", {}, { retries: 2, timeoutMs: 10000, retryDelayMs: 420, retryStatuses: [408, 425, 500, 502, 503, 504], }); if (!data.ok) { throw new Error(data.error || "加载标识失败"); } renderIssueMarks(data.items || [], data.count || 0); if (showMsg) { setMsg(`标识已刷新:活跃 ${data.count || 0} 条。`); } return data; } 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); }); } async function loadPreviewImageOneByOne(img, downloadUrl, token, retry = false) { if (!img || !downloadUrl) { return; } const media = img.closest(".preview-media"); if (media) { media.classList.add("is-loading"); media.classList.remove("is-error"); } let blob = null; try { if (retry) { const url = toInlineUrl(downloadUrl); const sep = url.includes("?") ? "&" : "?"; const retryUrl = `${url}${sep}_r=${Date.now()}`; blob = await fetchBlobWithRetry(retryUrl, { retries: 1, timeoutMs: 15000, retryDelayMs: 380, retryStatuses: [408, 425, 429, 500, 502, 503, 504], cache: "no-store", }); const key = imageCacheKey(downloadUrl); if (key) { state.imageBlobCache.set(key, blob); } } else { blob = await getImageBlob(downloadUrl); } } catch (err) { if (!retry && token === state.previewLoadToken) { return loadPreviewImageOneByOne(img, downloadUrl, token, true); } if (media) { media.classList.remove("is-loading"); media.classList.add("is-error"); } return; } if (token !== state.previewLoadToken || !blob) { return; } const objectUrl = URL.createObjectURL(blob); await new Promise((resolve) => { let settled = false; const cleanup = () => { img.removeEventListener("load", onLoad); img.removeEventListener("error", onError); }; const finish = () => { if (settled) { return; } settled = true; cleanup(); resolve(); }; const onLoad = () => { finish(); }; const onError = () => { finish(); }; img.addEventListener("load", onLoad); img.addEventListener("error", onError); img.src = objectUrl; setTimeout(finish, 20000); }); URL.revokeObjectURL(objectUrl); if (media && token === state.previewLoadToken) { media.classList.remove("is-loading"); media.classList.remove("is-error"); } } function updatePreviewNote(total, loaded = null) { const note = document.getElementById("preview-note"); if (!note) { return; } const totalNum = Math.max(0, Number(total || 0)); if (loaded == null) { note.textContent = totalNum > 0 ? `共 ${totalNum} 张` : "生成后显示"; return; } const loadedNum = Math.max(0, Math.min(totalNum, Number(loaded || 0))); if (loadedNum >= totalNum) { note.textContent = `共 ${totalNum} 张`; return; } note.textContent = `共 ${totalNum} 张 · 加载 ${loadedNum}/${totalNum}`; } function resolvePreviewLoadConcurrency() { const n = Number(state.previewLoadConcurrency || 0); if (Number.isFinite(n) && n > 0) { return Math.max(1, Math.min(4, Math.floor(n))); } return isMobileClient() ? 1 : 3; } function startPreviewLoad(queue, totalCount) { state.previewLoadToken += 1; const token = state.previewLoadToken; state.previewLoadedCount = 0; if (!Array.isArray(queue) || queue.length === 0) { updatePreviewNote(totalCount, totalCount); return; } const concurrency = Math.min(resolvePreviewLoadConcurrency(), queue.length); let cursor = 0; (async () => { const worker = async () => { while (true) { if (token !== state.previewLoadToken) { return; } const idx = cursor; cursor += 1; if (idx >= queue.length) { return; } const item = queue[idx]; if (!item?.img || !item?.downloadUrl) { continue; } await loadPreviewImageOneByOne(item.img, item.downloadUrl, token); if (token !== state.previewLoadToken) { return; } state.previewLoadedCount += 1; updatePreviewNote(totalCount, state.previewLoadedCount); } }; await Promise.all(Array.from({ length: concurrency }, () => worker())); if (token === state.previewLoadToken) { updatePreviewNote(totalCount, totalCount); } })(); } function renderPreview(items) { const grid = document.getElementById("preview-grid"); grid.innerHTML = ""; state.previewLoadToken += 1; if (!items || items.length === 0) { updatePreviewNote(0, null); const empty = document.createElement("div"); empty.className = "preview-empty muted"; empty.textContent = "暂无预览图"; grid.appendChild(empty); return; } updatePreviewNote(items.length, 0); const loadQueue = []; items.forEach((item) => { const card = document.createElement("article"); card.className = "preview-item"; const media = document.createElement("div"); media.className = "preview-media"; media.classList.add("is-loading"); const img = document.createElement("img"); img.className = "preview-image"; img.loading = "eager"; img.decoding = "async"; img.alt = item.name || "预览图"; const downloadUrl = item.download_url || ""; const inlineUrl = toInlineUrl(downloadUrl); if (downloadUrl) { loadQueue.push({ img, downloadUrl }); } img.addEventListener("click", () => { if (!downloadUrl) { return; } window.open(inlineUrl, "_blank", "noopener,noreferrer"); }); media.appendChild(img); const canAct = Boolean(item.download_url); if (!canAct) { media.classList.remove("is-loading"); media.classList.add("is-error"); } const actionBtn = document.createElement("button"); actionBtn.className = "preview-btn secondary"; actionBtn.type = "button"; actionBtn.textContent = getSingleImageActionLabel(); actionBtn.disabled = !canAct; const onAction = () => { if (!canAct) { return; } void handleSingleImageAction(item.download_url, item.name || ""); }; setupCopyIntentPrefetch(actionBtn, item.download_url); actionBtn.addEventListener("click", onAction); const bar = document.createElement("div"); bar.className = "preview-bar"; const name = document.createElement("span"); name.className = "preview-name"; name.textContent = item.name || "未命名图片"; bar.appendChild(name); bar.appendChild(actionBtn); card.appendChild(media); card.appendChild(bar); grid.appendChild(card); }); startPreviewLoad(loadQueue, items.length); } 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 || []); const dupCount = (result.duplicate_records || []).length; const skipCount = (result.skipped || []).length; const dupPanel = document.getElementById("dup-panel"); const skipPanel = document.getElementById("skip-panel"); if (dupPanel) { dupPanel.open = dupCount > 0 && dupCount <= DUP_AUTO_OPEN_LIMIT; } if (skipPanel) { skipPanel.open = skipCount > 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; } for (let i = 0; i < items.length; i += 1) { const it = items[i] || {}; if (it.download_url) { let localDownloaded = false; try { const blob = await getImageBlob(it.download_url); const filename = normalizeDownloadFilename( it.name || findImageNameByDownloadUrl(it.download_url), `喜报_${i + 1}.png` ); triggerBlobDownload(blob, filename); localDownloaded = true; } catch (err) { localDownloaded = false; } if (!localDownloaded) { triggerDownload(it.download_url); } await new Promise((resolve) => setTimeout(resolve, 140)); } } } function askInsuranceYears(pendingRecords = []) { return new Promise((resolve) => { const modal = document.getElementById("insurance-modal"); const listEl = document.getElementById("insurance-items"); const btnSubmit = document.getElementById("insurance-submit"); const btnCancel = document.getElementById("insurance-cancel"); if (!modal || !listEl || !btnSubmit || !btnCancel) { resolve(null); return; } const records = Array.isArray(pendingRecords) ? pendingRecords : []; const rowMeta = []; listEl.innerHTML = ""; const errEl = document.createElement("div"); errEl.className = "insurance-error"; errEl.textContent = ""; records.forEach((row, idx) => { const key = String(row?.insurance_choice_key || "").trim() || `pending_${idx + 1}`; const item = document.createElement("div"); item.className = "insurance-item"; const line = document.createElement("div"); line.className = "insurance-line"; line.textContent = row?.source_line || row?.raw_text || `保险条目 ${idx + 1}`; item.appendChild(line); const meta = document.createElement("div"); meta.className = "insurance-meta"; meta.textContent = `网点:${row?.branch || "-"} | 金额:${row?.amount || "-"} | 标识:${key}`; item.appendChild(meta); const options = document.createElement("div"); options.className = "insurance-options"; const group = `insurance_year_${idx}`; ["3", "5"].forEach((year) => { const label = document.createElement("label"); const radio = document.createElement("input"); radio.type = "radio"; radio.name = group; radio.value = year; label.appendChild(radio); label.appendChild(document.createTextNode(`${year}年交`)); options.appendChild(label); }); item.appendChild(options); rowMeta.push({ key, group }); listEl.appendChild(item); }); listEl.appendChild(errEl); const cleanup = () => { modal.classList.add("hidden"); btnSubmit.onclick = null; btnCancel.onclick = null; listEl.innerHTML = ""; }; btnSubmit.onclick = () => { const picked = {}; for (const item of rowMeta) { const selected = listEl.querySelector(`input[name="${item.group}"]:checked`); if (!selected) { errEl.textContent = "请为每一条保险记录选择年限后再继续。"; return; } picked[item.key] = selected.value; } errEl.textContent = ""; cleanup(); resolve(picked); }; btnCancel.onclick = () => { cleanup(); resolve(null); }; modal.classList.remove("hidden"); }); } async function loadConfig() { const { data } = await fetchJsonWithRetry("/api/config", {}, { retries: 2, timeoutMs: 10000, retryDelayMs: 420, retryStatuses: [408, 425, 500, 502, 503, 504], }); if (!data.ok) { throw new Error(data.error || "加载配置失败"); } const c = data.config; const delivery = c && typeof c === "object" ? c.image_delivery || {} : {}; const maxKbps = Number(delivery.max_kbps); const mobile = isMobileClient(); let previewConcurrency = mobile ? 1 : 3; if (!mobile && Number.isFinite(maxKbps)) { if (maxKbps <= 0) { previewConcurrency = 4; } else if (maxKbps <= 200) { previewConcurrency = 2; } else if (maxKbps <= 400) { previewConcurrency = 3; } else { previewConcurrency = 4; } } state.previewLoadConcurrency = Math.max(1, Math.min(4, previewConcurrency)); state.imageDeliveryMaxKbps = Number.isFinite(maxKbps) ? maxKbps : 300; const div = document.getElementById("config-summary"); div.innerHTML = [ `