Improve review export and AI progress UI

This commit is contained in:
Codex
2026-05-27 15:34:50 +08:00
parent a15ba058e3
commit 627fce79fe
4 changed files with 277 additions and 53 deletions

View File

@@ -4,12 +4,14 @@ import json
import os
import base64
import hashlib
import tempfile
import secrets
import re
import threading
import time
import urllib.error
import urllib.request
import zipfile
from datetime import date, datetime, timedelta
from decimal import Decimal
from pathlib import Path
@@ -21,7 +23,7 @@ from zoneinfo import ZoneInfo
import psycopg2
import psycopg2.extras
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
@@ -969,6 +971,12 @@ def session_from_request(request: Request) -> dict[str, Any] | None:
return SESSIONS.get(token)
def require_admin_user(request: Request) -> None:
user = getattr(request.state, "user", {}) or {}
if user.get("username") != admin_username():
raise HTTPException(status_code=403, detail="只有 admin 可以使用导出功能")
def page_permission_for_path(path: str, method: str) -> str | tuple[str, ...] | None:
if path in {"/api/status", "/api/schema"}:
return None
@@ -1927,6 +1935,22 @@ def get_pdf_path(source_file: str) -> Path | None:
return path if path.exists() and path.is_file() else None
def safe_download_name(value: Any, fallback: str = "未命名") -> str:
text = re.sub(r'[\\/:*?"<>|\r\n]+', "_", str(value or "").strip())
text = re.sub(r"\s+", "_", text).strip("._ ")
return text[:80] or fallback
def record_pdf_download_name(record: dict[str, Any]) -> str:
source_file = Path(str(record.get("source_file") or "")).name
stem = Path(source_file).stem or f"record_{record.get('id', '')}"
suffix = Path(source_file).suffix or ".pdf"
patient = safe_download_name(record.get("patient_name"), "")
inpatient_no = safe_download_name(record.get("inpatient_no") or record.get("medical_record_no"), "")
parts = [part for part in [inpatient_no, patient, stem] if part]
return "_".join(parts) + suffix
def parse_json_content(content: str) -> Any:
text = content.strip()
if text.startswith("```"):
@@ -3255,10 +3279,7 @@ def overview():
}
@app.get("/api/records")
def list_records(q: str = "", status_filter: str = "review_all", limit: int = 300, offset: int = 0):
limit = max(50, min(int(limit), 500))
offset = max(0, int(offset))
def record_filter_sql(q: str = "", status_filter: str = "review_all") -> tuple[sql.Composable, list[Any]]:
clauses = []
params: list[Any] = []
if q:
@@ -3275,9 +3296,37 @@ def list_records(q: str = "", status_filter: str = "review_all", limit: int = 30
else:
clauses.append("review_status = %s")
params.append(status_filter)
where_sql = sql.SQL("")
if clauses:
where_sql = sql.SQL("WHERE ") + sql.SQL(" AND ").join(sql.SQL(clause) for clause in clauses)
if not clauses:
return sql.SQL(""), params
return sql.SQL("WHERE ") + sql.SQL(" AND ").join(sql.SQL(clause) for clause in clauses), params
RECORD_LIST_ORDER_SQL = sql.SQL(
"""
ORDER BY
last_activity_at DESC NULLS LAST,
CASE review_status
WHEN 'needs_review' THEN 1
WHEN 'AI已处理-不OK' THEN 2
WHEN 'AI复核-待确认' THEN 2
WHEN 'AI已处理-OK' THEN 3
WHEN 'AI复核-无问题' THEN 3
WHEN 'auto_pass' THEN 3
WHEN 'auto_corrected' THEN 4
WHEN 'reviewed' THEN 5
WHEN '已提交' THEN 6
ELSE 7
END,
id
"""
)
@app.get("/api/records")
def list_records(q: str = "", status_filter: str = "review_all", limit: int = 300, offset: int = 0):
limit = max(50, min(int(limit), 500))
offset = max(0, int(offset))
where_sql, params = record_filter_sql(q, status_filter)
query = sql.SQL(
"""
@@ -3289,24 +3338,10 @@ def list_records(q: str = "", status_filter: str = "review_all", limit: int = 30
NULLIF(review_logs->-1->>'changed_at', '')::timestamp AS last_activity_at
FROM {table}
{where}
ORDER BY
last_activity_at DESC NULLS LAST,
CASE review_status
WHEN 'needs_review' THEN 1
WHEN 'AI已处理-不OK' THEN 2
WHEN 'AI复核-待确认' THEN 2
WHEN 'AI已处理-OK' THEN 3
WHEN 'AI复核-无问题' THEN 3
WHEN 'auto_pass' THEN 3
WHEN 'auto_corrected' THEN 4
WHEN 'reviewed' THEN 5
WHEN '已提交' THEN 6
ELSE 7
END,
id
{order_by}
LIMIT %s OFFSET %s
"""
).format(table=table_identifier(), where=where_sql)
).format(table=table_identifier(), where=where_sql, order_by=RECORD_LIST_ORDER_SQL)
with connect() as conn, conn.cursor() as cur:
cur.execute(query, [*params, limit + 1, offset])
rows = [row_to_json(dict(row)) for row in cur.fetchall()]
@@ -3318,11 +3353,77 @@ def list_records(q: str = "", status_filter: str = "review_all", limit: int = 30
return {"records": rows, "limit": limit, "offset": offset, "has_more": has_more}
@app.get("/api/records/export")
def export_records(request: Request, background_tasks: BackgroundTasks, q: str = "", status_filter: str = "review_all"):
require_admin_user(request)
where_sql, params = record_filter_sql(q, status_filter)
max_export = 5000
query = sql.SQL(
"""
SELECT
id, source_file, inpatient_no, medical_record_no, patient_name, review_status,
NULLIF(review_logs->-1->>'changed_at', '')::timestamp AS last_activity_at
FROM {table}
{where}
{order_by}
LIMIT %s
"""
).format(table=table_identifier(), where=where_sql, order_by=RECORD_LIST_ORDER_SQL)
with connect() as conn, conn.cursor() as cur:
cur.execute(query, [*params, max_export])
rows = [row_to_json(dict(row)) for row in cur.fetchall()]
export_items: list[tuple[dict[str, Any], Path]] = []
for row in rows:
pdf_path = get_pdf_path(row.get("source_file") or "")
if pdf_path:
export_items.append((row, pdf_path))
if not export_items:
raise HTTPException(status_code=404, detail="当前筛选下没有可导出的 PDF")
handle = tempfile.NamedTemporaryFile(prefix="frontpage_export_", suffix=".zip", delete=False)
zip_path = Path(handle.name)
handle.close()
used_names: set[str] = set()
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
for index, (record, pdf_path) in enumerate(export_items, start=1):
name = record_pdf_download_name(record)
arcname = f"{index:04d}_{name}"
while arcname in used_names:
arcname = f"{index:04d}_{record.get('id')}_{name}"
used_names.add(arcname)
archive.write(pdf_path, arcname)
background_tasks.add_task(zip_path.unlink, missing_ok=True)
stamp = datetime.now(APP_TIMEZONE).strftime("%Y%m%d_%H%M%S")
return FileResponse(
zip_path,
media_type="application/zip",
filename=f"患者首页影像导出_{status_filter}_{stamp}.zip",
background=background_tasks,
headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
)
@app.get("/api/records/{record_id}")
def get_record(record_id: int):
return {"record": fetch_record(record_id)}
@app.get("/api/records/{record_id}/export")
def export_record(record_id: int, request: Request):
require_admin_user(request)
record = fetch_record(record_id)
pdf_path = get_pdf_path(record.get("source_file") or "")
if not pdf_path:
raise HTTPException(status_code=404, detail="PDF 文件不存在")
return FileResponse(
pdf_path,
media_type="application/pdf",
filename=record_pdf_download_name(record),
headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
)
@app.get("/api/records/{record_id}/ai-question-image/{log_index}/{module_index}")
def ai_question_image(record_id: int, log_index: int, module_index: int):
if log_index < 0 or module_index < 0:
@@ -3548,6 +3649,30 @@ def cancel_ai_review():
return dict(AI_REVIEW_JOB)
@app.post("/api/ai/review/ack")
def ack_ai_review():
with AI_JOB_LOCK:
if AI_REVIEW_JOB.get("running"):
return dict(AI_REVIEW_JOB)
AI_REVIEW_JOB.update(
running=False,
cancel_requested=False,
scope="",
total=0,
processed=0,
ok=0,
pending=0,
failed=0,
concurrency=0,
message="",
errors=[],
started_at="",
finished_at="",
last_record_id=None,
)
return dict(AI_REVIEW_JOB)
@app.post("/api/ai/review/approve-no-issue")
def approve_ai_no_issue():
with BULK_JOB_LOCK:

View File

@@ -192,6 +192,10 @@ function userPermissions() {
return state.currentUser?.permissions || {};
}
function isAdminUser() {
return state.currentUser?.source === "env" || state.currentUser?.username === "admin";
}
function canOpenPage(page) {
const key = page === "auditHistory" ? "audit_history" : page;
return userPermissions()[key] !== false;
@@ -400,8 +404,11 @@ function renderRecords() {
const active = record.id === state.selectedId ? " is-active" : "";
const manual = record.manual_corrected ? " · 人工已改" : "";
const activity = record.last_activity_at ? `<span class="record-time">最近 ${escapeHtml(formatTime(record.last_activity_at))}</span>` : "";
const exportButton = isAdminUser() && record.has_pdf
? `<button class="record-download" type="button" data-export-record="${record.id}" title="下载影像/PDF">下载</button>`
: "";
return `
<button class="record-item${active}" type="button" data-id="${record.id}">
<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))}
@@ -409,19 +416,37 @@ function renderRecords() {
<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 class="record-footer">
<span>${activity}</span>
${exportButton}
</div>
</div>
</button>
</div>
`;
}).join("") + loadingNotice + endNotice;
list.querySelectorAll(".record-item").forEach((button) => {
button.addEventListener("click", () => selectRecord(Number(button.dataset.id)));
list.querySelectorAll(".record-item").forEach((item) => {
item.addEventListener("click", (event) => {
if (event.target.closest("[data-export-record]")) return;
selectRecord(Number(item.dataset.id));
});
item.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
selectRecord(Number(item.dataset.id));
});
});
list.querySelectorAll("[data-export-record]").forEach((button) => {
button.addEventListener("click", (event) => {
event.stopPropagation();
exportRecordPdf(Number(button.dataset.exportRecord));
});
});
list.scrollTop = previousScrollTop;
}
function renderFilterActions() {
const button = $("approveAiNoIssueBtn");
const exportButton = $("batchExportBtn");
if (!button) return;
const filter = $("statusFilter")?.value || "review_all";
const aiPassedCount = Number(state.status?.ai_passed || 0);
@@ -429,6 +454,33 @@ function renderFilterActions() {
button.classList.toggle("is-hidden", !shouldShow);
button.disabled = !shouldShow || aiPassedCount <= 0 || Boolean(state.bulkJob?.running);
button.textContent = aiPassedCount > 0 ? `通过所有AI已通过${aiPassedCount}` : "通过所有AI已通过";
if (exportButton) {
exportButton.classList.toggle("is-hidden", !isAdminUser());
exportButton.disabled = !isAdminUser() || state.recordsLoading;
}
}
function downloadUrl(url) {
const link = document.createElement("a");
link.href = url;
link.rel = "noopener";
document.body.appendChild(link);
link.click();
link.remove();
}
function exportRecordPdf(recordId) {
if (!isAdminUser() || !recordId) return;
downloadUrl(`/api/records/${recordId}/export`);
}
function exportCurrentFilter() {
if (!isAdminUser()) return;
const params = new URLSearchParams({
q: $("searchInput").value.trim(),
status_filter: $("statusFilter").value || "review_all",
});
downloadUrl(`/api/records/export?${params.toString()}`);
}
function scrollSelectedRecordIntoView() {
@@ -576,19 +628,18 @@ function baseReviewNotes(record) {
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));
}
});
const latestAiLog = (record.review_logs || []).find((log) => Boolean(log.ai_result));
const parsed = latestAiLog?.ai_result?.parsed;
if (!parsed) return notes;
if (Array.isArray(parsed.remaining_issues)) {
parsed.remaining_issues.forEach((item) => {
if (isReviewNeededText(item)) notes.push(item);
});
}
const classification = String(parsed.classification || parsed.decision || "").toLowerCase();
if (!notes.length && ["problem", "not_ok", "not ok", "confirm", "需复核", "待确认"].includes(classification)) {
notes.push(...aiAttentionSegments(parsed.summary));
}
return notes.map(displayText).filter(Boolean);
}
@@ -651,7 +702,7 @@ function renderTargetSummary(targets, targetId = "targetSummary", formId = "deta
? targets.aiNotes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")
: "<li>AI未提出需要复核的内容</li>";
$(targetId).innerHTML = `
<details class="collapsible-panel target-panel" open>
<details class="collapsible-panel target-panel">
<summary>
<strong>复核定位</strong>
<span>${baseGroups.length ? `${baseGroups.length} 个模块` : "未定位"}</span>
@@ -662,7 +713,7 @@ function renderTargetSummary(targets, targetId = "targetSummary", formId = "deta
<ul>${baseNotesHtml}</ul>
</details>
${targets.hasAiNotes ? `
<details class="collapsible-panel target-panel is-ai-target" open>
<details class="collapsible-panel target-panel is-ai-target">
<summary>
<strong>AI建议复核点</strong>
<span>${aiGroups.length ? `${aiGroups.length} 个模块` : "无待看点"}</span>
@@ -1427,6 +1478,7 @@ function clearAiProgressPanel() {
$("aiProgressBar").style.width = "0%";
$("aiProgressText").textContent = "0/0";
$("aiProgressMeta").textContent = "等待开始";
$("aiCancelBtn").textContent = "中止AI";
$("aiCancelBtn").classList.remove("is-hidden");
$("aiCancelBtn").disabled = true;
}
@@ -1434,7 +1486,7 @@ function clearAiProgressPanel() {
function renderAiProgress(job = state.aiJob) {
const panel = $("aiProgressPanel");
if (!panel) return;
if (!job || (!job.running && !job.total && !job.message) || (!job.running && job.cancel_requested)) {
if (!job || job.cancel_requested || (!job.running && !job.total && !job.message)) {
clearAiProgressPanel();
return;
}
@@ -1452,7 +1504,8 @@ function renderAiProgress(job = state.aiJob) {
? `已通过 ${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").disabled = isBulkApprove || !job.running;
$("aiCancelBtn").textContent = job.running ? "中止AI" : "确认";
$("aiCancelBtn").disabled = isBulkApprove;
}
async function cancelAiReview({ silent = false } = {}) {
@@ -1468,6 +1521,19 @@ async function cancelAiReview({ silent = false } = {}) {
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;
@@ -1828,8 +1894,9 @@ 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")));
$("aiCancelBtn").addEventListener("click", () => handleAiProgressAction().catch((error) => showMessage(error.message, "error")));
$("approveAiNoIssueBtn").addEventListener("click", approveAiNoIssueRecords);
$("batchExportBtn").addEventListener("click", exportCurrentFilter);
$("pdfZoomOutBtn").addEventListener("click", () => changePdfZoom(-PDF_ZOOM_STEP));
$("pdfZoomInBtn").addEventListener("click", () => changePdfZoom(PDF_ZOOM_STEP));
$("pdfZoomResetBtn").addEventListener("click", () => setPdfZoom(100));

View File

@@ -98,6 +98,7 @@
<button id="aiCancelBtn" type="button">中止AI</button>
</div>
<button id="approveAiNoIssueBtn" class="bulk-action is-hidden" type="button">通过所有AI已通过</button>
<button id="batchExportBtn" class="bulk-action export-action is-hidden" type="button">批量导出当前筛选</button>
</div>
<div class="record-list" id="recordList" tabindex="0"></div>
</aside>
@@ -133,7 +134,7 @@
</div>
<div id="targetSummary" class="target-summary"></div>
<form id="detailForm" class="detail-form structured-form"></form>
<details class="review-log-panel collapsible-panel" open>
<details class="review-log-panel collapsible-panel">
<summary class="section-head compact">
<h2>修改记录</h2>
<span id="changeLogCount">0 条</span>
@@ -207,13 +208,13 @@
<label for="auditNotes">抽查人工备注</label>
<textarea id="auditNotes" rows="3" placeholder="填写抽查判断、修改原因或后续处理说明"></textarea>
</div>
<section class="review-log-panel">
<div class="section-head compact">
<details class="review-log-panel collapsible-panel">
<summary class="section-head compact">
<h2>修改记录</h2>
<span id="auditChangeLogCount">0 条</span>
</div>
</summary>
<div id="auditChangeLogTable" class="data-table-wrap"></div>
</section>
</details>
</aside>
</section>
</main>

View File

@@ -362,6 +362,12 @@ h2 {
min-height: 34px;
}
.export-action {
border-color: #b6c3d0;
background: #f7fafc;
color: var(--accent-dark);
}
input,
select,
textarea {
@@ -397,6 +403,7 @@ textarea {
background: transparent;
text-align: left;
padding: 9px;
cursor: pointer;
}
.record-item:hover,
@@ -471,10 +478,34 @@ textarea {
.record-time {
display: block;
margin-top: 2px;
color: #7b8794;
}
.record-footer {
min-height: 26px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 8px;
margin-top: 2px;
}
.record-download {
flex: 0 0 auto;
min-height: 24px;
padding: 2px 8px;
border: 1px solid #b6c3d0;
border-radius: 5px;
background: #fff;
color: var(--accent);
font-size: 12px;
}
.record-download:hover {
border-color: var(--accent);
background: #eef6fb;
}
.pdf-panel {
display: flex;
flex-direction: column;