Add Docker PACS DICOM web viewer
This commit is contained in:
613
PACS_DICOM处理/数据处理网页端/static/app.js
Normal file
613
PACS_DICOM处理/数据处理网页端/static/app.js
Normal file
@@ -0,0 +1,613 @@
|
||||
const app = {
|
||||
token: localStorage.getItem("pacs_web_token") || "",
|
||||
studies: [],
|
||||
study: null,
|
||||
series: [],
|
||||
activeSeries: null,
|
||||
slice: 0,
|
||||
plane: "axial",
|
||||
window: "default",
|
||||
rotate: 0,
|
||||
zoom: 1,
|
||||
panX: 0,
|
||||
panY: 0,
|
||||
imageUrl: "",
|
||||
searchTimer: null,
|
||||
statusTimer: null,
|
||||
pendingImage: null,
|
||||
drag: null,
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const headers = { ...(options.headers || {}) };
|
||||
if (app.token) headers.Authorization = `Bearer ${app.token}`;
|
||||
if (options.body) headers["Content-Type"] = "application/json";
|
||||
const res = await fetch(path, { ...options, headers });
|
||||
if (res.status === 401) {
|
||||
showLogin(true);
|
||||
throw new Error("unauthorized");
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail = res.statusText;
|
||||
try {
|
||||
const data = await res.json();
|
||||
detail = data.detail || detail;
|
||||
} catch (_) {
|
||||
detail = await res.text();
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function json(path, options = {}) {
|
||||
const res = await request(path, options);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function showLogin(show) {
|
||||
$("loginOverlay").classList.toggle("hidden", !show);
|
||||
}
|
||||
|
||||
function fmtDate(value) {
|
||||
const text = String(value || "");
|
||||
if (text.length !== 8) return text;
|
||||
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
function fmtTime(value) {
|
||||
const digits = String(value || "").replace(/\D/g, "").slice(0, 6);
|
||||
if (digits.length < 6) return value || "";
|
||||
return `${digits.slice(0, 2)}:${digits.slice(2, 4)}:${digits.slice(4, 6)}`;
|
||||
}
|
||||
|
||||
function timeRange(series) {
|
||||
const first = fmtTime(series.first_time || series.series_time || series.study_time);
|
||||
const last = fmtTime(series.last_time);
|
||||
if (first && last && first !== last) return `${first}-${last}`;
|
||||
return first || last || "未记录";
|
||||
}
|
||||
|
||||
function phaseLabel(value) {
|
||||
return { arterial: "动脉期", portal_venous: "门静脉期", unknown: "无法判别" }[value] || "";
|
||||
}
|
||||
|
||||
function partLabel(value) {
|
||||
return {
|
||||
head_neck: "头颈部",
|
||||
chest: "胸部",
|
||||
upper_abdomen: "上腹部",
|
||||
lower_abdomen: "下腹部",
|
||||
pelvis: "盆腔",
|
||||
}[value] || value;
|
||||
}
|
||||
|
||||
async function login(event) {
|
||||
event.preventDefault();
|
||||
$("loginError").textContent = "";
|
||||
try {
|
||||
const data = await json("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username: $("username").value, password: $("password").value }),
|
||||
});
|
||||
app.token = data.token;
|
||||
localStorage.setItem("pacs_web_token", app.token);
|
||||
showLogin(false);
|
||||
await boot();
|
||||
} catch (_) {
|
||||
$("loginError").textContent = "登录失败";
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
app.token = "";
|
||||
localStorage.removeItem("pacs_web_token");
|
||||
showLogin(true);
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const data = await fetch("/api/status").then((res) => res.json());
|
||||
const pill = $("dbStatus");
|
||||
pill.textContent = data.database?.ok ? `数据库 ${data.database.rows ?? ""}` : "数据库异常";
|
||||
pill.title = data.database?.message || "";
|
||||
pill.classList.toggle("offline", !data.database?.ok);
|
||||
pill.classList.toggle("online", Boolean(data.database?.ok));
|
||||
} catch (_) {
|
||||
$("dbStatus").textContent = "数据库异常";
|
||||
$("dbStatus").classList.add("offline");
|
||||
$("dbStatus").classList.remove("online");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStudies() {
|
||||
const q = encodeURIComponent($("studySearch").value.trim());
|
||||
app.studies = 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);
|
||||
}
|
||||
|
||||
function renderStudies() {
|
||||
const list = $("studyList");
|
||||
list.innerHTML = "";
|
||||
for (const study of app.studies) {
|
||||
const button = document.createElement("button");
|
||||
button.className = "study-card";
|
||||
button.classList.toggle("active", app.study?.ct_number === study.ct_number);
|
||||
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
|
||||
button.innerHTML = `
|
||||
<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>
|
||||
<span>${Number(study.annotated_series || 0)} 个序列已标注</span>
|
||||
`;
|
||||
button.onclick = () => selectStudy(study.ct_number);
|
||||
list.appendChild(button);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectStudy(ctNumber) {
|
||||
app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber };
|
||||
app.series = [];
|
||||
app.activeSeries = null;
|
||||
$("activeStudyLabel").textContent = ctNumber;
|
||||
$("seriesCount").textContent = "读取中";
|
||||
$("seriesGrid").innerHTML = "";
|
||||
resetViewer();
|
||||
renderStudies();
|
||||
try {
|
||||
const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`);
|
||||
app.study = data.study;
|
||||
app.series = data.series || [];
|
||||
$("seriesCount").textContent = String(app.series.length);
|
||||
renderSeries();
|
||||
if (app.series.length) selectSeries(app.series[0].series_uid);
|
||||
} catch (err) {
|
||||
$("seriesGrid").innerHTML = `<p class="error-line">${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSeries() {
|
||||
const grid = $("seriesGrid");
|
||||
grid.innerHTML = "";
|
||||
for (const series of app.series) {
|
||||
const skipped = Boolean(series.annotation?.skipped);
|
||||
const parts = series.annotation?.body_parts || [];
|
||||
const tags = skipped
|
||||
? ["略过"]
|
||||
: [series.body_part_dicom, ...parts.map(partLabel), phaseLabel(series.annotation?.upper_abdomen_phase)].filter(Boolean);
|
||||
const card = document.createElement("button");
|
||||
card.className = "series-card";
|
||||
card.classList.toggle("active", app.activeSeries?.series_uid === series.series_uid);
|
||||
card.classList.toggle("skipped", skipped);
|
||||
card.innerHTML = `
|
||||
<div class="thumb">
|
||||
<img alt="" />
|
||||
<b>${Number(series.count || 0)} 张</b>
|
||||
${skipped ? "<i>略过</i>" : ""}
|
||||
</div>
|
||||
<div class="series-copy">
|
||||
<strong>${escapeHtml(series.description || "未命名序列")}</strong>
|
||||
<span class="shot-time">拍摄 ${escapeHtml(timeRange(series))}</span>
|
||||
<small>序列 ${escapeHtml(series.series_number || "-")} · ${escapeHtml(series.rows || "-")}×${escapeHtml(series.columns || "-")} · ${escapeHtml(series.modality || "")}</small>
|
||||
<div class="tag-line">${tags.length ? tags.map((tag) => `<em>${escapeHtml(tag)}</em>`).join("") : "<em>未标注</em>"}</div>
|
||||
</div>
|
||||
`;
|
||||
card.onclick = () => selectSeries(series.series_uid);
|
||||
grid.appendChild(card);
|
||||
loadThumb(series, card.querySelector("img"));
|
||||
}
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
async function loadThumb(series, img) {
|
||||
try {
|
||||
const index = Math.floor((Number(series.count) || 1) / 2);
|
||||
const blob = await request(imageUrlFor(series, index, "soft", "axial")).then((res) => res.blob());
|
||||
img.src = URL.createObjectURL(blob);
|
||||
} catch (_) {
|
||||
img.removeAttribute("src");
|
||||
}
|
||||
}
|
||||
|
||||
function maxSlice() {
|
||||
if (!app.activeSeries) return 0;
|
||||
if (app.plane === "sagittal") return Math.max(0, Number(app.activeSeries.columns || 1) - 1);
|
||||
if (app.plane === "coronal") return Math.max(0, Number(app.activeSeries.rows || 1) - 1);
|
||||
return Math.max(0, Number(app.activeSeries.count || 1) - 1);
|
||||
}
|
||||
|
||||
function resetViewState() {
|
||||
app.rotate = 0;
|
||||
app.zoom = 1;
|
||||
app.panX = 0;
|
||||
app.panY = 0;
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function selectSeries(seriesUid) {
|
||||
const series = app.series.find((item) => item.series_uid === seriesUid);
|
||||
if (!series) return;
|
||||
app.activeSeries = series;
|
||||
app.slice = Math.floor(maxSlice() / 2);
|
||||
resetViewState();
|
||||
$("sliceSlider").max = String(maxSlice());
|
||||
$("sliceSlider").value = String(app.slice);
|
||||
hydrateAnnotation();
|
||||
renderSeries();
|
||||
updateImage();
|
||||
}
|
||||
|
||||
function resetViewer() {
|
||||
if (app.imageUrl) URL.revokeObjectURL(app.imageUrl);
|
||||
app.imageUrl = "";
|
||||
app.pendingImage = null;
|
||||
app.activeSeries = null;
|
||||
$("dicomImage").removeAttribute("src");
|
||||
$("imageEmpty").classList.remove("hidden");
|
||||
$("sliceText").textContent = "0 / 0";
|
||||
$("saveState").textContent = "未保存";
|
||||
resetViewState();
|
||||
}
|
||||
|
||||
function applyTransform() {
|
||||
$("dicomImage").style.transform = `translate(${app.panX}px, ${app.panY}px) scale(${app.zoom}) rotate(${app.rotate}deg)`;
|
||||
}
|
||||
|
||||
async function updateImage() {
|
||||
if (!app.study || !app.activeSeries) return;
|
||||
const max = maxSlice();
|
||||
app.slice = clamp(app.slice, 0, max);
|
||||
$("sliceSlider").max = String(max);
|
||||
$("sliceSlider").value = String(app.slice);
|
||||
$("sliceText").textContent = `${app.slice + 1} / ${max + 1}`;
|
||||
$("imageEmpty").classList.add("hidden");
|
||||
const ticket = Symbol("image");
|
||||
app.pendingImage = ticket;
|
||||
try {
|
||||
const blob = await request(imageUrlFor()).then((res) => res.blob());
|
||||
if (app.pendingImage !== ticket) return;
|
||||
if (app.imageUrl) URL.revokeObjectURL(app.imageUrl);
|
||||
app.imageUrl = URL.createObjectURL(blob);
|
||||
$("dicomImage").src = app.imageUrl;
|
||||
applyTransform();
|
||||
} catch (err) {
|
||||
$("saveState").textContent = `图像读取失败:${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function isSkippedChecked() {
|
||||
return Boolean(document.querySelector('.part-grid input[value="skip"]')?.checked);
|
||||
}
|
||||
|
||||
function selectedParts() {
|
||||
return Array.from(document.querySelectorAll('.part-grid input:checked:not([value="skip"])')).map((input) => input.value);
|
||||
}
|
||||
|
||||
function syncPartState() {
|
||||
const skipped = isSkippedChecked();
|
||||
document.querySelectorAll('.part-grid input:not([value="skip"])').forEach((input) => {
|
||||
input.disabled = skipped;
|
||||
if (skipped) input.checked = false;
|
||||
});
|
||||
if (skipped) {
|
||||
document.querySelectorAll("input[name=phase]").forEach((input) => {
|
||||
input.checked = false;
|
||||
});
|
||||
}
|
||||
const show = !skipped && selectedParts().includes("upper_abdomen");
|
||||
$("phaseBox").classList.toggle("visible", show);
|
||||
}
|
||||
|
||||
function hydrateAnnotation() {
|
||||
const annotation = app.activeSeries?.annotation || {};
|
||||
const parts = new Set(annotation.body_parts || []);
|
||||
document.querySelectorAll(".part-grid input").forEach((input) => {
|
||||
if (input.value === "skip") {
|
||||
input.checked = Boolean(annotation.skipped);
|
||||
return;
|
||||
}
|
||||
input.checked = !annotation.skipped && parts.has(input.value);
|
||||
input.disabled = Boolean(annotation.skipped);
|
||||
});
|
||||
document.querySelectorAll("input[name=phase]").forEach((input) => {
|
||||
input.checked = !annotation.skipped && input.value === (annotation.upper_abdomen_phase || "");
|
||||
});
|
||||
$("annotationNotes").value = annotation.notes || "";
|
||||
syncPartState();
|
||||
}
|
||||
|
||||
async function reloadCurrentStudySeries(keepUid) {
|
||||
const data = await json(`/api/studies/${encodeURIComponent(app.study.ct_number)}/series`);
|
||||
app.study = data.study;
|
||||
app.series = data.series || [];
|
||||
app.activeSeries = app.series.find((item) => item.series_uid === keepUid) || app.series[0] || null;
|
||||
$("seriesCount").textContent = String(app.series.length);
|
||||
renderSeries();
|
||||
hydrateAnnotation();
|
||||
}
|
||||
|
||||
async function saveAnnotation() {
|
||||
if (!app.study || !app.activeSeries) return;
|
||||
const skipped = isSkippedChecked();
|
||||
const parts = skipped ? [] : selectedParts();
|
||||
let phase = skipped ? "" : document.querySelector("input[name=phase]:checked")?.value || "";
|
||||
if (parts.includes("upper_abdomen") && !phase) {
|
||||
$("saveState").textContent = "请选择上腹部期相";
|
||||
return;
|
||||
}
|
||||
if (!parts.includes("upper_abdomen")) phase = "";
|
||||
$("saveState").textContent = "保存中";
|
||||
const uid = app.activeSeries.series_uid;
|
||||
try {
|
||||
await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(uid)}/annotation`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ body_parts: parts, upper_abdomen_phase: phase, skipped, notes: $("annotationNotes").value }),
|
||||
});
|
||||
$("saveState").textContent = "已保存";
|
||||
await reloadCurrentStudySeries(uid);
|
||||
} catch (err) {
|
||||
$("saveState").textContent = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function applyAiSuggestion(data) {
|
||||
const parts = new Set(data.body_parts || []);
|
||||
document.querySelectorAll(".part-grid input").forEach((input) => {
|
||||
if (input.value === "skip") {
|
||||
input.checked = Boolean(data.skipped);
|
||||
return;
|
||||
}
|
||||
input.checked = !data.skipped && parts.has(input.value);
|
||||
});
|
||||
document.querySelectorAll("input[name=phase]").forEach((input) => {
|
||||
input.checked = !data.skipped && input.value === (data.upper_abdomen_phase || "");
|
||||
});
|
||||
if (data.notes) $("annotationNotes").value = data.notes;
|
||||
syncPartState();
|
||||
}
|
||||
|
||||
async function runAI() {
|
||||
if (!app.study || !app.activeSeries) return;
|
||||
const button = $("aiClassify");
|
||||
button.disabled = true;
|
||||
$("saveState").textContent = "AI 识别中";
|
||||
try {
|
||||
const data = await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(app.activeSeries.series_uid)}/ai`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sample_count: 3 }),
|
||||
});
|
||||
applyAiSuggestion(data);
|
||||
$("saveState").textContent = "AI 建议已填入,确认后保存";
|
||||
} catch (err) {
|
||||
$("saveState").textContent = err.message;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openInfo() {
|
||||
if (!app.study || !app.activeSeries) return;
|
||||
const data = await json(`/api/dicom-info?ct_number=${encodeURIComponent(app.study.ct_number)}&series_uid=${encodeURIComponent(app.activeSeries.series_uid)}&index=${app.slice}`);
|
||||
const content = $("infoContent");
|
||||
content.innerHTML = "";
|
||||
for (const [title, fields] of Object.entries(data.fields || {})) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "info-card";
|
||||
section.innerHTML = `<h3>${escapeHtml(title)}</h3>${Object.entries(fields)
|
||||
.map(([key, value]) => `<p class="info-row"><span>${escapeHtml(key)}</span><b>${escapeHtml(value || "-")}</b></p>`)
|
||||
.join("")}`;
|
||||
content.appendChild(section);
|
||||
}
|
||||
$("infoModal").classList.remove("hidden");
|
||||
}
|
||||
|
||||
function settingsTable(users) {
|
||||
return `
|
||||
<table class="settings-table">
|
||||
<thead><tr><th>账号</th><th>角色</th><th>状态</th></tr></thead>
|
||||
<tbody>
|
||||
${(users || [])
|
||||
.map((user) => `<tr><td>${escapeHtml(user.username)}</td><td>${escapeHtml(user.role)}</td><td>${escapeHtml(user.status)}</td></tr>`)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function roleCards(roles) {
|
||||
return (roles || [])
|
||||
.map(
|
||||
(role) => `
|
||||
<div class="role-card">
|
||||
<strong>${escapeHtml(role.name)}</strong>
|
||||
<span>${(role.permissions || []).map(escapeHtml).join(" / ")}</span>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function openSettings() {
|
||||
const [settings, status] = await Promise.all([json("/api/settings"), fetch("/api/status").then((res) => res.json())]);
|
||||
$("settingsContent").innerHTML = `
|
||||
<section class="settings-section">
|
||||
<div class="settings-title"><h3>用户创建</h3><span>当前登录:admin</span></div>
|
||||
<form id="createUserForm" class="settings-form">
|
||||
<input name="username" placeholder="新账号" autocomplete="off" />
|
||||
<input name="password" type="password" placeholder="初始密码" autocomplete="new-password" />
|
||||
<select name="role">
|
||||
<option value="阅片员">阅片员</option>
|
||||
<option value="管理员">管理员</option>
|
||||
<option value="访客">访客</option>
|
||||
</select>
|
||||
<button type="submit">创建</button>
|
||||
</form>
|
||||
${settingsTable(settings.users)}
|
||||
</section>
|
||||
<section class="settings-section">
|
||||
<div class="settings-title"><h3>权限控制</h3><span>按角色控制查看、标注、AI 与系统设置</span></div>
|
||||
<div class="role-grid">${roleCards(settings.roles)}</div>
|
||||
</section>
|
||||
<section class="settings-section split">
|
||||
<div>
|
||||
<div class="settings-title"><h3>AI 设置</h3><span>${settings.ai?.configured ? "已配置" : "未配置"}</span></div>
|
||||
<dl>
|
||||
<dt>供应商</dt><dd>${escapeHtml(settings.ai?.provider || "-")}</dd>
|
||||
<dt>名称</dt><dd>${escapeHtml(settings.ai?.name || "-")}</dd>
|
||||
<dt>模型</dt><dd>${escapeHtml(settings.ai?.model || "-")}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div>
|
||||
<div class="settings-title"><h3>数据库</h3><span>${status.database?.ok ? "已连接" : "异常"}</span></div>
|
||||
<dl>
|
||||
<dt>主机</dt><dd>${escapeHtml(settings.database?.host || "-")}:${escapeHtml(settings.database?.port || "-")}</dd>
|
||||
<dt>库表</dt><dd>${escapeHtml(settings.database?.database || "-")}.${escapeHtml(settings.database?.table || "-")}</dd>
|
||||
<dt>DICOM</dt><dd>${escapeHtml(settings.dicom?.processed_root || "-")}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
$("createUserForm").addEventListener("submit", createUser);
|
||||
$("settingsModal").classList.remove("hidden");
|
||||
}
|
||||
|
||||
async function createUser(event) {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget.querySelector("button");
|
||||
const form = new FormData(event.currentTarget);
|
||||
const payload = {
|
||||
username: String(form.get("username") || "").trim(),
|
||||
password: String(form.get("password") || ""),
|
||||
role: String(form.get("role") || "阅片员"),
|
||||
};
|
||||
if (!payload.username || !payload.password) return;
|
||||
button.disabled = true;
|
||||
button.textContent = "创建中";
|
||||
try {
|
||||
await json("/api/settings/users", { method: "POST", body: JSON.stringify(payload) });
|
||||
await openSettings();
|
||||
} catch (err) {
|
||||
button.textContent = err.message;
|
||||
setTimeout(() => {
|
||||
button.disabled = false;
|
||||
button.textContent = "创建";
|
||||
}, 1800);
|
||||
}
|
||||
}
|
||||
|
||||
function changeZoom(multiplier) {
|
||||
app.zoom = clamp(app.zoom * multiplier, 0.25, 6);
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function wireImageGestures() {
|
||||
const wrap = document.querySelector(".image-wrap");
|
||||
document.querySelector(".slice-rail").addEventListener("pointerdown", (event) => event.stopPropagation());
|
||||
wrap.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
if (!app.activeSeries) return;
|
||||
event.preventDefault();
|
||||
changeZoom(event.deltaY < 0 ? 1.12 : 1 / 1.12);
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
wrap.addEventListener("pointerdown", (event) => {
|
||||
if (!app.activeSeries) return;
|
||||
app.drag = { x: event.clientX, y: event.clientY, panX: app.panX, panY: app.panY };
|
||||
wrap.classList.add("dragging");
|
||||
wrap.setPointerCapture(event.pointerId);
|
||||
});
|
||||
wrap.addEventListener("pointermove", (event) => {
|
||||
if (!app.drag) return;
|
||||
app.panX = app.drag.panX + event.clientX - app.drag.x;
|
||||
app.panY = app.drag.panY + event.clientY - app.drag.y;
|
||||
applyTransform();
|
||||
});
|
||||
["pointerup", "pointercancel", "pointerleave"].forEach((name) => {
|
||||
wrap.addEventListener(name, () => {
|
||||
app.drag = null;
|
||||
wrap.classList.remove("dragging");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wire() {
|
||||
$("loginForm").addEventListener("submit", login);
|
||||
$("logoutBtn").addEventListener("click", logout);
|
||||
$("settingsBtn").addEventListener("click", openSettings);
|
||||
$("infoBtn").addEventListener("click", openInfo);
|
||||
$("aiClassify").addEventListener("click", runAI);
|
||||
$("zoomIn").addEventListener("click", () => changeZoom(1.18));
|
||||
$("zoomOut").addEventListener("click", () => changeZoom(1 / 1.18));
|
||||
$("studySearch").addEventListener("input", () => {
|
||||
clearTimeout(app.searchTimer);
|
||||
app.searchTimer = setTimeout(loadStudies, 250);
|
||||
});
|
||||
$("sliceSlider").addEventListener("input", (event) => {
|
||||
app.slice = Number(event.target.value);
|
||||
updateImage();
|
||||
});
|
||||
document.querySelectorAll("[data-plane]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
app.plane = button.dataset.plane;
|
||||
app.slice = Math.floor(maxSlice() / 2);
|
||||
document.querySelectorAll("[data-plane]").forEach((b) => b.classList.toggle("active", b === button));
|
||||
updateImage();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll("[data-window]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
app.window = button.dataset.window;
|
||||
document.querySelectorAll("[data-window]").forEach((b) => b.classList.toggle("active", b === button));
|
||||
updateImage();
|
||||
});
|
||||
});
|
||||
$("rotateLeft").addEventListener("click", () => {
|
||||
app.rotate = (app.rotate - 90 + 360) % 360;
|
||||
applyTransform();
|
||||
});
|
||||
$("rotateRight").addEventListener("click", () => {
|
||||
app.rotate = (app.rotate + 90) % 360;
|
||||
applyTransform();
|
||||
});
|
||||
$("resetView").addEventListener("click", resetViewState);
|
||||
document.querySelectorAll(".part-grid input").forEach((input) => input.addEventListener("change", syncPartState));
|
||||
$("saveAnnotation").addEventListener("click", saveAnnotation);
|
||||
document.querySelectorAll("[data-close]").forEach((button) => {
|
||||
button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden"));
|
||||
});
|
||||
wireImageGestures();
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
await refreshStatus();
|
||||
await loadStudies();
|
||||
if (!app.statusTimer) app.statusTimer = setInterval(refreshStatus, 15000);
|
||||
}
|
||||
|
||||
wire();
|
||||
refreshStatus();
|
||||
if (app.token) {
|
||||
showLogin(false);
|
||||
boot().catch(() => showLogin(true));
|
||||
} else {
|
||||
showLogin(true);
|
||||
}
|
||||
156
PACS_DICOM处理/数据处理网页端/static/index.html
Normal file
156
PACS_DICOM处理/数据处理网页端/static/index.html
Normal file
@@ -0,0 +1,156 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PACS DICOM Viewer</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="loginOverlay" class="login-overlay">
|
||||
<form id="loginForm" class="login-panel">
|
||||
<div class="brand-mark">PACS</div>
|
||||
<h1>DICOM 阅片标注</h1>
|
||||
<label>
|
||||
<span>账号</span>
|
||||
<input id="username" autocomplete="username" value="admin" />
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input id="password" type="password" autocomplete="current-password" value="123456" />
|
||||
</label>
|
||||
<button type="submit">登录</button>
|
||||
<p id="loginError"></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="product">
|
||||
<div class="logo-dot"></div>
|
||||
<div>
|
||||
<strong>PACS DICOM Viewer</strong>
|
||||
<span id="activeStudyLabel">未选择检查</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<div id="dbStatus" class="status-pill offline">数据库</div>
|
||||
<button id="settingsBtn" class="ghost-btn">设置</button>
|
||||
<button id="logoutBtn" class="dark-btn">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="workspace">
|
||||
<aside class="study-pane">
|
||||
<div class="pane-head">
|
||||
<h2>检查列表</h2>
|
||||
<span id="studyCount">0</span>
|
||||
</div>
|
||||
<input id="studySearch" class="search" placeholder="搜索检查号 / 姓名" />
|
||||
<div id="studyList" class="study-list"></div>
|
||||
</aside>
|
||||
|
||||
<section class="series-pane">
|
||||
<div class="pane-head">
|
||||
<h2>序列</h2>
|
||||
<span id="seriesCount">0</span>
|
||||
</div>
|
||||
<div id="seriesGrid" class="series-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="viewer-pane">
|
||||
<div class="viewer-toolbar">
|
||||
<div class="segmented" data-group="plane">
|
||||
<button data-plane="axial" class="active">横断面</button>
|
||||
<button data-plane="sagittal">矢状面</button>
|
||||
<button data-plane="coronal">冠状面</button>
|
||||
</div>
|
||||
<div class="segmented" data-group="window">
|
||||
<button data-window="default" class="active">默认</button>
|
||||
<button data-window="bone">骨窗</button>
|
||||
<button data-window="soft">软组织</button>
|
||||
<button data-window="contrast">高对比</button>
|
||||
</div>
|
||||
<div class="tool-row">
|
||||
<button id="rotateLeft">↶ 左转</button>
|
||||
<button id="rotateRight">↷ 右转</button>
|
||||
<button id="zoomOut">− 缩小</button>
|
||||
<button id="zoomIn">+ 放大</button>
|
||||
<button id="resetView">↺ 复位</button>
|
||||
<button id="infoBtn">DICOM 信息</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="viewer-stage">
|
||||
<div class="image-wrap">
|
||||
<img id="dicomImage" alt="" />
|
||||
<div id="imageEmpty" class="image-empty"></div>
|
||||
<div class="slice-rail">
|
||||
<input id="sliceSlider" type="range" min="0" max="0" value="0" orient="vertical" />
|
||||
<span id="sliceText">0 / 0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="annotation-panel">
|
||||
<div class="annotation-head">
|
||||
<div>
|
||||
<h2>部位标注</h2>
|
||||
<span id="saveState">未保存</span>
|
||||
</div>
|
||||
<button id="aiClassify" class="ghost-btn ai-btn">AI 识别</button>
|
||||
</div>
|
||||
<div class="part-grid">
|
||||
<label class="skip-option"><input type="checkbox" value="skip" />略过</label>
|
||||
<label><input type="checkbox" value="head_neck" />头颈部</label>
|
||||
<label><input type="checkbox" value="chest" />胸部</label>
|
||||
<label><input type="checkbox" value="upper_abdomen" />上腹部</label>
|
||||
<label><input type="checkbox" value="lower_abdomen" />下腹部</label>
|
||||
<label><input type="checkbox" value="pelvis" />盆腔</label>
|
||||
</div>
|
||||
<div id="phaseBox" class="phase-box">
|
||||
<span>上腹部期相</span>
|
||||
<div class="phase-options">
|
||||
<label><input name="phase" type="radio" value="arterial" />动脉期</label>
|
||||
<label><input name="phase" type="radio" value="portal_venous" />门静脉期</label>
|
||||
<label><input name="phase" type="radio" value="unknown" />无法判别</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="annotation-actions">
|
||||
<input id="annotationNotes" class="note-input" placeholder="备注" />
|
||||
<button id="saveAnnotation" class="primary-btn">保存标注</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="infoModal" class="modal hidden">
|
||||
<div class="modal-card">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<h2>DICOM 详细信息</h2>
|
||||
<span>基础元数据、图像矩阵、空间距离</span>
|
||||
</div>
|
||||
<button class="icon-btn" data-close="infoModal">×</button>
|
||||
</div>
|
||||
<div id="infoContent" class="info-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settingsModal" class="modal hidden">
|
||||
<div class="modal-card settings-card">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<h2>设置</h2>
|
||||
<span>用户、权限、AI 与数据源</span>
|
||||
</div>
|
||||
<button class="icon-btn" data-close="settingsModal">×</button>
|
||||
</div>
|
||||
<div id="settingsContent" class="settings-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
824
PACS_DICOM处理/数据处理网页端/static/styles.css
Normal file
824
PACS_DICOM处理/数据处理网页端/static/styles.css
Normal file
@@ -0,0 +1,824 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #06080c;
|
||||
--panel: #10151d;
|
||||
--panel-2: #151c27;
|
||||
--panel-3: #202b3a;
|
||||
--stroke: #2a3444;
|
||||
--stroke-strong: #3a475c;
|
||||
--muted: #92a3ba;
|
||||
--text: #eff5ff;
|
||||
--blue: #3474f6;
|
||||
--cyan: #19d4c2;
|
||||
--green: #12b981;
|
||||
--amber: #f0b54e;
|
||||
--red: #fb7185;
|
||||
--shadow: 0 18px 58px rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 1280px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(25, 212, 194, 0.05) 0 1px, transparent 1px 100%),
|
||||
linear-gradient(180deg, rgba(52, 116, 246, 0.04) 0 1px, transparent 1px 100%),
|
||||
#06080c;
|
||||
background-size: 42px 42px;
|
||||
color: var(--text);
|
||||
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.66;
|
||||
}
|
||||
|
||||
.login-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(6, 8, 12, 0.86);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.login-overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: 380px;
|
||||
padding: 28px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, rgba(21, 28, 39, 0.98), rgba(8, 11, 16, 0.98));
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: var(--blue);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.login-panel h1 {
|
||||
margin: 18px 0 24px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.login-panel label {
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.login-panel span {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-panel input,
|
||||
.search,
|
||||
.note-input,
|
||||
.settings-form input,
|
||||
.settings-form select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
background: #080c12;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.login-panel input,
|
||||
.search,
|
||||
.note-input,
|
||||
.settings-form input,
|
||||
.settings-form select {
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.login-panel button,
|
||||
.primary-btn {
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: var(--blue);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-panel button,
|
||||
.primary-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#loginError {
|
||||
min-height: 18px;
|
||||
color: var(--red);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 66px 1fr;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 22px;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
background: rgba(8, 11, 17, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.product {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.logo-dot {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--cyan), var(--blue));
|
||||
}
|
||||
|
||||
.product strong {
|
||||
display: block;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.product span {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
min-width: 118px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-pill.online {
|
||||
border-color: rgba(18, 185, 129, 0.35);
|
||||
color: #9af4cf;
|
||||
background: rgba(18, 185, 129, 0.08);
|
||||
}
|
||||
|
||||
.status-pill.offline {
|
||||
border-color: rgba(251, 113, 133, 0.35);
|
||||
color: #fecdd3;
|
||||
background: rgba(251, 113, 133, 0.08);
|
||||
}
|
||||
|
||||
.ghost-btn,
|
||||
.dark-btn,
|
||||
.tool-row button,
|
||||
.segmented button,
|
||||
.icon-btn {
|
||||
height: 34px;
|
||||
padding: 0 13px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.ghost-btn:hover,
|
||||
.dark-btn:hover,
|
||||
.tool-row button:hover,
|
||||
.segmented button:hover {
|
||||
border-color: var(--stroke-strong);
|
||||
background: #1b2532;
|
||||
}
|
||||
|
||||
.dark-btn {
|
||||
background: #070a10;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
height: calc(100vh - 66px);
|
||||
display: grid;
|
||||
grid-template-columns: 300px 450px minmax(650px, 1fr);
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.study-pane,
|
||||
.series-pane,
|
||||
.viewer-pane {
|
||||
min-height: 0;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
background: rgba(16, 21, 29, 0.9);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.study-pane,
|
||||
.series-pane {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pane-head,
|
||||
.annotation-head,
|
||||
.settings-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pane-head h2,
|
||||
.annotation-head h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.pane-head span,
|
||||
.annotation-head span,
|
||||
.settings-title span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.study-list,
|
||||
.series-grid {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.study-card {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: #0b0f16;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.study-card.active {
|
||||
border-color: rgba(52, 116, 246, 0.82);
|
||||
background: linear-gradient(180deg, rgba(52, 116, 246, 0.22), #0b0f16);
|
||||
}
|
||||
|
||||
.study-card strong,
|
||||
.series-card strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.study-card span {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.series-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.series-card {
|
||||
width: 100%;
|
||||
min-height: 156px;
|
||||
display: grid;
|
||||
grid-template-columns: 142px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: #0b0f16;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.series-card.active {
|
||||
border-color: rgba(25, 212, 194, 0.72);
|
||||
background: #0d141c;
|
||||
}
|
||||
|
||||
.series-card.skipped {
|
||||
border-color: rgba(240, 181, 78, 0.36);
|
||||
background: linear-gradient(180deg, rgba(240, 181, 78, 0.1), #0b0f16 42%);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: relative;
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
align-self: start;
|
||||
overflow: hidden;
|
||||
border: 1px solid #1d2734;
|
||||
border-radius: 6px;
|
||||
background:
|
||||
linear-gradient(45deg, rgba(255, 255, 255, 0.025) 25%, transparent 25% 75%, rgba(255, 255, 255, 0.025) 75%),
|
||||
#020306;
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.thumb b,
|
||||
.thumb i {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.74);
|
||||
color: #f8fafc;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.thumb b {
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
.thumb i {
|
||||
top: 8px;
|
||||
background: rgba(240, 181, 78, 0.86);
|
||||
color: #1b1305;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.series-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.series-copy strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shot-time,
|
||||
.series-copy small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tag-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag-line em {
|
||||
max-width: 100%;
|
||||
padding: 3px 7px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(146, 163, 186, 0.18);
|
||||
border-radius: 999px;
|
||||
color: #c8d6ea;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewer-pane {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewer-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
background: rgba(8, 11, 17, 0.86);
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 9px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.segmented button.active {
|
||||
border-color: var(--blue);
|
||||
background: var(--blue);
|
||||
}
|
||||
|
||||
.tool-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.viewer-stage {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(360px, 1fr) auto;
|
||||
}
|
||||
|
||||
.image-wrap {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.image-wrap.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
#dicomImage {
|
||||
max-width: calc(100% - 64px);
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
transform-origin: center center;
|
||||
will-change: transform;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.image-empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.slice-rail {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 12px;
|
||||
bottom: 16px;
|
||||
width: 38px;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border: 1px solid rgba(146, 163, 186, 0.24);
|
||||
border-radius: 8px;
|
||||
background: rgba(8, 12, 18, 0.86);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
#sliceSlider {
|
||||
width: 28px;
|
||||
height: 100%;
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl;
|
||||
accent-color: var(--cyan);
|
||||
}
|
||||
|
||||
#sliceText {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
writing-mode: vertical-rl;
|
||||
}
|
||||
|
||||
.annotation-panel {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--stroke);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.annotation-head {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ai-btn {
|
||||
min-width: 86px;
|
||||
color: #baf8ee;
|
||||
}
|
||||
|
||||
.part-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(82px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.part-grid label,
|
||||
.phase-options label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
background: #0b0f16;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.part-grid label:has(input:checked),
|
||||
.phase-options label:has(input:checked) {
|
||||
border-color: rgba(25, 212, 194, 0.58);
|
||||
background: rgba(25, 212, 194, 0.1);
|
||||
}
|
||||
|
||||
.part-grid .skip-option:has(input:checked) {
|
||||
border-color: rgba(240, 181, 78, 0.72);
|
||||
background: rgba(240, 181, 78, 0.12);
|
||||
}
|
||||
|
||||
.part-grid input:disabled + * {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.phase-box {
|
||||
display: none;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.phase-box.visible {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.phase-box > span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.phase-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(110px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.annotation-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) 160px;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.note-input {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 12;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(6, 8, 12, 0.68);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
width: min(900px, 86vw);
|
||||
max-height: 82vh;
|
||||
overflow: auto;
|
||||
border: 1px solid #d7e1ef;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #132033;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
width: min(980px, 90vw);
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 24px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.modal-head h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.modal-head span {
|
||||
color: #7890ad;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
background: #eef2f7;
|
||||
color: #18304f;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.info-card,
|
||||
.settings-section {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.info-card h3,
|
||||
.settings-title h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 5px 0;
|
||||
color: #7b8da7;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-row b {
|
||||
color: #14233a;
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.settings-section.split {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 140px 88px;
|
||||
gap: 8px;
|
||||
margin: 10px 0 14px;
|
||||
}
|
||||
|
||||
.settings-form input,
|
||||
.settings-form select {
|
||||
border-color: #cdd8e7;
|
||||
background: white;
|
||||
color: #132033;
|
||||
}
|
||||
|
||||
.settings-form button {
|
||||
border-radius: 8px;
|
||||
background: #1d5ff0;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-table th,
|
||||
.settings-table td {
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid #e5edf7;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-table th {
|
||||
color: #6f819a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.role-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
min-height: 78px;
|
||||
padding: 12px;
|
||||
border: 1px solid #dce5f1;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.role-card strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.role-card span,
|
||||
.settings-section dd,
|
||||
.settings-section dt {
|
||||
color: #6f819a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-section dl {
|
||||
display: grid;
|
||||
grid-template-columns: 70px minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.settings-section dt,
|
||||
.settings-section dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-section dd {
|
||||
color: #17263d;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.error-line {
|
||||
color: var(--red);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-3);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
Reference in New Issue
Block a user