Refine study controls and slice rail

This commit is contained in:
Codex
2026-05-27 15:23:22 +08:00
parent f5b40c5f5b
commit bfa2436327
4 changed files with 78 additions and 36 deletions

View File

@@ -317,6 +317,7 @@ def ensure_study_summary_table() -> None:
completed boolean NOT NULL DEFAULT false, completed boolean NOT NULL DEFAULT false,
completed_by text, completed_by text,
completed_at timestamptz, completed_at timestamptz,
completion_updated_at timestamptz,
refreshed_at timestamptz NOT NULL DEFAULT now() refreshed_at timestamptz NOT NULL DEFAULT now()
); );
ALTER TABLE public.pacs_dicom_study_summaries ALTER TABLE public.pacs_dicom_study_summaries
@@ -327,6 +328,8 @@ def ensure_study_summary_table() -> None:
ADD COLUMN IF NOT EXISTS completed_by text; ADD COLUMN IF NOT EXISTS completed_by text;
ALTER TABLE public.pacs_dicom_study_summaries ALTER TABLE public.pacs_dicom_study_summaries
ADD COLUMN IF NOT EXISTS completed_at timestamptz; ADD COLUMN IF NOT EXISTS completed_at timestamptz;
ALTER TABLE public.pacs_dicom_study_summaries
ADD COLUMN IF NOT EXISTS completion_updated_at timestamptz;
""" """
pg_scalar(sql) pg_scalar(sql)
@@ -511,7 +514,8 @@ def annotation_overview_by_study() -> dict[str, dict[str, Any]]:
COALESCE( COALESCE(
jsonb_agg(DISTINCT part.value) FILTER (WHERE part.value IS NOT NULL), jsonb_agg(DISTINCT part.value) FILTER (WHERE part.value IS NOT NULL),
'[]'::jsonb '[]'::jsonb
) AS body_parts ) AS body_parts,
max(a.updated_at) FILTER (WHERE COALESCE(a.updated_by, '') <> 'system') AS annotation_updated_at
FROM public.pacs_dicom_series_annotations a FROM public.pacs_dicom_series_annotations a
LEFT JOIN LATERAL jsonb_array_elements_text(a.body_parts) AS part(value) ON true LEFT JOIN LATERAL jsonb_array_elements_text(a.body_parts) AS part(value) ON true
GROUP BY a.ct_number GROUP BY a.ct_number
@@ -530,7 +534,7 @@ def summary_rows_by_study() -> dict[str, dict[str, Any]]:
""" """
SELECT SELECT
ct_number, series_count, annotated_series, unannotated_series, ct_number, series_count, annotated_series, unannotated_series,
body_parts, completed, completed_by, completed_at, refreshed_at body_parts, completed, completed_by, completed_at, completion_updated_at, refreshed_at
FROM public.pacs_dicom_study_summaries FROM public.pacs_dicom_study_summaries
""", """,
timeout=8, timeout=8,
@@ -545,7 +549,7 @@ def study_completion_fields(ct_number: str) -> dict[str, Any]:
ensure_study_summary_table() ensure_study_summary_table()
rows = pg_json_rows( rows = pg_json_rows(
f""" f"""
SELECT completed, completed_by, completed_at SELECT completed, completed_by, completed_at, completion_updated_at
FROM public.pacs_dicom_study_summaries FROM public.pacs_dicom_study_summaries
WHERE ct_number = {sql_literal(ct_number)} WHERE ct_number = {sql_literal(ct_number)}
LIMIT 1 LIMIT 1
@@ -559,6 +563,7 @@ def study_completion_fields(ct_number: str) -> dict[str, Any]:
"completed": bool(row.get("completed", False)), "completed": bool(row.get("completed", False)),
"completed_by": row.get("completed_by", ""), "completed_by": row.get("completed_by", ""),
"completed_at": row.get("completed_at", ""), "completed_at": row.get("completed_at", ""),
"completion_updated_at": row.get("completion_updated_at", ""),
} }
@@ -593,6 +598,12 @@ def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> li
row["completed"] = bool((summary or {}).get("completed", False)) row["completed"] = bool((summary or {}).get("completed", False))
row["completed_by"] = (summary or {}).get("completed_by", "") row["completed_by"] = (summary or {}).get("completed_by", "")
row["completed_at"] = (summary or {}).get("completed_at", "") row["completed_at"] = (summary or {}).get("completed_at", "")
row["completion_updated_at"] = (summary or {}).get("completion_updated_at", "")
row["annotation_updated_at"] = annotation_summary.get("annotation_updated_at", "")
row["modified_at"] = max(
str(row.get("annotation_updated_at") or ""),
str(row.get("completion_updated_at") or ""),
)
row["summary_updated_at"] = (summary or {}).get("refreshed_at", "") row["summary_updated_at"] = (summary or {}).get("refreshed_at", "")
return rows return rows
@@ -1218,19 +1229,21 @@ def update_study_completion(ct_number: str, data: StudyCompletionIn, user: str =
pg_scalar( pg_scalar(
f""" f"""
INSERT INTO public.pacs_dicom_study_summaries ( INSERT INTO public.pacs_dicom_study_summaries (
ct_number, completed, completed_by, completed_at, refreshed_at ct_number, completed, completed_by, completed_at, completion_updated_at, refreshed_at
) )
VALUES ( VALUES (
{sql_literal(ct_number)}, {sql_literal(ct_number)},
{'true' if data.completed else 'false'}, {'true' if data.completed else 'false'},
{sql_literal(user)}, {sql_literal(user)},
{'now()' if data.completed else 'NULL'}, {'now()' if data.completed else 'NULL'},
now(),
now() now()
) )
ON CONFLICT (ct_number) DO UPDATE SET ON CONFLICT (ct_number) DO UPDATE SET
completed = EXCLUDED.completed, completed = EXCLUDED.completed,
completed_by = EXCLUDED.completed_by, completed_by = EXCLUDED.completed_by,
completed_at = EXCLUDED.completed_at completed_at = EXCLUDED.completed_at,
completion_updated_at = EXCLUDED.completion_updated_at
""" """
) )
return {"ok": True, "ct_number": ct_number, **study_completion_fields(ct_number)} return {"ok": True, "ct_number": ct_number, **study_completion_fields(ct_number)}
@@ -1593,7 +1606,9 @@ def save_annotation_payload(
"skipped": skipped, "skipped": skipped,
"ai_skipped": ai_skipped, "ai_skipped": ai_skipped,
"notes": notes, "notes": notes,
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"ai_model": ai_model or series_row.get("annotation", {}).get("ai_model", ""), "ai_model": ai_model or series_row.get("annotation", {}).get("ai_model", ""),
"updated_by": user,
} }
) )

