Ignore PDF prefix truncation AI issues
This commit is contained in:
@@ -4,14 +4,12 @@ import json
|
|||||||
import os
|
import os
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import tempfile
|
|
||||||
import secrets
|
import secrets
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import zipfile
|
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -23,7 +21,7 @@ from zoneinfo import ZoneInfo
|
|||||||
import psycopg2
|
import psycopg2
|
||||||
import psycopg2.extras
|
import psycopg2.extras
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request, Response
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -965,12 +963,6 @@ def session_from_request(request: Request) -> dict[str, Any] | None:
|
|||||||
return SESSIONS.get(token)
|
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:
|
def page_permission_for_path(path: str, method: str) -> str | tuple[str, ...] | None:
|
||||||
if path in {"/api/status", "/api/schema"}:
|
if path in {"/api/status", "/api/schema"}:
|
||||||
return None
|
return None
|
||||||
@@ -1960,22 +1952,6 @@ def get_pdf_path(source_file: str) -> Path | None:
|
|||||||
return path if path.exists() and path.is_file() else 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:
|
def parse_json_content(content: str) -> Any:
|
||||||
text = content.strip()
|
text = content.strip()
|
||||||
if text.startswith("```"):
|
if text.startswith("```"):
|
||||||
@@ -2369,8 +2345,9 @@ def build_ai_prompt(record: dict[str, Any], pdf_context: dict[str, Any], privacy
|
|||||||
6. 如果手术表格中能看到“手术及操作编码”列但对应单元格为空,写“编码栏可见但为空白”,不要写“编码区域未在首页显示”;单元格本来为空且无需补录时不要因此判为 problem。
|
6. 如果手术表格中能看到“手术及操作编码”列但对应单元格为空,写“编码栏可见但为空白”,不要写“编码区域未在首页显示”;单元格本来为空且无需补录时不要因此判为 problem。
|
||||||
7. 手术操作名称可能因为换行被结构化解析截断;如果 PDF定位文本或局部截图中显示完整多行名称,请把完整名称放入 suggested_updates。
|
7. 手术操作名称可能因为换行被结构化解析截断;如果 PDF定位文本或局部截图中显示完整多行名称,请把完整名称放入 suggested_updates。
|
||||||
8. 门急诊诊断编码只能用于 outpatient_diagnosis_code;主要诊断请修正 discharge_diagnoses 中“诊断类别=主要诊断”的行,不要把门急诊诊断编码复制到 discharge_diagnoses[].疾病编码,除非出院诊断表格对应“疾病编码”单元格本身清楚显示该编码。
|
8. 门急诊诊断编码只能用于 outpatient_diagnosis_code;主要诊断请修正 discharge_diagnoses 中“诊断类别=主要诊断”的行,不要把门急诊诊断编码复制到 discharge_diagnoses[].疾病编码,除非出院诊断表格对应“疾病编码”单元格本身清楚显示该编码。
|
||||||
9. remaining_issues 只写当前文档复核人应该特别注意的内容;不要写如何修改,不要重复 suggested_updates,不要写“无需补录/无需处理/首页原貌”这类已判定无问题的说明。
|
9. PDF局部截图可能因遮挡、截屏边界或缩放只显示编码/文字前半段;如果 PDF 显示值是结构化字段值的前缀,且没有相反证据,应判为 match/ok,不要写“需确认完整编码”,也不要建议把结构化字段截短。
|
||||||
10. 不要编造 PDF 中看不见的内容,不要输出置信度。
|
10. remaining_issues 只写当前文档复核人应该特别注意的内容;不要写如何修改,不要重复 suggested_updates,不要写“无需补录/无需处理/首页原貌”这类已判定无问题的说明。
|
||||||
|
11. 不要编造 PDF 中看不见的内容,不要输出置信度。
|
||||||
|
|
||||||
必须返回这个 JSON 结构:
|
必须返回这个 JSON 结构:
|
||||||
{{
|
{{
|
||||||
@@ -2538,12 +2515,90 @@ def ai_needs_review_text(value: Any) -> bool:
|
|||||||
return bool(text) and not any(marker in text for marker in AI_NO_REVIEW_MARKERS)
|
return bool(text) and not any(marker in text for marker in AI_NO_REVIEW_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def ai_compact_compare_value(value: Any) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
text = text.replace(".", ".").replace("X", "X").replace("x", "x").replace("×", "x")
|
||||||
|
return re.sub(r"\s+", "", text).upper()
|
||||||
|
|
||||||
|
|
||||||
|
def ai_pdf_value_is_structured_prefix(pdf_value: Any, structured_value: Any) -> bool:
|
||||||
|
pdf_text = ai_compact_compare_value(pdf_value)
|
||||||
|
structured_text = ai_compact_compare_value(structured_value)
|
||||||
|
if len(pdf_text) < 3 or len(structured_text) <= len(pdf_text):
|
||||||
|
return False
|
||||||
|
return structured_text.startswith(pdf_text)
|
||||||
|
|
||||||
|
|
||||||
|
def ai_text_pdf_prefix_issue(text: Any) -> bool:
|
||||||
|
compact = re.sub(r"\s+", "", str(text or ""))
|
||||||
|
if not compact:
|
||||||
|
return False
|
||||||
|
patterns = [
|
||||||
|
r"PDF(?:显示|中显示|可见|值)?(?:为|是|[::])?([A-Za-z0-9][A-Za-z0-9.+\-*/xX]*)[,,;;。、]?(?:结构化字段|结构化值|字段|系统字段)(?:为|是|[::])?([A-Za-z0-9][A-Za-z0-9.+\-*/xX]*)",
|
||||||
|
r"PDF[^,,;;。]*?([A-Za-z0-9][A-Za-z0-9.+\-*/xX]{2,})[^,,;;。]*?(?:结构化字段|结构化值|字段|系统字段)[^,,;;。]*?([A-Za-z0-9][A-Za-z0-9.+\-*/xX]{2,})",
|
||||||
|
]
|
||||||
|
return any(
|
||||||
|
ai_pdf_value_is_structured_prefix(match.group(1), match.group(2))
|
||||||
|
for pattern in patterns
|
||||||
|
for match in re.finditer(pattern, compact, flags=re.IGNORECASE)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ai_pdf_prefix_truncation_issue(value: Any) -> bool:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
pdf_value = value.get("pdf_value") or value.get("pdf") or value.get("PDF值") or value.get("图片值")
|
||||||
|
structured_value = (
|
||||||
|
value.get("structured_value")
|
||||||
|
or value.get("structured")
|
||||||
|
or value.get("field_value")
|
||||||
|
or value.get("结构化值")
|
||||||
|
or value.get("结构化字段")
|
||||||
|
)
|
||||||
|
if ai_pdf_value_is_structured_prefix(pdf_value, structured_value):
|
||||||
|
return True
|
||||||
|
return ai_text_pdf_prefix_issue(value)
|
||||||
|
return ai_text_pdf_prefix_issue(value)
|
||||||
|
|
||||||
|
|
||||||
|
def ai_has_nonblank_suggested_updates(parsed: dict[str, Any]) -> bool:
|
||||||
|
suggested_updates = parsed.get("suggested_updates")
|
||||||
|
if not isinstance(suggested_updates, list):
|
||||||
|
return False
|
||||||
|
return any(isinstance(item, dict) and not blank_ai_value(suggested_update_value(item)) for item in suggested_updates)
|
||||||
|
|
||||||
|
|
||||||
|
def ai_problem_evidence(parsed: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
evidence = parsed.get("evidence")
|
||||||
|
if not isinstance(evidence, list):
|
||||||
|
return []
|
||||||
|
problem_results = {"mismatch", "uncertain", "missing", "problem", "not_match", "not ok", "not_ok"}
|
||||||
|
return [
|
||||||
|
item
|
||||||
|
for item in evidence
|
||||||
|
if isinstance(item, dict) and str(item.get("result") or "").strip().lower() in problem_results
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ai_only_pdf_prefix_truncation(parsed: dict[str, Any]) -> bool:
|
||||||
|
if ai_has_nonblank_suggested_updates(parsed):
|
||||||
|
return False
|
||||||
|
issues = ai_remaining_issues(parsed)
|
||||||
|
if issues:
|
||||||
|
return all(ai_pdf_prefix_truncation_issue(issue) for issue in issues)
|
||||||
|
evidence = ai_problem_evidence(parsed)
|
||||||
|
if evidence:
|
||||||
|
return all(ai_pdf_prefix_truncation_issue(item) for item in evidence)
|
||||||
|
return ai_pdf_prefix_truncation_issue(parsed.get("summary")) or ai_pdf_prefix_truncation_issue(parsed.get("decision"))
|
||||||
|
|
||||||
|
|
||||||
def normalize_ai_parsed(parsed: dict[str, Any]) -> dict[str, Any]:
|
def normalize_ai_parsed(parsed: dict[str, Any]) -> dict[str, Any]:
|
||||||
normalized = dict(parsed)
|
normalized = dict(parsed)
|
||||||
remaining = normalized.get("remaining_issues")
|
remaining = normalized.get("remaining_issues")
|
||||||
if isinstance(remaining, list):
|
if isinstance(remaining, list):
|
||||||
normalized["remaining_issues"] = [item for item in remaining if ai_needs_review_text(item)]
|
normalized["remaining_issues"] = [
|
||||||
elif remaining and ai_needs_review_text(remaining):
|
item for item in remaining if ai_needs_review_text(item) and not ai_pdf_prefix_truncation_issue(item)
|
||||||
|
]
|
||||||
|
elif remaining and ai_needs_review_text(remaining) and not ai_pdf_prefix_truncation_issue(remaining):
|
||||||
normalized["remaining_issues"] = [remaining]
|
normalized["remaining_issues"] = [remaining]
|
||||||
else:
|
else:
|
||||||
normalized["remaining_issues"] = []
|
normalized["remaining_issues"] = []
|
||||||
@@ -2567,7 +2622,7 @@ def ai_has_confirmed_problem(parsed: dict[str, Any]) -> bool:
|
|||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
result = str(item.get("result") or "").strip().lower()
|
result = str(item.get("result") or "").strip().lower()
|
||||||
if result in {"mismatch", "uncertain", "missing", "problem"}:
|
if result in {"mismatch", "uncertain", "missing", "problem"} and not ai_pdf_prefix_truncation_issue(item):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if resolution in {"false_positive", "ok", "no_issue", "误报", "无问题"}:
|
if resolution in {"false_positive", "ok", "no_issue", "误报", "无问题"}:
|
||||||
@@ -2596,6 +2651,8 @@ def ai_remaining_issues(parsed: dict[str, Any]) -> list[Any]:
|
|||||||
|
|
||||||
|
|
||||||
def ai_has_unresolved_problem(parsed: dict[str, Any]) -> bool:
|
def ai_has_unresolved_problem(parsed: dict[str, Any]) -> bool:
|
||||||
|
if ai_only_pdf_prefix_truncation(parsed):
|
||||||
|
return False
|
||||||
if ai_remaining_issues(parsed):
|
if ai_remaining_issues(parsed):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -2630,23 +2687,21 @@ def ai_classification(parsed: dict[str, Any]) -> str:
|
|||||||
resolution = str(parsed.get("issue_resolution") or "").strip().lower()
|
resolution = str(parsed.get("issue_resolution") or "").strip().lower()
|
||||||
ok_values = {"ok", "pass", "passed", "no_issue", "no issue", "无问题", "通过", "已通过"}
|
ok_values = {"ok", "pass", "passed", "no_issue", "no issue", "无问题", "通过", "已通过"}
|
||||||
problem_values = {"problem", "not_ok", "not ok", "confirm", "不ok", "不通过", "需确认", "待确认", "需复核"}
|
problem_values = {"problem", "not_ok", "not ok", "confirm", "不ok", "不通过", "需确认", "待确认", "需复核"}
|
||||||
|
if ai_only_pdf_prefix_truncation(parsed):
|
||||||
|
return AI_OK_STATUS
|
||||||
if classification in ok_values or decision in ok_values or resolution in {"false_positive", "ok", "no_issue", "误报", "无问题", "通过"}:
|
if classification in ok_values or decision in ok_values or resolution in {"false_positive", "ok", "no_issue", "误报", "无问题", "通过"}:
|
||||||
return AI_OK_STATUS
|
return AI_OK_STATUS
|
||||||
if classification in problem_values or decision in problem_values or resolution in {"confirmed_problem", "uncertain", "problem", "待确认", "已证实"}:
|
if classification in problem_values or decision in problem_values or resolution in {"confirmed_problem", "uncertain", "problem", "待确认", "已证实"}:
|
||||||
|
if not ai_remaining_issues(parsed) and ai_has_nonblank_suggested_updates(parsed):
|
||||||
|
return AI_OK_STATUS
|
||||||
return AI_PROBLEM_STATUS
|
return AI_PROBLEM_STATUS
|
||||||
if ai_remaining_issues(parsed) or ai_has_unresolved_problem(parsed):
|
if ai_remaining_issues(parsed) or ai_has_unresolved_problem(parsed):
|
||||||
return AI_PROBLEM_STATUS
|
return AI_PROBLEM_STATUS
|
||||||
suggested_updates = parsed.get("suggested_updates")
|
suggested_updates = parsed.get("suggested_updates")
|
||||||
if isinstance(suggested_updates, list) and suggested_updates:
|
if isinstance(suggested_updates, list) and suggested_updates:
|
||||||
return AI_OK_STATUS
|
return AI_OK_STATUS
|
||||||
evidence = parsed.get("evidence")
|
if any(not ai_pdf_prefix_truncation_issue(item) for item in ai_problem_evidence(parsed)):
|
||||||
if isinstance(evidence, list):
|
return AI_PROBLEM_STATUS
|
||||||
for item in evidence:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
continue
|
|
||||||
result = str(item.get("result") or "").strip().lower()
|
|
||||||
if result in {"mismatch", "uncertain", "missing", "problem"}:
|
|
||||||
return AI_PROBLEM_STATUS
|
|
||||||
return AI_OK_STATUS
|
return AI_OK_STATUS
|
||||||
|
|
||||||
|
|
||||||
@@ -2774,12 +2829,16 @@ def ai_suggested_updates(result: dict[str, Any], before: dict[str, Any]) -> tupl
|
|||||||
compare_old = rows[index].get(key)
|
compare_old = rows[index].get(key)
|
||||||
if ai_outpatient_code_leak(path, value, before, compare_old):
|
if ai_outpatient_code_leak(path, value, before, compare_old):
|
||||||
continue
|
continue
|
||||||
|
if ai_pdf_value_is_structured_prefix(value, compare_old):
|
||||||
|
continue
|
||||||
if comparable(compare_old) == comparable(value):
|
if comparable(compare_old) == comparable(value):
|
||||||
continue
|
continue
|
||||||
rows[index][key] = value
|
rows[index][key] = value
|
||||||
new_value = rows
|
new_value = rows
|
||||||
label = f"{FIELD_META[field]['label']}[{index + 1}].{key}"
|
label = f"{FIELD_META[field]['label']}[{index + 1}].{key}"
|
||||||
|
|
||||||
|
if index is None and ai_pdf_value_is_structured_prefix(value, compare_old):
|
||||||
|
continue
|
||||||
if comparable(compare_old) == comparable(value):
|
if comparable(compare_old) == comparable(value):
|
||||||
continue
|
continue
|
||||||
working[field] = new_value
|
working[field] = new_value
|
||||||
@@ -3397,77 +3456,11 @@ def list_records(q: str = "", status_filter: str = "review_all", limit: int = 30
|
|||||||
return {"records": rows, "limit": limit, "offset": offset, "has_more": has_more}
|
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}")
|
@app.get("/api/records/{record_id}")
|
||||||
def get_record(record_id: int):
|
def get_record(record_id: int):
|
||||||
return {"record": fetch_record(record_id)}
|
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}")
|
@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):
|
def ai_question_image(record_id: int, log_index: int, module_index: int):
|
||||||
if log_index < 0 or module_index < 0:
|
if log_index < 0 or module_index < 0:
|
||||||
|
|||||||
@@ -190,10 +190,6 @@ function userPermissions() {
|
|||||||
return state.currentUser?.permissions || {};
|
return state.currentUser?.permissions || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAdminUser() {
|
|
||||||
return state.currentUser?.source === "env" || state.currentUser?.username === "admin";
|
|
||||||
}
|
|
||||||
|
|
||||||
function canOpenPage(page) {
|
function canOpenPage(page) {
|
||||||
const key = page === "auditHistory" ? "audit_history" : page;
|
const key = page === "auditHistory" ? "audit_history" : page;
|
||||||
return userPermissions()[key] !== false;
|
return userPermissions()[key] !== false;
|
||||||
@@ -402,9 +398,6 @@ function renderRecords() {
|
|||||||
const active = record.id === state.selectedId ? " is-active" : "";
|
const active = record.id === state.selectedId ? " is-active" : "";
|
||||||
const manual = record.manual_corrected ? " · 人工已改" : "";
|
const manual = record.manual_corrected ? " · 人工已改" : "";
|
||||||
const activity = record.last_activity_at ? `<span class="record-time">最近 ${escapeHtml(formatTime(record.last_activity_at))}</span>` : "";
|
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 `
|
return `
|
||||||
<div class="record-item${active}" role="button" tabindex="0" data-id="${record.id}">
|
<div class="record-item${active}" role="button" tabindex="0" data-id="${record.id}">
|
||||||
<div class="record-main">
|
<div class="record-main">
|
||||||
@@ -414,17 +407,13 @@ function renderRecords() {
|
|||||||
<div class="record-sub">
|
<div class="record-sub">
|
||||||
${escapeHtml(record.inpatient_no || record.medical_record_no || "")} · ${escapeHtml(record.major_department || record.discharge_dept || "")}${manual}<br>
|
${escapeHtml(record.inpatient_no || record.medical_record_no || "")} · ${escapeHtml(record.major_department || record.discharge_dept || "")}${manual}<br>
|
||||||
${escapeHtml(record.primary_diagnosis || "")}
|
${escapeHtml(record.primary_diagnosis || "")}
|
||||||
<div class="record-footer">
|
${activity}
|
||||||
<span>${activity}</span>
|
|
||||||
${exportButton}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join("") + loadingNotice + endNotice;
|
}).join("") + loadingNotice + endNotice;
|
||||||
list.querySelectorAll(".record-item").forEach((item) => {
|
list.querySelectorAll(".record-item").forEach((item) => {
|
||||||
item.addEventListener("click", (event) => {
|
item.addEventListener("click", () => {
|
||||||
if (event.target.closest("[data-export-record]")) return;
|
|
||||||
selectRecord(Number(item.dataset.id));
|
selectRecord(Number(item.dataset.id));
|
||||||
});
|
});
|
||||||
item.addEventListener("keydown", (event) => {
|
item.addEventListener("keydown", (event) => {
|
||||||
@@ -433,18 +422,11 @@ function renderRecords() {
|
|||||||
selectRecord(Number(item.dataset.id));
|
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;
|
list.scrollTop = previousScrollTop;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFilterActions() {
|
function renderFilterActions() {
|
||||||
const button = $("approveAiNoIssueBtn");
|
const button = $("approveAiNoIssueBtn");
|
||||||
const exportButton = $("batchExportBtn");
|
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
const filter = $("statusFilter")?.value || "review_all";
|
const filter = $("statusFilter")?.value || "review_all";
|
||||||
const aiPassedCount = Number(state.status?.ai_passed || 0);
|
const aiPassedCount = Number(state.status?.ai_passed || 0);
|
||||||
@@ -452,33 +434,6 @@ function renderFilterActions() {
|
|||||||
button.classList.toggle("is-hidden", !shouldShow);
|
button.classList.toggle("is-hidden", !shouldShow);
|
||||||
button.disabled = !shouldShow || aiPassedCount <= 0 || Boolean(state.bulkJob?.running);
|
button.disabled = !shouldShow || aiPassedCount <= 0 || Boolean(state.bulkJob?.running);
|
||||||
button.textContent = aiPassedCount > 0 ? `通过所有AI已通过(${aiPassedCount})` : "通过所有AI已通过";
|
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() {
|
function scrollSelectedRecordIntoView() {
|
||||||
@@ -711,7 +666,7 @@ function renderTargetSummary(targets, targetId = "targetSummary", formId = "deta
|
|||||||
<ul>${baseNotesHtml}</ul>
|
<ul>${baseNotesHtml}</ul>
|
||||||
</details>
|
</details>
|
||||||
${targets.hasAiNotes ? `
|
${targets.hasAiNotes ? `
|
||||||
<details class="collapsible-panel target-panel is-ai-target">
|
<details class="collapsible-panel target-panel is-ai-target" open>
|
||||||
<summary>
|
<summary>
|
||||||
<strong>AI建议复核点</strong>
|
<strong>AI建议复核点</strong>
|
||||||
<span>${aiGroups.length ? `${aiGroups.length} 个模块` : "无待看点"}</span>
|
<span>${aiGroups.length ? `${aiGroups.length} 个模块` : "无待看点"}</span>
|
||||||
@@ -1894,7 +1849,6 @@ async function boot() {
|
|||||||
$("aiAllBtn").addEventListener("click", () => runAiReview("all"));
|
$("aiAllBtn").addEventListener("click", () => runAiReview("all"));
|
||||||
$("aiCancelBtn").addEventListener("click", () => handleAiProgressAction().catch((error) => showMessage(error.message, "error")));
|
$("aiCancelBtn").addEventListener("click", () => handleAiProgressAction().catch((error) => showMessage(error.message, "error")));
|
||||||
$("approveAiNoIssueBtn").addEventListener("click", approveAiNoIssueRecords);
|
$("approveAiNoIssueBtn").addEventListener("click", approveAiNoIssueRecords);
|
||||||
$("batchExportBtn").addEventListener("click", exportCurrentFilter);
|
|
||||||
$("pdfZoomOutBtn").addEventListener("click", () => changePdfZoom(-PDF_ZOOM_STEP));
|
$("pdfZoomOutBtn").addEventListener("click", () => changePdfZoom(-PDF_ZOOM_STEP));
|
||||||
$("pdfZoomInBtn").addEventListener("click", () => changePdfZoom(PDF_ZOOM_STEP));
|
$("pdfZoomInBtn").addEventListener("click", () => changePdfZoom(PDF_ZOOM_STEP));
|
||||||
$("pdfZoomResetBtn").addEventListener("click", () => setPdfZoom(100));
|
$("pdfZoomResetBtn").addEventListener("click", () => setPdfZoom(100));
|
||||||
|
|||||||
@@ -98,7 +98,6 @@
|
|||||||
<button id="aiCancelBtn" type="button">中止AI</button>
|
<button id="aiCancelBtn" type="button">中止AI</button>
|
||||||
</div>
|
</div>
|
||||||
<button id="approveAiNoIssueBtn" class="bulk-action is-hidden" type="button">通过所有AI已通过</button>
|
<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>
|
||||||
<div class="record-list" id="recordList" tabindex="0"></div>
|
<div class="record-list" id="recordList" tabindex="0"></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -362,12 +362,6 @@ h2 {
|
|||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-action {
|
|
||||||
border-color: #b6c3d0;
|
|
||||||
background: #f7fafc;
|
|
||||||
color: var(--accent-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
input,
|
||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
@@ -478,32 +472,8 @@ textarea {
|
|||||||
|
|
||||||
.record-time {
|
.record-time {
|
||||||
display: block;
|
display: block;
|
||||||
color: #7b8794;
|
|
||||||
}
|
|
||||||
|
|
||||||
.record-footer {
|
|
||||||
min-height: 26px;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
color: #7b8794;
|
||||||
|
|
||||||
.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 {
|
.pdf-panel {
|
||||||
|
|||||||
Reference in New Issue
Block a user