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 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
@@ -21,7 +23,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 FastAPI, HTTPException, Request, Response from fastapi import BackgroundTasks, 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
@@ -969,6 +971,12 @@ 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
@@ -1927,6 +1935,22 @@ 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("```"):
@@ -3255,10 +3279,7 @@ def overview():
} }
@app.get("/api/records") def record_filter_sql(q: str = "", status_filter: str = "review_all") -> tuple[sql.Composable, list[Any]]:
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))
clauses = [] clauses = []
params: list[Any] = [] params: list[Any] = []
if q: if q:
@@ -3275,9 +3296,37 @@ def list_records(q: str = "", status_filter: str = "review_all", limit: int = 30
else: else:
clauses.append("review_status = %s") clauses.append("review_status = %s")
params.append(status_filter) params.append(status_filter)
where_sql = sql.SQL("") if not clauses:
if clauses: return sql.SQL(""), params
where_sql = sql.SQL("WHERE ") + sql.SQL(" AND ").join(sql.SQL(clause) for clause in clauses) 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( 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 NULLIF(review_logs->-1->>'changed_at', '')::timestamp AS last_activity_at
FROM {table} FROM {table}
{where} {where}
ORDER BY {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
LIMIT %s OFFSET %s 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: with connect() as conn, conn.cursor() as cur:
cur.execute(query, [*params, limit + 1, offset]) cur.execute(query, [*params, limit + 1, offset])
rows = [row_to_json(dict(row)) for row in cur.fetchall()] 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} 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:
@@ -3548,6 +3649,30 @@ def cancel_ai_review():
return dict(AI_REVIEW_JOB) 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") @app.post("/api/ai/review/approve-no-issue")
def approve_ai_no_issue(): def approve_ai_no_issue():
with BULK_JOB_LOCK: with BULK_JOB_LOCK:

View File

@@ -192,6 +192,10 @@ 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;
@@ -400,8 +404,11 @@ 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 `
<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"> <div class="record-main">
<span class="record-name">${escapeHtml(record.patient_name || "未命名")}</span> <span class="record-name">${escapeHtml(record.patient_name || "未命名")}</span>
${statusTag(displayStatusValue(record))} ${statusTag(displayStatusValue(record))}
@@ -409,19 +416,37 @@ 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 || "")}
${activity} <div class="record-footer">
<span>${activity}</span>
${exportButton}
</div>
</div> </div>
</button> </div>
`; `;
}).join("") + loadingNotice + endNotice; }).join("") + loadingNotice + endNotice;
list.querySelectorAll(".record-item").forEach((button) => { list.querySelectorAll(".record-item").forEach((item) => {
button.addEventListener("click", () => selectRecord(Number(button.dataset.id))); 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; 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);
@@ -429,6 +454,33 @@ 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() {
@@ -576,19 +628,18 @@ function baseReviewNotes(record) {
function aiAttentionNotes(record) { function aiAttentionNotes(record) {
const notes = []; const notes = [];
(record.review_logs || []).forEach((log) => { const latestAiLog = (record.review_logs || []).find((log) => Boolean(log.ai_result));
const parsed = log.ai_result?.parsed; const parsed = latestAiLog?.ai_result?.parsed;
if (!parsed) return; if (!parsed) return notes;
if (Array.isArray(parsed.remaining_issues)) { if (Array.isArray(parsed.remaining_issues)) {
parsed.remaining_issues.forEach((item) => { parsed.remaining_issues.forEach((item) => {
if (isReviewNeededText(item)) notes.push(item); if (isReviewNeededText(item)) notes.push(item);
}); });
} }
const classification = String(parsed.classification || parsed.decision || "").toLowerCase(); const classification = String(parsed.classification || parsed.decision || "").toLowerCase();
if (!notes.length && ["problem", "not_ok", "not ok", "confirm", "需复核", "待确认"].includes(classification)) { if (!notes.length && ["problem", "not_ok", "not ok", "confirm", "需复核", "待确认"].includes(classification)) {
notes.push(...aiAttentionSegments(parsed.summary)); notes.push(...aiAttentionSegments(parsed.summary));
} }
});
return notes.map(displayText).filter(Boolean); 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("") ? targets.aiNotes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")
: "<li>AI未提出需要复核的内容</li>"; : "<li>AI未提出需要复核的内容</li>";
$(targetId).innerHTML = ` $(targetId).innerHTML = `
<details class="collapsible-panel target-panel" open> <details class="collapsible-panel target-panel">
<summary> <summary>
<strong>复核定位</strong> <strong>复核定位</strong>
<span>${baseGroups.length ? `${baseGroups.length} 个模块` : "未定位"}</span> <span>${baseGroups.length ? `${baseGroups.length} 个模块` : "未定位"}</span>
@@ -662,7 +713,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" open> <details class="collapsible-panel target-panel is-ai-target">
<summary> <summary>
<strong>AI建议复核点</strong> <strong>AI建议复核点</strong>
<span>${aiGroups.length ? `${aiGroups.length} 个模块` : "无待看点"}</span> <span>${aiGroups.length ? `${aiGroups.length} 个模块` : "无待看点"}</span>
@@ -1427,6 +1478,7 @@ function clearAiProgressPanel() {
$("aiProgressBar").style.width = "0%"; $("aiProgressBar").style.width = "0%";
$("aiProgressText").textContent = "0/0"; $("aiProgressText").textContent = "0/0";
$("aiProgressMeta").textContent = "等待开始"; $("aiProgressMeta").textContent = "等待开始";
$("aiCancelBtn").textContent = "中止AI";
$("aiCancelBtn").classList.remove("is-hidden"); $("aiCancelBtn").classList.remove("is-hidden");
$("aiCancelBtn").disabled = true; $("aiCancelBtn").disabled = true;
} }
@@ -1434,7 +1486,7 @@ function clearAiProgressPanel() {
function renderAiProgress(job = state.aiJob) { function renderAiProgress(job = state.aiJob) {
const panel = $("aiProgressPanel"); const panel = $("aiProgressPanel");
if (!panel) return; 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(); clearAiProgressPanel();
return; return;
} }
@@ -1452,7 +1504,8 @@ function renderAiProgress(job = state.aiJob) {
? `已通过 ${job.updated || 0} · 剩余 ${Math.max(0, total - processed)} · 失败 ${job.failed || 0}` ? `已通过 ${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}`; : `OK ${job.ok || 0} · 不OK ${job.pending || 0} · 失败 ${job.failed || 0} · 并发 ${job.concurrency || 1}`;
$("aiCancelBtn").classList.toggle("is-hidden", isBulkApprove); $("aiCancelBtn").classList.toggle("is-hidden", isBulkApprove);
$("aiCancelBtn").disabled = isBulkApprove || !job.running; $("aiCancelBtn").textContent = job.running ? "中止AI" : "确认";
$("aiCancelBtn").disabled = isBulkApprove;
} }
async function cancelAiReview({ silent = false } = {}) { async function cancelAiReview({ silent = false } = {}) {
@@ -1468,6 +1521,19 @@ async function cancelAiReview({ silent = false } = {}) {
return job; 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() { async function pollAiReviewJob() {
const job = await api("/api/ai/review/status"); const job = await api("/api/ai/review/status");
state.aiJob = job; state.aiJob = job;
@@ -1828,8 +1894,9 @@ async function boot() {
$("aiCurrentBtn").addEventListener("click", () => runAiReview("current")); $("aiCurrentBtn").addEventListener("click", () => runAiReview("current"));
$("aiFiveBtn").addEventListener("click", () => runAiReview("five")); $("aiFiveBtn").addEventListener("click", () => runAiReview("five"));
$("aiAllBtn").addEventListener("click", () => runAiReview("all")); $("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); $("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));

View File

@@ -98,6 +98,7 @@
<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>
@@ -133,7 +134,7 @@
</div> </div>
<div id="targetSummary" class="target-summary"></div> <div id="targetSummary" class="target-summary"></div>
<form id="detailForm" class="detail-form structured-form"></form> <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"> <summary class="section-head compact">
<h2>修改记录</h2> <h2>修改记录</h2>
<span id="changeLogCount">0 条</span> <span id="changeLogCount">0 条</span>
@@ -207,13 +208,13 @@
<label for="auditNotes">抽查人工备注</label> <label for="auditNotes">抽查人工备注</label>
<textarea id="auditNotes" rows="3" placeholder="填写抽查判断、修改原因或后续处理说明"></textarea> <textarea id="auditNotes" rows="3" placeholder="填写抽查判断、修改原因或后续处理说明"></textarea>
</div> </div>
<section class="review-log-panel"> <details class="review-log-panel collapsible-panel">
<div class="section-head compact"> <summary class="section-head compact">
<h2>修改记录</h2> <h2>修改记录</h2>
<span id="auditChangeLogCount">0 条</span> <span id="auditChangeLogCount">0 条</span>
</div> </summary>
<div id="auditChangeLogTable" class="data-table-wrap"></div> <div id="auditChangeLogTable" class="data-table-wrap"></div>
</section> </details>
</aside> </aside>
</section> </section>
</main> </main>

View File

@@ -362,6 +362,12 @@ h2 {
min-height: 34px; min-height: 34px;
} }
.export-action {
border-color: #b6c3d0;
background: #f7fafc;
color: var(--accent-dark);
}
input, input,
select, select,
textarea { textarea {
@@ -397,6 +403,7 @@ textarea {
background: transparent; background: transparent;
text-align: left; text-align: left;
padding: 9px; padding: 9px;
cursor: pointer;
} }
.record-item:hover, .record-item:hover,
@@ -471,10 +478,34 @@ textarea {
.record-time { .record-time {
display: block; display: block;
margin-top: 2px;
color: #7b8794; 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 { .pdf-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;