View File

@@ -24,7 +24,7 @@ const app = {
saving: false, saving: false,
pendingImage: null, pendingImage: null,
drag: null, drag: null,
studySortField: "time", studySortField: "modified",
studySortDir: "desc", studySortDir: "desc",
studyFilter: "all", studyFilter: "all",
studyPartFilter: "all", studyPartFilter: "all",
@@ -117,6 +117,11 @@ function studyTimeKey(study) {
return `${date}${time}`; return `${date}${time}`;
} }
function studyModifiedKey(study) {
const raw = String(study.modified_at || study.annotation_updated_at || study.completion_updated_at || study.completed_at || "");
return raw ? raw.replace(/\D/g, "").padEnd(14, "0") : studyTimeKey(study);
}
function sortArrow(direction) { function sortArrow(direction) {
return direction === "asc" ? "↑" : "↓"; return direction === "asc" ? "↑" : "↓";
} }
@@ -240,8 +245,8 @@ function sortStudies(list) {
const timeResult = const timeResult =
studyTimeKey(a).localeCompare(studyTimeKey(b)) || studyTimeKey(a).localeCompare(studyTimeKey(b)) ||
String(a.ct_number || "").localeCompare(String(b.ct_number || ""), undefined, { numeric: true }); String(a.ct_number || "").localeCompare(String(b.ct_number || ""), undefined, { numeric: true });
const unannotatedResult = Number(a.unannotated_series || 0) - Number(b.unannotated_series || 0) || timeResult; const modifiedResult = studyModifiedKey(a).localeCompare(studyModifiedKey(b)) || timeResult;
const result = app.studySortField === "unannotated" ? unannotatedResult : timeResult; const result = app.studySortField === "study_time" ? timeResult : modifiedResult;
return app.studySortDir === "desc" ? -result : result; return app.studySortDir === "desc" ? -result : result;
}); });
} }
@@ -509,16 +514,21 @@ function renderStudies() {
function applyStudyCompletion(data) { function applyStudyCompletion(data) {
const completed = Boolean(data.completed); const completed = Boolean(data.completed);
const modifiedAt = data.completion_updated_at || data.modified_at || data.completed_at || "";
const study = app.studies.find((item) => item.ct_number === data.ct_number); const study = app.studies.find((item) => item.ct_number === data.ct_number);
if (study) { if (study) {
study.completed = completed; study.completed = completed;
study.completed_by = data.completed_by || ""; study.completed_by = data.completed_by || "";
study.completed_at = data.completed_at || ""; study.completed_at = data.completed_at || "";
study.completion_updated_at = data.completion_updated_at || study.completion_updated_at || "";
if (modifiedAt) study.modified_at = modifiedAt;
} }
if (app.study?.ct_number === data.ct_number) { if (app.study?.ct_number === data.ct_number) {
app.study.completed = completed; app.study.completed = completed;
app.study.completed_by = data.completed_by || ""; app.study.completed_by = data.completed_by || "";
app.study.completed_at = data.completed_at || ""; app.study.completed_at = data.completed_at || "";
app.study.completion_updated_at = data.completion_updated_at || app.study.completion_updated_at || "";
if (modifiedAt) app.study.modified_at = modifiedAt;
} }
} }
@@ -993,6 +1003,16 @@ function updateLocalAnnotation(annotation, targetSeriesUid = app.activeSeries?.s
if (options.updateNotes !== false) $("annotationNotes").value = normalized.notes || ""; if (options.updateNotes !== false) $("annotationNotes").value = normalized.notes || "";
} }
refreshStudyAnnotationSummary(); refreshStudyAnnotationSummary();
const modifiedAt = normalized.updated_at || new Date().toISOString();
if (app.study && modifiedAt) {
app.study.modified_at = modifiedAt;
app.study.annotation_updated_at = modifiedAt;
const study = app.studies.find((item) => item.ct_number === app.study.ct_number);
if (study) {
study.modified_at = modifiedAt;
study.annotation_updated_at = modifiedAt;
}
}
renderSeries(); renderSeries();
renderStudies(); renderStudies();
if (isActive && applyToActive) applyAnnotationControls(); if (isActive && applyToActive) applyAnnotationControls();
@@ -1353,7 +1373,11 @@ function changeZoom(multiplier) {
function wireImageGestures() { function wireImageGestures() {
const wrap = document.querySelector(".image-wrap"); const wrap = document.querySelector(".image-wrap");
document.querySelector(".slice-rail").addEventListener("pointerdown", (event) => event.stopPropagation()); const sliceRail = document.querySelector(".slice-rail");
["pointerdown", "pointermove", "pointerup", "pointercancel", "click"].forEach((name) => {
sliceRail.addEventListener(name, (event) => event.stopPropagation());
});
sliceRail.addEventListener("wheel", (event) => event.stopPropagation(), { passive: true });
wrap.addEventListener( wrap.addEventListener(
"wheel", "wheel",
(event) => { (event) => {

View File

@@ -45,8 +45,8 @@
<div class="pane-head"> <div class="pane-head">
<h2>检查列表</h2> <h2>检查列表</h2>
<div class="sort-tools study-head-tools"> <div class="sort-tools study-head-tools">
<button class="sort-btn active" data-study-sort="time">时间 <span class="sort-arrow"></span></button> <button class="sort-btn active" data-study-sort="modified">修改时间 <span class="sort-arrow"></span></button>
<button class="sort-btn" data-study-sort="unannotated">未标注 <span class="sort-arrow"></span></button> <button class="sort-btn" data-study-sort="study_time">检查时间 <span class="sort-arrow"></span></button>
<span id="studyCount">0</span> <span id="studyCount">0</span>
</div> </div>
</div> </div>

View File

@@ -308,13 +308,14 @@ button:disabled {
.sort-btn { .sort-btn {
height: 26px; height: 26px;
min-width: 68px; min-width: 78px;
padding: 0 8px; padding: 0 8px;
border: 1px solid var(--stroke); border: 1px solid var(--stroke);
border-radius: 7px; border-radius: 7px;
background: #0b0f16; background: #0b0f16;
color: var(--muted); color: var(--muted);
font-size: 11px; font-size: 11px;
white-space: nowrap;
} }
.sort-arrow { .sort-arrow {
@@ -501,7 +502,7 @@ button:disabled {
} }
.study-summary-line:has(.study-export-btn) { .study-summary-line:has(.study-export-btn) {
grid-template-columns: minmax(0, 1fr) 28px; grid-template-columns: minmax(0, 1fr) 22px;
} }
.study-summary-line .study-export-btn { .study-summary-line .study-export-btn {
@@ -509,29 +510,28 @@ button:disabled {
} }
.study-export-btn { .study-export-btn {
width: 28px; width: 22px;
min-width: 28px; min-width: 22px;
height: 28px; height: 22px;
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
border: 1px solid var(--stroke); border: 0;
border-radius: 50%; border-radius: 4px;
background: rgba(21, 28, 39, 0.92); background: transparent;
color: #dbe8ff; color: #9fb1cb;
font-size: 11px; font-size: 11px;
font-style: normal; font-style: normal;
font-weight: 800; font-weight: 800;
} }
.study-export-btn:hover { .study-export-btn:hover {
border-color: rgba(25, 212, 194, 0.68);
color: #baf8ee; color: #baf8ee;
background: rgba(25, 212, 194, 0.1); background: transparent;
} }
.study-export-btn svg { .study-export-btn svg {
width: 15px; width: 18px;
height: 15px; height: 18px;
fill: none; fill: none;
stroke: currentColor; stroke: currentColor;
stroke-width: 2.2; stroke-width: 2.2;
@@ -768,7 +768,7 @@ button:disabled {
} }
#dicomImage { #dicomImage {
max-width: calc(100% - 64px); max-width: calc(100% - 82px);
max-height: 100%; max-height: 100%;
object-fit: contain; object-fit: contain;
transform-origin: center center; transform-origin: center center;
@@ -822,7 +822,7 @@ button:disabled {
.ov-right-top { .ov-right-top {
top: 12px; top: 12px;
right: 54px; right: 72px;
text-align: right; text-align: right;
} }
@@ -832,7 +832,7 @@ button:disabled {
} }
.ov-right-bottom { .ov-right-bottom {
right: 54px; right: 72px;
bottom: 12px; bottom: 12px;
text-align: right; text-align: right;
} }
@@ -840,22 +840,25 @@ button:disabled {
.slice-rail { .slice-rail {
position: absolute; position: absolute;
top: 16px; top: 16px;
right: 12px; right: 10px;
bottom: 16px; bottom: 16px;
width: 38px; z-index: 4;
width: 52px;
display: grid; display: grid;
grid-template-rows: 1fr auto; grid-template-rows: 1fr auto;
justify-items: center; justify-items: center;
align-items: center; align-items: center;
padding: 10px 0; padding: 10px 4px;
border: 1px solid rgba(146, 163, 186, 0.24); border: 1px solid rgba(146, 163, 186, 0.24);
border-radius: 8px; border-radius: 8px;
background: rgba(8, 12, 18, 0.86); background: rgba(8, 12, 18, 0.86);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
cursor: ns-resize;
touch-action: none;
} }
#sliceSlider { #sliceSlider {
width: 28px; width: 44px;
height: 100%; height: 100%;
writing-mode: vertical-lr; writing-mode: vertical-lr;
direction: ltr; direction: ltr;
@@ -873,9 +876,9 @@ button:disabled {
} }
#sliceSlider::-webkit-slider-thumb { #sliceSlider::-webkit-slider-thumb {
width: 16px; width: 22px;
height: 16px; height: 22px;
margin-left: -6px; margin-left: -9px;
border: 2px solid #d8fbff; border: 2px solid #d8fbff;
border-radius: 999px; border-radius: 999px;
background: var(--cyan); background: var(--cyan);
@@ -896,8 +899,8 @@ button:disabled {
} }
#sliceSlider::-moz-range-thumb { #sliceSlider::-moz-range-thumb {
width: 14px; width: 20px;
height: 14px; height: 20px;
border: 2px solid #d8fbff; border: 2px solid #d8fbff;
border-radius: 999px; border-radius: 999px;
background: var(--cyan); background: var(--cyan);