diff --git a/患者首页处理/数据可视化网页端/app/main.py b/患者首页处理/数据可视化网页端/app/main.py index 60713ed..07c6698 100644 --- a/患者首页处理/数据可视化网页端/app/main.py +++ b/患者首页处理/数据可视化网页端/app/main.py @@ -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: diff --git a/患者首页处理/数据可视化网页端/app/static/app.js b/患者首页处理/数据可视化网页端/app/static/app.js index 24286a0..7ee5ba4 100644 --- a/患者首页处理/数据可视化网页端/app/static/app.js +++ b/患者首页处理/数据可视化网页端/app/static/app.js @@ -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 ? `最近 ${escapeHtml(formatTime(record.last_activity_at))}` : ""; + const exportButton = isAdminUser() && record.has_pdf + ? `` + : ""; return ` - + `; }).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) => `
  • ${escapeHtml(note)}
  • `).join("") : "
  • AI未提出需要复核的内容
  • "; $(targetId).innerHTML = ` -
    +
    复核定位 ${baseGroups.length ? `${baseGroups.length} 个模块` : "未定位"} @@ -662,7 +713,7 @@ function renderTargetSummary(targets, targetId = "targetSummary", formId = "deta
      ${baseNotesHtml}
    ${targets.hasAiNotes ? ` -
    +
    AI建议复核点 ${aiGroups.length ? `${aiGroups.length} 个模块` : "无待看点"} @@ -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)); diff --git a/患者首页处理/数据可视化网页端/app/static/index.html b/患者首页处理/数据可视化网页端/app/static/index.html index f26979e..9ef7a4d 100644 --- a/患者首页处理/数据可视化网页端/app/static/index.html +++ b/患者首页处理/数据可视化网页端/app/static/index.html @@ -98,6 +98,7 @@ +
    @@ -133,7 +134,7 @@
    -
    +

    修改记录

    0 条 @@ -207,13 +208,13 @@ -
    -
    +
    +

    修改记录

    0 条 -
    +
    - +
    diff --git a/患者首页处理/数据可视化网页端/app/static/styles.css b/患者首页处理/数据可视化网页端/app/static/styles.css index 04a9176..cdbd6c3 100644 --- a/患者首页处理/数据可视化网页端/app/static/styles.css +++ b/患者首页处理/数据可视化网页端/app/static/styles.css @@ -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;