const state = {
records: [],
recordLimit: 300,
recordOffset: 0,
recordHasMore: false,
recordsLoading: false,
selectedId: null,
selectedRecord: null,
schema: [],
view: localStorage.getItem("frontPageReviewView") || "pdf",
activePage: "overview",
overview: null,
auditSamples: [],
auditCurrentId: null,
auditView: localStorage.getItem("frontPageAuditView") || "pdf",
auditLogs: [],
settings: null,
ai: null,
aiJob: null,
aiJobOwned: false,
aiPolling: null,
bulkJob: null,
bulkPolling: null,
currentUser: null,
authenticated: false,
pdfZoom: Number(localStorage.getItem("frontPagePdfZoom")) || 110,
};
const $ = (id) => document.getElementById(id);
const PDF_ZOOM_STEP = 15;
const PDF_MIN_ZOOM = 55;
const PDF_MAX_ZOOM = 220;
const OPERATION_COLUMNS = ["手术操作编码", "手术操作日期", "手术级别", "手术操作名称", "术者", "I助", "II助", "切口愈合等级", "麻醉方式", "麻醉医师", "原始内容"];
const DIAGNOSIS_COLUMNS = ["诊断类别", "出院诊断", "疾病编码", "入院病情"];
const AI_ACTION_MODE_OPTIONS = [
["off", "关闭"],
["default", "跟随默认模型"],
["k25", "K2.5 关 Thinking"],
["k25_thinking", "K2.5 开 Thinking"],
["k26", "K2.6 关 Thinking"],
["k26_thinking", "K2.6 开 Thinking"],
];
const AI_ACTION_SCOPE_LABELS = {
current: "AI当前项",
five: "AI后5项",
all: "AI后全部",
};
const GROUP_KEYWORDS = {
基本信息: ["住院号", "病案号", "首页病案号", "姓名", "性别", "出生", "年龄", "身份证", "婚姻", "健康卡", "入院", "出院", "科别", "科室", "病房", "住院天数"],
地址联系人: ["地址", "电话", "联系人", "单位", "邮编", "户口"],
诊断表格: ["诊断", "疾病编码", "编码格式", "病理", "入院病情"],
手术表格: ["手术", "操作", "麻醉", "切口"],
离院费用: ["费用", "金额", "自付", "总费用", "离院", "再住院", "昏迷"],
};
const FIELD_KEYWORDS = {
inpatient_no: ["住院号", "ZY", "12位"],
medical_record_no: ["病案号", "10位", "前导0"],
front_page_medical_record_no: ["首页病案号"],
patient_name: ["姓名"],
id_card_no: ["身份证"],
contact_address: ["联系人地址"],
contact_phone: ["联系人电话"],
admission_time: ["入院时间"],
admission_dept: ["入院科别"],
discharge_time: ["出院时间"],
discharge_dept: ["出院科别", "科室"],
hospital_days: ["住院天数"],
major_department: ["大科室"],
primary_diagnosis: ["主要诊断"],
primary_diagnosis_code: ["主要诊断编码", "疾病编码"],
discharge_diagnoses: ["出院诊断", "其他诊断", "诊断编码", "入院病情"],
operations: ["手术", "操作", "麻醉", "切口"],
total_cost: ["总费用"],
self_pay_amount: ["自付"],
fee_details: ["费用明细"],
};
function statusTag(status) {
const value = status || "pending";
const meta = {
auto_pass: ["自动通过", "auto_pass"],
auto_corrected: ["自动修正", "auto_corrected"],
needs_review: ["需复核", "needs_review"],
reviewed: ["已复核", "reviewed"],
"AI已处理-OK": ["AI已通过", "ai_passed"],
"AI已处理-不OK": ["AI建议复核", "ai_pending"],
"AI复核-无问题": ["AI已通过", "ai_passed"],
"AI复核-待确认": ["AI建议复核", "ai_pending"],
"已提交": ["已提交", "submitted"],
pending: ["待处理", "pending"],
};
const [label, className] = meta[value] || [value, "pending"];
return `${label}`;
}
function hasAiReview(record) {
return Boolean(record?.ai_reviewed) || (record?.review_logs || []).some((log) => isAiSource(log?.changed_by));
}
function displayStatusValue(record) {
if (record?.review_status === "auto_pass" && hasAiReview(record)) return "AI已处理-OK";
return record?.review_status;
}
function auditTag(status) {
const labels = { pending: "待抽查", passed: "抽查通过", failed: "抽查异常", unsure: "不确定" };
return `${labels[status] || status || "待抽查"}`;
}
function displayText(value) {
return String(value ?? "")
.replaceAll("Kimi AI", "AI")
.replaceAll("Kimi视觉核验", "AI视觉核验")
.replaceAll("Kimi", "AI")
.replaceAll("AI判断不OK", "AI建议复核")
.replaceAll("AI判断OK", "AI判断通过")
.replaceAll("需要人工", "需要复核")
.replaceAll("需人工", "需复核")
.replaceAll("人工确认", "复核")
.replaceAll("人工核对", "复核")
.replaceAll("人工重点看", "复核");
}
function isAiSource(value) {
const text = String(value || "");
return text.includes("AI") || text.includes("Kimi");
}
function displaySource(value) {
return isAiSource(value) ? "AI" : displayText(value || "web");
}
function isReviewNeededText(value) {
const text = String(value || "").trim();
return Boolean(text) && !["无需", "无须", "不需", "不用", "无需补录", "无须补录", "无需处理", "原貌"].some((marker) => text.includes(marker));
}
function aiAttentionSegments(value) {
return displayText(value)
.split(/[,,;;。]/)
.map((item) => item.trim())
.filter(isReviewNeededText);
}
function locateTargetsFromNotes(notes) {
const text = notes.join(";");
const fieldAlerts = new Set();
const groupAlerts = new Set();
state.schema.forEach((group) => {
let groupHit = (GROUP_KEYWORDS[group.name] || []).some((keyword) => text.includes(keyword));
group.fields.forEach(([name, label]) => {
const keywords = [label, name, ...(FIELD_KEYWORDS[name] || [])].filter((keyword) => String(keyword).length > 1);
if (keywords.some((keyword) => text.includes(keyword))) {
fieldAlerts.add(name);
groupHit = true;
}
});
if (groupHit) groupAlerts.add(group.name);
});
return { fieldAlerts, groupAlerts };
}
async function api(path, options = {}) {
let response;
try {
response = await fetch(path, {
headers: { "Content-Type": "application/json" },
...options,
});
} catch (_) {
throw new Error("无法连接网页服务,请确认 8501 服务正在运行后重试");
}
if (!response.ok) {
let message = response.statusText;
try {
const data = await response.json();
message = data.detail || message;
} catch (_) {
// ignore non-json errors
}
if (response.status === 401) {
showLogin(message || "请先登录");
}
throw new Error(message);
}
return response.json();
}
function userPermissions() {
return state.currentUser?.permissions || {};
}
function isAdminUser() {
return state.currentUser?.source === "env" || state.currentUser?.username === "admin";
}
function canOpenPage(page) {
const key = page === "auditHistory" ? "audit_history" : page;
return userPermissions()[key] !== false;
}
function firstAllowedPage() {
return ["overview", "review", "audit", "auditHistory", "settings"].find(canOpenPage) || "overview";
}
function applyPermissions() {
const permissions = userPermissions();
document.querySelectorAll(".nav-button").forEach((button) => {
const key = button.dataset.page === "auditHistory" ? "audit_history" : button.dataset.page;
button.classList.toggle("is-hidden", permissions[key] === false);
});
}
function showLogin(message = "") {
state.authenticated = false;
$("appShell")?.classList.add("is-hidden");
$("loginView")?.classList.remove("is-hidden");
if ($("loginMessage")) $("loginMessage").textContent = message;
setTimeout(() => $("loginPassword")?.focus(), 0);
}
function showApp() {
state.authenticated = true;
$("loginView")?.classList.add("is-hidden");
$("appShell")?.classList.remove("is-hidden");
$("currentUserLabel").textContent = state.currentUser?.username ? `当前用户:${state.currentUser.username}` : "已登录";
applyPermissions();
}
function showMessage(message, kind = "") {
const box = $("saveMessage");
if (!box) return;
box.textContent = message;
box.className = `save-message ${kind ? `is-${kind}` : ""}`;
}
function showAuditMessage(message, kind = "") {
const box = $("auditMessage");
box.textContent = message;
box.className = `inline-message ${kind ? `is-${kind}` : ""}`;
}
function pdfViewHash() {
return `#toolbar=0&navpanes=0&scrollbar=1&zoom=${state.pdfZoom}`;
}
function setPdfFrame(frameId, url) {
const frame = $(frameId);
if (!frame) return;
frame.src = "about:blank";
if (!url) return;
const separator = url.includes("?") ? "&" : "?";
requestAnimationFrame(() => {
frame.src = `${url}${separator}zoom_refresh=${Date.now()}${pdfViewHash()}`;
});
}
function refreshPdfFrames() {
if (state.selectedRecord?.pdf_url) {
setPdfFrame("pdfFrame", state.selectedRecord.pdf_url);
}
const auditRecord = state.auditSamples.find((sample) => sample.record.id === state.auditCurrentId)?.record;
if (auditRecord?.pdf_url) {
setPdfFrame("auditPdfFrame", auditRecord.pdf_url);
}
}
function updatePdfZoomLabels() {
["pdfZoomLabel", "auditPdfZoomLabel"].forEach((id) => {
if ($(id)) $(id).textContent = `${state.pdfZoom}%`;
});
}
function setPdfZoom(value) {
state.pdfZoom = Math.max(PDF_MIN_ZOOM, Math.min(PDF_MAX_ZOOM, Number(value) || 110));
localStorage.setItem("frontPagePdfZoom", String(state.pdfZoom));
updatePdfZoomLabels();
refreshPdfFrames();
}
function changePdfZoom(delta) {
setPdfZoom(state.pdfZoom + delta);
}
function switchPage(page) {
if (!state.authenticated || !canOpenPage(page)) return;
if (page !== "review" && state.aiJob?.running && state.aiJobOwned) {
const leave = window.confirm("AI正在处理。离开复核页会中断本次AI处理,确认离开?");
if (!leave) return;
cancelAiReview({ silent: true }).catch(() => null);
}
state.activePage = page;
document.querySelectorAll(".page-view").forEach((el) => el.classList.add("is-hidden"));
$(`${page}Page`)?.classList.remove("is-hidden");
document.querySelectorAll(".nav-button").forEach((button) => {
button.classList.toggle("is-active", button.dataset.page === page);
});
if (page === "overview") loadOverview();
if (page === "review") loadRecords();
if (page === "auditHistory") loadAuditLogs();
if (page === "settings") loadSettings();
}
async function loadStatus() {
const data = await api("/api/status");
const stateLabel = data.database === "online" ? "在线" : data.database === "unchecked" ? "未检查" : "离线";
$("dbState").textContent = stateLabel;
$("dbState").className = data.database === "online" ? "status-online" : data.database === "unchecked" ? "status-muted" : "status-offline";
$("dbTotal").textContent = data.workbench_total ?? data.review_needed ?? "--";
$("dbNeedsReview").textContent = data.needs_review ?? "--";
$("dbAiPassed").textContent = data.ai_passed ?? "--";
$("dbAiPending").textContent = data.ai_pending ?? "--";
$("dbReviewed").textContent = data.reviewed ?? "--";
$("statusGrid").title = `${data.host}:${data.port}/${data.database_name} · ${data.table} · ${data.message || ""} · ${data.checked_at ? `上次检查 ${formatTime(data.checked_at)}` : "尚未检查"}`;
state.status = data;
renderFilterActions();
return data;
}
async function loadOverview() {
const data = await api("/api/overview");
state.overview = data;
renderOverview(data);
}
function renderOverview(data) {
const summary = data.summary || {};
const metrics = [
["总记录", summary.total],
["待复核总数", summary.review_queue],
["需复核", summary.needs_review],
["AI建议复核", summary.ai_pending],
["AI已通过", summary.ai_passed],
["已复核", summary.reviewed],
["已提交", summary.submitted],
["自动通过", summary.auto_passed],
["抽查记录", summary.audit_total],
["抽查异常", summary.audit_failed],
];
$("overviewMetrics").innerHTML = metrics.map(([label, value]) => `
${escapeHtml(label)}
${value ?? 0}
`).join("");
$("overviewQueues").innerHTML = `
复核工作台默认全部数据:需复核 ${summary.needs_review || 0} / AI已通过 ${summary.ai_passed || 0} / AI建议复核 ${summary.ai_pending || 0} / 已复核 ${summary.reviewed || 0}
抽查候选池已复核 ${summary.reviewed || 0} 条 / 自动通过 ${summary.auto_passed || 0} 条
日志存储人工复核和抽查记录均写入主表 JSONB 列
`;
$("overviewRecentLogs").innerHTML = renderLogTable(logChangeRows(data.recent_logs || []));
}
async function loadSchema() {
const data = await api("/api/schema");
state.schema = data.groups;
}
async function loadRecords({ append = false } = {}) {
if (state.recordsLoading) return;
state.recordsLoading = true;
const query = encodeURIComponent($("searchInput").value.trim());
const status = encodeURIComponent($("statusFilter").value || "review_all");
const offset = append ? state.recordOffset : 0;
try {
const data = await api(`/api/records?q=${query}&status_filter=${status}&limit=${state.recordLimit || 300}&offset=${offset}`);
state.records = append ? [...state.records, ...data.records] : data.records;
state.recordLimit = data.limit || 300;
state.recordOffset = (data.offset || 0) + data.records.length;
state.recordHasMore = Boolean(data.has_more);
if (!append && !state.records.some((record) => record.id === state.selectedId)) {
state.selectedId = null;
state.selectedRecord = null;
}
renderRecords();
renderFilterActions();
if (!state.selectedId && state.records.length) {
await selectRecord(state.records[0].id);
}
} catch (error) {
showMessage(error.message || "加载列表失败", "error");
} finally {
state.recordsLoading = false;
renderRecords();
renderFilterActions();
}
}
function renderRecords() {
const list = $("recordList");
const previousScrollTop = list.scrollTop;
if (!state.records.length) {
list.innerHTML = `当前筛选下没有需要处理的记录
`;
return;
}
const limitNotice = state.recordHasMore
? `已显示 ${state.records.length} 条,继续下拉加载更多
`
: "";
const loadingNotice = state.recordsLoading ? `正在加载...
` : "";
const endNotice = !state.recordHasMore && state.records.length ? `已加载全部 ${state.records.length} 条
` : "";
list.innerHTML = limitNotice + state.records.map((record) => {
const active = record.id === state.selectedId ? " is-active" : "";
const manual = record.manual_corrected ? " · 人工已改" : "";
const activity = record.last_activity_at ? `最近 ${escapeHtml(formatTime(record.last_activity_at))}` : "";
const exportButton = isAdminUser() && record.has_pdf
? ``
: "";
return `
${escapeHtml(record.patient_name || "未命名")}
${statusTag(displayStatusValue(record))}
${escapeHtml(record.inpatient_no || record.medical_record_no || "")} · ${escapeHtml(record.major_department || record.discharge_dept || "")}${manual}
${escapeHtml(record.primary_diagnosis || "")}
`;
}).join("") + loadingNotice + endNotice;
list.querySelectorAll(".record-item").forEach((item) => {
item.addEventListener("click", (event) => {
if (event.target.closest("[data-export-record]")) return;
selectRecord(Number(item.dataset.id));
});
item.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
selectRecord(Number(item.dataset.id));
});
});
list.querySelectorAll("[data-export-record]").forEach((button) => {
button.addEventListener("click", (event) => {
event.stopPropagation();
exportRecordPdf(Number(button.dataset.exportRecord));
});
});
list.scrollTop = previousScrollTop;
}
function renderFilterActions() {
const button = $("approveAiNoIssueBtn");
const exportButton = $("batchExportBtn");
if (!button) return;
const filter = $("statusFilter")?.value || "review_all";
const aiPassedCount = Number(state.status?.ai_passed || 0);
const shouldShow = ["review_all", "ai_passed"].includes(filter);
button.classList.toggle("is-hidden", !shouldShow);
button.disabled = !shouldShow || aiPassedCount <= 0 || Boolean(state.bulkJob?.running);
button.textContent = aiPassedCount > 0 ? `通过所有AI已通过(${aiPassedCount})` : "通过所有AI已通过";
if (exportButton) {
exportButton.classList.toggle("is-hidden", !isAdminUser());
exportButton.disabled = !isAdminUser() || state.recordsLoading;
}
}
function downloadUrl(url) {
const link = document.createElement("a");
link.href = url;
link.rel = "noopener";
document.body.appendChild(link);
link.click();
link.remove();
}
function exportRecordPdf(recordId) {
if (!isAdminUser() || !recordId) return;
downloadUrl(`/api/records/${recordId}/export`);
}
function exportCurrentFilter() {
if (!isAdminUser()) return;
const params = new URLSearchParams({
q: $("searchInput").value.trim(),
status_filter: $("statusFilter").value || "review_all",
});
downloadUrl(`/api/records/export?${params.toString()}`);
}
function scrollSelectedRecordIntoView() {
const selected = $("recordList")?.querySelector(`.record-item[data-id="${state.selectedId}"]`);
selected?.scrollIntoView({ block: "nearest" });
}
function maybeLoadMoreRecords() {
const list = $("recordList");
if (!list || !state.recordHasMore || state.recordsLoading) return;
const distanceToBottom = list.scrollHeight - list.scrollTop - list.clientHeight;
if (distanceToBottom < 240) loadRecords({ append: true });
}
async function selectRecord(id, options = {}) {
state.selectedId = id;
renderRecords();
if (options.keepInView) scrollSelectedRecordIntoView();
showMessage("");
try {
const data = await api(`/api/records/${id}`);
if (state.selectedId !== id) return;
state.selectedRecord = data.record;
renderSelectedRecord();
} catch (error) {
showMessage(error.message || "记录加载失败,请刷新后重试", "error");
}
}
function recordListIndex() {
return state.records.findIndex((record) => record.id === state.selectedId);
}
function recordMatchesActiveFilter(record) {
const status = $("statusFilter").value || "review_all";
if (status === "review_all") {
return ["needs_review", "reviewed", "AI已处理-OK", "AI已处理-不OK", "AI复核-无问题", "AI复核-待确认"].includes(record.review_status)
|| (record.review_status === "auto_pass" && hasAiReview(record))
|| record.manual_corrected === true;
}
if (status === "needs_review") {
return record.review_status === "needs_review";
}
if (status === "ai_passed") {
return ["AI已处理-OK", "AI复核-无问题"].includes(record.review_status) || (record.review_status === "auto_pass" && hasAiReview(record));
}
if (status === "reviewed") {
return record.review_status === "reviewed";
}
return status === "all" || record.review_status === status;
}
async function selectAdjacentRecord(delta) {
if (!state.records.length || state.recordsLoading) return;
let index = recordListIndex();
if (index < 0) index = delta > 0 ? -1 : state.records.length;
let nextIndex = index + delta;
if (nextIndex >= state.records.length && state.recordHasMore) {
await loadRecords({ append: true });
nextIndex = index + delta;
}
if (nextIndex < 0 || nextIndex >= state.records.length) return;
await selectRecord(state.records[nextIndex].id, { keepInView: true });
}
function handleRecordListKeydown(event) {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
selectAdjacentRecord(event.key === "ArrowDown" ? 1 : -1);
}
function isTextEntryTarget(target) {
if (!target) return false;
const tagName = target.tagName;
return target.isContentEditable || tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT";
}
function preventWorkspacePageScroll(event) {
const scrollKeys = new Set(["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "]);
if (!scrollKeys.has(event.key) || isTextEntryTarget(event.target)) return;
if (state.activePage === "review" && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault();
event.stopPropagation();
selectAdjacentRecord(event.key === "ArrowDown" ? 1 : -1);
return;
}
if (state.activePage !== "review" && state.activePage !== "audit") return;
event.preventDefault();
}
function releasePdfKeyboardFocus(event) {
event.currentTarget.blur();
document.body.setAttribute("tabindex", "-1");
document.body.focus({ preventScroll: true });
}
function renderSelectedRecord() {
const record = state.selectedRecord;
$("saveBtn").disabled = false;
$("pdfTitle").textContent = record.source_file || "未找到文件";
$("pdfSubtitle").textContent = `${record.patient_name || ""} · ${record.inpatient_no || record.medical_record_no || ""} · ${record.primary_diagnosis || ""}`;
$("detailTitle").textContent = `${record.patient_name || "未命名"} · ${record.inpatient_no || record.medical_record_no || ""}`;
$("detailMeta").textContent = `${record.source_file || ""} · ${record.major_department || ""}`;
setPdfFrame("pdfFrame", record.pdf_url);
renderReviewStrip(record);
renderForm(record, {
formId: "detailForm",
targetId: "targetSummary",
noteId: "manualNote",
logCountId: "changeLogCount",
logTableId: "changeLogTable",
});
renderChangeLogs(record.review_logs || [], "changeLogCount", "changeLogTable");
renderAiQuestionLogs(record.review_logs || []);
setView(state.view);
}
async function refreshSelectedRecord() {
if (!state.selectedId) return;
try {
const data = await api(`/api/records/${state.selectedId}`);
if (state.selectedId !== data.record.id) return;
state.selectedRecord = data.record;
renderSelectedRecord();
const index = state.records.findIndex((record) => record.id === data.record.id);
if (index >= 0) state.records[index] = { ...state.records[index], ...compactRecord(data.record) };
renderRecords();
} catch (_) {
// keep the current UI if a background refresh loses the selected record
}
}
function baseReviewNotes(record) {
const notes = [];
["review_notes", "quality_notes", "auto_corrections"].forEach((key) => {
const value = record[key];
if (Array.isArray(value)) {
value.forEach((item) => notes.push(typeof item === "string" ? item : JSON.stringify(item)));
} else if (value) {
notes.push(String(value));
}
});
return notes.map(displayText).filter(Boolean);
}
function aiAttentionNotes(record) {
const notes = [];
const latestAiLog = (record.review_logs || []).find((log) => Boolean(log.ai_result));
const parsed = latestAiLog?.ai_result?.parsed;
if (!parsed) return notes;
if (Array.isArray(parsed.remaining_issues)) {
parsed.remaining_issues.forEach((item) => {
if (isReviewNeededText(item)) notes.push(item);
});
}
const classification = String(parsed.classification || parsed.decision || "").toLowerCase();
if (!notes.length && ["problem", "not_ok", "not ok", "confirm", "需复核", "待确认"].includes(classification)) {
notes.push(...aiAttentionSegments(parsed.summary));
}
return notes.map(displayText).filter(Boolean);
}
function reviewTargets(record) {
const notes = baseReviewNotes(record);
const aiNotes = aiAttentionNotes(record);
const baseTargets = locateTargetsFromNotes(notes);
const aiTargets = locateTargetsFromNotes(aiNotes);
const fieldAlerts = new Set([...baseTargets.fieldAlerts, ...aiTargets.fieldAlerts]);
const groupAlerts = new Set([...baseTargets.groupAlerts, ...aiTargets.groupAlerts]);
const hasAiNotes = (record.review_logs || []).some((log) => Boolean(log.ai_result));
return {
notes,
aiNotes,
fieldAlerts,
groupAlerts,
baseGroupAlerts: baseTargets.groupAlerts,
aiGroupAlerts: aiTargets.groupAlerts,
hasAiNotes,
};
}
function renderReviewStrip(record) {
$("reviewStrip").innerHTML = `
复核状态${statusTag(displayStatusValue(record))}
`;
}
function renderForm(record, options = {}) {
const formId = options.formId || "detailForm";
const targetId = options.targetId || "targetSummary";
const noteId = options.noteId || "manualNote";
const form = $(formId);
const targets = reviewTargets(record);
const sourceMap = fieldSourceMap(record.review_logs || []);
const hasTarget = targets.groupAlerts.size > 0;
renderTargetSummary(targets, targetId, formId);
form.innerHTML = state.schema.map((group, index) => {
const groupAlert = targets.groupAlerts.has(group.name);
const fields = group.fields.map(([name, label, type, options]) => renderField(record, name, label, type, options, targets.fieldAlerts.has(name), sourceMap)).join("");
const open = groupAlert || (!hasTarget && index === 0);
return `
${escapeHtml(group.name)}${groupAlert ? "需核对" : ""}
${fields}
`;
}).join("");
if ($(noteId)) $(noteId).value = "";
}
function renderTargetSummary(targets, targetId = "targetSummary", formId = "detailForm") {
const baseGroups = [...targets.baseGroupAlerts];
const aiGroups = [...targets.aiGroupAlerts];
const baseNotesHtml = targets.notes.length
? targets.notes.map((note) => `${escapeHtml(note)}`).join("")
: "暂无原始复核定位提示";
const aiNotesHtml = targets.aiNotes.length
? targets.aiNotes.map((note) => `${escapeHtml(note)}`).join("")
: "AI未提出需要复核的内容";
$(targetId).innerHTML = `
复核定位
${baseGroups.length ? `${baseGroups.length} 个模块` : "未定位"}
${baseGroups.length ? baseGroups.map((group) => ``).join("") : "原始复核信息未定位到具体模块"}
${targets.hasAiNotes ? `
AI建议复核点
${aiGroups.length ? `${aiGroups.length} 个模块` : "无待看点"}
${aiGroups.length ? aiGroups.map((group) => ``).join("") : "AI未标出需要特别关注的模块"}
` : ""}
`;
$(targetId).querySelectorAll("[data-target-group]").forEach((button) => {
button.addEventListener("click", () => {
const group = $(formId).querySelector(`.field-group[data-group="${CSS.escape(button.dataset.targetGroup)}"]`);
if (group) {
group.open = true;
group.scrollIntoView({ block: "center", behavior: "smooth" });
}
});
});
}
function fieldSourceMap(logs) {
const map = {};
logs.forEach((log) => {
const source = isAiSource(log.changed_by) ? "ai" : "manual";
rightSideChangedFields(log).forEach((field) => {
const path = String(field.field || "");
if (path) map[path] = source;
});
});
return map;
}
function renderField(record, name, label, type, options, alerted = false, sourceMap = {}) {
const value = record[name];
const wide = type === "json" || String(value || "").length > 28 ? " is-wide" : "";
const alertClass = alerted ? " is-alert" : "";
const source = type === "json" ? "" : sourceMap[name] || "";
const sourceClass = source === "ai" ? " is-ai-modified" : source === "manual" ? " is-manual-modified" : "";
let control = "";
if (type === "select") {
const normalizedValue = String(value ?? "");
const normalizedOptions = (options || []).map((option) => String(option));
const optionItems = ["", ...normalizedOptions];
if (normalizedValue && !normalizedOptions.includes(normalizedValue)) optionItems.push(normalizedValue);
control = ``;
} else if (type === "json") {
control = renderJsonEditor(name, value, sourceMap);
} else {
const inputType = type === "date" ? "date" : "text";
const placeholder = type === "datetime" ? "YYYY-MM-DD HH:MM:SS" : "";
control = ``;
}
return ``;
}
function preferredColumns(fieldName, value) {
if (fieldName === "operations") return OPERATION_COLUMNS;
if (fieldName === "discharge_diagnoses") return DIAGNOSIS_COLUMNS;
if (Array.isArray(value)) {
const keys = [];
value.forEach((row) => {
if (row && typeof row === "object" && !Array.isArray(row)) {
Object.keys(row).forEach((key) => {
if (!keys.includes(key)) keys.push(key);
});
}
});
return keys.length ? keys : ["值"];
}
if (value && typeof value === "object") return ["项目", "金额"];
return ["值"];
}
function renderJsonEditor(fieldName, value, sourceMap = {}) {
const columns = preferredColumns(fieldName, value);
const rows = normalizeJsonRows(value, columns);
const header = columns.map((column) => `${escapeHtml(column)} | `).join("");
const body = rows.map((row, rowIndex) => renderJsonRow(columns, row, fieldName, rowIndex, sourceMap)).join("");
return `
${header}| 操作 |
${body || renderJsonRow(columns, {}, fieldName, 0, sourceMap)}
`;
}
function normalizeJsonRows(value, columns) {
if (Array.isArray(value)) {
return value.map((row) => {
if (row && typeof row === "object" && !Array.isArray(row)) return row;
return { [columns[0] || "值"]: row ?? "" };
});
}
if (value && typeof value === "object") {
return Object.entries(value).map(([key, amount]) => ({ 项目: key, 金额: amount }));
}
if (value === null || value === undefined || value === "") return [];
return [{ [columns[0] || "值"]: value }];
}
function renderJsonRow(columns, row, fieldName = "", rowIndex = 0, sourceMap = {}) {
const cells = columns.map((column) => {
const value = row[column] ?? "";
const path = `${fieldName}[${rowIndex}].${column}`;
const source = sourceMap[path] || "";
const sourceClass = source === "ai" ? " is-ai-cell" : source === "manual" ? " is-manual-cell" : "";
const title = source === "ai" ? "AI修改" : source === "manual" ? "人工修改" : "";
return ` | `;
}).join("");
return `${cells} |
`;
}
function collectFields(formId = "detailForm") {
const fields = {};
$(formId).querySelectorAll("input[data-field], select[data-field], textarea[data-field]").forEach((control) => {
fields[control.dataset.field] = control.value;
});
$(formId).querySelectorAll(".json-editor[data-json-field]").forEach((editor) => {
fields[editor.dataset.jsonField] = collectJsonEditor(editor);
});
return fields;
}
function collectJsonEditor(editor) {
const table = editor.querySelector("table");
const columns = JSON.parse(table.dataset.columns || "[]");
const rows = [...table.querySelectorAll("tbody tr")].map((tr) => {
const row = {};
tr.querySelectorAll("[data-json-key]").forEach((input) => {
row[input.dataset.jsonKey] = input.value;
});
return row;
}).filter((row) => Object.values(row).some((value) => String(value).trim() !== ""));
if (editor.dataset.kind === "object") {
const result = {};
rows.forEach((row) => {
const key = row["项目"];
if (key) result[key] = row["金额"] ?? "";
});
return result;
}
return rows;
}
async function advanceAfterSave(savedId, savedRecord) {
const savedSummary = compactRecord(savedRecord);
const listIndex = state.records.findIndex((record) => record.id === savedId);
let nextIndex = listIndex >= 0 ? listIndex : recordListIndex() + 1;
if (listIndex >= 0) {
if (recordMatchesActiveFilter(savedSummary)) {
state.records[listIndex] = { ...state.records[listIndex], ...savedSummary };
nextIndex = listIndex + 1;
} else {
state.records.splice(listIndex, 1);
state.recordOffset = Math.max(0, state.recordOffset - 1);
nextIndex = listIndex;
}
}
renderRecords();
if (nextIndex >= state.records.length && state.recordHasMore) {
await loadRecords({ append: true });
}
if (nextIndex < state.records.length) {
await selectRecord(state.records[nextIndex].id, { keepInView: true });
showMessage("已复核并保存,已自动跳到下一条", "ok");
return;
}
state.selectedId = savedId;
state.selectedRecord = savedRecord;
renderSelectedRecord();
renderRecords();
showMessage("已复核并保存,当前列表已没有下一条", "ok");
}
async function saveRecord() {
if (!state.selectedId) return;
$("saveBtn").disabled = true;
showMessage("正在保存...");
const savedId = state.selectedId;
try {
const payload = {
fields: collectFields(),
manual_note: $("manualNote").value.trim(),
note_prefix: "人工复核",
};
const data = await api(`/api/records/${state.selectedId}`, {
method: "POST",
body: JSON.stringify(payload),
});
state.selectedRecord = data.record;
renderChangeLogs(data.record.review_logs || [], "changeLogCount", "changeLogTable");
await advanceAfterSave(savedId, data.record);
loadStatus().catch(() => null);
} catch (error) {
showMessage(error.message, "error");
} finally {
$("saveBtn").disabled = false;
}
}
function compactRecord(record) {
return {
id: record.id,
source_file: record.source_file,
inpatient_no: record.inpatient_no,
medical_record_no: record.medical_record_no,
patient_name: record.patient_name,
review_status: record.review_status,
ai_reviewed: record.ai_reviewed || hasAiReview(record),
manual_corrected: record.manual_corrected,
major_department: record.major_department,
discharge_dept: record.discharge_dept,
primary_diagnosis: record.primary_diagnosis,
primary_diagnosis_code: record.primary_diagnosis_code,
contact_phone: record.contact_phone,
last_activity_at: record.last_activity_at,
has_pdf: Boolean(record.pdf_url),
};
}
function setView(view) {
state.view = "pdf";
$("pdfView").classList.remove("is-hidden");
}
function renderChangeLogs(logs, countId = "changeLogCount", tableId = "changeLogTable") {
const rows = logChangeRows(logs);
$(countId).textContent = `${rows.length} 项`;
$(tableId).innerHTML = renderLogTable(rows);
}
function rightSideChangedFields(log) {
const ignored = new Set(["review_status", "review_notes", "manual_corrected", "ai_method"]);
const fields = Array.isArray(log.changed_fields) ? log.changed_fields : [];
return fields.flatMap((field) => expandChangedField(field)).filter((field) => {
const root = String(field.field || "").split("[")[0].split(".")[0];
return root && !ignored.has(root);
});
}
function expandChangedField(field) {
const path = String(field.field || "");
const root = path.split("[")[0].split(".")[0];
if (!root || path.includes("[") || path.includes(".")) return [field];
const oldValue = field.old;
const newValue = field.new;
if (!Array.isArray(oldValue) && !Array.isArray(newValue)) return [field];
const columns = preferredColumns(root, Array.isArray(newValue) ? newValue : oldValue);
const oldRows = normalizeJsonRows(oldValue, columns);
const newRows = normalizeJsonRows(newValue, columns);
const maxRows = Math.max(oldRows.length, newRows.length);
const expanded = [];
for (let rowIndex = 0; rowIndex < maxRows; rowIndex += 1) {
columns.forEach((column) => {
const oldCell = oldRows[rowIndex]?.[column] ?? "";
const newCell = newRows[rowIndex]?.[column] ?? "";
if (comparableValue(oldCell) === comparableValue(newCell)) return;
expanded.push({
field: `${root}[${rowIndex}].${column}`,
label: `${fieldLabel(root)}[${rowIndex + 1}].${column}`,
old: oldCell,
new: newCell,
});
});
}
return expanded.length ? expanded : [field];
}
function fieldLabel(fieldName) {
for (const group of state.schema || []) {
for (const [name, label] of group.fields || []) {
if (name === fieldName) return label;
}
}
return fieldName;
}
function comparableValue(value) {
return JSON.stringify(value ?? "");
}
function logChangeRows(logs) {
return logs.flatMap((log) => rightSideChangedFields(log).map((field) => ({ log, field })));
}
function renderLogTable(rows) {
if (!rows.length) return `暂无修改记录
`;
const html = rows.map(({ log, field }) => {
const sourceClass = isAiSource(log.changed_by) ? " log-ai" : " log-manual";
return `
| ${escapeHtml(formatTime(log.changed_at))} |
${escapeHtml(displaySource(log.changed_by))} |
${escapeHtml(field.label || field.field || "")} |
${escapeHtml(shortValue(field.old))} |
${escapeHtml(shortValue(field.new))} |
${escapeHtml(displayText(log.manual_note || ""))} |
`;
}).join("");
return `
`;
}
function renderAiQuestionLogs(logs) {
const box = $("aiQuestionTable");
const count = $("aiQuestionCount");
if (!box || !count) return;
const aiLogs = logs.filter((log) => log.ai_result);
count.textContent = `${aiLogs.length} 条`;
if (!aiLogs.length) {
box.innerHTML = `暂无AI提问记录
`;
return;
}
const recordId = state.selectedRecord?.id;
box.innerHTML = aiLogs.map((log, logIndex) => {
const result = log.ai_result || {};
const parsed = result.parsed || {};
const modules = result.pdf_context?.modules || [];
const moduleText = modules.map((item) => `${item.name || ""}P${item.page || ""}`).filter(Boolean).join(";") || "未定位";
const question = result.ai_question || "";
const imageGrid = modules.length && recordId ? `
传入图片
${modules.map((item, moduleIndex) => `
${escapeHtml(item.name || "局部截图")} · P${escapeHtml(item.page || "")} · ${escapeHtml((item.bbox || []).join(", "))}
`).join("")}
` : ``;
return `
${escapeHtml(formatTime(log.changed_at))}
${escapeHtml(displayText(parsed.classification || parsed.decision || ""))}
${escapeHtml(moduleText)}
结论${escapeHtml(displayText(parsed.summary || log.manual_note || ""))}
定位${escapeHtml(moduleText)}
${imageGrid}
提问${escapeHtml(displayText(question).slice(0, 8000))}
`;
}).join("");
wireAiQuestionImages(box);
}
function wireAiQuestionImages(box) {
box.querySelectorAll(".ai-question-item").forEach((item) => {
item.addEventListener("toggle", () => {
if (!item.open) return;
item.querySelectorAll("img[data-ai-src]").forEach((image) => {
image.src = image.dataset.aiSrc;
image.removeAttribute("data-ai-src");
});
});
});
}
function shortValue(value) {
if (value === null || value === undefined || value === "") return "空";
const text = typeof value === "string" ? value : JSON.stringify(value);
return text.length > 90 ? `${text.slice(0, 90)}...` : text;
}
async function sampleAudit() {
const source = encodeURIComponent($("auditSource").value);
const count = encodeURIComponent($("auditCount").value || "5");
showAuditMessage("正在抽取...");
try {
const data = await api(`/api/audit/sample?source=${source}&count=${count}`, { method: "POST" });
state.auditSamples = data.records.map((record) => ({
record,
audit_source: data.audit_source,
audit_status: "pending",
audit_notes: "",
}));
state.auditCurrentId = state.auditSamples[0]?.record.id || null;
renderAuditSamples();
if (state.auditCurrentId) renderAuditCurrent();
showAuditMessage(`已抽取 ${state.auditSamples.length} 条`, "ok");
await loadAuditLogs();
await loadOverview();
} catch (error) {
showAuditMessage(error.message, "error");
}
}
function renderAuditSamples() {
const box = $("auditSampleList");
if (!state.auditSamples.length) {
box.innerHTML = `暂无抽查样本
`;
return;
}
box.innerHTML = state.auditSamples.map((item) => {
const { record } = item;
return `
`;
}).join("");
box.querySelectorAll("[data-audit-record]").forEach((button) => {
button.addEventListener("click", () => {
state.auditCurrentId = Number(button.dataset.auditRecord);
renderAuditSamples();
renderAuditCurrent();
});
});
}
function renderAuditCurrent() {
const item = state.auditSamples.find((sample) => sample.record.id === state.auditCurrentId);
if (!item) {
$("auditPdfTitle").textContent = "未选择抽查记录";
$("auditPdfSubtitle").textContent = "";
setPdfFrame("auditPdfFrame", "");
$("auditDetailTitle").textContent = "抽查信息";
$("auditDetailMeta").textContent = "随机抽取后开始抽查";
$("auditDetailForm").innerHTML = "";
$("auditTargetSummary").innerHTML = "";
renderChangeLogs([], "auditChangeLogCount", "auditChangeLogTable");
return;
}
const record = item.record;
$("auditPdfTitle").textContent = record.source_file || "未找到文件";
$("auditPdfSubtitle").textContent = `${record.patient_name || ""} · ${record.inpatient_no || record.medical_record_no || ""} · ${record.primary_diagnosis || ""}`;
$("auditDetailTitle").textContent = `${record.patient_name || "未命名"} · ${record.inpatient_no || record.medical_record_no || ""}`;
$("auditDetailMeta").textContent = `${record.source_file || ""} · ${record.major_department || ""}`;
setPdfFrame("auditPdfFrame", record.pdf_url);
$("auditReviewStrip").innerHTML = `
复核状态${statusTag(displayStatusValue(record))}
抽查状态${auditTag(item.audit_status || "pending")}
主要诊断${escapeHtml(record.primary_diagnosis || "无")}
抽查来源${escapeHtml(item.audit_source || "")}
`;
renderForm(record, {
formId: "auditDetailForm",
targetId: "auditTargetSummary",
noteId: "auditNotes",
});
$("auditNotes").value = item.audit_notes || "";
renderChangeLogs(record.review_logs || [], "auditChangeLogCount", "auditChangeLogTable");
setAuditView(state.auditView);
}
async function saveAuditStatus(status) {
const item = state.auditSamples.find((sample) => sample.record.id === state.auditCurrentId);
if (!item) return;
try {
const data = await api("/api/audit/classify", {
method: "POST",
body: JSON.stringify({
record_id: item.record.id,
audit_source: item.audit_source || $("auditSource").value,
audit_status: status,
audit_notes: $("auditNotes").value.trim(),
fields: collectFields("auditDetailForm"),
}),
});
item.log = data.log;
item.audit_status = data.log.audit_status;
item.audit_notes = data.log.audit_notes || "";
item.record = data.record;
renderAuditSamples();
renderAuditCurrent();
showAuditMessage("抽查已归类并保存", "ok");
await loadAuditLogs();
await loadOverview();
} catch (error) {
showAuditMessage(error.message, "error");
}
}
function setAuditView(view) {
state.auditView = "pdf";
$("auditPdfView").classList.remove("is-hidden");
}
async function loadAuditLogs() {
const data = await api("/api/audit/logs?limit=200");
state.auditLogs = data.logs;
renderAuditHistory();
}
function renderAuditHistory() {
const box = $("auditHistoryTable");
if (!box) return;
if (!state.auditLogs.length) {
box.innerHTML = `暂无抽查记录
`;
return;
}
const rows = state.auditLogs.map((log) => `
| ${escapeHtml(formatTime(log.updated_at || log.created_at))} |
${escapeHtml(log.patient_name || "")} |
${escapeHtml(log.inpatient_no || log.medical_record_no || "")} |
${escapeHtml(log.audit_source || "")} |
${auditTag(log.audit_status)} |
${escapeHtml(changeSummaryFromSnapshot(log.snapshot))} |
${escapeHtml(log.audit_notes || "")} |
`).join("");
box.innerHTML = `
| 时间 | 姓名 | 住院号 | 来源 | 状态 | 修改内容 | 人工备注 |
${rows}
`;
}
function changeSummaryFromSnapshot(snapshot) {
const fields = Array.isArray(snapshot?.changed_fields) ? snapshot.changed_fields : [];
if (!fields.length) return "未修改字段";
return fields.map((field) => `${field.label || field.field}: ${shortValue(field.old)} -> ${shortValue(field.new)}`).join(";");
}
async function loadSettings() {
const data = await api("/api/settings");
state.settings = data;
state.ai = data.kimi || state.ai;
renderSettings();
renderAiActions();
}
async function loadAiConfig() {
const data = await api("/api/ai/config");
state.ai = data.kimi;
renderAiActions();
}
function aiAvailable() {
return Boolean(state.ai?.available);
}
function aiActionModes() {
return {
current: "default",
five: "default",
all: "default",
...(state.ai?.ai_action_modes || {}),
};
}
function aiActionPrivacyModes() {
const modes = {
current: true,
five: true,
all: true,
...(state.ai?.ai_action_privacy_modes || {}),
};
return {
current: modes.current !== false,
five: modes.five !== false,
all: modes.all !== false,
};
}
function aiAllowedScopes() {
const modes = aiActionModes();
return Object.keys(modes).filter((scope) => modes[scope] !== "off");
}
function aiActionModeLabel(mode) {
return (AI_ACTION_MODE_OPTIONS.find(([value]) => value === mode) || AI_ACTION_MODE_OPTIONS[1])[1];
}
function aiActionOverride(scope) {
const mode = aiActionModes()[scope] || "default";
const override = { privacy_mode: aiActionPrivacyModes()[scope] !== false };
if (mode === "k25") return { ...override, model: "kimi-k2.5", thinking_enabled: false };
if (mode === "k25_thinking") return { ...override, model: "kimi-k2.5", thinking_enabled: true };
if (mode === "k26") return { ...override, model: "kimi-k2.6", thinking_enabled: false };
if (mode === "k26_thinking") return { ...override, model: "kimi-k2.6", thinking_enabled: true };
return override;
}
function renderAiActionModeOptions() {
document.querySelectorAll("[data-ai-action-mode]").forEach((select) => {
if (select.options.length) return;
select.innerHTML = AI_ACTION_MODE_OPTIONS.map(([value, label]) => ``).join("");
});
}
function renderAiActionTable(kimi = state.ai || {}) {
renderAiActionModeOptions();
const modes = { current: "default", five: "default", all: "default", ...(kimi.ai_action_modes || {}) };
const privacyModes = { current: true, five: true, all: true, ...(kimi.ai_action_privacy_modes || {}) };
[
["current", "aiActionModeCurrent", "aiActionPrivacyCurrent", "aiActionPreviewCurrent"],
["five", "aiActionModeFive", "aiActionPrivacyFive", "aiActionPreviewFive"],
["all", "aiActionModeAll", "aiActionPrivacyAll", "aiActionPreviewAll"],
].forEach(([scope, selectId, privacyId, previewId]) => {
const select = $(selectId);
if (select) select.value = modes[scope] || "default";
const privacy = $(privacyId);
if (privacy) privacy.checked = privacyModes[scope] !== false;
const preview = $(previewId);
if (preview) {
const mode = modes[scope] || "default";
const privacyLabel = privacyModes[scope] === false ? "隐私关" : "隐私开";
preview.textContent = mode === "off"
? "复核页隐藏"
: `${AI_ACTION_SCOPE_LABELS[scope]} 使用 ${aiActionModeLabel(mode)} · ${privacyLabel}`;
}
});
renderAiPrivacyNote(kimi);
}
function renderAiPrivacyNote(kimi = state.ai || {}) {
const note = $("aiPrivacyNote");
if (!note) return;
const modes = { current: true, five: true, all: true, ...(kimi.ai_action_privacy_modes || {}) };
const disabled = Object.entries(modes)
.filter(([, enabled]) => enabled === false)
.map(([scope]) => AI_ACTION_SCOPE_LABELS[scope] || scope);
note.classList.toggle("is-warning", disabled.length > 0);
note.textContent = disabled.length
? `隐私模式按按钮生效:${disabled.join("、")} 已关闭,AI会上传基本信息、地址联系人等相关内容用于核验。`
: "隐私模式全部开启:AI上传内容会排除基本信息、地址联系人、整页截图和PDF全文摘录。";
}
function renderAiActions() {
const actions = $("aiActions");
if (!actions) return;
const allowed = aiAvailable() ? aiAllowedScopes() : [];
actions.classList.toggle("is-hidden", allowed.length === 0);
[
["current", "aiCurrentBtn"],
["five", "aiFiveBtn"],
["all", "aiAllBtn"],
].forEach(([scope, id]) => {
const button = $(id);
if (!button) return;
button.classList.toggle("is-hidden", !allowed.includes(scope));
});
}
function renderSettings() {
const status = state.status || {};
$("settingsStatus").innerHTML = `
数据库${escapeHtml(status.database || "未知")}
连接${escapeHtml(`${status.host || ""}:${status.port || ""}/${status.database_name || ""}`)}
表名${escapeHtml(status.table || "")}
PDF目录${escapeHtml(status.pdf_dir || "")}
上次检查${escapeHtml(status.checked_at ? formatTime(status.checked_at) : "尚未检查")}
下次检查${escapeHtml(status.next_check_at ? formatTime(status.next_check_at) : "--")}
检查结果${escapeHtml(status.message || "")}
`;
const settings = state.settings || { users: [], permission_labels: {} };
const system = settings.system || {};
const kimi = settings.kimi || {};
$("statusCheckTime").value = system.status_check_time || "03:00";
$("statusCheckMeta").textContent = `上次检查:${status.checked_at ? formatTime(status.checked_at) : "尚未检查"} · 下次:${status.next_check_at ? formatTime(status.next_check_at) : "--"}`;
$("kimiEnabled").checked = Boolean(kimi.enabled);
$("kimiModel").value = kimi.model || "";
$("kimiApiKey").value = "";
$("kimiApiKey").placeholder = kimi.api_key_configured ? "已配置,留空不变" : "粘贴 API Key";
$("kimiConcurrency").value = kimi.concurrency || 3;
$("kimiThinkingEnabled").checked = Boolean(kimi.thinking_enabled);
const keySource = kimi.api_key_source === "settings" ? "设置" : kimi.api_key_source === "env" ? ".env" : "未配置";
$("kimiMeta").textContent = `API Key:${keySource} · ${kimi.available ? "AI核验已启用" : "AI核验已关闭"} · 并发 ${kimi.concurrency || 3} · 默认Thinking ${kimi.thinking_enabled ? "开" : "关"}`;
renderAiActionTable(kimi);
$("settingsUserCount").textContent = `${settings.users.length} 个用户`;
renderPermissionControls("newUserPermissions", settings.permission_labels || {}, {});
$("userList").innerHTML = settings.users.map((user) => renderUserCard(user, settings.permission_labels || {})).join("");
$("userList").querySelectorAll("[data-save-user]").forEach((button) => {
button.addEventListener("click", () => saveUser(button.dataset.saveUser));
});
$("userList").querySelectorAll("[data-delete-user]").forEach((button) => {
button.addEventListener("click", () => deleteUser(button.dataset.deleteUser));
});
}
async function saveKimiSettings(event) {
event.preventDefault();
const button = $("saveKimiSettingsBtn");
button.disabled = true;
$("kimiMeta").textContent = "正在保存...";
try {
const data = await api("/api/settings/kimi", {
method: "POST",
body: JSON.stringify({
enabled: $("kimiEnabled").checked,
model: $("kimiModel").value.trim(),
api_key: $("kimiApiKey").value.trim(),
concurrency: Number($("kimiConcurrency").value) || 3,
ai_action_modes: {
current: $("aiActionModeCurrent").value,
five: $("aiActionModeFive").value,
all: $("aiActionModeAll").value,
},
ai_action_privacy_modes: {
current: $("aiActionPrivacyCurrent").checked,
five: $("aiActionPrivacyFive").checked,
all: $("aiActionPrivacyAll").checked,
},
thinking_enabled: $("kimiThinkingEnabled").checked,
}),
});
state.settings = data;
state.ai = data.kimi;
renderSettings();
renderAiActions();
} catch (error) {
$("kimiMeta").textContent = error.message;
} finally {
button.disabled = false;
renderFilterActions();
}
}
function setAiButtonsDisabled(disabled) {
["aiCurrentBtn", "aiFiveBtn", "aiAllBtn"].forEach((id) => {
if ($(id)) $(id).disabled = disabled;
});
}
function clearAiProgressPanel() {
const panel = $("aiProgressPanel");
if (!panel) return;
panel.classList.add("is-hidden");
$("aiProgressBar").style.width = "0%";
$("aiProgressText").textContent = "0/0";
$("aiProgressMeta").textContent = "等待开始";
$("aiCancelBtn").textContent = "中止AI";
$("aiCancelBtn").classList.remove("is-hidden");
$("aiCancelBtn").disabled = true;
}
function renderAiProgress(job = state.aiJob) {
const panel = $("aiProgressPanel");
if (!panel) return;
if (!job || job.cancel_requested || (!job.running && !job.total && !job.message)) {
clearAiProgressPanel();
return;
}
const total = Number(job.total || 0);
const processed = Number(job.processed || 0);
const percent = total ? Math.min(100, Math.round((processed / total) * 100)) : 0;
const isBulkApprove = job.kind === "approve_ai_passed";
panel.classList.remove("is-hidden");
$("aiProgressTitle").textContent = isBulkApprove
? (job.running ? "批量通过中" : displayText(job.message || "批量通过完成"))
: (job.running ? "AI处理中" : displayText(job.message || "AI处理完成"));
$("aiProgressText").textContent = total ? `${processed}/${total}` : "--";
$("aiProgressBar").style.width = `${percent}%`;
$("aiProgressMeta").textContent = isBulkApprove
? `已通过 ${job.updated || 0} · 剩余 ${Math.max(0, total - processed)} · 失败 ${job.failed || 0}`
: `OK ${job.ok || 0} · 不OK ${job.pending || 0} · 失败 ${job.failed || 0} · 并发 ${job.concurrency || 1}`;
$("aiCancelBtn").classList.toggle("is-hidden", isBulkApprove);
$("aiCancelBtn").textContent = job.running ? "中止AI" : "确认";
$("aiCancelBtn").disabled = isBulkApprove;
}
async function cancelAiReview({ silent = false } = {}) {
const job = await api("/api/ai/review/cancel", { method: "POST" });
state.aiJob = job;
state.aiJobOwned = false;
renderAiProgress(job);
setAiButtonsDisabled(Boolean(job.running));
if (!silent) showMessage(displayText(job.message || "AI核验正在中断..."));
if (job.running && !state.aiPolling) {
state.aiPolling = setTimeout(() => pollAiReviewJob().catch((error) => showMessage(error.message, "error")), 1200);
}
return job;
}
async function confirmAiProgress() {
clearAiProgressPanel();
state.aiJob = await api("/api/ai/review/ack", { method: "POST" }).catch(() => null);
}
async function handleAiProgressAction() {
if (state.aiJob?.running) {
await cancelAiReview();
return;
}
await confirmAiProgress();
}
async function pollAiReviewJob() {
const job = await api("/api/ai/review/status");
state.aiJob = job;
renderAiProgress(job);
const total = job.total || 0;
const processed = job.processed || 0;
if (job.running) {
showMessage(`AI处理中:${processed}/${total},并发 ${job.concurrency || 1},OK ${job.ok || 0},不OK ${job.pending || 0},失败 ${job.failed || 0}`, "");
if (state.aiJobOwned && !job.cancel_requested && state.activePage !== "review") switchPage("review");
setAiButtonsDisabled(true);
state.aiPolling = setTimeout(() => pollAiReviewJob().catch((error) => showMessage(error.message, "error")), 2000);
return;
}
state.aiPolling = null;
state.aiJobOwned = false;
setAiButtonsDisabled(false);
if (job.cancel_requested) {
clearAiProgressPanel();
showMessage(displayText(job.message || "AI核验已中断"), "ok");
await loadStatus().catch(() => null);
await loadOverview().catch(() => null);
await loadRecords();
await refreshSelectedRecord();
return;
}
showMessage(displayText(`${job.message || "AI处理完成"}:OK ${job.ok || 0},不OK ${job.pending || 0},失败 ${job.failed || 0}`), job.failed ? "error" : "ok");
await loadStatus().catch(() => null);
await loadOverview().catch(() => null);
await loadRecords();
await refreshSelectedRecord();
}
async function runAiReview(scope) {
if (!aiAvailable()) {
showMessage("AI 核验未启用,请先在设置中开启", "error");
return;
}
if (!aiAllowedScopes().includes(scope)) {
showMessage("当前设置未开放这个 AI 处理范围", "error");
return;
}
if ((scope === "current" || scope === "five") && !state.selectedId) return;
if (scope === "all" && !window.confirm("确认对当前项之后的所有需复核记录执行 AI 核验?")) return;
if (state.aiPolling) clearTimeout(state.aiPolling);
setAiButtonsDisabled(true);
showMessage("正在启动 AI 核验...");
try {
const override = aiActionOverride(scope);
const job = await api("/api/ai/review", {
method: "POST",
body: JSON.stringify({ scope, record_id: state.selectedId, ...override }),
});
state.aiJob = job;
state.aiJobOwned = true;
renderAiProgress(job);
showMessage(`AI核验已启动:0/${job.total || 0}`);
await pollAiReviewJob();
} catch (error) {
setAiButtonsDisabled(false);
showMessage(error.message, "error");
}
}
async function approveAiNoIssueRecords() {
if (!window.confirm("确认将所有历史“AI已通过”批量标记为已人工复核?")) return;
const button = $("approveAiNoIssueBtn");
button.disabled = true;
showMessage("正在批量确认历史 AI 已通过记录...");
try {
const job = await api("/api/ai/review/approve-no-issue", { method: "POST" });
state.bulkJob = job;
renderAiProgress(job);
await pollApproveAiNoIssueJob();
} catch (error) {
showMessage(error.message, "error");
} finally {
renderFilterActions();
}
}
async function pollApproveAiNoIssueJob() {
const job = await api("/api/ai/review/approve-no-issue/status");
state.bulkJob = job;
renderAiProgress(job);
const total = Number(job.total || 0);
const processed = Number(job.processed || 0);
if (job.running) {
showMessage(`批量通过AI已通过:${processed}/${total},已更新 ${job.updated || 0} 条`, "");
renderFilterActions();
state.bulkPolling = setTimeout(() => pollApproveAiNoIssueJob().catch((error) => showMessage(error.message, "error")), 1200);
return;
}
state.bulkPolling = null;
showMessage(displayText(job.message || `批量通过完成,已更新 ${job.updated || 0} 条`), job.failed ? "error" : "ok");
await loadStatus().catch(() => null);
await loadOverview().catch(() => null);
await loadRecords();
renderFilterActions();
}
async function submitReviewedRecords() {
if (!window.confirm("确认提交所有已人工复核项目?提交后这些项目将不再出现在复核工作台列表中。")) return;
const button = $("submitReviewedBtn");
button.disabled = true;
$("statusCheckMeta").textContent = "正在提交已人工复核项目...";
try {
const data = await api("/api/settings/submit-reviewed", { method: "POST" });
state.status = data.status_snapshot || state.status;
$("statusCheckMeta").textContent = `已提交 ${data.updated || 0} 条已人工复核项目`;
await loadStatus().catch(() => null);
await loadOverview().catch(() => null);
await loadRecords().catch(() => null);
} catch (error) {
$("statusCheckMeta").textContent = error.message;
} finally {
button.disabled = false;
}
}
async function saveSystemSettings(event) {
event.preventDefault();
const button = $("saveSystemSettingsBtn");
button.disabled = true;
$("statusCheckMeta").textContent = "正在保存...";
try {
const data = await api("/api/settings/system", {
method: "POST",
body: JSON.stringify({ status_check_time: $("statusCheckTime").value }),
});
state.settings = data;
state.status = data.status_snapshot || state.status;
renderSettings();
await loadStatus();
} catch (error) {
$("statusCheckMeta").textContent = error.message;
} finally {
button.disabled = false;
}
}
async function runStatusCheck() {
const button = $("statusCheckBtn");
button.disabled = true;
$("statusCheckMeta").textContent = "正在检查数据库与 PDF 目录...";
try {
const data = await api("/api/settings/status/check", { method: "POST" });
state.status = data.status_snapshot;
await loadSettings();
await loadStatus();
} catch (error) {
$("statusCheckMeta").textContent = error.message;
} finally {
button.disabled = false;
}
}
function renderPermissionControls(containerId, labels, permissions) {
const container = $(containerId);
container.innerHTML = Object.entries(labels).map(([key, label]) => `
`).join("");
}
function renderUserCard(user, labels) {
const editable = user.source !== "env";
const permissionHtml = Object.entries(labels).map(([key, label]) => `
`).join("");
return `
${escapeHtml(user.username)}
${user.source === "env" ? "内置管理员" : "本地配置用户"}${user.has_password ? " · 已设密码" : ""}
${permissionHtml}
${editable ? `
` : ""}
`;
}
function permissionsFromContainer(container) {
const result = {};
container.querySelectorAll("input[data-permission]").forEach((input) => {
result[input.dataset.permission] = input.checked;
});
return result;
}
function permissionsFromUserCard(card) {
const result = {};
card.querySelectorAll("input[data-user-permission]").forEach((input) => {
result[input.dataset.userPermission] = input.checked;
});
return result;
}
function cssEscape(value) {
if (window.CSS?.escape) return CSS.escape(value);
return String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
async function createUser(event) {
event.preventDefault();
const username = $("newUsername").value.trim();
const password = $("newPassword").value;
const permissions = permissionsFromContainer($("newUserPermissions"));
const data = await api("/api/settings/users", {
method: "POST",
body: JSON.stringify({ username, password, permissions }),
});
state.settings = data;
$("newUsername").value = "";
$("newPassword").value = "";
renderSettings();
}
async function saveUser(username) {
const card = document.querySelector(`[data-user-card="${cssEscape(username)}"]`);
if (!card) return;
const data = await api(`/api/settings/users/${encodeURIComponent(username)}`, {
method: "POST",
body: JSON.stringify({
username: card.querySelector("[data-user-name]").value.trim(),
password: card.querySelector("[data-user-password]").value,
permissions: permissionsFromUserCard(card),
}),
});
state.settings = data;
renderSettings();
}
async function deleteUser(username) {
if (!window.confirm(`确认删除用户 ${username}?`)) return;
const data = await api(`/api/settings/users/${encodeURIComponent(username)}`, { method: "DELETE" });
state.settings = data;
renderSettings();
}
async function login(event) {
event.preventDefault();
$("loginMessage").textContent = "";
$("loginBtn").disabled = true;
try {
const data = await api("/api/auth/login", {
method: "POST",
body: JSON.stringify({
username: $("loginUsername").value.trim(),
password: $("loginPassword").value,
}),
});
state.currentUser = data.user;
$("loginPassword").value = "";
showApp();
await loadWorkspace();
} catch (error) {
$("loginMessage").textContent = error.message;
} finally {
$("loginBtn").disabled = false;
}
}
async function logout() {
await api("/api/auth/logout", { method: "POST" }).catch(() => null);
state.currentUser = null;
state.records = [];
state.selectedId = null;
state.selectedRecord = null;
showLogin("已退出登录");
}
function wireJsonEditorButtons() {
document.querySelectorAll(".structured-form").forEach((form) => form.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (target.matches("[data-json-delete]")) {
const tbody = target.closest("tbody");
target.closest("tr")?.remove();
if (tbody && !tbody.querySelector("tr")) {
const table = tbody.closest("table");
const columns = JSON.parse(table.dataset.columns || "[]");
tbody.insertAdjacentHTML("beforeend", renderJsonRow(columns, {}));
}
}
if (target.matches("[data-json-add]")) {
const editor = target.closest(".json-editor");
const table = editor.querySelector("table");
const columns = JSON.parse(table.dataset.columns || "[]");
table.querySelector("tbody").insertAdjacentHTML("beforeend", renderJsonRow(columns, {}));
}
if (target.matches("[data-json-raw]")) {
const editor = target.closest(".json-editor");
const value = collectJsonEditor(editor);
navigator.clipboard?.writeText(JSON.stringify(value, null, 2));
showMessage("当前表格 JSON 已复制", "ok");
}
}));
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function formatTime(value) {
if (!value) return "";
return String(value).replace("T", " ").slice(0, 19);
}
function debounce(fn, wait = 250) {
let timer = null;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
async function boot() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", logout);
document.querySelectorAll(".nav-button").forEach((button) => {
button.addEventListener("click", () => switchPage(button.dataset.page));
});
$("overviewRefreshBtn").addEventListener("click", loadOverview);
$("searchInput").addEventListener("input", debounce(() => loadRecords()));
$("statusFilter").addEventListener("change", () => {
renderFilterActions();
loadRecords();
});
$("recordList").addEventListener("scroll", maybeLoadMoreRecords);
$("recordList").addEventListener("keydown", handleRecordListKeydown);
document.addEventListener("keydown", preventWorkspacePageScroll, { capture: true });
$("saveBtn").addEventListener("click", saveRecord);
$("aiCurrentBtn").addEventListener("click", () => runAiReview("current"));
$("aiFiveBtn").addEventListener("click", () => runAiReview("five"));
$("aiAllBtn").addEventListener("click", () => runAiReview("all"));
$("aiCancelBtn").addEventListener("click", () => handleAiProgressAction().catch((error) => showMessage(error.message, "error")));
$("approveAiNoIssueBtn").addEventListener("click", approveAiNoIssueRecords);
$("batchExportBtn").addEventListener("click", exportCurrentFilter);
$("pdfZoomOutBtn").addEventListener("click", () => changePdfZoom(-PDF_ZOOM_STEP));
$("pdfZoomInBtn").addEventListener("click", () => changePdfZoom(PDF_ZOOM_STEP));
$("pdfZoomResetBtn").addEventListener("click", () => setPdfZoom(100));
$("pdfFrame").addEventListener("focus", releasePdfKeyboardFocus);
$("auditPdfZoomOutBtn").addEventListener("click", () => changePdfZoom(-PDF_ZOOM_STEP));
$("auditPdfZoomInBtn").addEventListener("click", () => changePdfZoom(PDF_ZOOM_STEP));
$("auditPdfZoomResetBtn").addEventListener("click", () => setPdfZoom(100));
$("auditPdfFrame").addEventListener("focus", releasePdfKeyboardFocus);
updatePdfZoomLabels();
$("auditSampleBtn").addEventListener("click", sampleAudit);
$("auditPassBtn").addEventListener("click", () => saveAuditStatus("passed"));
$("auditFailBtn").addEventListener("click", () => saveAuditStatus("failed"));
$("auditUnsureBtn").addEventListener("click", () => saveAuditStatus("unsure"));
$("auditHistoryRefreshBtn").addEventListener("click", loadAuditLogs);
$("userForm").addEventListener("submit", createUser);
$("systemSettingsForm").addEventListener("submit", saveSystemSettings);
$("kimiSettingsForm").addEventListener("submit", saveKimiSettings);
document.querySelectorAll("[data-ai-action-mode]").forEach((select) => {
select.addEventListener("change", () => renderAiActionTable({
...(state.ai || {}),
ai_action_modes: {
current: $("aiActionModeCurrent").value,
five: $("aiActionModeFive").value,
all: $("aiActionModeAll").value,
},
ai_action_privacy_modes: {
current: $("aiActionPrivacyCurrent").checked,
five: $("aiActionPrivacyFive").checked,
all: $("aiActionPrivacyAll").checked,
},
}));
});
document.querySelectorAll("[data-ai-action-privacy]").forEach((input) => {
input.addEventListener("change", () => renderAiActionTable({
...(state.ai || {}),
ai_action_modes: {
current: $("aiActionModeCurrent").value,
five: $("aiActionModeFive").value,
all: $("aiActionModeAll").value,
},
ai_action_privacy_modes: {
current: $("aiActionPrivacyCurrent").checked,
five: $("aiActionPrivacyFive").checked,
all: $("aiActionPrivacyAll").checked,
},
}));
});
$("submitReviewedBtn").addEventListener("click", submitReviewedRecords);
$("statusCheckBtn").addEventListener("click", runStatusCheck);
window.addEventListener("beforeunload", (event) => {
if (!state.aiJob?.running || !state.aiJobOwned) return;
fetch("/api/ai/review/cancel", { method: "POST", keepalive: true }).catch(() => null);
event.preventDefault();
event.returnValue = "";
});
wireJsonEditorButtons();
try {
const session = await api("/api/auth/me");
if (!session.authenticated) {
showLogin();
return;
}
state.currentUser = session.user;
showApp();
await loadWorkspace();
} catch (error) {
showLogin(error.message);
}
}
async function loadWorkspace() {
await loadStatus();
await loadSchema();
await loadAiConfig().catch(() => null);
const job = await api("/api/ai/review/status").catch(() => null);
if (job) {
state.aiJob = job;
state.aiJobOwned = false;
renderAiProgress(job);
}
const bulkJob = await api("/api/ai/review/approve-no-issue/status").catch(() => null);
if (bulkJob?.running) {
state.bulkJob = bulkJob;
renderAiProgress(bulkJob);
}
const page = canOpenPage(state.activePage) ? state.activePage : firstAllowedPage();
switchPage(job?.running && state.aiJobOwned && canOpenPage("review") ? "review" : page);
if (job?.running) pollAiReviewJob().catch((error) => showMessage(error.message, "error"));
if (bulkJob?.running) pollApproveAiNoIssueJob().catch((error) => showMessage(error.message, "error"));
}
boot();