Files

2043 lines
79 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 columns = [
"姓名",
"性别",
"年龄",
"住院号",
"诊断",
"入院时间",
"最后书写时间",
"住院天数",
"出院时间",
"手术后天数",
];
const state = {
items: [],
current: null,
auditItems: [],
auditCurrent: null,
auditHistory: [],
auditHistoryCurrent: null,
processingOverview: null,
permissions: {},
permissionLabels: {},
kimiEnabled: true,
activeView: "overview",
aiBusy: false,
aiTaskLabel: "",
imageZoom: 1,
imagePan: null,
auditImageZoom: 1,
auditImagePan: null,
auditHistoryImageZoom: 1,
auditHistoryImagePan: null,
page: 1,
pageSize: 60,
totalItems: 0,
loadingItems: false,
pgTimer: null,
debounce: null,
};
const datetimeColumns = new Set(["入院时间", "出院时间", "最后书写时间"]);
const $ = (selector) => document.querySelector(selector);
function permissionKeyForView(view) {
return view === "auditHistory" ? "audit_history" : view;
}
function permissionEntries() {
const labels = state.permissionLabels || {};
const fallback = {
overview: "概览",
review: "复核",
audit: "抽查",
audit_history: "抽查一览",
settings: "设置",
};
return Object.entries({ ...fallback, ...labels });
}
function badgeClass(item) {
if (item.manual_state === "AI修改-待确认") return "ai";
if (item.manual_state === "人工复核通过") return "done";
if (item.manual_state === "修订后仍需确认") return "warn";
return "pending";
}
function auditBadgeClass(item) {
if (item.audit_result === "人工核验异常") return "pending";
if (item.audit_result === "人工核验通过") return "done";
if (item.audit_result === "暂不确定") return "warn";
if (item.audit_machine_verdict === "异常") return "pending";
if (item.audit_machine_verdict) return "ai";
return "warn";
}
function auditBadgeText(item) {
if (item.audit_result) return item.audit_result;
if (item.audit_machine_verdict === "通过") return "AI核验通过";
if (item.audit_machine_verdict === "异常") return "AI核验异常";
if (item.audit_machine_verdict === "不确定") return "AI无法确定";
return "未核验";
}
function auditHistoryBadgeText(item) {
if (item.audit_result) return item.audit_result;
return item.audit_ai_feedback || item.audit_ai_raw_output || item.audit_machine_verdict ? "未人工核验" : "未填写人工结论";
}
function auditSourceLabel(value) {
const labels = {
manual_review_passed: "已处理-人工复核通过项",
db_auto_passed: "数据库-自动复核通过",
};
return labels[value] || value || "";
}
function validationBadgeHtml(item) {
const validationWarnings = validatePatientLocal(item.patient || {});
return validationWarnings.length
? `<span class="badge warn validation-badge">当前字段未通过校验</span>`
: "";
}
function auditTitleHtml(item, badgeText = auditBadgeText(item)) {
return `
${escapeHtml(item.patient["姓名"] || "未识别姓名")} · ${escapeHtml(item.patient["住院号"] || "无住院号")}
<span class="queue-badges title-badges">
<span class="badge ${auditBadgeClass(item)}">${escapeHtml(badgeText)}</span>
${validationBadgeHtml(item)}
</span>
`;
}
function auditQueueItemHtml(item, activeKey, extraRows = [], badgeText = auditBadgeText(item)) {
const metaRows = [
item.patient["住院号"] || "无住院号",
...extraRows,
].filter(Boolean);
return `
<button class="queue-item ${activeKey === item.key ? "active" : ""}" data-key="${item.key}">
<div class="queue-title">
<span>${escapeHtml(item.patient["姓名"] || "未识别姓名")}</span>
<span class="queue-badges">
<span class="badge ${auditBadgeClass(item)}">${escapeHtml(badgeText)}</span>
${validationBadgeHtml(item)}
</span>
</div>
${metaRows.map((row) => `<div class="queue-meta">${escapeHtml(row)}</div>`).join("")}
</button>
`;
}
function auditProgressText() {
const total = state.auditItems.length;
if (!total) return "";
const aiDone = state.auditItems.filter((item) => item.audit_machine_verdict || item.audit_ai_feedback).length;
const manualDone = state.auditItems.filter((item) => item.audit_result).length;
return `抽查进度AI ${aiDone}/${total},人工 ${manualDone}/${total}`;
}
function setSaveState(text) {
$("#saveState").textContent = text || "";
}
function setStatusFilter(value, reload = true) {
if (value === "ai_pending" || value === "still_issue") value = "confirming";
$("#statusFilter").value = value;
document.querySelectorAll(".status-pill").forEach((button) => {
button.classList.toggle("active", button.dataset.status === value);
});
const more = $("#moreStatusFilter");
more.value = value === "pending" ? "" : value;
if (value === "done") {
$("#sortFilter").value = "updated_desc";
}
if (reload) loadItems(true);
}
function reviewConfirmCount(summary) {
return summary.state_counts?.["待确认"] ?? (
(summary.state_counts?.["AI修改-待确认"] || 0) + (summary.state_counts?.["修订后仍需确认"] || 0)
);
}
function setAiBusy(label, busy) {
state.aiBusy = busy;
state.aiTaskLabel = busy ? label : "";
document.body.classList.toggle("ai-busy", busy);
document.querySelectorAll("#kimiCurrentButton, #kimiFiveButton, #kimiRemainingButton, #auditKimiButton, #auditKimiAllButton").forEach((button) => {
button.disabled = busy;
});
}
function updateAiVisibility() {
document.body.classList.toggle("ai-disabled", !state.kimiEnabled);
document.querySelectorAll(".review-ai-panel, .audit-ai-panel").forEach((panel) => {
panel.classList.toggle("hidden", !state.kimiEnabled);
});
}
function ensureAiReady(label) {
if (state.aiBusy) {
setSaveState(`${state.aiTaskLabel || "AI处理"}中,请等待完成后再操作`);
window.alert(`${state.aiTaskLabel || "AI处理"}请等待完成后再切换页面或发起新的AI任务。`);
return false;
}
if (!state.kimiEnabled) {
setSaveState("目前无AI功能");
return false;
}
setAiBusy(label, true);
return true;
}
function finishAiTask() {
setAiBusy("", false);
}
function extractJsonData(text) {
const raw = String(text ?? "").trim();
if (!raw) return null;
const candidates = [];
const fencePattern = /```(?:json)?\s*([\s\S]*?)```/gi;
let fenced;
while ((fenced = fencePattern.exec(raw))) {
candidates.push(fenced[1].trim());
}
candidates.push(raw);
for (let index = 0; index < raw.length; index += 1) {
if (raw[index] === "{") candidates.push(raw.slice(index));
}
for (const candidate of candidates) {
try {
const data = JSON.parse(candidate);
if (data && typeof data === "object" && !Array.isArray(data)) return data;
} catch (error) {
const parsed = parseJsonPrefix(candidate);
if (parsed) return parsed;
}
}
return null;
}
function parseJsonPrefix(text) {
let depth = 0;
let inString = false;
let escaped = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
} else if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
try {
const data = JSON.parse(text.slice(0, index + 1));
return data && typeof data === "object" && !Array.isArray(data) ? data : null;
} catch (error) {
return null;
}
}
}
}
return null;
}
function equivalentFieldValue(column, left, right) {
const leftText = normalizeFieldValue(column, left);
const rightText = normalizeFieldValue(column, right);
return leftText === rightText;
}
function cleanAuditJsonData(data, patient = null) {
if (!data) return null;
const cleaned = { ...data };
const patterns = [
"最后书写时间与出院时间顺序异常",
"最后书写时间晚于出院时间",
"出院后仍有书写记录",
"已出院后仍有书写记录",
"实际值一致",
"时间值一致",
"格式差异",
"补零差异",
"小时格式",
"日期格式补零",
"仅格式",
"无需修正",
];
if (cleaned.建议修正 && typeof cleaned.建议修正 === "object" && !Array.isArray(cleaned.建议修正) && patient) {
const keptSuggestions = {};
Object.entries(cleaned.建议修正).forEach(([column, value]) => {
if (columns.includes(column) && equivalentFieldValue(column, value, patient[column] || "")) return;
keptSuggestions[column] = value;
});
cleaned.建议修正 = keptSuggestions;
}
if (Array.isArray(cleaned.问题)) {
const kept = cleaned.问题.filter((issue) => !patterns.some((pattern) => String(issue).includes(pattern)));
cleaned.问题 = kept;
}
if ((!cleaned.问题 || !cleaned.问题.length) && (!cleaned.建议修正 || !Object.keys(cleaned.建议修正).length)) {
cleaned.结论 = "通过";
}
return cleaned;
}
function normalizeModelResultText(text, patient = null) {
const data = cleanAuditJsonData(extractJsonData(text), patient);
if (data) return JSON.stringify(data, null, 2);
return "";
}
function renderModelResult(element, jsonText, rawText = jsonText, patient = null) {
const normalized = normalizeModelResultText(jsonText || rawText, patient);
element.dataset.json = normalized;
element.dataset.raw = String(rawText ?? "");
const data = extractJsonData(normalized);
if (!String(jsonText || rawText || "").trim()) {
element.innerHTML = `<div class="tip">暂无AI输出</div>`;
return;
}
if (!data) {
element.innerHTML = `<div class="validation error">无法解析</div>`;
return;
}
const rows = Object.entries(data).map(([key, value]) => {
const display = typeof value === "string" ? value : JSON.stringify(value, null, 2);
return `<tr><th>${escapeHtml(key)}</th><td><pre>${escapeHtml(display)}</pre></td></tr>`;
}).join("");
element.innerHTML = `<table><tbody>${rows}</tbody></table>`;
}
function setAuditModelResult(jsonText, rawText = jsonText, patient = state.auditCurrent?.patient || null) {
renderModelResult($("#auditModelResult"), jsonText, rawText, patient);
$("#auditRawOutput").textContent = String(rawText || jsonText || "");
const hasStructuredResult = Boolean($("#auditModelResult").dataset.json || String(jsonText || rawText || "").trim());
$("#auditPromptDetails").open = !hasStructuredResult;
$("#auditRawOutputDetails").open = !hasStructuredResult;
}
function setAuditPrompt(text) {
const value = String(text || "");
$("#auditPrompt").value = value;
$("#auditPromptText").textContent = value || "暂无询问提示词";
}
function getAuditModelResult() {
return $("#auditModelResult").dataset.json || "";
}
function getAuditRawOutput() {
return $("#auditModelResult").dataset.raw || "";
}
function setAuditHistoryModelResult(jsonText, rawText = jsonText, patient = state.auditHistoryCurrent?.patient || null) {
renderModelResult($("#auditHistoryAiResult"), jsonText, rawText, patient);
$("#auditHistoryRawOutput").textContent = String(rawText || jsonText || "");
}
function modelVerdict(rawText, patient = null) {
const data = cleanAuditJsonData(extractJsonData(rawText), patient);
const verdict = String(data?.结论 || data?.verdict || data?.result || "").trim();
return ["通过", "异常", "不确定"].includes(verdict) ? verdict : verdict;
}
function formatTime(value) {
if (!value) return "";
return String(value).replace("T", " ");
}
async function api(path, options = {}) {
let response;
try {
response = await fetch(path, {
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
...options,
});
} catch (error) {
throw new Error("网络连接中断或网页端服务暂不可用,请确认服务仍在运行后重试");
}
if (response.status === 401) {
window.location.href = "/login";
return null;
}
if (!response.ok) {
const text = await response.text();
try {
const data = JSON.parse(text);
throw new Error(data.error || text || response.statusText);
} catch (error) {
if (error instanceof SyntaxError) throw new Error(text || response.statusText);
throw error;
}
}
return response.json();
}
async function loadSummary() {
const summary = await api("/api/summary");
if (!summary) return;
renderOverview(summary);
$("#statsGrid").innerHTML = `
<div class="stat"><strong>${summary.total}</strong><span>复核条目</span></div>
<div class="stat"><strong>${summary.state_counts["待处理"]}</strong><span>待处理</span></div>
<div class="stat"><strong>${reviewConfirmCount(summary)}</strong><span>待确认</span></div>
<div class="stat"><strong>${summary.state_counts["人工复核通过"]}</strong><span>人工通过条目</span></div>
`;
if (summary.postgres) {
renderPostgresState(summary.postgres);
}
const current = $("#batchFilter").value;
$("#batchFilter").innerHTML = `<option value="">全部批次</option>` + summary.batches
.map((batch) => `<option value="${escapeHtml(batch.batch_name)}">${escapeHtml(batch.batch_name)} (待处理${batch.pending}/总${batch.total})</option>`)
.join("");
$("#batchFilter").value = current;
}
function renderOverview(summary) {
$("#overviewGrid").innerHTML = `
<div class="stat"><strong>${summary.total}</strong><span>复核条目</span></div>
<div class="stat"><strong>${summary.state_counts["待处理"]}</strong><span>待处理</span></div>
<div class="stat"><strong>${reviewConfirmCount(summary)}</strong><span>待确认</span></div>
<div class="stat"><strong>${summary.state_counts["人工复核通过"]}</strong><span>人工通过</span></div>
<div class="stat"><strong>${summary.postgres?.pending_sync_count ?? 0}</strong><span>PG待同步</span></div>
`;
$("#overviewBatches").innerHTML = summary.batches.map((batch) => `
<article class="batch-card">
<h3>${escapeHtml(batch.batch_name)}</h3>
<div class="batch-metrics">
<span><strong>${escapeHtml(batch.pending)}</strong>待处理</span>
<span><strong>${escapeHtml(batch.still_issue)}</strong>待确认</span>
<span><strong>${escapeHtml(batch.done)}</strong>人工通过</span>
<span><strong>${escapeHtml(batch.total)}</strong>总条目</span>
</div>
</article>
`).join("");
renderAuditOverview(summary.audit_summary || {});
}
function renderAuditOverview(auditSummary) {
const cards = [
["抽查记录", auditSummary.total || 0],
["未人工核验", auditSummary.unreviewed || 0],
["人工通过", auditSummary.passed || 0],
["人工异常", auditSummary.failed || 0],
["暂不确定", auditSummary.unsure || 0],
];
$("#overviewAudit").innerHTML = cards.map(([label, value]) => `
<article class="batch-card">
<h3>${escapeHtml(label)}</h3>
<div class="batch-metrics">
<span><strong>${escapeHtml(value)}</strong>${escapeHtml(label)}</span>
</div>
</article>
`).join("");
}
function renderProcessingOverview(data) {
state.processingOverview = data;
const grid = $("#processingFinderGrid");
if (!grid) return;
const cards = [
["复核待处理", data.pending_review || 0],
["待确认", data.still_issue || 0],
["抽查未人工核验", data.audit_unreviewed || 0],
["抽查异常", data.audit_failed || 0],
["待同步", data.postgres_pending_sync || 0],
];
grid.innerHTML = cards.map(([label, value]) => `
<div class="finder-card">
<strong>${escapeHtml(value)}</strong>
<span>${escapeHtml(label)}</span>
</div>
`).join("");
}
async function findProcessingItems(quiet = false) {
const stateBox = $("#processingFinderState");
if (!quiet && stateBox) stateBox.textContent = "寻找中";
try {
const data = await api("/api/processing-items");
if (!data) return;
renderProcessingOverview(data);
if (stateBox) {
stateBox.textContent = `复核待处理${data.pending_review || 0},抽查未人工核验${data.audit_unreviewed || 0},待同步${data.postgres_pending_sync || 0}`;
}
} catch (error) {
if (stateBox) stateBox.textContent = `寻找失败:${error.message}`;
}
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function itemSubtitle(item) {
const rowText = displayRowText(item);
return [
item.batch_name,
item.source_folder,
item.image_name ? `${item.image_name} ${rowText}` : "",
].filter(Boolean).join(" / ");
}
function displayRowNo(item) {
const row = Number(item.image_row_no || 0);
if (!row) return "";
return Math.max(1, row - 2);
}
function displayRowText(item) {
const row = Number(item.image_row_no || 0);
if (!row) return "";
const patientRow = displayRowNo(item);
return patientRow === row ? `${row}` : `${patientRow}条(图片第${row}行)`;
}
async function loadItems(selectFirst = false, append = false) {
if (state.loadingItems) return;
state.loadingItems = true;
if (!append) {
state.page = 1;
}
const params = new URLSearchParams({
page: state.page,
page_size: state.pageSize,
status: $("#statusFilter").value,
sort: $("#sortFilter").value,
batch: $("#batchFilter").value,
q: $("#searchInput").value,
});
try {
const data = await api(`/api/items?${params.toString()}`);
if (!data) return;
state.totalItems = data.total;
state.page = data.page;
if (append) {
const seen = new Set(state.items.map((item) => item.key));
state.items = state.items.concat((data.items || []).filter((item) => !seen.has(item.key)));
} else {
state.items = data.items || [];
}
renderQueue();
if (selectFirst && state.items.length) {
selectItem(state.items[0].key);
} else if (state.current) {
highlightActive(state.current.key);
}
} catch (error) {
setSaveState(`列表加载失败:${error.message}`);
if (!append) {
$("#queueList").innerHTML = `<div class="validation error">${escapeHtml(error.message)}</div>`;
}
} finally {
state.loadingItems = false;
}
}
async function loadNextItems() {
if (state.loadingItems || state.items.length >= state.totalItems) return;
state.page += 1;
await loadItems(false, true);
}
async function reloadLoadedItems() {
const pageCount = Math.max(1, Math.ceil(Math.max(state.items.length, state.pageSize) / state.pageSize));
state.items = [];
state.totalItems = 0;
for (let page = 1; page <= pageCount; page += 1) {
state.page = page;
await loadItems(false, page > 1);
if (state.items.length >= state.totalItems) break;
}
}
function renderQueue() {
const list = $("#queueList");
if (!state.items.length) {
list.innerHTML = `<div class="tip">当前筛选下没有记录</div>`;
return;
}
const footer = state.items.length < state.totalItems ? "继续向下滚动加载更多" : "已加载全部";
list.innerHTML = state.items.map((item) => `
<button class="queue-item" data-key="${item.key}">
<div class="queue-title">
<span>${escapeHtml(item.patient["姓名"] || "未识别姓名")}</span>
<span class="badge ${badgeClass(item)}">${escapeHtml(item.manual_state)}</span>
</div>
<div class="queue-meta">${escapeHtml(item.patient["住院号"] || "无住院号")}</div>
${item.updated_at ? `<div class="queue-meta">处理时间 ${escapeHtml(formatTime(item.updated_at))}</div>` : ""}
</button>
`).join("") + `<div class="queue-load-state">${state.items.length} / ${state.totalItems} · ${footer}</div>`;
list.querySelectorAll(".queue-item").forEach((button) => {
button.addEventListener("click", () => {
$("#queuePanel").focus({ preventScroll: true });
selectItem(button.dataset.key);
});
});
if (state.current) highlightActive(state.current.key);
}
function highlightActive(key) {
document.querySelectorAll(".queue-item").forEach((button) => {
const active = button.dataset.key === key;
button.classList.toggle("active", active);
if (active) {
button.scrollIntoView({ block: "nearest" });
}
});
}
async function selectItem(key) {
const item = await api(`/api/items/${key}`);
if (!item) return;
state.current = item;
highlightActive(key);
renderCurrent();
}
async function selectAdjacentItem(offset) {
if (!state.current) return;
let index = state.items.findIndex((item) => item.key === state.current.key);
if (index < 0) return;
let nextIndex = index + offset;
if (nextIndex >= state.items.length && state.items.length < state.totalItems) {
await loadNextItems();
index = state.items.findIndex((item) => item.key === state.current.key);
nextIndex = index + offset;
}
if (nextIndex < 0 || nextIndex >= state.items.length) return;
await selectItem(state.items[nextIndex].key);
}
function renderCurrent() {
const item = state.current;
if (!item) return;
$("#recordTitle").textContent = `${item.patient["姓名"] || "未识别姓名"} · ${item.patient["住院号"] || "无住院号"}`;
const handledAt = item.updated_at ? ` / 处理时间 ${formatTime(item.updated_at)}` : "";
$("#recordMeta").textContent = `${item.major_department} / ${item.sub_department} / ${item.source_folder} / ${item.image_name} ${displayRowText(item)}${handledAt}`;
const cropUrl = `/api/crop?path=${encodeURIComponent(item.image_path)}&row=${encodeURIComponent(item.image_row_no)}&context=2&v=${Date.now()}`;
$("#cropImage").src = cropUrl;
resetImageView();
$("#fullImageLink").href = `/api/image?path=${encodeURIComponent(item.image_path)}`;
columns.forEach((column) => {
const field = document.querySelector(`[name="${column}"]`);
if (field) field.value = item.patient[column] ?? "";
});
$("#manualNote").value = item.manual_note || "";
renderReviewChangeLog();
renderTips();
}
function renderTips() {
const item = state.current;
const currentTips = (item.validation_warnings || []).map((tip) => ({ text: `当前修订: ${tip}`, cls: "error" }));
const historyTips = (item.review_tips || []).map((tip) => ({ text: `原始OCR提示: ${tip}`, cls: "info" }));
if (!currentTips.length && !historyTips.length) {
$("#tipsBox").innerHTML = `<div class="validation ok">当前字段通过校验</div>`;
return;
}
const currentHtml = currentTips.length
? currentTips.map((tip) => `<div class="validation ${tip.cls}">${escapeHtml(tip.text)}</div>`).join("")
: `<div class="validation ok">当前字段通过校验</div>`;
$("#tipsBox").innerHTML = currentHtml + historyTips.map((tip) => `<div class="validation ${tip.cls}">${escapeHtml(tip.text)}</div>`).join("");
}
function formPatient(writeBack = false) {
const patient = {};
columns.forEach((column) => {
const field = document.querySelector(`[name="${column}"]`);
patient[column] = field ? normalizeFieldValue(column, field.value) : "";
if (writeBack && field && datetimeColumns.has(column)) field.value = patient[column];
});
return patient;
}
function normalizeDateTime(value) {
const text = String(value ?? "").replaceAll("", ":").trim().replace(/\s+/g, " ");
if (!text) return "";
const match = text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})$/);
if (!match) return text;
const [, year, month, day, hour, minute, second] = match;
const date = new Date(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second));
if (
date.getFullYear() !== Number(year) ||
date.getMonth() !== Number(month) - 1 ||
date.getDate() !== Number(day) ||
date.getHours() !== Number(hour) ||
date.getMinutes() !== Number(minute) ||
date.getSeconds() !== Number(second)
) {
return text;
}
const pad = (value) => String(value).padStart(2, "0");
return `${year}-${pad(month)}-${pad(day)} ${pad(hour)}:${pad(minute)}:${pad(second)}`;
}
function normalizeFieldValue(column, value) {
const text = String(value ?? "").replaceAll("", ":").trim();
return datetimeColumns.has(column) ? normalizeDateTime(text) : text;
}
function patientFromEditor(selector) {
const patient = {};
document.querySelectorAll(`${selector} [data-patient-field]`).forEach((field) => {
patient[field.dataset.patientField] = normalizeFieldValue(field.dataset.patientField, field.value);
});
return patient;
}
function parseDateTime(value) {
const text = normalizeDateTime(value);
const match = text.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
if (!match) return null;
const [, year, month, day, hour, minute, second] = match.map(Number);
const date = new Date(year, month - 1, day, hour, minute, second);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day ||
date.getHours() !== hour ||
date.getMinutes() !== minute ||
date.getSeconds() !== second
) return null;
return date;
}
function validatePatientLocal(patient) {
const warnings = [];
if (!String(patient["姓名"] || "").trim()) warnings.push("缺少姓名");
if (!["男", "女"].includes(String(patient["性别"] || "").trim())) warnings.push("性别异常");
const age = String(patient["年龄"] || "").trim();
if (age && !/^\d{1,3}岁$/.test(age)) warnings.push("年龄格式异常");
if (!String(patient["住院号"] || "").trim()) warnings.push("缺少住院号");
const admission = String(patient["入院时间"] || "").trim();
const discharge = String(patient["出院时间"] || "").trim();
const lastWrite = String(patient["最后书写时间"] || "").trim();
const admissionTime = parseDateTime(admission);
const dischargeTime = parseDateTime(discharge);
const lastWriteTime = parseDateTime(lastWrite);
if (!admission) warnings.push("缺少入院时间");
else if (!admissionTime) warnings.push("入院时间格式异常");
if (discharge && !dischargeTime) warnings.push("出院时间格式异常");
if (lastWrite && !lastWriteTime) warnings.push("最后书写时间格式异常");
if (admissionTime && dischargeTime && dischargeTime < admissionTime) warnings.push("出院时间早于入院时间");
if (admissionTime && lastWriteTime && lastWriteTime < admissionTime) warnings.push("最后书写时间早于入院时间");
const hospitalDays = String(patient["住院天数"] || "").trim();
if (hospitalDays && !/^\d+$/.test(hospitalDays)) warnings.push("住院天数格式异常");
const postoperativeDays = String(patient["手术后天数"] || "").trim();
if (postoperativeDays && !/^后\d+天$/.test(postoperativeDays)) warnings.push("手术后天数格式异常");
return warnings;
}
function renderValidationBox(selector, warnings, prefix = "当前修订") {
const box = $(selector);
if (!box) return;
if (!warnings.length) {
box.innerHTML = `<div class="validation ok">当前字段通过校验</div>`;
return;
}
box.innerHTML = warnings.map((warning) => `<div class="validation error">${escapeHtml(prefix)}: ${escapeHtml(warning)}</div>`).join("");
}
function updateAuditValidation(editorSelector, tipsSelector, targetItem = null) {
const patient = patientFromEditor(editorSelector);
const warnings = validatePatientLocal(patient);
if (targetItem) targetItem.validation_warnings = warnings;
renderValidationBox(tipsSelector, warnings);
return warnings;
}
function patientChanges(before = {}, after = {}) {
return columns
.map((column) => ({
column,
before: normalizeFieldValue(column, before[column] || ""),
after: normalizeFieldValue(column, after[column] || ""),
}))
.filter((item) => item.before !== item.after);
}
function displayChangeLogEntries(entries = []) {
if (!Array.isArray(entries)) return [];
return entries
.map((entry) => ({
column: entry.column || entry["字段"] || "",
before: normalizeFieldValue(entry.column || entry["字段"] || "", entry.before ?? entry["修改前"] ?? ""),
after: normalizeFieldValue(entry.column || entry["字段"] || "", entry.after ?? entry["修改后"] ?? ""),
source: entry.source || entry["修改者"] || "",
}))
.filter((entry) => columns.includes(entry.column));
}
function renderChangeLog(selector, item, title = "修改记录", sourceLabel = "", explicitChanges = null) {
const box = $(selector);
if (!box) return;
const changes = displayChangeLogEntries(explicitChanges || item.change_log || [])
.concat(explicitChanges || item.change_log ? [] : patientChanges(item.audit_original_patient || item.original_patient || {}, item.patient || {}));
if (!changes.length && !item.audit_change_summary) {
box.innerHTML = "";
return;
}
const showSource = changes.some((change) => change.source);
const rows = changes.length
? changes.map((change) => `
<tr>
<th>${escapeHtml(change.column)}</th>
<td>${escapeHtml(change.before || "空")}</td>
<td>${escapeHtml(change.after || "空")}</td>
${showSource ? `<td>${escapeHtml(change.source || sourceLabel || "人工")}</td>` : ""}
</tr>
`).join("")
: `<tr><td colspan="${showSource ? 4 : 3}">${escapeHtml(item.audit_change_summary || "")}</td></tr>`;
box.innerHTML = `
<details open>
<summary>${escapeHtml(title)}</summary>
<table>
<thead><tr><th>字段</th><th>修改前</th><th>修改后</th>${showSource ? "<th>修改者</th>" : ""}</tr></thead>
<tbody>${rows}</tbody>
</table>
</details>
`;
}
function reviewPatientChangesWithSource(draftPatient) {
const currentPatient = state.current?.patient || {};
const savedLog = displayChangeLogEntries(state.current?.change_log || []);
const pendingManualChanges = patientChanges(currentPatient, draftPatient).map((change) => ({ ...change, source: "人工" }));
if (savedLog.length || pendingManualChanges.length) return savedLog.concat(pendingManualChanges);
const original = state.current?.original_patient || {};
const source = state.current?.ai_corrected ? "AI" : "人工";
return patientChanges(original, draftPatient).map((change) => ({ ...change, source }));
}
function renderReviewChangeLog() {
if (!state.current) {
$("#reviewChangeLog").innerHTML = "";
return;
}
const draft = {
...state.current,
patient: formPatient(),
};
const changes = reviewPatientChangesWithSource(draft.patient);
renderChangeLog("#reviewChangeLog", draft, "修改记录", "", changes);
}
function replaceFullwidthColon(field) {
if (!field.value.includes("")) return;
const start = field.selectionStart;
const end = field.selectionEnd;
field.value = field.value.replaceAll("", ":");
if (typeof start === "number" && typeof end === "number") {
field.setSelectionRange(start, end);
}
}
function formOptions() {
return {};
}
async function validateForm() {
if (!state.current) return;
const data = await api(`/api/items/${state.current.key}/validate`, {
method: "POST",
body: JSON.stringify({ patient: formPatient(), options: formOptions() }),
});
if (!data) return;
if (data.patient) fillPatientFields(data.patient);
if (state.current) state.current.validation_warnings = data.warnings || [];
renderTips();
renderReviewChangeLog();
return data.warnings || [];
}
function updateLocalReviewValidation() {
if (!state.current) return;
state.current.validation_warnings = validatePatientLocal(formPatient(false));
renderTips();
renderReviewChangeLog();
}
function setImageZoom(value, anchorEvent = null) {
const frame = $("#cropFrame");
const image = $("#cropImage");
const previousZoom = state.imageZoom;
const nextZoom = Math.min(3, Math.max(0.4, value));
if (!image.naturalWidth) {
state.imageZoom = nextZoom;
$("#zoomLabel").textContent = `${Math.round(state.imageZoom * 100)}%`;
return;
}
const rect = frame.getBoundingClientRect();
const anchorX = anchorEvent ? anchorEvent.clientX - rect.left : frame.clientWidth / 2;
const anchorY = anchorEvent ? anchorEvent.clientY - rect.top : frame.clientHeight / 2;
const scrollX = frame.scrollLeft + anchorX;
const scrollY = frame.scrollTop + anchorY;
const ratio = nextZoom / previousZoom;
state.imageZoom = nextZoom;
image.style.width = `${Math.round(image.naturalWidth * state.imageZoom)}px`;
$("#zoomLabel").textContent = `${Math.round(state.imageZoom * 100)}%`;
requestAnimationFrame(() => {
frame.scrollLeft = scrollX * ratio - anchorX;
frame.scrollTop = scrollY * ratio - anchorY;
});
}
function resetImageView() {
const frame = $("#cropFrame");
state.imageZoom = 1;
$("#cropImage").style.width = "";
$("#zoomLabel").textContent = "100%";
requestAnimationFrame(() => {
frame.scrollLeft = 0;
frame.scrollTop = 0;
});
}
function startImagePan(event) {
if (event.button !== 0) return;
const frame = $("#cropFrame");
state.imagePan = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
scrollLeft: frame.scrollLeft,
scrollTop: frame.scrollTop,
};
frame.classList.add("is-dragging");
frame.setPointerCapture(event.pointerId);
}
function moveImagePan(event) {
const pan = state.imagePan;
if (!pan || pan.pointerId !== event.pointerId) return;
event.preventDefault();
const frame = $("#cropFrame");
frame.scrollLeft = pan.scrollLeft - (event.clientX - pan.startX);
frame.scrollTop = pan.scrollTop - (event.clientY - pan.startY);
}
function endImagePan(event) {
const pan = state.imagePan;
if (!pan || pan.pointerId !== event.pointerId) return;
const frame = $("#cropFrame");
state.imagePan = null;
frame.classList.remove("is-dragging");
if (frame.hasPointerCapture(event.pointerId)) {
frame.releasePointerCapture(event.pointerId);
}
}
function setAuditImageZoom(value, anchorEvent = null) {
const frame = $("#auditFrame");
const image = $("#auditImage");
const previousZoom = state.auditImageZoom;
const nextZoom = Math.min(3, Math.max(0.4, value));
if (!image.naturalWidth) {
state.auditImageZoom = nextZoom;
$("#auditZoomLabel").textContent = `${Math.round(state.auditImageZoom * 100)}%`;
return;
}
const rect = frame.getBoundingClientRect();
const anchorX = anchorEvent ? anchorEvent.clientX - rect.left : frame.clientWidth / 2;
const anchorY = anchorEvent ? anchorEvent.clientY - rect.top : frame.clientHeight / 2;
const scrollX = frame.scrollLeft + anchorX;
const scrollY = frame.scrollTop + anchorY;
const ratio = nextZoom / previousZoom;
state.auditImageZoom = nextZoom;
image.style.width = `${Math.round(image.naturalWidth * state.auditImageZoom)}px`;
$("#auditZoomLabel").textContent = `${Math.round(state.auditImageZoom * 100)}%`;
requestAnimationFrame(() => {
frame.scrollLeft = scrollX * ratio - anchorX;
frame.scrollTop = scrollY * ratio - anchorY;
});
}
function resetAuditImageView() {
const frame = $("#auditFrame");
state.auditImageZoom = 1;
$("#auditImage").style.width = "";
$("#auditZoomLabel").textContent = "100%";
requestAnimationFrame(() => {
frame.scrollLeft = Math.max(0, (frame.scrollWidth - frame.clientWidth) / 2);
frame.scrollTop = Math.max(0, (frame.scrollHeight - frame.clientHeight) / 2);
});
}
function startAuditImagePan(event) {
if (event.button !== 0) return;
const frame = $("#auditFrame");
state.auditImagePan = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
scrollLeft: frame.scrollLeft,
scrollTop: frame.scrollTop,
};
frame.classList.add("is-dragging");
frame.setPointerCapture(event.pointerId);
}
function moveAuditImagePan(event) {
const pan = state.auditImagePan;
if (!pan || pan.pointerId !== event.pointerId) return;
event.preventDefault();
const frame = $("#auditFrame");
frame.scrollLeft = pan.scrollLeft - (event.clientX - pan.startX);
frame.scrollTop = pan.scrollTop - (event.clientY - pan.startY);
}
function endAuditImagePan(event) {
const pan = state.auditImagePan;
if (!pan || pan.pointerId !== event.pointerId) return;
const frame = $("#auditFrame");
state.auditImagePan = null;
frame.classList.remove("is-dragging");
if (frame.hasPointerCapture(event.pointerId)) {
frame.releasePointerCapture(event.pointerId);
}
}
function setAuditHistoryImageZoom(value, anchorEvent = null) {
const frame = $("#auditHistoryFrame");
const image = $("#auditHistoryImage");
const previousZoom = state.auditHistoryImageZoom;
const nextZoom = Math.min(3, Math.max(0.4, value));
if (!image.naturalWidth) {
state.auditHistoryImageZoom = nextZoom;
$("#auditHistoryZoomLabel").textContent = `${Math.round(state.auditHistoryImageZoom * 100)}%`;
return;
}
const rect = frame.getBoundingClientRect();
const anchorX = anchorEvent ? anchorEvent.clientX - rect.left : frame.clientWidth / 2;
const anchorY = anchorEvent ? anchorEvent.clientY - rect.top : frame.clientHeight / 2;
const scrollX = frame.scrollLeft + anchorX;
const scrollY = frame.scrollTop + anchorY;
const ratio = nextZoom / previousZoom;
state.auditHistoryImageZoom = nextZoom;
image.style.width = `${Math.round(image.naturalWidth * state.auditHistoryImageZoom)}px`;
$("#auditHistoryZoomLabel").textContent = `${Math.round(state.auditHistoryImageZoom * 100)}%`;
requestAnimationFrame(() => {
frame.scrollLeft = scrollX * ratio - anchorX;
frame.scrollTop = scrollY * ratio - anchorY;
});
}
function resetAuditHistoryImageView() {
const frame = $("#auditHistoryFrame");
state.auditHistoryImageZoom = 1;
$("#auditHistoryImage").style.width = "";
$("#auditHistoryZoomLabel").textContent = "100%";
requestAnimationFrame(() => {
frame.scrollLeft = Math.max(0, (frame.scrollWidth - frame.clientWidth) / 2);
frame.scrollTop = Math.max(0, (frame.scrollHeight - frame.clientHeight) / 2);
});
}
function startAuditHistoryImagePan(event) {
if (event.button !== 0) return;
const frame = $("#auditHistoryFrame");
state.auditHistoryImagePan = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
scrollLeft: frame.scrollLeft,
scrollTop: frame.scrollTop,
};
frame.classList.add("is-dragging");
frame.setPointerCapture(event.pointerId);
}
function moveAuditHistoryImagePan(event) {
const pan = state.auditHistoryImagePan;
if (!pan || pan.pointerId !== event.pointerId) return;
event.preventDefault();
const frame = $("#auditHistoryFrame");
frame.scrollLeft = pan.scrollLeft - (event.clientX - pan.startX);
frame.scrollTop = pan.scrollTop - (event.clientY - pan.startY);
}
function endAuditHistoryImagePan(event) {
const pan = state.auditHistoryImagePan;
if (!pan || pan.pointerId !== event.pointerId) return;
const frame = $("#auditHistoryFrame");
state.auditHistoryImagePan = null;
frame.classList.remove("is-dragging");
if (frame.hasPointerCapture(event.pointerId)) {
frame.releasePointerCapture(event.pointerId);
}
}
function switchView(view) {
if (state.permissions && state.permissions[permissionKeyForView(view)] === false) return;
if (state.aiBusy && view !== state.activeView) {
window.alert(`${state.aiTaskLabel || "AI处理"}中,请保持当前页面打开,完成后再切换到其他页面。`);
setSaveState(`${state.aiTaskLabel || "AI处理"}中,请保持当前页面打开`);
return;
}
document.querySelectorAll(".page-view").forEach((panel) => {
panel.classList.toggle("hidden", panel.id !== `${view}View`);
});
document.querySelectorAll(".nav-button").forEach((button) => {
button.classList.toggle("active", button.dataset.view === view);
});
state.activeView = view;
if (view === "review" && !state.items.length) loadItems(true);
if (view === "auditHistory") loadAuditHistory();
if (view === "settings") loadSettings();
}
async function loadSession() {
const data = await api("/api/session");
if (!data) return;
state.permissions = data.permissions || {};
state.permissionLabels = data.permission_labels || {};
state.kimiEnabled = data.kimi_enabled !== false;
updateAiVisibility();
document.querySelectorAll(".nav-button").forEach((button) => {
const view = button.dataset.view;
button.classList.toggle("hidden", state.permissions[permissionKeyForView(view)] === false);
});
const active = document.querySelector(".nav-button.active:not(.hidden)");
if (!active) {
const first = document.querySelector(".nav-button:not(.hidden)");
if (first) switchView(first.dataset.view);
}
}
async function saveCorrection(event) {
event.preventDefault();
if (!state.current) return;
const currentKey = state.current.key;
const wasAiPending = state.current.manual_state === "AI修改-待确认";
const currentIndex = state.items.findIndex((item) => item.key === currentKey);
const nextKey = state.items[currentIndex + 1]?.key || state.items[currentIndex - 1]?.key || "";
const precheckWarnings = await validateForm();
if (precheckWarnings && precheckWarnings.length) {
const message = `当前修订仍有 ${precheckWarnings.length} 个提示:\n\n${precheckWarnings.join("\n")}\n\n仍然保存吗?`;
if (!window.confirm(message)) {
setSaveState("已取消保存");
return;
}
}
setSaveState("保存中");
const result = await api(`/api/items/${state.current.key}/correction`, {
method: "POST",
body: JSON.stringify({
patient: formPatient(),
options: formOptions(),
manual_note: $("#manualNote").value.trim(),
}),
});
if (!result) return;
if (result.postgres?.error) {
window.alert(`修订已保存到本地,但同步 PostgreSQL 失败,已纳入待同步:\n${result.postgres.error}`);
} else if (result.postgres?.enabled && result.postgres.updated === 0) {
window.alert("修订已保存到本地,但 PostgreSQL 暂未找到匹配记录,已纳入待同步。");
}
await loadSummary();
if (wasAiPending) {
setStatusFilter("confirming", false);
}
await reloadLoadedItems();
if (!result.warnings.length && state.items.length) {
const fallbackIndex = currentIndex >= 0 ? Math.min(currentIndex, state.items.length - 1) : 0;
const targetKey = state.items.some((item) => item.key === nextKey) ? nextKey : state.items[fallbackIndex].key;
await selectItem(targetKey);
} else {
state.current = result.item;
renderCurrent();
highlightActive(result.item.key);
}
setSaveState(result.warnings.length ? "已保存,仍需确认" : "已保存");
}
async function deleteCorrection() {
if (!state.current) return;
setSaveState("删除中");
const result = await api(`/api/items/${state.current.key}/correction`, { method: "DELETE" });
if (result?.postgres?.error) {
window.alert(`本地修订已删除,但同步 PostgreSQL 失败:\n${result.postgres.error}`);
}
const item = await api(`/api/items/${state.current.key}`);
state.current = item;
renderCurrent();
await loadSummary();
await reloadLoadedItems();
setSaveState("已删除修订");
}
function resetToOriginal() {
if (!state.current) return;
columns.forEach((column) => {
const field = document.querySelector(`[name="${column}"]`);
if (field) field.value = state.current.original_patient[column] ?? "";
});
$("#manualNote").value = "";
renderReviewChangeLog();
validateForm();
}
function fillPatientFields(patient) {
columns.forEach((column) => {
const field = document.querySelector(`[name="${column}"]`);
if (field && Object.prototype.hasOwnProperty.call(patient, column)) {
field.value = patient[column] ?? "";
}
});
}
function bindEvents() {
document.querySelectorAll(".nav-button").forEach((button) => {
button.addEventListener("click", () => switchView(button.dataset.view));
});
$("#batchFilter").addEventListener("change", () => loadItems(true));
document.querySelectorAll(".status-pill").forEach((button) => {
button.addEventListener("click", () => setStatusFilter(button.dataset.status));
});
$("#moreStatusFilter").addEventListener("change", () => {
if ($("#moreStatusFilter").value) {
setStatusFilter($("#moreStatusFilter").value);
}
});
$("#sortFilter").addEventListener("change", () => loadItems(true));
$("#queuePanel").addEventListener("scroll", () => {
const panel = $("#queuePanel");
if (panel.scrollTop + panel.clientHeight >= panel.scrollHeight - 180) {
loadNextItems();
}
});
$("#queuePanel").addEventListener("keydown", (event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
selectAdjacentItem(1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
selectAdjacentItem(-1);
}
});
$("#pgTestButton").addEventListener("click", () => testPostgres(false));
$("#pgSyncButton").addEventListener("click", syncPostgres);
$("#searchInput").addEventListener("input", () => {
clearTimeout(state.debounce);
state.debounce = setTimeout(() => loadItems(true), 220);
});
$("#editForm").addEventListener("submit", saveCorrection);
$("#deleteButton").addEventListener("click", deleteCorrection);
$("#resetButton").addEventListener("click", resetToOriginal);
$("#zoomInButton").addEventListener("click", () => setImageZoom(state.imageZoom + 0.15));
$("#zoomOutButton").addEventListener("click", () => setImageZoom(state.imageZoom - 0.15));
$("#zoomResetButton").addEventListener("click", resetImageView);
$("#cropFrame").addEventListener("wheel", (event) => {
event.preventDefault();
setImageZoom(state.imageZoom + (event.deltaY < 0 ? 0.12 : -0.12), event);
}, { passive: false });
$("#cropFrame").addEventListener("pointerdown", startImagePan);
$("#cropFrame").addEventListener("pointermove", moveImagePan);
$("#cropFrame").addEventListener("pointerup", endImagePan);
$("#cropFrame").addEventListener("pointercancel", endImagePan);
$("#cropImage").addEventListener("load", resetImageView);
$("#cropImage").addEventListener("error", () => {
$("#tipsBox").innerHTML = `<div class="validation error">截图加载失败,请检查原图路径或点击“原图”查看。</div>`;
});
$("#auditZoomInButton").addEventListener("click", () => setAuditImageZoom(state.auditImageZoom + 0.15));
$("#auditZoomOutButton").addEventListener("click", () => setAuditImageZoom(state.auditImageZoom - 0.15));
$("#auditZoomResetButton").addEventListener("click", resetAuditImageView);
$("#auditFrame").addEventListener("wheel", (event) => {
event.preventDefault();
setAuditImageZoom(state.auditImageZoom + (event.deltaY < 0 ? 0.12 : -0.12), event);
}, { passive: false });
$("#auditFrame").addEventListener("pointerdown", startAuditImagePan);
$("#auditFrame").addEventListener("pointermove", moveAuditImagePan);
$("#auditFrame").addEventListener("pointerup", endAuditImagePan);
$("#auditFrame").addEventListener("pointercancel", endAuditImagePan);
$("#auditImage").addEventListener("load", resetAuditImageView);
$("#auditImage").addEventListener("error", () => {
$("#auditState").textContent = "截图加载失败,请检查原图路径";
});
$("#auditSampleButton").addEventListener("click", loadAuditSample);
$("#auditKimiButton").addEventListener("click", () => runKimiAudit());
$("#auditKimiAllButton").addEventListener("click", runKimiAuditAll);
$("#auditHistoryRefreshButton").addEventListener("click", loadAuditHistory);
$("#auditHistorySourceFilter").addEventListener("change", loadAuditHistory);
$("#auditHistoryStatusFilter").addEventListener("change", loadAuditHistory);
$("#auditHistorySortFilter").addEventListener("change", loadAuditHistory);
$("#auditHistorySearchInput").addEventListener("input", () => {
clearTimeout(state.debounce);
state.debounce = setTimeout(loadAuditHistory, 220);
});
$("#auditHistoryZoomInButton").addEventListener("click", () => setAuditHistoryImageZoom(state.auditHistoryImageZoom + 0.15));
$("#auditHistoryZoomOutButton").addEventListener("click", () => setAuditHistoryImageZoom(state.auditHistoryImageZoom - 0.15));
$("#auditHistoryZoomResetButton").addEventListener("click", resetAuditHistoryImageView);
$("#auditHistoryFrame").addEventListener("wheel", (event) => {
event.preventDefault();
setAuditHistoryImageZoom(state.auditHistoryImageZoom + (event.deltaY < 0 ? 0.12 : -0.12), event);
}, { passive: false });
$("#auditHistoryFrame").addEventListener("pointerdown", startAuditHistoryImagePan);
$("#auditHistoryFrame").addEventListener("pointermove", moveAuditHistoryImagePan);
$("#auditHistoryFrame").addEventListener("pointerup", endAuditHistoryImagePan);
$("#auditHistoryFrame").addEventListener("pointercancel", endAuditHistoryImagePan);
$("#auditHistoryImage").addEventListener("load", resetAuditHistoryImageView);
$("#auditHistoryImage").addEventListener("error", () => {
$("#auditHistoryState").textContent = "截图加载失败,请检查原图路径";
});
$("#auditPassButton").addEventListener("click", () => saveAuditResult("人工核验通过"));
$("#auditFailButton").addEventListener("click", () => saveAuditResult("人工核验异常"));
$("#auditUnsureButton").addEventListener("click", () => saveAuditResult("暂不确定"));
$("#auditHistoryPassButton").addEventListener("click", () => saveAuditHistoryResult("人工核验通过"));
$("#auditHistoryFailButton").addEventListener("click", () => saveAuditHistoryResult("人工核验异常"));
$("#auditHistoryUnsureButton").addEventListener("click", () => saveAuditHistoryResult("暂不确定"));
$("#userForm").addEventListener("submit", saveUser);
$("#kimiForm").addEventListener("submit", saveKimiSettings);
$("#kimiToggleButton").addEventListener("click", toggleKimiEnabled);
$("#processingFindButton").addEventListener("click", () => findProcessingItems(false));
$("#commitManualPassedButton").addEventListener("click", commitManualPassed);
$("#resetAuditHistoryButton").addEventListener("click", resetAuditHistory);
$("#kimiCurrentButton").addEventListener("click", kimiCorrectCurrent);
$("#kimiFiveButton").addEventListener("click", kimiCorrectFive);
$("#kimiRemainingButton").addEventListener("click", kimiCorrectRemaining);
document.querySelectorAll(".edit-form input, .edit-form textarea").forEach((field) => {
field.addEventListener("input", () => {
const column = field.getAttribute("name");
if (datetimeColumns.has(column)) {
replaceFullwidthColon(field);
}
updateLocalReviewValidation();
});
field.addEventListener("keydown", (event) => {
const column = field.getAttribute("name");
if (event.key === "Enter" && datetimeColumns.has(column)) {
event.preventDefault();
field.value = normalizeDateTime(field.value);
validateForm();
}
});
field.addEventListener("blur", () => {
const column = field.getAttribute("name");
if (datetimeColumns.has(column)) {
field.value = normalizeDateTime(field.value);
validateForm();
} else {
updateLocalReviewValidation();
}
});
});
}
async function loadAuditSample() {
$("#auditState").textContent = "抽取中";
const count = Math.min(20, Math.max(1, Number($("#auditCount").value || 5)));
$("#auditCount").value = count;
const source = $("#auditSource").value;
try {
const data = await api("/api/audit/sample", {
method: "POST",
body: JSON.stringify({ count, source }),
});
if (!data) return;
state.auditItems = data.items || [];
renderAuditList();
if (state.auditItems.length) selectAuditItem(state.auditItems[0].key);
$("#auditState").textContent = `已抽取 ${state.auditItems.length} 条,${auditProgressText()}`;
} catch (error) {
$("#auditState").textContent = `抽取失败:${error.message}`;
}
}
function renderAuditList() {
$("#auditList").innerHTML = state.auditItems
.map((item) => auditQueueItemHtml(item, state.auditCurrent?.key, item.audit_checked_at ? [`抽查时间 ${formatTime(item.audit_checked_at)}`] : []))
.join("");
$("#auditList").querySelectorAll(".queue-item").forEach((button) => {
button.addEventListener("click", () => selectAuditItem(button.dataset.key));
});
}
function selectAuditItem(key) {
const item = state.auditItems.find((candidate) => candidate.key === key);
if (!item) return;
if (!item.audit_original_patient) {
item.audit_original_patient = { ...(item.patient || {}) };
}
state.auditCurrent = item;
$("#auditTitle").innerHTML = auditTitleHtml(item);
$("#auditMeta").textContent = `${item.major_department} / ${item.sub_department} / ${item.source_folder} / ${item.image_name} ${displayRowText(item)}`;
$("#auditImage").src = `/api/crop?path=${encodeURIComponent(item.image_path)}&row=${encodeURIComponent(item.image_row_no)}&context=2&v=${Date.now()}`;
$("#auditFullImageLink").href = `/api/image?path=${encodeURIComponent(item.image_path)}`;
resetAuditImageView();
setAuditPrompt(item.audit_prompt || "");
setAuditModelResult(item.audit_ai_feedback || item.audit_result_text || "", item.audit_ai_raw_output || item.audit_ai_feedback || item.audit_result_text || "", item.patient || null);
$("#auditMachineVerdict").value = item.audit_machine_verdict || "";
$("#auditManualFeedback").value = item.audit_manual_feedback || "";
renderAuditPatientInfo(item);
renderAuditVerdicts(item);
updateAuditValidation("#auditPatientInfo", "#auditTipsBox", item);
renderChangeLog("#auditChangeLog", item);
renderAuditList();
}
function renderAuditPatientInfo(item) {
renderPatientEditor("#auditPatientInfo", item, "#auditTipsBox", "#auditChangeLog");
}
function renderPatientEditor(selector, item, tipsSelector = "", changeLogSelector = "") {
const wideColumns = new Set(["诊断", "手术后天数"]);
$(selector).innerHTML = columns.map((column) => {
const value = escapeHtml(item.patient[column] || "");
const wide = wideColumns.has(column) ? "wide" : "";
const control = column === "诊断"
? `<textarea data-patient-field="${escapeHtml(column)}">${value}</textarea>`
: `<input data-patient-field="${escapeHtml(column)}" value="${value}">`;
return `<label class="${wide}">${escapeHtml(column)}${control}</label>`;
}).join("");
document.querySelectorAll(`${selector} [data-patient-field]`).forEach((field) => {
field.addEventListener("input", () => {
const column = field.dataset.patientField;
if (datetimeColumns.has(column)) replaceFullwidthColon(field);
readPatientEditor(selector, item, false);
if (tipsSelector) updateAuditValidation(selector, tipsSelector, item);
if (changeLogSelector) renderChangeLog(changeLogSelector, item);
refreshAuditIdentity(selector, item);
});
field.addEventListener("blur", () => {
const column = field.dataset.patientField;
field.value = normalizeFieldValue(column, field.value);
readPatientEditor(selector, item);
if (tipsSelector) updateAuditValidation(selector, tipsSelector, item);
if (changeLogSelector) renderChangeLog(changeLogSelector, item);
refreshAuditIdentity(selector, item);
});
});
}
function refreshAuditIdentity(selector, item) {
if (selector === "#auditPatientInfo") {
$("#auditTitle").innerHTML = auditTitleHtml(item);
renderAuditList();
} else if (selector === "#auditHistoryPatientInfo") {
$("#auditHistoryTitle").innerHTML = auditTitleHtml(item, auditHistoryBadgeText(item));
renderAuditHistoryList();
}
}
function readPatientEditor(selector, targetItem, writeBack = true) {
const patient = { ...(targetItem.patient || {}) };
document.querySelectorAll(`${selector} [data-patient-field]`).forEach((field) => {
const column = field.dataset.patientField;
patient[column] = normalizeFieldValue(column, field.value);
if (writeBack) field.value = patient[column];
});
targetItem.patient = patient;
return patient;
}
function renderAuditVerdicts(item) {
$("#auditMachineVerdictText").textContent = item.audit_machine_verdict || "未判断";
$("#auditManualVerdictText").textContent = item.audit_result || "未选择";
}
async function runKimiAudit(options = {}) {
if (!state.auditCurrent) return;
if (!ensureAiReady("AI抽查")) return;
readPatientEditor("#auditPatientInfo", state.auditCurrent);
setSaveState("AI抽查中请保持当前页面打开");
$("#auditState").textContent = auditProgressText();
try {
await runKimiAuditForItem(state.auditCurrent);
setAuditPrompt(state.auditCurrent.audit_prompt);
setAuditModelResult(state.auditCurrent.audit_ai_feedback, state.auditCurrent.audit_ai_raw_output, state.auditCurrent.patient || null);
$("#auditMachineVerdict").value = state.auditCurrent.audit_machine_verdict;
renderAuditVerdicts(state.auditCurrent);
renderAuditList();
await persistAuditCurrent(state.auditCurrent.audit_result || "");
$("#auditState").textContent = auditProgressText();
setSaveState("AI抽查完成AI反馈已保存");
} catch (error) {
setSaveState(`AI抽查失败${error.message}`);
$("#auditState").textContent = auditProgressText();
if (options.bubble) throw error;
} finally {
finishAiTask();
}
}
async function runKimiAuditForItem(item) {
const result = await api("/api/audit/kimi", {
method: "POST",
body: JSON.stringify({ key: item.key, item }),
});
if (!result) return null;
item.audit_prompt = result.prompt || "";
item.audit_ai_feedback = normalizeModelResultText(result.result || "", item.patient || null);
item.audit_ai_raw_output = result.raw_result || result.result || "";
item.audit_machine_verdict = result.machine_verdict || modelVerdict(item.audit_ai_feedback || item.audit_ai_raw_output, item.patient || null) || "";
return result;
}
async function persistAuditItem(item, status, source = $("#auditSource").value) {
const originalPatient = item.audit_original_patient || item.original_patient || item.patient || {};
const changes = patientChanges(originalPatient, item.patient || {});
const updates = {
audit_result: status,
audit_ai_feedback: normalizeModelResultText(item.audit_ai_feedback || item.audit_ai_raw_output || "", item.patient || null),
audit_ai_raw_output: item.audit_ai_raw_output || item.audit_ai_feedback || "",
audit_manual_feedback: item.audit_manual_feedback || "",
audit_machine_verdict: item.audit_machine_verdict || "",
audit_source: source || item.audit_source || "",
audit_change_summary: changes.map((change) => `${change.column}: ${change.before || "空"} -> ${change.after || "空"}`).join(""),
};
const payload = {
key: item.key,
item,
original_patient: originalPatient,
...updates,
};
const result = await api("/api/audit/result", {
method: "POST",
body: JSON.stringify(payload),
});
if (!result) return null;
Object.assign(item, updates, {
audit_checked_at: new Date().toISOString().slice(0, 19),
});
return result;
}
async function persistAuditCurrent(status) {
if (!state.auditCurrent) return;
readPatientEditor("#auditPatientInfo", state.auditCurrent);
state.auditCurrent.audit_result = status;
state.auditCurrent.audit_ai_feedback = getAuditModelResult();
state.auditCurrent.audit_ai_raw_output = getAuditRawOutput();
state.auditCurrent.audit_manual_feedback = $("#auditManualFeedback").value.trim();
state.auditCurrent.audit_machine_verdict = $("#auditMachineVerdict").value;
const result = await persistAuditItem(state.auditCurrent, status, $("#auditSource").value);
if (!result) return;
renderAuditList();
renderAuditVerdicts(state.auditCurrent);
return result;
}
async function saveAuditResult(status) {
if (!state.auditCurrent) return;
state.auditCurrent.audit_result = status;
renderAuditVerdicts(state.auditCurrent);
const result = await persistAuditCurrent(status);
if (!result) return;
$("#auditState").textContent = result.postgres?.updated ? `当前项已保存:${status}${auditProgressText()}` : `当前项未匹配数据库,${auditProgressText()}`;
renderChangeLog("#auditChangeLog", state.auditCurrent);
}
async function runKimiAuditAll() {
if (!state.auditItems.length) return;
if (!window.confirm(`将对当前抽取的 ${state.auditItems.length} 条逐条调用 AI确定继续`)) return;
if (!ensureAiReady("AI抽查")) return;
let success = 0;
let failed = 0;
const currentKey = state.auditCurrent?.key || "";
try {
for (const item of state.auditItems) {
try {
await runKimiAuditForItem(item);
await persistAuditItem(item, item.audit_result || "", item.audit_source || $("#auditSource").value);
success += 1;
if (item.key === currentKey) {
setAuditPrompt(item.audit_prompt || "");
setAuditModelResult(item.audit_ai_feedback || "", item.audit_ai_raw_output || item.audit_ai_feedback || "", item.patient || null);
$("#auditMachineVerdict").value = item.audit_machine_verdict || "";
renderAuditVerdicts(item);
}
renderAuditList();
const progress = `AI抽查中 ${success + failed}/${state.auditItems.length}${auditProgressText()}`;
$("#auditState").textContent = auditProgressText();
setSaveState(progress);
} catch (error) {
failed += 1;
item.audit_ai_feedback = `调用失败:${error.message}`;
item.audit_ai_raw_output = item.audit_ai_feedback;
item.audit_machine_verdict = "不确定";
if (state.auditCurrent?.key === item.key) {
setAuditModelResult("", item.audit_ai_raw_output);
$("#auditMachineVerdict").value = item.audit_machine_verdict;
renderAuditVerdicts(item);
}
renderAuditList();
}
}
} finally {
finishAiTask();
}
const doneText = `AI抽查所有项完成成功${success},失败${failed}${auditProgressText()}`;
$("#auditState").textContent = auditProgressText();
setSaveState(doneText);
}
async function loadAuditHistory() {
$("#auditHistoryState").textContent = "加载中";
try {
const params = new URLSearchParams({
page: "1",
page_size: "80",
source: $("#auditHistorySourceFilter").value,
status: $("#auditHistoryStatusFilter").value,
sort: $("#auditHistorySortFilter").value,
q: $("#auditHistorySearchInput").value,
});
const data = await api(`/api/audit/history?${params.toString()}`);
if (!data) return;
state.auditHistory = data.items || [];
$("#auditHistoryState").textContent = `${state.auditHistory.length} / ${data.total}`;
renderAuditHistoryList();
if (state.auditHistory.length) {
selectAuditHistoryItem(state.auditHistory[0].key);
} else {
clearAuditHistoryDetail();
}
} catch (error) {
$("#auditHistoryState").textContent = `加载失败:${error.message}`;
}
}
function renderAuditHistoryList() {
$("#auditHistoryList").innerHTML = state.auditHistory.length
? state.auditHistory.map((item) => auditQueueItemHtml(item, state.auditHistoryCurrent?.key, [
`抽查源 ${auditSourceLabel(item.audit_source)}`,
`抽查时间 ${formatTime(item.audit_checked_at)}`,
], auditHistoryBadgeText(item))).join("")
: `<div class="tip">暂无抽查记录</div>`;
$("#auditHistoryList").querySelectorAll(".queue-item").forEach((button) => {
button.addEventListener("click", () => selectAuditHistoryItem(button.dataset.key));
});
}
function clearAuditHistoryDetail() {
state.auditHistoryCurrent = null;
$("#auditHistoryTitle").textContent = "未选择抽查记录";
$("#auditHistoryMeta").textContent = "";
$("#auditHistoryFullImageLink").removeAttribute("href");
$("#auditHistoryImage").removeAttribute("src");
setAuditHistoryModelResult("", "");
$("#auditHistoryPatientInfo").innerHTML = "";
$("#auditHistoryTipsBox").innerHTML = "";
$("#auditHistoryChangeLog").innerHTML = "";
$("#auditHistoryAiVerdict").textContent = "";
$("#auditHistoryManualVerdict").textContent = "";
$("#auditHistorySource").textContent = "";
$("#auditHistoryUser").textContent = "";
$("#auditHistoryCheckedAt").textContent = "";
resetAuditHistoryImageView();
}
function selectAuditHistoryItem(key) {
const item = state.auditHistory.find((candidate) => candidate.key === key);
if (!item) return;
if (!item.audit_original_patient) {
item.audit_original_patient = { ...(item.patient || {}) };
}
state.auditHistoryCurrent = item;
$("#auditHistoryTitle").innerHTML = auditTitleHtml(item, auditHistoryBadgeText(item));
$("#auditHistoryMeta").textContent = `${item.major_department} / ${item.sub_department} / ${item.source_folder} / ${item.image_name} ${displayRowText(item)}`;
$("#auditHistoryImage").src = `/api/crop?path=${encodeURIComponent(item.image_path)}&row=${encodeURIComponent(item.image_row_no)}&context=2&v=${Date.now()}`;
$("#auditHistoryFullImageLink").href = `/api/image?path=${encodeURIComponent(item.image_path)}`;
resetAuditHistoryImageView();
setAuditHistoryModelResult(item.audit_ai_feedback || "", item.audit_ai_raw_output || item.audit_ai_feedback || "", item.patient || null);
renderPatientEditor("#auditHistoryPatientInfo", item, "#auditHistoryTipsBox", "#auditHistoryChangeLog");
updateAuditValidation("#auditHistoryPatientInfo", "#auditHistoryTipsBox", item);
renderChangeLog("#auditHistoryChangeLog", item);
$("#auditHistoryAiVerdict").textContent = item.audit_machine_verdict || "未判断";
$("#auditHistoryManualVerdict").textContent = item.audit_result || "未人工核验";
$("#auditHistorySource").textContent = auditSourceLabel(item.audit_source);
$("#auditHistoryUser").textContent = item.audit_checked_by || "";
$("#auditHistoryCheckedAt").textContent = formatTime(item.audit_checked_at);
renderAuditHistoryList();
}
async function saveAuditHistoryResult(status) {
if (!state.auditHistoryCurrent) return;
readPatientEditor("#auditHistoryPatientInfo", state.auditHistoryCurrent);
state.auditHistoryCurrent.audit_result = status;
state.auditHistoryCurrent.audit_manual_feedback = "";
const result = await persistAuditItem(
state.auditHistoryCurrent,
status,
state.auditHistoryCurrent.audit_source || $("#auditHistorySourceFilter").value,
);
if (!result) return;
$("#auditHistoryManualVerdict").textContent = status;
renderChangeLog("#auditHistoryChangeLog", state.auditHistoryCurrent);
renderAuditHistoryList();
$("#auditHistoryState").textContent = result.postgres?.updated ? `已保存:${status}` : "保存成功,本次未匹配数据库";
}
async function loadSettings() {
const data = await api("/api/settings");
if (!data) return;
state.permissionLabels = data.permission_labels || state.permissionLabels;
state.kimiEnabled = data.kimi_enabled !== false;
updateAiVisibility();
$("#kimiModel").value = data.kimi_model || "kimi-k2.6";
$("#kimiApiKey").placeholder = data.kimi_api_key_set ? "已设置,留空表示不修改" : "未设置";
$("#kimiToggleButton").textContent = state.kimiEnabled ? "关闭API" : "开启API";
const defaultPermissions = { overview: true, review: true, audit: true, audit_history: true, settings: false };
$("#settingsUserCount").textContent = `${(data.users || []).length} 个用户`;
$("#newUserPermissions").innerHTML = renderPermissionControls("new-perm", defaultPermissions);
$("#userList").innerHTML = (data.users || []).map((user, index) => `
<section class="settings-user" data-username="${escapeHtml(user.username)}" data-prefix="user-perm-${index}">
<div class="queue-title">
<span>${escapeHtml(user.username)}</span>
<span class="badge ${user.builtin ? "done" : "warn"}">${user.builtin ? "环境变量用户" : "配置用户"}</span>
</div>
<div class="queue-meta">${escapeHtml(user.created_at || "")}</div>
<div class="user-permissions">${renderPermissionControls(`user-perm-${index}`, user.permissions || {}, user.builtin)}</div>
${user.builtin ? "" : `<button type="button" class="secondary-button user-permission-save">保存权限</button>`}
</section>
`).join("");
document.querySelectorAll(".user-permission-save").forEach((button) => {
button.addEventListener("click", async () => {
const wrapper = button.closest(".settings-user");
await saveUserPermissions(wrapper.dataset.username, wrapper.dataset.prefix);
});
});
if (state.processingOverview) {
renderProcessingOverview(state.processingOverview);
} else {
findProcessingItems(true);
}
}
async function saveUser(event) {
event.preventDefault();
const username = $("#newUsername").value.trim();
const password = $("#newPassword").value;
if (!username || !password) return;
await api("/api/settings/users", {
method: "POST",
body: JSON.stringify({ username, password, permissions: readPermissionControls("new-perm") }),
});
$("#newUsername").value = "";
$("#newPassword").value = "";
await loadSettings();
}
function renderPermissionControls(prefix, permissions = {}, disabled = false) {
return permissionEntries().map(([key, label]) => {
const checked = permissions[key] !== false;
return `
<label>
<input type="checkbox" data-permission="${escapeHtml(key)}" class="${escapeHtml(prefix)}" ${checked ? "checked" : ""} ${disabled ? "disabled" : ""}>
<span>${escapeHtml(label)}</span>
</label>
`;
}).join("");
}
function readPermissionControls(prefix) {
const permissions = {};
document.querySelectorAll(`.${prefix}[data-permission]`).forEach((input) => {
permissions[input.dataset.permission] = input.checked;
});
return permissions;
}
async function saveUserPermissions(username, prefix) {
if (!username || !prefix) return;
await api(`/api/settings/users/${encodeURIComponent(username)}/permissions`, {
method: "POST",
body: JSON.stringify({ permissions: readPermissionControls(prefix) }),
});
$("#settingsState").textContent = "用户权限已保存";
await loadSettings();
}
async function saveKimiSettings(event) {
event.preventDefault();
await api("/api/settings/kimi", {
method: "POST",
body: JSON.stringify({ api_key: $("#kimiApiKey").value.trim(), model: $("#kimiModel").value.trim() }),
});
$("#kimiApiKey").value = "";
$("#settingsState").textContent = "已保存";
await loadSettings();
}
async function toggleKimiEnabled() {
const enabled = !state.kimiEnabled;
await api("/api/settings/kimi/enabled", {
method: "POST",
body: JSON.stringify({ enabled }),
});
state.kimiEnabled = enabled;
updateAiVisibility();
$("#kimiToggleButton").textContent = enabled ? "关闭API" : "开启API";
$("#settingsState").textContent = enabled ? "AI功能已开启" : "AI功能已关闭";
}
async function commitManualPassed() {
if (!window.confirm("确定提交已人工复核通过的数据?提交后这些人工通过条目会归档隐藏,不再出现在复核工作台。")) return;
$("#commitManualPassedState").textContent = "提交中";
try {
const result = await api("/api/settings/commit-manual-passed", {
method: "POST",
body: JSON.stringify({}),
});
if (!result) return;
const message = [
`可提交${result.eligible}`,
`合并结果更新${result.merged_updated}`,
`合并已是最新${result.merged_already_current ?? 0}`,
`数据库成功${result.postgres_success}`,
`已同步${result.postgres_already_success ?? 0}`,
`未匹配${result.postgres_not_found}`,
`失败${result.postgres_failed}`,
`跳过${result.skipped}AI待确认${result.skipped_ai ?? 0},仍有问题${result.skipped_issue ?? 0}`,
`已归档隐藏${result.archived_corrections ?? 0}`,
].join("");
$("#commitManualPassedState").textContent = message;
setSaveState("已提交人工通过数据");
await loadSummary();
window.alert(message);
} catch (error) {
$("#commitManualPassedState").textContent = `提交失败:${error.message}`;
}
}
async function resetAuditHistory() {
if (!window.confirm("确定清空抽查一览结果这只会删除抽查结论、AI反馈和人工核验结果不会删除患者基础信息。")) return;
$("#resetAuditHistoryState").textContent = "重置中";
try {
const result = await api("/api/settings/reset-audit-history", {
method: "POST",
body: JSON.stringify({}),
});
if (!result) return;
const message = `已重置${result.updated ?? 0}条抽查记录`;
$("#resetAuditHistoryState").textContent = message;
state.auditHistory = [];
clearAuditHistoryDetail();
renderAuditHistoryList();
$("#auditHistoryState").textContent = "暂无抽查记录";
} catch (error) {
$("#resetAuditHistoryState").textContent = `重置失败:${error.message}`;
}
}
function renderPostgresState(pg) {
const pending = `待同步${pg.pending_sync_count ?? 0}`;
const text = pg.ok
? `数据库已连接,${pending}`
: `数据库${pg.enabled ? "未连接" : "未启用"}${pending}${pg.error ? `${pg.error}` : ""}`;
$("#pgState").textContent = text;
$("#pgState").classList.toggle("warn", !pg.ok && pg.enabled);
}
async function testPostgres(quiet = false) {
if (!quiet) setSaveState("测试PG中");
try {
const status = await api("/api/postgres/status");
if (!status) return;
renderPostgresState(status);
if (!quiet) setSaveState(status.ok ? `待同步${status.pending_sync_count ?? 0}` : "数据库未连接");
} catch (error) {
$("#pgState").textContent = `数据库未连接:${error.message}`;
$("#pgState").classList.add("warn");
if (!quiet) setSaveState("数据库连接失败");
}
}
async function syncPostgres() {
setSaveState("提交待同步中");
try {
const result = await api("/api/postgres/sync", { method: "POST", body: JSON.stringify({}) });
if (!result) return;
if (result.postgres) renderPostgresState(result.postgres);
await loadSummary();
await loadItems(false);
const message = `成功${result.success},未匹配${result.not_found},失败${result.failed}`;
window.alert(message);
const failedKeys = (result.items || []).filter((item) => item.status !== "success").map((item) => item.key);
if (failedKeys.length && window.confirm(`${failedKeys.length} 条未成功同步。确定删除这些待同步记录?取消则继续保留。`)) {
const cleanup = await api("/api/postgres/sync/drop_failed", {
method: "POST",
body: JSON.stringify({ keys: failedKeys }),
});
if (cleanup?.postgres) renderPostgresState(cleanup.postgres);
await loadSummary();
setSaveState(`已删除${cleanup?.removed ?? 0}条未成功待同步`);
} else {
setSaveState(`待同步${result.postgres?.pending_sync_count ?? 0}`);
}
} catch (error) {
setSaveState(`同步失败:${error.message}`);
}
}
async function applyKimiCorrection(key) {
let result;
try {
result = await api(`/api/items/${key}/kimi-correction`, {
method: "POST",
body: JSON.stringify({}),
});
} catch (error) {
throw new Error(`AI修改失败${error.message}`);
}
if (!result) return null;
const index = state.items.findIndex((item) => item.key === key);
if (index >= 0) state.items[index] = result.item;
if (state.current?.key === key) {
state.current = result.item;
renderCurrent();
}
renderQueue();
return result.item;
}
async function refreshKimiReviewList(focusKey = "") {
setStatusFilter("confirming", false);
await loadSummary();
await reloadLoadedItems();
if (focusKey && state.items.some((item) => item.key === focusKey)) {
await selectItem(focusKey);
} else if (state.items.length) {
await selectItem(state.items[0].key);
}
}
async function loadKimiTargetsFromCurrent(limit = null) {
if (!state.current) return [];
const start = state.items.findIndex((item) => item.key === state.current.key);
if (start < 0) return [];
const targetEnd = limit ? start + limit : Number.POSITIVE_INFINITY;
while (state.items.length < state.totalItems && state.items.length < targetEnd) {
await loadNextItems();
}
return state.items.slice(start, limit ? start + limit : undefined);
}
async function kimiCorrectCurrent() {
if (!state.current) return;
if (!window.confirm("确定调用 AI 修改当前项?修改后会标记为 AI修改-待确认。")) return;
if (!ensureAiReady("AI修改")) return;
const focusKey = state.current.key;
setSaveState("AI修改当前项中请保持当前页面打开");
try {
await applyKimiCorrection(focusKey);
await refreshKimiReviewList(focusKey);
setSaveState("已生成 AI 修改,待人工确认");
} catch (error) {
setSaveState(error.message);
} finally {
finishAiTask();
}
}
async function kimiCorrectFive() {
if (!state.current) return;
if (!window.confirm("将从当前项开始调用 AI 修改下 5 项,确定继续?")) return;
if (!ensureAiReady("AI修改")) return;
setSaveState("AI修改5项中请保持当前页面打开");
const focusKey = state.current.key;
let success = 0;
let failed = 0;
const failureReasons = new Map();
let targets = [];
try {
targets = await loadKimiTargetsFromCurrent(5);
for (const item of targets) {
try {
await applyKimiCorrection(item.key);
success += 1;
setSaveState(`AI修改中 ${success + failed}/${targets.length}`);
} catch (error) {
failed += 1;
failureReasons.set(error.message, (failureReasons.get(error.message) || 0) + 1);
setSaveState(`AI修改中 ${success + failed}/${targets.length},失败原因:${error.message}`);
}
}
await refreshKimiReviewList(focusKey);
} catch (error) {
failureReasons.set(`刷新列表失败:${error.message}`, 1);
} finally {
finishAiTask();
}
const reasonText = [...failureReasons.entries()]
.map(([reason, count]) => `${reason}${count > 1 ? ` x${count}` : ""}`)
.join("");
setSaveState(`AI修改完成成功${success},失败${failed}${reasonText ? `,失败原因:${reasonText}` : ""}`);
}
async function kimiCorrectRemaining() {
if (!state.current) return;
const start = state.items.findIndex((item) => item.key === state.current.key);
const remainingCount = start >= 0 ? Math.max(0, state.totalItems - start) : 0;
if (!remainingCount) return;
const message = `将从当前项开始调用 AI 修改下方全部 ${remainingCount} 项,会产生约 ${remainingCount} 次 AI 调用,确定继续?`;
if (!window.confirm(message)) return;
if (!ensureAiReady("AI修改")) return;
const focusKey = state.current.key;
setSaveState(`AI修改全部剩余项中请保持当前页面打开`);
let success = 0;
let failed = 0;
const failureReasons = new Map();
let targets = [];
try {
targets = await loadKimiTargetsFromCurrent(null);
for (const item of targets) {
try {
await applyKimiCorrection(item.key);
success += 1;
setSaveState(`AI修改中 ${success + failed}/${targets.length}`);
} catch (error) {
failed += 1;
failureReasons.set(error.message, (failureReasons.get(error.message) || 0) + 1);
setSaveState(`AI修改中 ${success + failed}/${targets.length},失败原因:${error.message}`);
}
}
await refreshKimiReviewList(focusKey);
} catch (error) {
failureReasons.set(`刷新列表失败:${error.message}`, 1);
} finally {
finishAiTask();
}
const reasonText = [...failureReasons.entries()]
.map(([reason, count]) => `${reason}${count > 1 ? ` x${count}` : ""}`)
.join("");
setSaveState(`AI修改全部剩余项完成成功${success},失败${failed}${reasonText ? `,失败原因:${reasonText}` : ""}`);
}
async function boot() {
bindEvents();
await loadSession();
await loadSummary();
await findProcessingItems(true);
if (state.permissions.review !== false) await loadItems(true);
state.pgTimer = setInterval(() => testPostgres(true).catch(() => {}), 20000);
}
boot().catch((error) => {
setSaveState("加载失败");
$("#queueList").innerHTML = `<div class="validation error">${escapeHtml(error.message)}</div>`;
});