diff --git a/PACS_DICOM处理/数据处理网页端/app.py b/PACS_DICOM处理/数据处理网页端/app.py
index 0240a50..8256680 100644
--- a/PACS_DICOM处理/数据处理网页端/app.py
+++ b/PACS_DICOM处理/数据处理网页端/app.py
@@ -116,6 +116,10 @@ class AISettingsIn(BaseModel):
enabled: bool = True
+class StudyCompletionIn(BaseModel):
+ completed: bool = False
+
+
class UserIn(BaseModel):
username: str
password: str
@@ -296,8 +300,17 @@ def ensure_study_summary_table() -> None:
series_count integer NOT NULL DEFAULT 0,
annotated_series integer NOT NULL DEFAULT 0,
unannotated_series integer NOT NULL DEFAULT 0,
+ completed boolean NOT NULL DEFAULT false,
+ completed_by text,
+ completed_at timestamptz,
refreshed_at timestamptz NOT NULL DEFAULT now()
);
+ ALTER TABLE public.pacs_dicom_study_summaries
+ ADD COLUMN IF NOT EXISTS completed boolean NOT NULL DEFAULT false;
+ ALTER TABLE public.pacs_dicom_study_summaries
+ ADD COLUMN IF NOT EXISTS completed_by text;
+ ALTER TABLE public.pacs_dicom_study_summaries
+ ADD COLUMN IF NOT EXISTS completed_at timestamptz;
"""
pg_scalar(sql)
@@ -492,7 +505,9 @@ def summary_rows_by_study() -> dict[str, dict[str, Any]]:
ensure_study_summary_table()
rows = pg_json_rows(
"""
- SELECT ct_number, series_count, annotated_series, unannotated_series, refreshed_at
+ SELECT
+ ct_number, series_count, annotated_series, unannotated_series,
+ completed, completed_by, completed_at, refreshed_at
FROM public.pacs_dicom_study_summaries
""",
timeout=8,
@@ -502,6 +517,28 @@ def summary_rows_by_study() -> dict[str, dict[str, Any]]:
return {row["ct_number"]: row for row in rows}
+def study_completion_fields(ct_number: str) -> dict[str, Any]:
+ try:
+ ensure_study_summary_table()
+ rows = pg_json_rows(
+ f"""
+ SELECT completed, completed_by, completed_at
+ FROM public.pacs_dicom_study_summaries
+ WHERE ct_number = {sql_literal(ct_number)}
+ LIMIT 1
+ """,
+ timeout=8,
+ )
+ except Exception:
+ rows = []
+ row = rows[0] if rows else {}
+ return {
+ "completed": bool(row.get("completed", False)),
+ "completed_by": row.get("completed_by", ""),
+ "completed_at": row.get("completed_at", ""),
+ }
+
+
@app.get("/api/studies")
def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> list[dict[str, Any]]:
where = ""
@@ -528,6 +565,9 @@ def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> li
row["series_count"] = total_series
row["annotated_series"] = min(total_series, int((summary or {}).get("annotated_series") if summary else annotation_map.get(row["ct_number"], 0)))
row["unannotated_series"] = max(0, total_series - int(row["annotated_series"]))
+ row["completed"] = bool((summary or {}).get("completed", False))
+ row["completed_by"] = (summary or {}).get("completed_by", "")
+ row["completed_at"] = (summary or {}).get("completed_at", "")
row["summary_updated_at"] = (summary or {}).get("refreshed_at", "")
return rows
@@ -1035,12 +1075,38 @@ def summary_scheduler_loop() -> None:
@app.get("/api/studies/{ct_number}/series")
def series(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]:
data = scan_study(ct_number)
- return {"study": data["study"], "series": data["series"]}
+ study = {**data["study"], **study_completion_fields(ct_number)}
+ return {"study": study, "series": data["series"]}
@app.get("/api/studies/{ct_number}/summary")
def study_summary(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]:
- return study_summary_payload(scan_study(ct_number))
+ return {**study_summary_payload(scan_study(ct_number)), **study_completion_fields(ct_number)}
+
+
+@app.put("/api/studies/{ct_number}/completion")
+def update_study_completion(ct_number: str, data: StudyCompletionIn, user: str = Depends(require_auth)) -> dict[str, Any]:
+ get_study_record(ct_number)
+ ensure_study_summary_table()
+ pg_scalar(
+ f"""
+ INSERT INTO public.pacs_dicom_study_summaries (
+ ct_number, completed, completed_by, completed_at, refreshed_at
+ )
+ VALUES (
+ {sql_literal(ct_number)},
+ {'true' if data.completed else 'false'},
+ {sql_literal(user)},
+ {'now()' if data.completed else 'NULL'},
+ now()
+ )
+ ON CONFLICT (ct_number) DO UPDATE SET
+ completed = EXCLUDED.completed,
+ completed_by = EXCLUDED.completed_by,
+ completed_at = EXCLUDED.completed_at
+ """
+ )
+ return {"ok": True, "ct_number": ct_number, **study_completion_fields(ct_number)}
def get_series_files(ct_number: str, series_uid: str) -> list[Path]:
diff --git a/PACS_DICOM处理/数据处理网页端/static/app.js b/PACS_DICOM处理/数据处理网页端/static/app.js
index e7db332..2950ef2 100644
--- a/PACS_DICOM处理/数据处理网页端/static/app.js
+++ b/PACS_DICOM处理/数据处理网页端/static/app.js
@@ -241,10 +241,7 @@ function sortStudies(list) {
}
function studyStatus(study) {
- const total = Number(study.series_count || 0);
- const unannotated = Number(study.unannotated_series || 0);
- if (total > 0 && unannotated === 0) return "complete";
- return "incomplete";
+ return study?.completed ? "complete" : "incomplete";
}
function studyStatusLabel(status) {
@@ -321,22 +318,60 @@ function renderStudies() {
for (const study of studies) {
const button = document.createElement("button");
const status = studyStatus(study);
+ const nextStatusLabel = status === "complete" ? "待处理" : "已完成";
button.className = "study-card";
button.classList.toggle("active", app.study?.ct_number === study.ct_number);
button.classList.toggle("complete", status === "complete");
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
button.innerHTML = `
- ${studyStatusLabel(status)}
+ ${studyStatusLabel(status)}
${escapeHtml(study.ct_number)}
${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}
${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张
${Number(study.annotated_series || 0)} 个序列已标注,${Number(study.unannotated_series || 0)} 个序列未标注
`;
button.onclick = () => selectStudy(study.ct_number);
+ button.querySelector(".study-status-badge").onclick = (event) => toggleStudyCompletion(study.ct_number, event);
list.appendChild(button);
}
}
+function applyStudyCompletion(data) {
+ const completed = Boolean(data.completed);
+ const study = app.studies.find((item) => item.ct_number === data.ct_number);
+ if (study) {
+ study.completed = completed;
+ study.completed_by = data.completed_by || "";
+ study.completed_at = data.completed_at || "";
+ }
+ if (app.study?.ct_number === data.ct_number) {
+ app.study.completed = completed;
+ app.study.completed_by = data.completed_by || "";
+ app.study.completed_at = data.completed_at || "";
+ }
+}
+
+async function toggleStudyCompletion(ctNumber, event) {
+ event.stopPropagation();
+ const study = app.studies.find((item) => item.ct_number === ctNumber);
+ if (!study) return;
+ const previous = Boolean(study.completed);
+ applyStudyCompletion({ ct_number: ctNumber, completed: !previous });
+ renderStudies();
+ try {
+ const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/completion`, {
+ method: "PUT",
+ body: JSON.stringify({ completed: !previous }),
+ });
+ applyStudyCompletion(data);
+ renderStudies();
+ } catch (err) {
+ applyStudyCompletion({ ct_number: ctNumber, completed: previous });
+ renderStudies();
+ $("saveState").textContent = `状态更新失败:${err.message}`;
+ }
+}
+
async function selectStudy(ctNumber) {
if (app.study?.ct_number && app.study.ct_number !== ctNumber) await confirmSaveIfDirty();
app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber };
@@ -351,6 +386,7 @@ async function selectStudy(ctNumber) {
try {
const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`);
app.study = data.study;
+ applyStudyCompletion(data.study);
setSeries(data.series || []);
refreshStudyAnnotationSummary();
renderStudies();
diff --git a/PACS_DICOM处理/数据处理网页端/static/styles.css b/PACS_DICOM处理/数据处理网页端/static/styles.css
index 475176d..3863e55 100644
--- a/PACS_DICOM处理/数据处理网页端/static/styles.css
+++ b/PACS_DICOM处理/数据处理网页端/static/styles.css
@@ -385,6 +385,12 @@ button:disabled {
font-size: 10px;
font-style: normal;
font-weight: 800;
+ cursor: pointer;
+}
+
+.study-status-badge:hover {
+ border-color: rgba(239, 245, 255, 0.58);
+ color: var(--text);
}
.study-status-badge.status-complete {