Improve review export and AI progress UI
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user