Add manual study completion toggle
This commit is contained in:
@@ -116,6 +116,10 @@ class AISettingsIn(BaseModel):
|
|||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class StudyCompletionIn(BaseModel):
|
||||||
|
completed: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UserIn(BaseModel):
|
class UserIn(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
@@ -296,8 +300,17 @@ def ensure_study_summary_table() -> None:
|
|||||||
series_count integer NOT NULL DEFAULT 0,
|
series_count integer NOT NULL DEFAULT 0,
|
||||||
annotated_series integer NOT NULL DEFAULT 0,
|
annotated_series integer NOT NULL DEFAULT 0,
|
||||||
unannotated_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()
|
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)
|
pg_scalar(sql)
|
||||||
|
|
||||||
@@ -492,7 +505,9 @@ def summary_rows_by_study() -> dict[str, dict[str, Any]]:
|
|||||||
ensure_study_summary_table()
|
ensure_study_summary_table()
|
||||||
rows = pg_json_rows(
|
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
|
FROM public.pacs_dicom_study_summaries
|
||||||
""",
|
""",
|
||||||
timeout=8,
|
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}
|
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")
|
@app.get("/api/studies")
|
||||||
def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> list[dict[str, Any]]:
|
def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> list[dict[str, Any]]:
|
||||||
where = ""
|
where = ""
|
||||||
@@ -528,6 +565,9 @@ def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> li
|
|||||||
row["series_count"] = total_series
|
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["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["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", "")
|
row["summary_updated_at"] = (summary or {}).get("refreshed_at", "")
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
@@ -1035,12 +1075,38 @@ def summary_scheduler_loop() -> None:
|
|||||||
@app.get("/api/studies/{ct_number}/series")
|
@app.get("/api/studies/{ct_number}/series")
|
||||||
def series(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]:
|
def series(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]:
|
||||||
data = scan_study(ct_number)
|
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")
|
@app.get("/api/studies/{ct_number}/summary")
|
||||||
def study_summary(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]:
|
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]:
|
def get_series_files(ct_number: str, series_uid: str) -> list[Path]:
|
||||||
|
|||||||
@@ -241,10 +241,7 @@ function sortStudies(list) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function studyStatus(study) {
|
function studyStatus(study) {
|
||||||
const total = Number(study.series_count || 0);
|
return study?.completed ? "complete" : "incomplete";
|
||||||
const unannotated = Number(study.unannotated_series || 0);
|
|
||||||
if (total > 0 && unannotated === 0) return "complete";
|
|
||||||
return "incomplete";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function studyStatusLabel(status) {
|
function studyStatusLabel(status) {
|
||||||
@@ -321,22 +318,60 @@ function renderStudies() {
|
|||||||
for (const study of studies) {
|
for (const study of studies) {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
const status = studyStatus(study);
|
const status = studyStatus(study);
|
||||||
|
const nextStatusLabel = status === "complete" ? "待处理" : "已完成";
|
||||||
button.className = "study-card";
|
button.className = "study-card";
|
||||||
button.classList.toggle("active", app.study?.ct_number === study.ct_number);
|
button.classList.toggle("active", app.study?.ct_number === study.ct_number);
|
||||||
button.classList.toggle("complete", status === "complete");
|
button.classList.toggle("complete", status === "complete");
|
||||||
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
|
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
|
||||||
button.innerHTML = `
|
button.innerHTML = `
|
||||||
<i class="study-status-badge status-${status}" title="${status === "complete" ? "已处理完毕" : "仍有序列未标注"}">${studyStatusLabel(status)}</i>
|
<i class="study-status-badge status-${status}" title="点击切换为${nextStatusLabel}">${studyStatusLabel(status)}</i>
|
||||||
<strong>${escapeHtml(study.ct_number)}</strong>
|
<strong>${escapeHtml(study.ct_number)}</strong>
|
||||||
<span>${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}</span>
|
<span>${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}</span>
|
||||||
<span>${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张</span>
|
<span>${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张</span>
|
||||||
<span>${Number(study.annotated_series || 0)} 个序列已标注,${Number(study.unannotated_series || 0)} 个序列未标注</span>
|
<span>${Number(study.annotated_series || 0)} 个序列已标注,${Number(study.unannotated_series || 0)} 个序列未标注</span>
|
||||||
`;
|
`;
|
||||||
button.onclick = () => selectStudy(study.ct_number);
|
button.onclick = () => selectStudy(study.ct_number);
|
||||||
|
button.querySelector(".study-status-badge").onclick = (event) => toggleStudyCompletion(study.ct_number, event);
|
||||||
list.appendChild(button);
|
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) {
|
async function selectStudy(ctNumber) {
|
||||||
if (app.study?.ct_number && app.study.ct_number !== ctNumber) await confirmSaveIfDirty();
|
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 };
|
app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber };
|
||||||
@@ -351,6 +386,7 @@ async function selectStudy(ctNumber) {
|
|||||||
try {
|
try {
|
||||||
const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`);
|
const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`);
|
||||||
app.study = data.study;
|
app.study = data.study;
|
||||||
|
applyStudyCompletion(data.study);
|
||||||
setSeries(data.series || []);
|
setSeries(data.series || []);
|
||||||
refreshStudyAnnotationSummary();
|
refreshStudyAnnotationSummary();
|
||||||
renderStudies();
|
renderStudies();
|
||||||
|
|||||||
@@ -385,6 +385,12 @@ button:disabled {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 800;
|
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 {
|
.study-status-badge.status-complete {
|
||||||
|
|||||||
Reference in New Issue
Block a user