2122 lines
60 KiB
JavaScript
2122 lines
60 KiB
JavaScript
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");
|
||
})();
|