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