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