Improve study status and sequence loading
This commit is contained in:
@@ -22,10 +22,13 @@ const app = {
|
||||
drag: null,
|
||||
studySortField: "time",
|
||||
studySortDir: "desc",
|
||||
studyFilter: "all",
|
||||
summaryRun: 0,
|
||||
seriesSortField: "time",
|
||||
seriesSortDir: "asc",
|
||||
showOverlay: true,
|
||||
thumbUrls: new Map(),
|
||||
imageCache: new Map(),
|
||||
thumbObserver: null,
|
||||
thumbSeries: new WeakMap(),
|
||||
};
|
||||
@@ -214,6 +217,27 @@ function sortStudies(list) {
|
||||
});
|
||||
}
|
||||
|
||||
function studyStatus(study) {
|
||||
if (study.summary_loading) return "loading";
|
||||
const total = Number(study.series_count || 0);
|
||||
const unannotated = Number(study.unannotated_series || 0);
|
||||
if (total > 0 && unannotated === 0) return "complete";
|
||||
return "incomplete";
|
||||
}
|
||||
|
||||
function studyStatusLabel(status) {
|
||||
return { complete: "完成", incomplete: "待", loading: "同步" }[status] || "待";
|
||||
}
|
||||
|
||||
function visibleStudies() {
|
||||
return sortStudies(app.studies).filter((study) => {
|
||||
const status = studyStatus(study);
|
||||
if (app.studyFilter === "complete") return status === "complete";
|
||||
if (app.studyFilter === "incomplete") return status !== "complete";
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function updateSortButtons() {
|
||||
document.querySelectorAll("[data-study-sort]").forEach((button) => {
|
||||
const active = button.dataset.studySort === app.studySortField;
|
||||
@@ -227,6 +251,9 @@ function updateSortButtons() {
|
||||
const arrow = button.querySelector(".sort-arrow");
|
||||
if (arrow) arrow.textContent = sortArrow(active ? app.seriesSortDir : "asc");
|
||||
});
|
||||
document.querySelectorAll("[data-study-filter]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.studyFilter === app.studyFilter);
|
||||
});
|
||||
}
|
||||
|
||||
function setSeries(list) {
|
||||
@@ -256,24 +283,31 @@ function refreshStudyAnnotationSummary() {
|
||||
}
|
||||
|
||||
async function loadStudies() {
|
||||
app.summaryRun += 1;
|
||||
const q = encodeURIComponent($("studySearch").value.trim());
|
||||
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
|
||||
$("studyCount").textContent = String(app.studies.length);
|
||||
renderStudies();
|
||||
if (!app.study && app.studies.length) selectStudy(app.studies[0].ct_number);
|
||||
if (!app.study && app.studies.length) await selectStudy(app.studies[0].ct_number);
|
||||
refreshStudySummaries();
|
||||
}
|
||||
|
||||
function renderStudies() {
|
||||
const list = $("studyList");
|
||||
app.studies = sortStudies(app.studies);
|
||||
updateSortButtons();
|
||||
const studies = visibleStudies();
|
||||
$("studyCount").textContent = studies.length === app.studies.length ? String(studies.length) : `${studies.length}/${app.studies.length}`;
|
||||
list.innerHTML = "";
|
||||
for (const study of app.studies) {
|
||||
for (const study of studies) {
|
||||
const button = document.createElement("button");
|
||||
const status = studyStatus(study);
|
||||
button.className = "study-card";
|
||||
button.classList.toggle("active", app.study?.ct_number === study.ct_number);
|
||||
button.classList.toggle("complete", status === "complete");
|
||||
button.classList.toggle("loading", status === "loading");
|
||||
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
|
||||
button.innerHTML = `
|
||||
<i class="study-status-badge status-${status}" title="${status === "complete" ? "已处理完毕" : status === "loading" ? "正在同步摘要" : "仍有序列未标注"}">${studyStatusLabel(status)}</i>
|
||||
<strong>${escapeHtml(study.ct_number)}</strong>
|
||||
<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>
|
||||
@@ -284,6 +318,25 @@ function renderStudies() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStudySummaries() {
|
||||
const run = app.summaryRun;
|
||||
for (const study of [...app.studies]) {
|
||||
if (run !== app.summaryRun) return;
|
||||
study.summary_loading = true;
|
||||
renderStudies();
|
||||
try {
|
||||
const summary = await json(`/api/studies/${encodeURIComponent(study.ct_number)}/summary`);
|
||||
if (run !== app.summaryRun) return;
|
||||
Object.assign(study, summary, { summary_loading: false, summary_loaded: true });
|
||||
if (app.study?.ct_number === study.ct_number) Object.assign(app.study, summary);
|
||||
} catch (_) {
|
||||
study.summary_loading = false;
|
||||
study.summary_error = true;
|
||||
}
|
||||
renderStudies();
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -292,7 +345,7 @@ async function selectStudy(ctNumber) {
|
||||
app.draft = null;
|
||||
$("activeStudyLabel").textContent = ctNumber;
|
||||
$("seriesCount").textContent = "读取中";
|
||||
$("seriesGrid").innerHTML = "";
|
||||
renderSeriesLoading();
|
||||
resetViewer();
|
||||
renderStudies();
|
||||
try {
|
||||
@@ -312,6 +365,12 @@ function sourceTag(value, annotation) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function mergeTagSource(...sources) {
|
||||
if (sources.includes("manual")) return "manual";
|
||||
if (sources.includes("ai")) return "ai";
|
||||
return "";
|
||||
}
|
||||
|
||||
function seriesTags(series) {
|
||||
const annotation = series.annotation || {};
|
||||
const tags = [];
|
||||
@@ -321,22 +380,33 @@ function seriesTags(series) {
|
||||
if (annotation.plain_ct) {
|
||||
tags.push({ label: "平扫CT", source: annotation.manual_plain_ct !== null && annotation.manual_plain_ct !== undefined ? "manual" : "ai" });
|
||||
}
|
||||
for (const part of asList(annotation.body_parts)) {
|
||||
tags.push({ label: partLabel(part), source: sourceTag(part, annotation) });
|
||||
}
|
||||
const phase = phaseLabel(annotation.upper_abdomen_phase);
|
||||
if (phase) {
|
||||
const phaseSource = annotation.manual_upper_abdomen_phase ? "manual" : annotation.ai_upper_abdomen_phase ? "ai" : "";
|
||||
const chestWindow = chestWindowLabel(annotation.chest_window);
|
||||
const chestWindowSource = annotation.manual_chest_window ? "manual" : annotation.ai_chest_window ? "ai" : "";
|
||||
for (const part of asList(annotation.body_parts)) {
|
||||
const partSource = sourceTag(part, annotation);
|
||||
if (part === "upper_abdomen" && phase && annotation.upper_abdomen_phase !== "unknown") {
|
||||
tags.push({ label: `${partLabel(part)}-${phase}`, source: mergeTagSource(partSource, phaseSource) });
|
||||
continue;
|
||||
}
|
||||
if (part === "chest" && chestWindow && annotation.chest_window !== "unknown") {
|
||||
tags.push({ label: `${partLabel(part)}-${chestWindow}`, source: mergeTagSource(partSource, chestWindowSource) });
|
||||
continue;
|
||||
}
|
||||
tags.push({ label: partLabel(part), source: partSource });
|
||||
}
|
||||
if (phase && annotation.upper_abdomen_phase === "unknown") {
|
||||
tags.push({
|
||||
label: phase,
|
||||
source: annotation.manual_upper_abdomen_phase ? "manual" : annotation.ai_upper_abdomen_phase ? "ai" : "",
|
||||
source: phaseSource,
|
||||
warn: annotation.upper_abdomen_phase === "unknown",
|
||||
});
|
||||
}
|
||||
const chestWindow = chestWindowLabel(annotation.chest_window);
|
||||
if (chestWindow) {
|
||||
if (chestWindow && annotation.chest_window === "unknown") {
|
||||
tags.push({
|
||||
label: chestWindow,
|
||||
source: annotation.manual_chest_window ? "manual" : annotation.ai_chest_window ? "ai" : "",
|
||||
source: chestWindowSource,
|
||||
warn: annotation.chest_window === "unknown",
|
||||
});
|
||||
}
|
||||
@@ -358,6 +428,7 @@ function renderSeries() {
|
||||
card.innerHTML = `
|
||||
<div class="thumb">
|
||||
<img alt="" />
|
||||
<span class="thumb-state">加载中</span>
|
||||
<b>${Number(series.count || 0)} 张</b>
|
||||
${skipped ? "<i>略过/不采用</i>" : ""}
|
||||
</div>
|
||||
@@ -374,24 +445,71 @@ function renderSeries() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderSeriesLoading() {
|
||||
$("seriesGrid").innerHTML = Array.from({ length: 4 })
|
||||
.map(
|
||||
() => `
|
||||
<div class="series-card loading-card">
|
||||
<div class="thumb"><span class="thumb-state">序列读取中</span></div>
|
||||
<div class="series-copy">
|
||||
<strong>读取中</strong>
|
||||
<span class="shot-time">正在同步序列信息</span>
|
||||
<small>请稍候</small>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function imageUrlFor(series = app.activeSeries, index = app.slice, windowName = app.window, plane = app.plane) {
|
||||
return `/api/image?ct_number=${encodeURIComponent(app.study.ct_number)}&series_uid=${encodeURIComponent(series.series_uid)}&index=${index}&plane=${plane}&window=${windowName}&rotate=0`;
|
||||
}
|
||||
|
||||
function cacheImageUrl(key, url) {
|
||||
app.imageCache.set(key, url);
|
||||
while (app.imageCache.size > 90) {
|
||||
const oldestKey = app.imageCache.keys().next().value;
|
||||
const oldestUrl = app.imageCache.get(oldestKey);
|
||||
URL.revokeObjectURL(oldestUrl);
|
||||
app.imageCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function showImageMessage(message) {
|
||||
const empty = $("imageEmpty");
|
||||
empty.textContent = message;
|
||||
empty.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideImageMessage() {
|
||||
$("imageEmpty").classList.add("hidden");
|
||||
}
|
||||
|
||||
async function loadThumb(series, img) {
|
||||
const thumb = img.closest(".thumb");
|
||||
const state = thumb?.querySelector(".thumb-state");
|
||||
try {
|
||||
const key = thumbKey(series);
|
||||
if (app.thumbUrls.has(key)) {
|
||||
img.src = app.thumbUrls.get(key);
|
||||
thumb?.classList.add("thumb-ready");
|
||||
return;
|
||||
}
|
||||
thumb?.classList.add("thumb-loading");
|
||||
if (state) state.textContent = "加载中";
|
||||
const index = Math.floor((Number(series.count) || 1) / 2);
|
||||
const blob = await request(imageUrlFor(series, index, "soft", "axial")).then((res) => res.blob());
|
||||
const url = URL.createObjectURL(blob);
|
||||
app.thumbUrls.set(key, url);
|
||||
img.src = url;
|
||||
thumb?.classList.remove("thumb-loading");
|
||||
thumb?.classList.add("thumb-ready");
|
||||
} catch (_) {
|
||||
img.removeAttribute("src");
|
||||
thumb?.classList.remove("thumb-loading");
|
||||
thumb?.classList.add("thumb-failed");
|
||||
if (state) state.textContent = "未加载";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +521,7 @@ function queueThumb(series, img) {
|
||||
const key = thumbKey(series);
|
||||
if (app.thumbUrls.has(key)) {
|
||||
img.src = app.thumbUrls.get(key);
|
||||
img.closest(".thumb")?.classList.add("thumb-ready");
|
||||
return;
|
||||
}
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
@@ -448,6 +567,9 @@ async function selectSeries(seriesUid) {
|
||||
if (!series) return;
|
||||
app.activeSeries = series;
|
||||
app.slice = Math.floor(maxSlice() / 2);
|
||||
app.pendingImage = null;
|
||||
$("dicomImage").removeAttribute("src");
|
||||
showImageMessage("图像加载中");
|
||||
resetViewState();
|
||||
$("sliceSlider").max = String(maxSlice());
|
||||
$("sliceSlider").value = String(app.slice);
|
||||
@@ -457,12 +579,11 @@ async function selectSeries(seriesUid) {
|
||||
}
|
||||
|
||||
function resetViewer() {
|
||||
if (app.imageUrl) URL.revokeObjectURL(app.imageUrl);
|
||||
app.imageUrl = "";
|
||||
app.pendingImage = null;
|
||||
app.activeSeries = null;
|
||||
$("dicomImage").removeAttribute("src");
|
||||
$("imageEmpty").classList.remove("hidden");
|
||||
hideImageMessage();
|
||||
$("sliceText").textContent = "0 / 0";
|
||||
$("saveState").textContent = "已保存";
|
||||
clearAnnotationControls();
|
||||
@@ -515,22 +636,49 @@ async function updateImage() {
|
||||
$("sliceSlider").max = String(max);
|
||||
$("sliceSlider").value = String(app.slice);
|
||||
$("sliceText").textContent = `${app.slice + 1} / ${max + 1}`;
|
||||
$("imageEmpty").classList.add("hidden");
|
||||
const url = imageUrlFor();
|
||||
if (app.imageCache.has(url)) {
|
||||
app.imageUrl = app.imageCache.get(url);
|
||||
$("dicomImage").src = app.imageUrl;
|
||||
hideImageMessage();
|
||||
updateOverlay();
|
||||
applyTransform();
|
||||
preloadNeighborImages();
|
||||
return;
|
||||
}
|
||||
showImageMessage("图像加载中");
|
||||
updateOverlay();
|
||||
const ticket = Symbol("image");
|
||||
app.pendingImage = ticket;
|
||||
try {
|
||||
const blob = await request(imageUrlFor()).then((res) => res.blob());
|
||||
const blob = await request(url).then((res) => res.blob());
|
||||
if (app.pendingImage !== ticket) return;
|
||||
if (app.imageUrl) URL.revokeObjectURL(app.imageUrl);
|
||||
app.imageUrl = URL.createObjectURL(blob);
|
||||
cacheImageUrl(url, app.imageUrl);
|
||||
$("dicomImage").src = app.imageUrl;
|
||||
hideImageMessage();
|
||||
applyTransform();
|
||||
preloadNeighborImages();
|
||||
} catch (err) {
|
||||
showImageMessage("图像读取失败");
|
||||
$("saveState").textContent = `图像读取失败:${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function preloadNeighborImages() {
|
||||
if (!app.study || !app.activeSeries) return;
|
||||
const max = maxSlice();
|
||||
const indexes = [app.slice - 2, app.slice - 1, app.slice + 1, app.slice + 2].filter((index) => index >= 0 && index <= max);
|
||||
for (const index of indexes) {
|
||||
const url = imageUrlFor(app.activeSeries, index, app.window, app.plane);
|
||||
if (app.imageCache.has(url)) continue;
|
||||
request(url)
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => cacheImageUrl(url, URL.createObjectURL(blob)))
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function cloneAnnotation(annotation = {}) {
|
||||
return {
|
||||
body_parts: asList(annotation.body_parts),
|
||||
@@ -981,6 +1129,12 @@ function wire() {
|
||||
document.querySelectorAll("[data-study-sort]").forEach((button) => {
|
||||
button.addEventListener("click", () => setStudySort(button.dataset.studySort));
|
||||
});
|
||||
document.querySelectorAll("[data-study-filter]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
app.studyFilter = button.dataset.studyFilter;
|
||||
renderStudies();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll("[data-series-sort]").forEach((button) => {
|
||||
button.addEventListener("click", () => setSeriesSort(button.dataset.seriesSort));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user