Add per-action AI privacy mode

This commit is contained in:
2026-05-27 00:21:16 +08:00
parent 7e329f1d85
commit 5d96c1de6d
15 changed files with 1993 additions and 340 deletions

View File

@@ -16,6 +16,8 @@ const state = {
auditLogs: [],
settings: null,
ai: null,
aiJob: null,
aiJobOwned: false,
aiPolling: null,
currentUser: null,
authenticated: false,
@@ -28,6 +30,19 @@ 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 = {
基本信息: ["住院号", "病案号", "首页病案号", "姓名", "性别", "出生", "年龄", "身份证", "婚姻", "健康卡", "入院", "出院", "科别", "科室", "病房", "住院天数"],
@@ -67,8 +82,10 @@ function statusTag(status) {
auto_corrected: ["自动修正", "auto_corrected"],
needs_review: ["需复核", "needs_review"],
reviewed: ["已复核", "reviewed"],
"AI复核-无问题": ["AI复核-无问题", "ai_passed"],
"AI复核-待确认": ["AI复核-待确认", "ai_pending"],
"AI已处理-OK": ["AI已通过", "ai_passed"],
"AI已处理-不OK": ["AI建议复核", "ai_pending"],
"AI复核-无问题": ["AI已通过", "ai_passed"],
"AI复核-待确认": ["AI建议复核", "ai_pending"],
"已提交": ["已提交", "submitted"],
pending: ["待处理", "pending"],
};
@@ -76,11 +93,73 @@ function statusTag(status) {
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 {
@@ -201,6 +280,11 @@ function changePdfZoom(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");
@@ -218,13 +302,14 @@ async function loadStatus() {
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.total ?? "--";
$("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;
}
@@ -238,10 +323,10 @@ function renderOverview(data) {
const summary = data.summary || {};
const metrics = [
["总记录", summary.total],
["复核队列", summary.review_queue],
["复核总数", summary.review_queue],
["需复核", summary.needs_review],
["AI待确认", summary.ai_pending],
["AI无问题", summary.ai_passed],
["AI建议复核", summary.ai_pending],
["AI已通过", summary.ai_passed],
["已复核", summary.reviewed],
["已提交", summary.submitted],
["自动通过", summary.auto_passed],
@@ -255,11 +340,11 @@ function renderOverview(data) {
</article>
`).join("");
$("overviewQueues").innerHTML = `
<div class="queue-line"><span>复核工作台默认</span><strong>全部数据:需复核 ${summary.needs_review || 0} / AI待确认 ${summary.ai_pending || 0} / 已复核 ${summary.reviewed || 0}</strong></div>
<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(data.recent_logs || []);
$("overviewRecentLogs").innerHTML = renderLogTable(logChangeRows(data.recent_logs || []));
}
async function loadSchema() {
@@ -281,6 +366,7 @@ async function loadRecords({ append = false } = {}) {
state.recordHasMore = Boolean(data.has_more);
if (!append && !state.records.some((record) => record.id === state.selectedId)) {
state.selectedId = null;
state.selectedRecord = null;
}
renderRecords();
renderFilterActions();
@@ -316,7 +402,7 @@ function renderRecords() {
<button class="record-item${active}" type="button" data-id="${record.id}">
<div class="record-main">
<span class="record-name">${escapeHtml(record.patient_name || "未命名")}</span>
${statusTag(record.review_status)}
${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>
@@ -335,7 +421,12 @@ function renderRecords() {
function renderFilterActions() {
const button = $("approveAiNoIssueBtn");
if (!button) return;
button.classList.toggle("is-hidden", $("statusFilter").value !== "AI复核-无问题");
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;
button.textContent = aiPassedCount > 0 ? `通过所有AI已通过${aiPassedCount}` : "通过所有AI已通过";
}
function scrollSelectedRecordIntoView() {
@@ -372,11 +463,16 @@ function recordListIndex() {
function recordMatchesActiveFilter(record) {
const status = $("statusFilter").value || "review_all";
if (status === "review_all") {
return ["needs_review", "reviewed", "AI复核-无问题", "AI复核-待确认"].includes(record.review_status) || record.manual_corrected === true;
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";
}
@@ -444,6 +540,7 @@ function renderSelectedRecord() {
logTableId: "changeLogTable",
});
renderChangeLogs(record.review_logs || [], "changeLogCount", "changeLogTable");
renderAiQuestionLogs(record.review_logs || []);
setView(state.view);
}
@@ -462,7 +559,7 @@ async function refreshSelectedRecord() {
}
}
function reviewNotes(record) {
function baseReviewNotes(record) {
const notes = [];
["review_notes", "quality_notes", "auto_corrections"].forEach((key) => {
const value = record[key];
@@ -472,31 +569,49 @@ function reviewNotes(record) {
notes.push(String(value));
}
});
return notes.filter(Boolean);
return notes.map(displayText).filter(Boolean);
}
function aiAttentionNotes(record) {
const notes = [];
(record.review_logs || []).forEach((log) => {
const parsed = log.ai_result?.parsed;
if (!parsed) return;
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 = reviewNotes(record);
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 { notes, fieldAlerts, groupAlerts };
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(record.review_status)}</div>
<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>
`;
}
@@ -507,11 +622,12 @@ function renderForm(record, options = {}) {
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))).join("");
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" : ""}>
@@ -524,21 +640,37 @@ function renderForm(record, options = {}) {
}
function renderTargetSummary(targets, targetId = "targetSummary", formId = "detailForm") {
const groups = [...targets.groupAlerts];
const notesHtml = targets.notes.length
const baseGroups = [...targets.baseGroupAlerts];
const aiGroups = [...targets.aiGroupAlerts];
const baseNotesHtml = targets.notes.length
? targets.notes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")
: "<li>暂无复核提示</li>";
: "<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" open>
<summary>
<strong>复核定位</strong>
<span>${groups.length ? `${groups.length} 个模块` : "未定位"}</span>
<span>${baseGroups.length ? `${baseGroups.length} 个模块` : "未定位"}</span>
</summary>
<div class="target-chips">
${groups.length ? groups.map((group) => `<button type="button" data-target-group="${escapeHtml(group)}">${escapeHtml(group)}</button>`).join("") : "<span>未定位到具体模块,先核对基本信息</span>"}
${baseGroups.length ? baseGroups.map((group) => `<button type="button" data-target-group="${escapeHtml(group)}">${escapeHtml(group)}</button>`).join("") : "<span>原始复核信息未定位到具体模块</span>"}
</div>
<ul>${notesHtml}</ul>
<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", () => {
@@ -551,23 +683,37 @@ function renderTargetSummary(targets, targetId = "targetSummary", formId = "deta
});
}
function renderField(record, name, label, type, options, alerted = false) {
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") {
control = `<select data-field="${name}" data-type="${type}">
${(options || []).map((option) => `<option value="${escapeHtml(option)}" ${option === value ? "selected" : ""}>${escapeHtml(option)}</option>`).join("")}
</select>`;
} else if (type === "json") {
control = renderJsonEditor(name, value);
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}"><span>${escapeHtml(label)}${alerted ? "<em>需核对</em>" : ""}</span>${control}</label>`;
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) {
@@ -588,11 +734,11 @@ function preferredColumns(fieldName, value) {
return ["值"];
}
function renderJsonEditor(fieldName, value) {
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) => renderJsonRow(columns, row)).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">
@@ -602,7 +748,7 @@ function renderJsonEditor(fieldName, value) {
<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, {})}</tbody>
<tbody>${body || renderJsonRow(columns, {}, fieldName, 0, sourceMap)}</tbody>
</table>
</div>
</div>
@@ -623,10 +769,14 @@ function normalizeJsonRows(value, columns) {
return [{ [columns[0] || "值"]: value }];
}
function renderJsonRow(columns, row) {
function renderJsonRow(columns, row, fieldName = "", rowIndex = 0, sourceMap = {}) {
const cells = columns.map((column) => {
const value = row[column] ?? "";
return `<td><input data-json-key="${escapeHtml(column)}" value="${escapeHtml(value)}"></td>`;
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>`;
}
@@ -733,6 +883,7 @@ function compactRecord(record) {
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,
@@ -750,38 +901,160 @@ function setView(view) {
}
function renderChangeLogs(logs, countId = "changeLogCount", tableId = "changeLogTable") {
$(countId).textContent = `${logs.length}`;
$(tableId).innerHTML = renderLogTable(logs);
const rows = logChangeRows(logs);
$(countId).textContent = `${rows.length}`;
$(tableId).innerHTML = renderLogTable(rows);
}
function renderLogTable(logs) {
if (!logs.length) return `<div class="empty small">暂无修改记录</div>`;
const rows = logs.map((log) => {
const fields = Array.isArray(log.changed_fields) ? log.changed_fields : [];
const summary = fields.length
? fields.map((field) => `${field.label || field.field}: ${shortValue(field.old)} -> ${shortValue(field.new)}`).join("")
: "仅记录人工备注或状态确认";
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>
<tr class="${sourceClass}">
<td>${escapeHtml(formatTime(log.changed_at))}</td>
<td>${escapeHtml(log.changed_by || "web")}</td>
<td>${escapeHtml(summary)}</td>
<td>${escapeHtml(log.manual_note || "")}</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></tr></thead>
<tbody>${rows}</tbody>
<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 > 36 ? `${text.slice(0, 36)}...` : text;
return text.length > 90 ? `${text.slice(0, 90)}...` : text;
}
async function sampleAudit() {
@@ -854,7 +1127,7 @@ function renderAuditCurrent() {
$("auditDetailMeta").textContent = `${record.source_file || ""} · ${record.major_department || ""}`;
setPdfFrame("auditPdfFrame", record.pdf_url);
$("auditReviewStrip").innerHTML = `
<div class="strip-item"><span>复核状态</span>${statusTag(record.review_status)}</div>
<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>
@@ -958,10 +1231,107 @@ 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;
actions.classList.toggle("is-hidden", !aiAvailable());
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() {
@@ -982,9 +1352,13 @@ function renderSettings() {
$("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 || "";
$("kimiApiBase").value = kimi.api_base || "";
$("kimiApiKey").value = "";
$("kimiApiKey").placeholder = kimi.api_key_configured ? "已配置,留空不变" : "粘贴 API Key";
$("kimiConcurrency").value = kimi.concurrency || 3;
$("kimiMeta").textContent = `${kimi.api_key_configured ? "API Key 已从 .env 读取" : "未配置 API Key"} · ${kimi.available ? "AI核验已启用" : "AI核验已关闭"} · 并发 ${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("");
@@ -1007,8 +1381,19 @@ async function saveKimiSettings(event) {
body: JSON.stringify({
enabled: $("kimiEnabled").checked,
model: $("kimiModel").value.trim(),
api_base: $("kimiApiBase").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;
@@ -1019,6 +1404,7 @@ async function saveKimiSettings(event) {
$("kimiMeta").textContent = error.message;
} finally {
button.disabled = false;
renderFilterActions();
}
}
@@ -1028,17 +1414,51 @@ function setAiButtonsDisabled(disabled) {
});
}
function renderAiProgress(job = state.aiJob) {
const panel = $("aiProgressPanel");
if (!panel) return;
if (!job || (!job.running && !job.total && !job.message)) {
panel.classList.add("is-hidden");
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;
panel.classList.remove("is-hidden");
$("aiProgressTitle").textContent = job.running ? "AI处理中" : displayText(job.message || "AI处理完成");
$("aiProgressText").textContent = total ? `${processed}/${total}` : "--";
$("aiProgressBar").style.width = `${percent}%`;
$("aiProgressMeta").textContent = `OK ${job.ok || 0} · 不OK ${job.pending || 0} · 失败 ${job.failed || 0} · 并发 ${job.concurrency || 1}`;
$("aiCancelBtn").disabled = !job.running;
}
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核验正在中断..."));
return job;
}
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;
showMessage(`AI核验中:${processed}/${total},并发 ${job.concurrency || 1}无问题 ${job.ok || 0}待确认 ${job.pending || 0},失败 ${job.failed || 0}`, "");
showMessage(`AI处理中:${processed}/${total},并发 ${job.concurrency || 1}OK ${job.ok || 0}不OK ${job.pending || 0},失败 ${job.failed || 0}`, "");
if (job.running) {
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);
showMessage(`AI核验完成无问题 ${job.ok || 0}待确认 ${job.pending || 0},失败 ${job.failed || 0}`, job.failed ? "error" : "ok");
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();
@@ -1047,7 +1467,11 @@ async function pollAiReviewJob() {
async function runAiReview(scope) {
if (!aiAvailable()) {
showMessage("Kimi AI 核验未启用,请先在设置中开启", "error");
showMessage("AI 核验未启用,请先在设置中开启", "error");
return;
}
if (!aiAllowedScopes().includes(scope)) {
showMessage("当前设置未开放这个 AI 处理范围", "error");
return;
}
if ((scope === "current" || scope === "five") && !state.selectedId) return;
@@ -1056,10 +1480,14 @@ async function runAiReview(scope) {
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 }),
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) {
@@ -1069,14 +1497,14 @@ async function runAiReview(scope) {
}
async function approveAiNoIssueRecords() {
if (!window.confirm("确认将所有“AI复核-无问题”批量标记为已人工复核?")) return;
if (!window.confirm("确认将所有历史“AI已通过”批量标记为已人工复核?")) return;
const button = $("approveAiNoIssueBtn");
button.disabled = true;
showMessage("正在批量确认 AI 无问题记录...");
showMessage("正在批量确认历史 AI 已通过记录...");
try {
const data = await api("/api/ai/review/approve-no-issue", { method: "POST" });
state.status = data.status_snapshot || state.status;
showMessage(`已批量确认 ${data.updated || 0} 条 AI 无问题记录`, "ok");
showMessage(`已批量确认 ${data.updated || 0}历史 AI 已通过记录`, "ok");
await loadStatus().catch(() => null);
await loadOverview().catch(() => null);
await loadRecords();
@@ -1347,6 +1775,7 @@ async function boot() {
$("aiCurrentBtn").addEventListener("click", () => runAiReview("current"));
$("aiFiveBtn").addEventListener("click", () => runAiReview("five"));
$("aiAllBtn").addEventListener("click", () => runAiReview("all"));
$("aiCancelBtn").addEventListener("click", () => cancelAiReview().catch((error) => showMessage(error.message, "error")));
$("approveAiNoIssueBtn").addEventListener("click", approveAiNoIssueRecords);
$("pdfZoomOutBtn").addEventListener("click", () => changePdfZoom(-PDF_ZOOM_STEP));
$("pdfZoomInBtn").addEventListener("click", () => changePdfZoom(PDF_ZOOM_STEP));
@@ -1365,8 +1794,44 @@ async function boot() {
$("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");
@@ -1386,8 +1851,15 @@ 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 page = canOpenPage(state.activePage) ? state.activePage : firstAllowedPage();
switchPage(page);
switchPage(job?.running && state.aiJobOwned && canOpenPage("review") ? "review" : page);
if (job?.running) pollAiReviewJob().catch((error) => showMessage(error.message, "error"));
}
boot();