Refine study controls and slice rail
This commit is contained in:
@@ -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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user