const columns = [ "姓名", "性别", "年龄", "住院号", "诊断", "入院时间", "最后书写时间", "住院天数", "出院时间", "手术后天数", ]; const state = { items: [], current: null, auditItems: [], auditCurrent: null, auditHistory: [], auditHistoryCurrent: null, processingOverview: null, permissions: {}, permissionLabels: {}, kimiEnabled: true, activeView: "overview", aiBusy: false, aiTaskLabel: "", imageZoom: 1, imagePan: null, auditImageZoom: 1, auditImagePan: null, auditHistoryImageZoom: 1, auditHistoryImagePan: null, page: 1, pageSize: 60, totalItems: 0, loadingItems: false, pgTimer: null, debounce: null, }; const datetimeColumns = new Set(["入院时间", "出院时间", "最后书写时间"]); const $ = (selector) => document.querySelector(selector); function permissionKeyForView(view) { return view === "auditHistory" ? "audit_history" : view; } function permissionEntries() { const labels = state.permissionLabels || {}; const fallback = { overview: "概览", review: "复核", audit: "抽查", audit_history: "抽查一览", settings: "设置", }; return Object.entries({ ...fallback, ...labels }); } function badgeClass(item) { if (item.manual_state === "AI修改-待确认") return "ai"; if (item.manual_state === "人工复核通过") return "done"; if (item.manual_state === "修订后仍需确认") return "warn"; return "pending"; } function auditBadgeClass(item) { if (item.audit_result === "人工核验异常") return "pending"; if (item.audit_result === "人工核验通过") return "done"; if (item.audit_result === "暂不确定") return "warn"; if (item.audit_machine_verdict === "异常") return "pending"; if (item.audit_machine_verdict) return "ai"; return "warn"; } function auditBadgeText(item) { if (item.audit_result) return item.audit_result; if (item.audit_machine_verdict === "通过") return "AI核验通过"; if (item.audit_machine_verdict === "异常") return "AI核验异常"; if (item.audit_machine_verdict === "不确定") return "AI无法确定"; return "未核验"; } function auditHistoryBadgeText(item) { if (item.audit_result) return item.audit_result; return item.audit_ai_feedback || item.audit_ai_raw_output || item.audit_machine_verdict ? "未人工核验" : "未填写人工结论"; } function auditSourceLabel(value) { const labels = { manual_review_passed: "已处理-人工复核通过项", db_auto_passed: "数据库-自动复核通过", }; return labels[value] || value || ""; } function validationBadgeHtml(item) { const validationWarnings = validatePatientLocal(item.patient || {}); return validationWarnings.length ? `当前字段未通过校验` : ""; } function auditTitleHtml(item, badgeText = auditBadgeText(item)) { return ` ${escapeHtml(item.patient["姓名"] || "未识别姓名")} · ${escapeHtml(item.patient["住院号"] || "无住院号")} ${escapeHtml(badgeText)} ${validationBadgeHtml(item)} `; } function auditQueueItemHtml(item, activeKey, extraRows = [], badgeText = auditBadgeText(item)) { const metaRows = [ item.patient["住院号"] || "无住院号", ...extraRows, ].filter(Boolean); return ` `; } function auditProgressText() { const total = state.auditItems.length; if (!total) return ""; const aiDone = state.auditItems.filter((item) => item.audit_machine_verdict || item.audit_ai_feedback).length; const manualDone = state.auditItems.filter((item) => item.audit_result).length; return `抽查进度:AI ${aiDone}/${total},人工 ${manualDone}/${total}`; } function setSaveState(text) { $("#saveState").textContent = text || ""; } function setStatusFilter(value, reload = true) { if (value === "ai_pending" || value === "still_issue") value = "confirming"; $("#statusFilter").value = value; document.querySelectorAll(".status-pill").forEach((button) => { button.classList.toggle("active", button.dataset.status === value); }); const more = $("#moreStatusFilter"); more.value = value === "pending" ? "" : value; if (value === "done") { $("#sortFilter").value = "updated_desc"; } if (reload) loadItems(true); } function reviewConfirmCount(summary) { return summary.state_counts?.["待确认"] ?? ( (summary.state_counts?.["AI修改-待确认"] || 0) + (summary.state_counts?.["修订后仍需确认"] || 0) ); } function setAiBusy(label, busy) { state.aiBusy = busy; state.aiTaskLabel = busy ? label : ""; document.body.classList.toggle("ai-busy", busy); document.querySelectorAll("#kimiCurrentButton, #kimiFiveButton, #kimiRemainingButton, #auditKimiButton, #auditKimiAllButton").forEach((button) => { button.disabled = busy; }); } function updateAiVisibility() { document.body.classList.toggle("ai-disabled", !state.kimiEnabled); document.querySelectorAll(".review-ai-panel, .audit-ai-panel").forEach((panel) => { panel.classList.toggle("hidden", !state.kimiEnabled); }); } function ensureAiReady(label) { if (state.aiBusy) { setSaveState(`${state.aiTaskLabel || "AI处理"}中,请等待完成后再操作`); window.alert(`${state.aiTaskLabel || "AI处理"}中,请等待完成后再切换页面或发起新的AI任务。`); return false; } if (!state.kimiEnabled) { setSaveState("目前无AI功能"); return false; } setAiBusy(label, true); return true; } function finishAiTask() { setAiBusy("", false); } function extractJsonData(text) { const raw = String(text ?? "").trim(); if (!raw) return null; const candidates = []; const fencePattern = /```(?:json)?\s*([\s\S]*?)```/gi; let fenced; while ((fenced = fencePattern.exec(raw))) { candidates.push(fenced[1].trim()); } candidates.push(raw); for (let index = 0; index < raw.length; index += 1) { if (raw[index] === "{") candidates.push(raw.slice(index)); } for (const candidate of candidates) { try { const data = JSON.parse(candidate); if (data && typeof data === "object" && !Array.isArray(data)) return data; } catch (error) { const parsed = parseJsonPrefix(candidate); if (parsed) return parsed; } } return null; } function parseJsonPrefix(text) { let depth = 0; let inString = false; let escaped = false; for (let index = 0; index < text.length; index += 1) { const char = text[index]; if (inString) { if (escaped) { escaped = false; } else if (char === "\\") { escaped = true; } else if (char === '"') { inString = false; } continue; } if (char === '"') { inString = true; } else if (char === "{") { depth += 1; } else if (char === "}") { depth -= 1; if (depth === 0) { try { const data = JSON.parse(text.slice(0, index + 1)); return data && typeof data === "object" && !Array.isArray(data) ? data : null; } catch (error) { return null; } } } } return null; } function equivalentFieldValue(column, left, right) { const leftText = normalizeFieldValue(column, left); const rightText = normalizeFieldValue(column, right); return leftText === rightText; } function cleanAuditJsonData(data, patient = null) { if (!data) return null; const cleaned = { ...data }; const patterns = [ "最后书写时间与出院时间顺序异常", "最后书写时间晚于出院时间", "出院后仍有书写记录", "已出院后仍有书写记录", "实际值一致", "时间值一致", "格式差异", "补零差异", "小时格式", "日期格式补零", "仅格式", "无需修正", ]; if (cleaned.建议修正 && typeof cleaned.建议修正 === "object" && !Array.isArray(cleaned.建议修正) && patient) { const keptSuggestions = {}; Object.entries(cleaned.建议修正).forEach(([column, value]) => { if (columns.includes(column) && equivalentFieldValue(column, value, patient[column] || "")) return; keptSuggestions[column] = value; }); cleaned.建议修正 = keptSuggestions; } if (Array.isArray(cleaned.问题)) { const kept = cleaned.问题.filter((issue) => !patterns.some((pattern) => String(issue).includes(pattern))); cleaned.问题 = kept; } if ((!cleaned.问题 || !cleaned.问题.length) && (!cleaned.建议修正 || !Object.keys(cleaned.建议修正).length)) { cleaned.结论 = "通过"; } return cleaned; } function normalizeModelResultText(text, patient = null) { const data = cleanAuditJsonData(extractJsonData(text), patient); if (data) return JSON.stringify(data, null, 2); return ""; } function renderModelResult(element, jsonText, rawText = jsonText, patient = null) { const normalized = normalizeModelResultText(jsonText || rawText, patient); element.dataset.json = normalized; element.dataset.raw = String(rawText ?? ""); const data = extractJsonData(normalized); if (!String(jsonText || rawText || "").trim()) { element.innerHTML = `
暂无AI输出
`; return; } if (!data) { element.innerHTML = `
无法解析
`; return; } const rows = Object.entries(data).map(([key, value]) => { const display = typeof value === "string" ? value : JSON.stringify(value, null, 2); return `${escapeHtml(key)}
${escapeHtml(display)}
`; }).join(""); element.innerHTML = `${rows}
`; } function setAuditModelResult(jsonText, rawText = jsonText, patient = state.auditCurrent?.patient || null) { renderModelResult($("#auditModelResult"), jsonText, rawText, patient); $("#auditRawOutput").textContent = String(rawText || jsonText || ""); const hasStructuredResult = Boolean($("#auditModelResult").dataset.json || String(jsonText || rawText || "").trim()); $("#auditPromptDetails").open = !hasStructuredResult; $("#auditRawOutputDetails").open = !hasStructuredResult; } function setAuditPrompt(text) { const value = String(text || ""); $("#auditPrompt").value = value; $("#auditPromptText").textContent = value || "暂无询问提示词"; } function getAuditModelResult() { return $("#auditModelResult").dataset.json || ""; } function getAuditRawOutput() { return $("#auditModelResult").dataset.raw || ""; } function setAuditHistoryModelResult(jsonText, rawText = jsonText, patient = state.auditHistoryCurrent?.patient || null) { renderModelResult($("#auditHistoryAiResult"), jsonText, rawText, patient); $("#auditHistoryRawOutput").textContent = String(rawText || jsonText || ""); } function modelVerdict(rawText, patient = null) { const data = cleanAuditJsonData(extractJsonData(rawText), patient); const verdict = String(data?.结论 || data?.verdict || data?.result || "").trim(); return ["通过", "异常", "不确定"].includes(verdict) ? verdict : verdict; } function formatTime(value) { if (!value) return ""; return String(value).replace("T", " "); } async function api(path, options = {}) { let response; try { response = await fetch(path, { headers: { "Content-Type": "application/json", ...(options.headers || {}) }, ...options, }); } catch (error) { throw new Error("网络连接中断或网页端服务暂不可用,请确认服务仍在运行后重试"); } if (response.status === 401) { window.location.href = "/login"; return null; } if (!response.ok) { const text = await response.text(); try { const data = JSON.parse(text); throw new Error(data.error || text || response.statusText); } catch (error) { if (error instanceof SyntaxError) throw new Error(text || response.statusText); throw error; } } return response.json(); } async function loadSummary() { const summary = await api("/api/summary"); if (!summary) return; renderOverview(summary); $("#statsGrid").innerHTML = `
${summary.total}复核条目
${summary.state_counts["待处理"]}待处理
${reviewConfirmCount(summary)}待确认
${summary.state_counts["人工复核通过"]}人工通过条目
`; if (summary.postgres) { renderPostgresState(summary.postgres); } const current = $("#batchFilter").value; $("#batchFilter").innerHTML = `` + summary.batches .map((batch) => ``) .join(""); $("#batchFilter").value = current; } function renderOverview(summary) { $("#overviewGrid").innerHTML = `
${summary.total}复核条目
${summary.state_counts["待处理"]}待处理
${reviewConfirmCount(summary)}待确认
${summary.state_counts["人工复核通过"]}人工通过
${summary.postgres?.pending_sync_count ?? 0}PG待同步
`; $("#overviewBatches").innerHTML = summary.batches.map((batch) => `

${escapeHtml(batch.batch_name)}

${escapeHtml(batch.pending)}待处理 ${escapeHtml(batch.still_issue)}待确认 ${escapeHtml(batch.done)}人工通过 ${escapeHtml(batch.total)}总条目
`).join(""); renderAuditOverview(summary.audit_summary || {}); } function renderAuditOverview(auditSummary) { const cards = [ ["抽查记录", auditSummary.total || 0], ["未人工核验", auditSummary.unreviewed || 0], ["人工通过", auditSummary.passed || 0], ["人工异常", auditSummary.failed || 0], ["暂不确定", auditSummary.unsure || 0], ]; $("#overviewAudit").innerHTML = cards.map(([label, value]) => `

${escapeHtml(label)}

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