1197 lines
33 KiB
JavaScript
1197 lines
33 KiB
JavaScript
// 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 = [
|
||
`<div>模板文件:${c.template_file || "(未配置)"}</div>`,
|
||
`<div>输出目录:${c.output_dir || "(未配置)"}</div>`,
|
||
`<div>触发关键词:${c.trigger_keyword || "#接龙"}</div>`,
|
||
`<div>历史条数:${data.history_count}</div>`,
|
||
`<div>今日日志:${data.review_log_count || 0}</div>`,
|
||
].join("");
|
||
|
||
if (!getTemplateFile()) {
|
||
document.getElementById("template-file").value = c.template_file || "";
|
||
}
|
||
if (!getOutputDir()) {
|
||
document.getElementById("output-dir").value = c.output_dir || "";
|
||
}
|
||
}
|
||
|
||
async function parseOnly(insuranceYear = null) {
|
||
const rawText = getRawText();
|
||
if (!rawText) {
|
||
setMsg("请先输入接龙文本", true);
|
||
return;
|
||
}
|
||
|
||
setMsg("解析中...");
|
||
setLoading(true);
|
||
try {
|
||
const { data } = await postJson("/api/parse", {
|
||
raw_text: rawText,
|
||
insurance_year: insuranceYear,
|
||
});
|
||
|
||
if (!data.ok) {
|
||
throw new Error(data.error || "解析失败");
|
||
}
|
||
|
||
renderResult(data.result);
|
||
if (data.result.needs_insurance_choice) {
|
||
setMsg("检测到保险未写年限,生成时会要求选择3年交或5年交。", false);
|
||
} else {
|
||
setMsg(
|
||
`完成:有效 ${data.result.summary.parsed} 条,新增 ${data.result.summary.new} 条,重复 ${data.result.summary.duplicate} 条。`
|
||
);
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function generateOnly() {
|
||
const rawText = getRawText();
|
||
if (!rawText) {
|
||
setMsg("请先输入接龙文本", true);
|
||
return;
|
||
}
|
||
|
||
const templateFile = getTemplateFile();
|
||
const outputDir = getOutputDir();
|
||
const progressToken = `${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
|
||
|
||
setMsg("生成中...");
|
||
setProgressVisible(true);
|
||
renderProgress({
|
||
stage: "提交任务",
|
||
percent: 1,
|
||
detail: "请求已发出",
|
||
status: "running",
|
||
});
|
||
startProgressPolling(progressToken);
|
||
void fetchProgressOnce(progressToken);
|
||
setLoading(true);
|
||
|
||
let insuranceYear = null;
|
||
|
||
try {
|
||
for (let i = 0; i < 2; i += 1) {
|
||
const { status, data } = await postJson("/api/generate", {
|
||
raw_text: rawText,
|
||
insurance_year: insuranceYear,
|
||
progress_token: progressToken,
|
||
template_file: templateFile || undefined,
|
||
output_dir: outputDir || undefined,
|
||
save_history: true,
|
||
});
|
||
if (data.progress_token) {
|
||
state.activeProgressToken = data.progress_token;
|
||
}
|
||
await fetchProgressOnce(state.activeProgressToken || progressToken);
|
||
|
||
if (data.ok) {
|
||
if (data.result) {
|
||
renderResult(data.result);
|
||
}
|
||
|
||
if (data.generated_count > 0) {
|
||
const downloadImages = data.download_images || [];
|
||
state.lastGeneratedImages = downloadImages;
|
||
renderPreview(downloadImages);
|
||
updateDownloadButtonState(false);
|
||
setMsg(`生成完成:${data.generated_count} 张,可点击“下载全部”按钮下载。`);
|
||
} else {
|
||
state.lastGeneratedImages = [];
|
||
renderPreview([]);
|
||
updateDownloadButtonState(false);
|
||
setMsg(data.message || "没有可生成的新记录");
|
||
}
|
||
renderProgress({
|
||
stage: "完成",
|
||
percent: 100,
|
||
detail: `生成结束,产出 ${data.generated_count || 0} 张`,
|
||
status: "done",
|
||
});
|
||
stopProgressPolling();
|
||
|
||
await loadConfig();
|
||
await loadHistoryView();
|
||
return;
|
||
}
|
||
|
||
if (status === 400 && data.error_code === "insurance_year_required") {
|
||
renderProgress({
|
||
stage: "等待选择",
|
||
percent: 15,
|
||
detail: "保险年限待选择(3年交/5年交)",
|
||
status: "need_input",
|
||
});
|
||
if (data.result) {
|
||
renderResult(data.result);
|
||
}
|
||
const selected = await askInsuranceYear();
|
||
if (!selected) {
|
||
setMsg("你已取消保险年限选择,本次未生成。", true);
|
||
return;
|
||
}
|
||
insuranceYear = selected;
|
||
continue;
|
||
}
|
||
|
||
throw new Error(data.error || data.message || "生成失败");
|
||
}
|
||
|
||
throw new Error("保险年限选择后仍未生成,请重试");
|
||
} catch (err) {
|
||
renderProgress({
|
||
stage: "失败",
|
||
percent: 100,
|
||
detail: err?.message || String(err),
|
||
status: "error",
|
||
error: err?.message || String(err),
|
||
});
|
||
stopProgressPolling();
|
||
throw err;
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function downloadAllGenerated() {
|
||
const items = state.lastGeneratedImages || [];
|
||
if (!Array.isArray(items) || items.length === 0) {
|
||
setMsg("暂无可下载图片,请先生成。", true);
|
||
return;
|
||
}
|
||
|
||
setMsg(`开始下载:${items.length} 张...`);
|
||
setLoading(true);
|
||
try {
|
||
const stat = await triggerMultiDownloads(items);
|
||
if (stat.mobile) {
|
||
if (stat.blocked > 0) {
|
||
setMsg(
|
||
`移动端已触发 ${stat.started}/${stat.total} 张下载。若未全部下载,请允许弹窗后重试,或在预览区逐张下载。`,
|
||
true
|
||
);
|
||
} else {
|
||
setMsg(`移动端已触发 ${stat.started} 张下载。`);
|
||
}
|
||
} else {
|
||
setMsg(`下载完成:${stat.started} 张。`);
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function clearHistory() {
|
||
const ok = window.confirm("确认清空历史记录?");
|
||
if (!ok) {
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
setMsg("清空中...");
|
||
try {
|
||
const { data } = await postJson("/api/history/clear", {});
|
||
if (!data.ok) {
|
||
throw new Error(data.error || "清空失败");
|
||
}
|
||
setMsg("历史已清空");
|
||
await loadConfig();
|
||
await loadHistoryView();
|
||
state.lastGeneratedImages = [];
|
||
renderPreview([]);
|
||
updateDownloadButtonState(false);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function forceClearOutput() {
|
||
const ok = window.confirm("确认强制清理已生成截图与任务文件?这不会清空历史记录。");
|
||
if (!ok) {
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
setMsg("清理截图中...");
|
||
try {
|
||
const { data } = await postJson("/api/output/clear", {});
|
||
if (!data.ok) {
|
||
throw new Error(data.error || "清理失败");
|
||
}
|
||
state.lastGeneratedImages = [];
|
||
renderPreview([]);
|
||
updateDownloadButtonState(false);
|
||
await loadHistoryView();
|
||
setProgressVisible(false);
|
||
stopProgressPolling();
|
||
setMsg(
|
||
`清理完成:删除任务目录 ${data.removed_dirs || 0} 个,删除文件 ${data.removed_files || 0} 个。`
|
||
);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
function bindEvents() {
|
||
document.getElementById("parse-btn").addEventListener("click", async () => {
|
||
try {
|
||
await parseOnly();
|
||
} catch (err) {
|
||
setMsg(err.message || String(err), true);
|
||
}
|
||
});
|
||
|
||
document.getElementById("generate-btn").addEventListener("click", async () => {
|
||
try {
|
||
await generateOnly();
|
||
} catch (err) {
|
||
setMsg(err.message || String(err), true);
|
||
}
|
||
});
|
||
|
||
document.getElementById("download-btn").addEventListener("click", async () => {
|
||
try {
|
||
await downloadAllGenerated();
|
||
} catch (err) {
|
||
setMsg(err.message || String(err), true);
|
||
}
|
||
});
|
||
|
||
document.getElementById("force-clear-btn").addEventListener("click", async () => {
|
||
try {
|
||
await forceClearOutput();
|
||
} catch (err) {
|
||
setMsg(err.message || String(err), true);
|
||
}
|
||
});
|
||
|
||
document.getElementById("history-refresh-btn").addEventListener("click", async () => {
|
||
try {
|
||
await loadHistoryView(true);
|
||
} catch (err) {
|
||
setMsg(err.message || String(err), true);
|
||
}
|
||
});
|
||
|
||
document.getElementById("clear-btn").addEventListener("click", async () => {
|
||
try {
|
||
await clearHistory();
|
||
} catch (err) {
|
||
setMsg(err.message || String(err), true);
|
||
}
|
||
});
|
||
|
||
const corrModal = document.getElementById("correction-modal");
|
||
const corrRemember = document.getElementById("corr-remember");
|
||
const corrKeywordWrap = document.getElementById("corr-keyword-wrap");
|
||
|
||
document.getElementById("corr-cancel").addEventListener("click", () => {
|
||
closeCorrectionModal();
|
||
});
|
||
document.getElementById("corr-submit").addEventListener("click", async () => {
|
||
try {
|
||
await applyCorrection();
|
||
} catch (err) {
|
||
setMsg(err.message || String(err), true);
|
||
}
|
||
});
|
||
corrRemember.addEventListener("change", () => {
|
||
corrKeywordWrap.classList.toggle("hidden", !corrRemember.checked);
|
||
});
|
||
corrModal.addEventListener("click", (ev) => {
|
||
if (ev.target === corrModal) {
|
||
closeCorrectionModal();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function init() {
|
||
bindEvents();
|
||
state.lastGeneratedImages = [];
|
||
renderPreview([]);
|
||
updateDownloadButtonState(false);
|
||
try {
|
||
await loadConfig();
|
||
await loadHistoryView();
|
||
setMsg("可直接粘贴接龙并生成。", false);
|
||
} catch (err) {
|
||
setMsg(err.message || String(err), true);
|
||
}
|
||
}
|
||
|
||
init();
|