Add compact study and series sorting
This commit is contained in:
@@ -36,8 +36,8 @@ uvicorn app:app --host 127.0.0.1 --port 8107
|
|||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- 左侧检查列表:来自 PostgreSQL `pacs_dicom_files`。
|
- 左侧检查列表:来自 PostgreSQL `pacs_dicom_files`,支持按检查时间或未标注序列数升降序排序。
|
||||||
- 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息。
|
- 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息,支持按时间或张数升降序排序。
|
||||||
- 右侧查看器:支持轴位原始、矢状位重建、冠状位重建,窗宽窗位、旋转、切片进度条。
|
- 右侧查看器:支持轴位原始、矢状位重建、冠状位重建,窗宽窗位、旋转、切片进度条。
|
||||||
- 影像操作:支持鼠标滚轮缩放、拖拽平移、按钮缩放和复位。
|
- 影像操作:支持鼠标滚轮缩放、拖拽平移、按钮缩放和复位。
|
||||||
- 图像叠层:可显示/隐藏患者、检查、张数、窗宽窗位和缩放信息。
|
- 图像叠层:可显示/隐藏患者、检查、张数、窗宽窗位和缩放信息。
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ const app = {
|
|||||||
saving: false,
|
saving: false,
|
||||||
pendingImage: null,
|
pendingImage: null,
|
||||||
drag: null,
|
drag: null,
|
||||||
seriesSort: "timeAsc",
|
studySortField: "time",
|
||||||
|
studySortDir: "desc",
|
||||||
|
seriesSortField: "time",
|
||||||
|
seriesSortDir: "asc",
|
||||||
showOverlay: true,
|
showOverlay: true,
|
||||||
thumbUrls: new Map(),
|
thumbUrls: new Map(),
|
||||||
thumbObserver: null,
|
thumbObserver: null,
|
||||||
@@ -99,6 +102,16 @@ function timeKey(series) {
|
|||||||
return String(series.series_time || series.first_time || series.study_time || "").replace(/\D/g, "").padEnd(6, "0");
|
return String(series.series_time || series.first_time || series.study_time || "").replace(/\D/g, "").padEnd(6, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function studyTimeKey(study) {
|
||||||
|
const date = String(study.study_date || "").replace(/\D/g, "").padEnd(8, "0");
|
||||||
|
const time = String(study.study_time || "").replace(/\D/g, "").slice(0, 6).padEnd(6, "0");
|
||||||
|
return `${date}${time}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortArrow(direction) {
|
||||||
|
return direction === "asc" ? "↑" : "↓";
|
||||||
|
}
|
||||||
|
|
||||||
function phaseLabel(value) {
|
function phaseLabel(value) {
|
||||||
return { arterial: "动脉期", portal_venous: "门静脉期", delayed: "延迟期", unknown: "无法判别" }[value] || "";
|
return { arterial: "动脉期", portal_venous: "门静脉期", delayed: "延迟期", unknown: "无法判别" }[value] || "";
|
||||||
}
|
}
|
||||||
@@ -185,10 +198,34 @@ function sortSeries(list) {
|
|||||||
if (skipA !== skipB) return skipA - skipB;
|
if (skipA !== skipB) return skipA - skipB;
|
||||||
const timeResult = timeKey(a).localeCompare(timeKey(b)) || String(a.series_number || "").localeCompare(String(b.series_number || ""), undefined, { numeric: true });
|
const timeResult = timeKey(a).localeCompare(timeKey(b)) || String(a.series_number || "").localeCompare(String(b.series_number || ""), undefined, { numeric: true });
|
||||||
const countResult = Number(a.count || 0) - Number(b.count || 0) || timeResult;
|
const countResult = Number(a.count || 0) - Number(b.count || 0) || timeResult;
|
||||||
if (app.seriesSort === "timeDesc") return -timeResult;
|
const result = app.seriesSortField === "count" ? countResult : timeResult;
|
||||||
if (app.seriesSort === "countAsc") return countResult;
|
return app.seriesSortDir === "desc" ? -result : result;
|
||||||
if (app.seriesSort === "countDesc") return -countResult;
|
});
|
||||||
return timeResult;
|
}
|
||||||
|
|
||||||
|
function sortStudies(list) {
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
const timeResult =
|
||||||
|
studyTimeKey(a).localeCompare(studyTimeKey(b)) ||
|
||||||
|
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 result = app.studySortField === "unannotated" ? unannotatedResult : timeResult;
|
||||||
|
return app.studySortDir === "desc" ? -result : result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSortButtons() {
|
||||||
|
document.querySelectorAll("[data-study-sort]").forEach((button) => {
|
||||||
|
const active = button.dataset.studySort === app.studySortField;
|
||||||
|
button.classList.toggle("active", active);
|
||||||
|
const arrow = button.querySelector(".sort-arrow");
|
||||||
|
if (arrow) arrow.textContent = sortArrow(active ? app.studySortDir : "desc");
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-series-sort]").forEach((button) => {
|
||||||
|
const active = button.dataset.seriesSort === app.seriesSortField;
|
||||||
|
button.classList.toggle("active", active);
|
||||||
|
const arrow = button.querySelector(".sort-arrow");
|
||||||
|
if (arrow) arrow.textContent = sortArrow(active ? app.seriesSortDir : "asc");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +233,7 @@ function setSeries(list) {
|
|||||||
app.series = sortSeries(list || []);
|
app.series = sortSeries(list || []);
|
||||||
$("seriesCount").textContent = String(app.series.length);
|
$("seriesCount").textContent = String(app.series.length);
|
||||||
renderSeries();
|
renderSeries();
|
||||||
|
updateSortButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSeriesAnnotated(series) {
|
function isSeriesAnnotated(series) {
|
||||||
@@ -219,7 +257,7 @@ function refreshStudyAnnotationSummary() {
|
|||||||
|
|
||||||
async function loadStudies() {
|
async function loadStudies() {
|
||||||
const q = encodeURIComponent($("studySearch").value.trim());
|
const q = encodeURIComponent($("studySearch").value.trim());
|
||||||
app.studies = await json(`/api/studies?q=${q}&limit=500`);
|
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
|
||||||
$("studyCount").textContent = String(app.studies.length);
|
$("studyCount").textContent = String(app.studies.length);
|
||||||
renderStudies();
|
renderStudies();
|
||||||
if (!app.study && app.studies.length) selectStudy(app.studies[0].ct_number);
|
if (!app.study && app.studies.length) selectStudy(app.studies[0].ct_number);
|
||||||
@@ -227,6 +265,8 @@ async function loadStudies() {
|
|||||||
|
|
||||||
function renderStudies() {
|
function renderStudies() {
|
||||||
const list = $("studyList");
|
const list = $("studyList");
|
||||||
|
app.studies = sortStudies(app.studies);
|
||||||
|
updateSortButtons();
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
for (const study of app.studies) {
|
for (const study of app.studies) {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
@@ -901,16 +941,29 @@ function wireImageGestures() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSort(direction) {
|
function setStudySort(field) {
|
||||||
app.seriesSort = direction;
|
if (app.studySortField === field) {
|
||||||
document.querySelectorAll("[data-sort]").forEach((button) => {
|
app.studySortDir = app.studySortDir === "asc" ? "desc" : "asc";
|
||||||
button.classList.toggle("active", button.dataset.sort === direction);
|
} else {
|
||||||
});
|
app.studySortField = field;
|
||||||
|
app.studySortDir = "desc";
|
||||||
|
}
|
||||||
|
renderStudies();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSeriesSort(field) {
|
||||||
|
if (app.seriesSortField === field) {
|
||||||
|
app.seriesSortDir = app.seriesSortDir === "asc" ? "desc" : "asc";
|
||||||
|
} else {
|
||||||
|
app.seriesSortField = field;
|
||||||
|
app.seriesSortDir = field === "count" ? "desc" : "asc";
|
||||||
|
}
|
||||||
app.series = sortSeries(app.series);
|
app.series = sortSeries(app.series);
|
||||||
renderSeries();
|
renderSeries();
|
||||||
if (app.activeSeries) {
|
if (app.activeSeries) {
|
||||||
app.activeSeries = app.series.find((item) => item.series_uid === app.activeSeries.series_uid) || app.activeSeries;
|
app.activeSeries = app.series.find((item) => item.series_uid === app.activeSeries.series_uid) || app.activeSeries;
|
||||||
}
|
}
|
||||||
|
updateSortButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function wire() {
|
function wire() {
|
||||||
@@ -925,8 +978,11 @@ function wire() {
|
|||||||
app.showOverlay = !app.showOverlay;
|
app.showOverlay = !app.showOverlay;
|
||||||
updateOverlay();
|
updateOverlay();
|
||||||
});
|
});
|
||||||
document.querySelectorAll("[data-sort]").forEach((button) => {
|
document.querySelectorAll("[data-study-sort]").forEach((button) => {
|
||||||
button.addEventListener("click", () => setSort(button.dataset.sort));
|
button.addEventListener("click", () => setStudySort(button.dataset.studySort));
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-series-sort]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => setSeriesSort(button.dataset.seriesSort));
|
||||||
});
|
});
|
||||||
$("studySearch").addEventListener("input", () => {
|
$("studySearch").addEventListener("input", () => {
|
||||||
clearTimeout(app.searchTimer);
|
clearTimeout(app.searchTimer);
|
||||||
|
|||||||
@@ -44,8 +44,12 @@
|
|||||||
<aside class="study-pane">
|
<aside class="study-pane">
|
||||||
<div class="pane-head">
|
<div class="pane-head">
|
||||||
<h2>检查列表</h2>
|
<h2>检查列表</h2>
|
||||||
|
<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" data-study-sort="unannotated">未标注 <span class="sort-arrow">↓</span></button>
|
||||||
<span id="studyCount">0</span>
|
<span id="studyCount">0</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<input id="studySearch" class="search" placeholder="搜索检查号 / 姓名" />
|
<input id="studySearch" class="search" placeholder="搜索检查号 / 姓名" />
|
||||||
<div id="studyList" class="study-list"></div>
|
<div id="studyList" class="study-list"></div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -53,11 +57,9 @@
|
|||||||
<section class="series-pane">
|
<section class="series-pane">
|
||||||
<div class="pane-head">
|
<div class="pane-head">
|
||||||
<h2>序列</h2>
|
<h2>序列</h2>
|
||||||
<div class="series-head-tools">
|
<div class="sort-tools series-head-tools">
|
||||||
<button id="sortTimeAsc" class="sort-btn active" data-sort="timeAsc">时间 ↑</button>
|
<button class="sort-btn active" data-series-sort="time">时间 <span class="sort-arrow">↑</span></button>
|
||||||
<button id="sortTimeDesc" class="sort-btn" data-sort="timeDesc">时间 ↓</button>
|
<button class="sort-btn" data-series-sort="count">张数 <span class="sort-arrow">↑</span></button>
|
||||||
<button id="sortCountAsc" class="sort-btn" data-sort="countAsc">张数 ↑</button>
|
|
||||||
<button id="sortCountDesc" class="sort-btn" data-sort="countDesc">张数 ↓</button>
|
|
||||||
<span id="seriesCount">0</span>
|
<span id="seriesCount">0</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -284,19 +284,22 @@ button:disabled {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.series-head-tools {
|
.sort-tools {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-head-tools {
|
||||||
max-width: 330px;
|
max-width: 330px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sort-btn {
|
.sort-btn {
|
||||||
height: 26px;
|
height: 26px;
|
||||||
min-width: 56px;
|
min-width: 68px;
|
||||||
padding: 0 7px;
|
padding: 0 8px;
|
||||||
border: 1px solid var(--stroke);
|
border: 1px solid var(--stroke);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: #0b0f16;
|
background: #0b0f16;
|
||||||
@@ -304,6 +307,15 @@ button:disabled {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sort-arrow {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 10px;
|
||||||
|
margin-left: 2px;
|
||||||
|
color: #d7e4fa;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.sort-btn.active {
|
.sort-btn.active {
|
||||||
border-color: var(--blue);
|
border-color: var(--blue);
|
||||||
background: rgba(52, 116, 246, 0.2);
|
background: rgba(52, 116, 246, 0.2);
|
||||||
|
|||||||
Reference in New Issue
Block a user