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: ["大科室"], 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 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))}` : ""; 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 || "")} ${activity}
`; }).join("") + loadingNotice + endNotice; list.querySelectorAll(".record-item").forEach((item) => { item.addEventListener("click", () => { selectRecord(Number(item.dataset.id)); }); item.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); selectRecord(Number(item.dataset.id)); }); }); list.scrollTop = previousScrollTop; } function renderFilterActions() { const button = $("approveAiNoIssueBtn"); 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已通过"; } 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 ` ${html}
    修改时间来源字段修改前修改后备注
    `; } 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 ||
    ${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); $("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();