Files
HIS/患者首页处理/数据可视化网页端/app/static/app.js
2026-05-27 16:52:53 +08:00

1944 lines
74 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
records: [],
recordLimit: 300,
recordOffset: 0,
recordHasMore: false,
recordsLoading: false,
selectedId: null,
selectedRecord: null,
schema: [],
view: localStorage.getItem("frontPageReviewView") || "pdf",
activePage: "overview",
overview: null,
auditSamples: [],
auditCurrentId: null,
auditView: localStorage.getItem("frontPageAuditView") || "pdf",
auditLogs: [],
settings: null,
ai: null,
aiJob: null,
aiJobOwned: false,
aiPolling: null,
bulkJob: null,
bulkPolling: null,
currentUser: null,
authenticated: false,
pdfZoom: Number(localStorage.getItem("frontPagePdfZoom")) || 110,
};
const $ = (id) => document.getElementById(id);
const PDF_ZOOM_STEP = 15;
const PDF_MIN_ZOOM = 55;
const PDF_MAX_ZOOM = 220;
const OPERATION_COLUMNS = ["手术操作编码", "手术操作日期", "手术级别", "手术操作名称", "术者", "I助", "II助", "切口愈合等级", "麻醉方式", "麻醉医师", "原始内容"];
const DIAGNOSIS_COLUMNS = ["诊断类别", "出院诊断", "疾病编码", "入院病情"];
const AI_ACTION_MODE_OPTIONS = [
["off", "关闭"],
["default", "跟随默认模型"],
["k25", "K2.5 关 Thinking"],
["k25_thinking", "K2.5 开 Thinking"],
["k26", "K2.6 关 Thinking"],
["k26_thinking", "K2.6 开 Thinking"],
];
const AI_ACTION_SCOPE_LABELS = {
current: "AI当前项",
five: "AI后5项",
all: "AI后全部",
};
const GROUP_KEYWORDS = {
基本信息: ["住院号", "病案号", "首页病案号", "姓名", "性别", "出生", "年龄", "身份证", "婚姻", "健康卡", "入院", "出院", "科别", "科室", "病房", "住院天数"],
地址联系人: ["地址", "电话", "联系人", "单位", "邮编", "户口"],
诊断表格: ["诊断", "疾病编码", "编码格式", "病理", "入院病情"],
手术表格: ["手术", "操作", "麻醉", "切口"],
离院费用: ["费用", "金额", "自付", "总费用", "离院", "再住院", "昏迷"],
};
const FIELD_KEYWORDS = {
inpatient_no: ["住院号", "ZY", "12位"],
medical_record_no: ["病案号", "10位", "前导0"],
front_page_medical_record_no: ["首页病案号"],
patient_name: ["姓名"],
id_card_no: ["身份证"],
contact_address: ["联系人地址"],
contact_phone: ["联系人电话"],
admission_time: ["入院时间"],
admission_dept: ["入院科别"],
discharge_time: ["出院时间"],
discharge_dept: ["出院科别", "科室"],
hospital_days: ["住院天数"],
major_department: ["大科室"],
discharge_diagnoses: ["出院诊断", "主要诊断", "主要诊断编码", "主要诊断入院病情", "其他诊断", "诊断编码", "入院病情"],
operations: ["手术", "操作", "麻醉", "切口"],
total_cost: ["总费用"],
self_pay_amount: ["自付"],
fee_details: ["费用明细"],
};
function statusTag(status) {
const value = status || "pending";
const meta = {
auto_pass: ["自动通过", "auto_pass"],
auto_corrected: ["自动修正", "auto_corrected"],
needs_review: ["需复核", "needs_review"],
reviewed: ["已复核", "reviewed"],
"AI已处理-OK": ["AI已通过", "ai_passed"],
"AI已处理-不OK": ["AI建议复核", "ai_pending"],
"AI复核-无问题": ["AI已通过", "ai_passed"],
"AI复核-待确认": ["AI建议复核", "ai_pending"],
"已提交": ["已提交", "submitted"],
pending: ["待处理", "pending"],
};
const [label, className] = meta[value] || [value, "pending"];
return `<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 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>` : "";
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 || "")}
${activity}
</div>
</div>
`;
}).join("") + loadingNotice + endNotice;
list.querySelectorAll(".record-item").forEach((item) => {
item.addEventListener("click", () => {
selectRecord(Number(item.dataset.id));
});
item.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
selectRecord(Number(item.dataset.id));
});
});
list.scrollTop = previousScrollTop;
}
function renderFilterActions() {
const button = $("approveAiNoIssueBtn");
if (!button) return;
const filter = $("statusFilter")?.value || "review_all";
const aiPassedCount = Number(state.status?.ai_passed || 0);
const shouldShow = ["review_all", "ai_passed"].includes(filter);
button.classList.toggle("is-hidden", !shouldShow);
button.disabled = !shouldShow || aiPassedCount <= 0 || Boolean(state.bulkJob?.running);
button.textContent = aiPassedCount > 0 ? `通过所有AI已通过${aiPassedCount}` : "通过所有AI已通过";
}
function scrollSelectedRecordIntoView() {
const selected = $("recordList")?.querySelector(`.record-item[data-id="${state.selectedId}"]`);
selected?.scrollIntoView({ block: "nearest" });
}
function maybeLoadMoreRecords() {
const list = $("recordList");
if (!list || !state.recordHasMore || state.recordsLoading) return;
const distanceToBottom = list.scrollHeight - list.scrollTop - list.clientHeight;
if (distanceToBottom < 240) loadRecords({ append: true });
}
async function selectRecord(id, options = {}) {
state.selectedId = id;
renderRecords();
if (options.keepInView) scrollSelectedRecordIntoView();
showMessage("");
try {
const data = await api(`/api/records/${id}`);
if (state.selectedId !== id) return;
state.selectedRecord = data.record;
renderSelectedRecord();
} catch (error) {
showMessage(error.message || "记录加载失败,请刷新后重试", "error");
}
}
function recordListIndex() {
return state.records.findIndex((record) => record.id === state.selectedId);
}
function recordMatchesActiveFilter(record) {
const status = $("statusFilter").value || "review_all";
if (status === "review_all") {
return ["needs_review", "reviewed", "AI已处理-OK", "AI已处理-不OK", "AI复核-无问题", "AI复核-待确认"].includes(record.review_status)
|| (record.review_status === "auto_pass" && hasAiReview(record))
|| record.manual_corrected === true;
}
if (status === "needs_review") {
return record.review_status === "needs_review";
}
if (status === "ai_passed") {
return ["AI已处理-OK", "AI复核-无问题"].includes(record.review_status) || (record.review_status === "auto_pass" && hasAiReview(record));
}
if (status === "reviewed") {
return record.review_status === "reviewed";
}
return status === "all" || record.review_status === status;
}
async function selectAdjacentRecord(delta) {
if (!state.records.length || state.recordsLoading) return;
let index = recordListIndex();
if (index < 0) index = delta > 0 ? -1 : state.records.length;
let nextIndex = index + delta;
if (nextIndex >= state.records.length && state.recordHasMore) {
await loadRecords({ append: true });
nextIndex = index + delta;
}
if (nextIndex < 0 || nextIndex >= state.records.length) return;
await selectRecord(state.records[nextIndex].id, { keepInView: true });
}
function handleRecordListKeydown(event) {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
selectAdjacentRecord(event.key === "ArrowDown" ? 1 : -1);
}
function isTextEntryTarget(target) {
if (!target) return false;
const tagName = target.tagName;
return target.isContentEditable || tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT";
}
function preventWorkspacePageScroll(event) {
const scrollKeys = new Set(["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "]);
if (!scrollKeys.has(event.key) || isTextEntryTarget(event.target)) return;
if (state.activePage === "review" && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault();
event.stopPropagation();
selectAdjacentRecord(event.key === "ArrowDown" ? 1 : -1);
return;
}
if (state.activePage !== "review" && state.activePage !== "audit") return;
event.preventDefault();
}
function releasePdfKeyboardFocus(event) {
event.currentTarget.blur();
document.body.setAttribute("tabindex", "-1");
document.body.focus({ preventScroll: true });
}
function renderSelectedRecord() {
const record = state.selectedRecord;
$("saveBtn").disabled = false;
$("pdfTitle").textContent = record.source_file || "未找到文件";
$("pdfSubtitle").textContent = `${record.patient_name || ""} · ${record.inpatient_no || record.medical_record_no || ""} · ${record.primary_diagnosis || ""}`;
$("detailTitle").textContent = `${record.patient_name || "未命名"} · ${record.inpatient_no || record.medical_record_no || ""}`;
$("detailMeta").textContent = `${record.source_file || ""} · ${record.major_department || ""}`;
setPdfFrame("pdfFrame", record.pdf_url);
renderReviewStrip(record);
renderForm(record, {
formId: "detailForm",
targetId: "targetSummary",
noteId: "manualNote",
logCountId: "changeLogCount",
logTableId: "changeLogTable",
});
renderChangeLogs(record.review_logs || [], "changeLogCount", "changeLogTable");
renderAiQuestionLogs(record.review_logs || []);
setView(state.view);
}
async function refreshSelectedRecord() {
if (!state.selectedId) return;
try {
const data = await api(`/api/records/${state.selectedId}`);
if (state.selectedId !== data.record.id) return;
state.selectedRecord = data.record;
renderSelectedRecord();
const index = state.records.findIndex((record) => record.id === data.record.id);
if (index >= 0) state.records[index] = { ...state.records[index], ...compactRecord(data.record) };
renderRecords();
} catch (_) {
// keep the current UI if a background refresh loses the selected record
}
}
function baseReviewNotes(record) {
const notes = [];
["review_notes", "quality_notes", "auto_corrections"].forEach((key) => {
const value = record[key];
if (Array.isArray(value)) {
value.forEach((item) => notes.push(typeof item === "string" ? item : JSON.stringify(item)));
} else if (value) {
notes.push(String(value));
}
});
return notes.map(displayText).filter(Boolean);
}
function aiAttentionNotes(record) {
const notes = [];
const latestAiLog = [...(record.review_logs || [])].reverse().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" open>
<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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function formatTime(value) {
if (!value) return "";
return String(value).replace("T", " ").slice(0, 19);
}
function debounce(fn, wait = 250) {
let timer = null;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
async function boot() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", logout);
document.querySelectorAll(".nav-button").forEach((button) => {
button.addEventListener("click", () => switchPage(button.dataset.page));
});
$("overviewRefreshBtn").addEventListener("click", loadOverview);
$("searchInput").addEventListener("input", debounce(() => loadRecords()));
$("statusFilter").addEventListener("change", () => {
renderFilterActions();
loadRecords();
});
$("recordList").addEventListener("scroll", maybeLoadMoreRecords);
$("recordList").addEventListener("keydown", handleRecordListKeydown);
document.addEventListener("keydown", preventWorkspacePageScroll, { capture: true });
$("saveBtn").addEventListener("click", saveRecord);
$("aiCurrentBtn").addEventListener("click", () => runAiReview("current"));
$("aiFiveBtn").addEventListener("click", () => runAiReview("five"));
$("aiAllBtn").addEventListener("click", () => runAiReview("all"));
$("aiCancelBtn").addEventListener("click", () => handleAiProgressAction().catch((error) => showMessage(error.message, "error")));
$("approveAiNoIssueBtn").addEventListener("click", approveAiNoIssueRecords);
$("pdfZoomOutBtn").addEventListener("click", () => changePdfZoom(-PDF_ZOOM_STEP));
$("pdfZoomInBtn").addEventListener("click", () => changePdfZoom(PDF_ZOOM_STEP));
$("pdfZoomResetBtn").addEventListener("click", () => setPdfZoom(100));
$("pdfFrame").addEventListener("focus", releasePdfKeyboardFocus);
$("auditPdfZoomOutBtn").addEventListener("click", () => changePdfZoom(-PDF_ZOOM_STEP));
$("auditPdfZoomInBtn").addEventListener("click", () => changePdfZoom(PDF_ZOOM_STEP));
$("auditPdfZoomResetBtn").addEventListener("click", () => setPdfZoom(100));
$("auditPdfFrame").addEventListener("focus", releasePdfKeyboardFocus);
updatePdfZoomLabels();
$("auditSampleBtn").addEventListener("click", sampleAudit);
$("auditPassBtn").addEventListener("click", () => saveAuditStatus("passed"));
$("auditFailBtn").addEventListener("click", () => saveAuditStatus("failed"));
$("auditUnsureBtn").addEventListener("click", () => saveAuditStatus("unsure"));
$("auditHistoryRefreshBtn").addEventListener("click", loadAuditLogs);
$("userForm").addEventListener("submit", createUser);
$("systemSettingsForm").addEventListener("submit", saveSystemSettings);
$("kimiSettingsForm").addEventListener("submit", saveKimiSettings);
document.querySelectorAll("[data-ai-action-mode]").forEach((select) => {
select.addEventListener("change", () => renderAiActionTable({
...(state.ai || {}),
ai_action_modes: {
current: $("aiActionModeCurrent").value,
five: $("aiActionModeFive").value,
all: $("aiActionModeAll").value,
},
ai_action_privacy_modes: {
current: $("aiActionPrivacyCurrent").checked,
five: $("aiActionPrivacyFive").checked,
all: $("aiActionPrivacyAll").checked,
},
}));
});
document.querySelectorAll("[data-ai-action-privacy]").forEach((input) => {
input.addEventListener("change", () => renderAiActionTable({
...(state.ai || {}),
ai_action_modes: {
current: $("aiActionModeCurrent").value,
five: $("aiActionModeFive").value,
all: $("aiActionModeAll").value,
},
ai_action_privacy_modes: {
current: $("aiActionPrivacyCurrent").checked,
five: $("aiActionPrivacyFive").checked,
all: $("aiActionPrivacyAll").checked,
},
}));
});
$("submitReviewedBtn").addEventListener("click", submitReviewedRecords);
$("statusCheckBtn").addEventListener("click", runStatusCheck);
window.addEventListener("beforeunload", (event) => {
if (!state.aiJob?.running || !state.aiJobOwned) return;
fetch("/api/ai/review/cancel", { method: "POST", keepalive: true }).catch(() => null);
event.preventDefault();
event.returnValue = "";
});
wireJsonEditorButtons();
try {
const session = await api("/api/auth/me");
if (!session.authenticated) {
showLogin();
return;
}
state.currentUser = session.user;
showApp();
await loadWorkspace();
} catch (error) {
showLogin(error.message);
}
}
async function loadWorkspace() {
await loadStatus();
await loadSchema();
await loadAiConfig().catch(() => null);
const job = await api("/api/ai/review/status").catch(() => null);
if (job) {
state.aiJob = job;
state.aiJobOwned = false;
renderAiProgress(job);
}
const bulkJob = await api("/api/ai/review/approve-no-issue/status").catch(() => null);
if (bulkJob?.running) {
state.bulkJob = bulkJob;
renderAiProgress(bulkJob);
}
const page = canOpenPage(state.activePage) ? state.activePage : firstAllowedPage();
switchPage(job?.running && state.aiJobOwned && canOpenPage("review") ? "review" : page);
if (job?.running) pollAiReviewJob().catch((error) => showMessage(error.message, "error"));
if (bulkJob?.running) pollApproveAiNoIssueJob().catch((error) => showMessage(error.message, "error"));
}
boot();