Files
xb/app/static/js/main.js

2122 lines
60 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = [
`<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>`,
`<div>活跃标识:${data.active_issue_count || 0}</div>`,
].join("");
if (!getTemplateFile()) {
document.getElementById("template-file").value = c.template_file || "";
}
if (!getOutputDir()) {
document.getElementById("output-dir").value = c.output_dir || "";
}
return data;
}
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 attemptRecoverGenerationAfterNetworkIssue(progressToken) {
const token = String(progressToken || "").trim();
if (!token) {
return false;
}
const deadline = Date.now() + 45000;
let seenProgress = false;
while (Date.now() < deadline) {
const progress = await fetchProgressOnce(token);
if (progress) {
seenProgress = true;
if (progress.status === "done") {
stopProgressPolling();
try {
await loadConfig();
await loadHistoryView();
} catch (err) {
void err;
}
setMsg("网络波动,任务已在后台完成,已自动刷新历史。");
return true;
}
if (progress.status === "error") {
const detail = progress.error || progress.detail || "生成过程异常";
throw new Error(detail);
}
}
await sleepMs(1000);
}
if (seenProgress) {
setMsg("网络波动,任务可能仍在后台执行,请稍后查看历史记录。");
return true;
}
return 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);
setLoading(true);
let insuranceYear = null;
let insuranceYearChoices = {};
try {
for (let i = 0; i < 2; i += 1) {
let status = 0;
let data = {};
try {
const resp = await postJson(
"/api/generate",
{
raw_text: rawText,
insurance_year: insuranceYear,
insurance_year_choices: insuranceYearChoices,
progress_token: progressToken,
template_file: templateFile || undefined,
output_dir: outputDir || undefined,
save_history: true,
},
{
retries: 2,
timeoutMs: 60000,
retryDelayMs: 650,
retryStatuses: [408, 425, 500, 502, 503, 504],
}
);
status = resp.status;
data = resp.data || {};
} catch (err) {
const recovered = await attemptRecoverGenerationAfterNetworkIssue(progressToken);
if (recovered) {
return;
}
throw new Error("网络波动导致生成请求失败,请重试。");
}
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 || [];
warmGeneratedImages(downloadImages);
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 pendingRows = (data.result && data.result.pending_insurance_records) || [];
const selected = await askInsuranceYears(pendingRows);
if (!selected) {
setMsg("你已取消保险年限选择,本次未生成。", true);
return;
}
insuranceYear = null;
insuranceYearChoices = selected;
continue;
}
if (status === 429 && data.error_code === "generate_busy") {
const recovered = await attemptRecoverGenerationAfterNetworkIssue(progressToken);
if (recovered) {
return;
}
throw new Error("系统正在处理上一个生成任务,请稍后再试。");
}
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 {
await triggerMultiDownloads(items);
setMsg(`下载完成:${items.length} 张。`);
} 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);
}
}
async function pasteToRawText() {
if (!navigator.clipboard || !navigator.clipboard.readText) {
setMsg("当前环境不支持一键粘贴,请使用 Ctrl+V或 Command+V。", true);
return;
}
try {
const text = await navigator.clipboard.readText();
if (!text) {
setMsg("剪贴板为空。", true);
return;
}
setRawText(text);
setMsg("已粘贴到输入框。");
} catch (err) {
setMsg("粘贴失败,请检查浏览器剪贴板权限后重试。", true);
}
}
function bindEvents() {
document.getElementById("paste-btn").addEventListener("click", async () => {
try {
await pasteToRawText();
} catch (err) {
setMsg(err.message || String(err), true);
}
});
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("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("issue-refresh-btn").addEventListener("click", async () => {
try {
await loadIssueMarks(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();
await loadIssueMarks();
setMsg("可直接粘贴接龙并生成。", false);
} catch (err) {
setMsg(err.message || String(err), true);
}
}
init();
(function mountVueShell() {
const shellRoot = document.getElementById("vue-shell");
if (!shellRoot || !window.Vue || typeof window.Vue.createApp !== "function") {
return;
}
const { createApp } = window.Vue;
createApp({
data() {
return {
loading: false,
error: "",
historyCount: 0,
activeIssueCount: 0,
reviewLogCount: 0,
updatedAt: "",
};
},
computed: {
statusText() {
if (this.loading) {
return "同步中";
}
return this.error ? "异常" : "正常";
},
},
methods: {
async refreshMeta(showMsg) {
this.loading = true;
this.error = "";
try {
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 || "加载配置失败");
}
this.historyCount = Number(data.history_count || 0);
this.activeIssueCount = Number(data.active_issue_count || 0);
this.reviewLogCount = Number(data.review_log_count || 0);
this.updatedAt = new Date().toLocaleTimeString("zh-CN", { hour12: false });
if (showMsg && typeof window.showToast === "function") {
window.showToast("状态已刷新");
}
} catch (err) {
this.error = err?.message || String(err);
} finally {
this.loading = false;
}
},
},
mounted() {
void this.refreshMeta(false);
},
template: `
<div class="vue-shell-wrap">
<div class="vue-shell-head">
<h2>前端迁移状态Vue</h2>
<span class="vue-shell-status" :class="{ error: !!error }">{{ statusText }}</span>
</div>
<div class="vue-shell-metrics">
<div class="vue-shell-metric"><span>历史条数</span><strong>{{ historyCount }}</strong></div>
<div class="vue-shell-metric"><span>活跃标识</span><strong>{{ activeIssueCount }}</strong></div>
<div class="vue-shell-metric"><span>今日日志</span><strong>{{ reviewLogCount }}</strong></div>
<div class="vue-shell-metric"><span>更新时间</span><strong>{{ updatedAt || '-' }}</strong></div>
</div>
<div class="vue-shell-actions">
<button type="button" class="secondary" @click="refreshMeta(true)" :disabled="loading">刷新状态</button>
<span v-if="error" class="muted" style="color:#9f2f2f">{{ error }}</span>
</div>
</div>
`,
}).mount("#vue-shell");
})();