Refine DICOM UPP registration workstation layout
This commit is contained in:
@@ -44,6 +44,8 @@ const state = {
|
||||
statusFilter: "",
|
||||
partFilter: "",
|
||||
search: "",
|
||||
caseCollapsed: false,
|
||||
activeToolTab: "series",
|
||||
cases: [],
|
||||
activeCase: null,
|
||||
series: [],
|
||||
@@ -54,7 +56,10 @@ const state = {
|
||||
registrationStatus: "unregistered",
|
||||
pose: { ...DEFAULT_POSE },
|
||||
windowMode: "default",
|
||||
fusionMode: "fusion",
|
||||
sliceIndex: 0,
|
||||
dicomPreviewTimer: 0,
|
||||
autoResult: null,
|
||||
dirty: false,
|
||||
saving: false,
|
||||
fusionVersion: 0,
|
||||
@@ -68,7 +73,9 @@ const state = {
|
||||
modelGroup: null,
|
||||
rawModelGroup: null,
|
||||
baseModelScale: 1,
|
||||
dicomSceneBox: null,
|
||||
resizeObserver: null,
|
||||
resizeScene: null,
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
@@ -98,6 +105,13 @@ function setSaveState(text, tone = "") {
|
||||
el.className = tone;
|
||||
}
|
||||
|
||||
function setAutoState(text, tone = "") {
|
||||
const el = $("autoState");
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.className = tone;
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
state.dirty = true;
|
||||
setSaveState("未保存", "warn");
|
||||
@@ -298,9 +312,16 @@ function clearDetail() {
|
||||
$("activeTitle").textContent = "未选择 CT";
|
||||
$("activeMeta").textContent = "从左侧选择一个完整关联检查";
|
||||
$("activeTags").innerHTML = "";
|
||||
$("patientSummary").textContent = "未选择 CT";
|
||||
$("summaryCt").textContent = "-";
|
||||
$("summaryPatientId").textContent = "-";
|
||||
$("summaryStudyTime").textContent = "-";
|
||||
$("summaryBodyPart").textContent = "-";
|
||||
$("matchedSeries").innerHTML = "";
|
||||
$("seriesList").innerHTML = `<div class="empty-state">未选择 CT</div>`;
|
||||
$("stlList").innerHTML = `<div class="empty-state">未选择 STL</div>`;
|
||||
$("modelRail").innerHTML = "";
|
||||
renderDicomAnnotation();
|
||||
clearFusion();
|
||||
}
|
||||
|
||||
@@ -327,6 +348,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
|
||||
state.stlFiles = stlPayload.files || [];
|
||||
state.registrationStatus = registration.registration_status || "unregistered";
|
||||
state.pose = { ...DEFAULT_POSE, ...(registration.transform || {}) };
|
||||
state.autoResult = null;
|
||||
state.sliceIndex = 0;
|
||||
$("notes").value = registration.notes || "";
|
||||
|
||||
@@ -389,34 +411,43 @@ function renderActiveHeader() {
|
||||
$("activeTags").innerHTML = tags.join("");
|
||||
$("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准";
|
||||
$("statusBtn").classList.toggle("unregistered", state.registrationStatus !== "registered");
|
||||
$("patientSummary").textContent = `${row.patient_name || row.upp_patient_name || "-"} ${row.patient_sex || ""}`.trim();
|
||||
$("summaryCt").textContent = row.ct_number || "-";
|
||||
$("summaryPatientId").textContent = row.patient_id || "-";
|
||||
$("summaryStudyTime").textContent = fmtDateTime(row.study_date, row.study_time) || "-";
|
||||
$("summaryBodyPart").textContent = bodyPartTags(row).join("、") || row.study_description || "-";
|
||||
}
|
||||
|
||||
function renderSeries() {
|
||||
$("seriesCount").textContent = state.series.length;
|
||||
const activeSeries = currentSeries();
|
||||
$("matchedSeries").innerHTML = activeSeries ? seriesCardHtml(activeSeries, true) : `<div class="empty-state">尚未选择配准序列</div>`;
|
||||
$("seriesList").innerHTML = state.series.length
|
||||
? state.series
|
||||
.map((item) => {
|
||||
const active = item.series_uid === state.selectedSeriesUid;
|
||||
.map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid))
|
||||
.join("")
|
||||
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
|
||||
document.querySelectorAll(".series-card[data-series]").forEach((button) => {
|
||||
button.addEventListener("click", () => selectSeries(button.dataset.series));
|
||||
});
|
||||
updateSliceControl();
|
||||
renderDicomAnnotation();
|
||||
}
|
||||
|
||||
function seriesCardHtml(item, active = false) {
|
||||
const labels = (item.annotation_labels || []).map((label) => `<i>${escapeHtml(label)}</i>`).join("");
|
||||
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
|
||||
return `
|
||||
<button class="series-card ${active ? "active" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
|
||||
<strong>${escapeHtml(item.description || "未命名序列")}</strong>
|
||||
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span>
|
||||
<small>序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small>
|
||||
<small>${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small>
|
||||
<div class="mini-tags">
|
||||
${item.body_part_dicom ? `<i>${escapeHtml(item.body_part_dicom)}</i>` : ""}
|
||||
${labels || `<i>未标注</i>`}
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
})
|
||||
.join("")
|
||||
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
|
||||
$("seriesList").querySelectorAll(".series-card").forEach((button) => {
|
||||
button.addEventListener("click", () => selectSeries(button.dataset.series));
|
||||
});
|
||||
updateSliceControl();
|
||||
}
|
||||
|
||||
async function selectSeries(uid) {
|
||||
@@ -427,6 +458,7 @@ async function selectSeries(uid) {
|
||||
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
|
||||
markDirty();
|
||||
renderSeries();
|
||||
renderDicomAnnotation();
|
||||
await loadFusion();
|
||||
}
|
||||
|
||||
@@ -479,8 +511,11 @@ function buildPoseControls() {
|
||||
(item) => `
|
||||
<div class="pose-control" data-pose="${item.key}">
|
||||
<label>${item.label}</label>
|
||||
<button type="button" data-nudge="${item.key}" data-delta="${-item.step}">-</button>
|
||||
<input type="range" min="${item.min}" max="${item.max}" step="${item.step}" />
|
||||
<button type="button" data-nudge="${item.key}" data-delta="${item.step}">+</button>
|
||||
<input type="number" min="${item.min}" max="${item.max}" step="${item.step}" />
|
||||
${item.key.startsWith("rotate") ? `<div class="quick-row"><button type="button" data-nudge="${item.key}" data-delta="-90">-90°</button><button type="button" data-nudge="${item.key}" data-delta="90">+90°</button></div>` : ""}
|
||||
</div>
|
||||
`,
|
||||
).join("");
|
||||
@@ -499,6 +534,9 @@ function buildPoseControls() {
|
||||
range.addEventListener("input", () => sync(range.value, range));
|
||||
number.addEventListener("input", () => sync(number.value, number));
|
||||
});
|
||||
document.querySelectorAll("[data-nudge]").forEach((button) => {
|
||||
button.addEventListener("click", () => nudgePose(button.dataset.nudge, Number(button.dataset.delta || 0)));
|
||||
});
|
||||
document.querySelectorAll("[data-flip]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const key = button.dataset.flip;
|
||||
@@ -510,6 +548,17 @@ function buildPoseControls() {
|
||||
});
|
||||
}
|
||||
|
||||
function nudgePose(key, delta) {
|
||||
if (!key || !Number.isFinite(delta)) return;
|
||||
const option = POSE_CONTROLS.find((item) => item.key === key);
|
||||
const current = Number(state.pose[key] ?? DEFAULT_POSE[key]);
|
||||
const next = Math.max(option?.min ?? -Infinity, Math.min(option?.max ?? Infinity, current + delta));
|
||||
state.pose[key] = Number(next.toFixed(3));
|
||||
renderPoseControls();
|
||||
applyPose();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function renderPoseControls() {
|
||||
POSE_CONTROLS.forEach((item) => {
|
||||
const row = $(`poseGrid`).querySelector(`[data-pose="${item.key}"]`);
|
||||
@@ -532,6 +581,46 @@ function updateSliceControl() {
|
||||
$("sliceLabel").textContent = count ? `切片 ${Number($("sliceSlider").value) + 1} / ${count}` : "切片 0 / 0";
|
||||
}
|
||||
|
||||
function renderDicomAnnotation() {
|
||||
const row = state.activeCase;
|
||||
const selected = currentSeries();
|
||||
const count = Number(selected?.count || 0);
|
||||
$("dicomPanelMeta").textContent = selected ? `${selected.description || "未命名序列"} · ${count} 张` : "当前序列切片";
|
||||
$("dicomInfoLeft").textContent = row && selected
|
||||
? `${row.patient_name || row.upp_patient_name || "-"}\n${row.patient_id || "-"}\n${fmtDateTime(row.study_date, row.study_time) || "-"}\n${selected.description || "-"}`
|
||||
: "未选择序列";
|
||||
$("dicomInfoRight").textContent = selected
|
||||
? `${selected.modality || "CT"}\n${selected.rows || "-"}×${selected.columns || "-"}\n${selected.slice_thickness || "厚度 -"}`
|
||||
: "";
|
||||
$("dicomInfoBottom").textContent = count ? `Img:${state.sliceIndex + 1}/${count}` : "Img:-";
|
||||
const tags = selected?.annotation_labels?.length ? selected.annotation_labels : bodyPartTags(row || {});
|
||||
$("annotationTags").innerHTML = tags.length
|
||||
? tags.map((tag) => `<span>${escapeHtml(tag)}</span>`).join("")
|
||||
: `<span>未标注</span>`;
|
||||
}
|
||||
|
||||
function updateDicomPreview() {
|
||||
const selected = currentSeries();
|
||||
if (!state.activeCase || !selected) {
|
||||
$("dicomPreview").removeAttribute("src");
|
||||
renderDicomAnnotation();
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(state.dicomPreviewTimer);
|
||||
state.dicomPreviewTimer = window.setTimeout(() => {
|
||||
const params = new URLSearchParams({
|
||||
ct_number: state.activeCase.ct_number,
|
||||
series_uid: selected.series_uid,
|
||||
index: String(state.sliceIndex),
|
||||
window: state.windowMode,
|
||||
access_token: state.token,
|
||||
});
|
||||
$("dicomLoading").classList.remove("hidden");
|
||||
$("dicomPreview").src = `/api/dicom/image?${params.toString()}`;
|
||||
renderDicomAnnotation();
|
||||
}, 80);
|
||||
}
|
||||
|
||||
function initScene() {
|
||||
if (state.sceneReady) return;
|
||||
const viewport = $("fusionViewport");
|
||||
@@ -570,6 +659,7 @@ function initScene() {
|
||||
camera.aspect = rect.width / rect.height;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
state.resizeScene = resize;
|
||||
state.resizeObserver = new ResizeObserver(resize);
|
||||
state.resizeObserver.observe(viewport);
|
||||
resize();
|
||||
@@ -606,6 +696,14 @@ function clearFusion() {
|
||||
updateSliceControl();
|
||||
}
|
||||
|
||||
function applyFusionMode() {
|
||||
if (state.dicomGroup) state.dicomGroup.visible = state.fusionMode !== "model";
|
||||
if (state.modelGroup) state.modelGroup.visible = true;
|
||||
$("fusionMeta").textContent = state.fusionMode === "model"
|
||||
? `单独模型 · STL ${state.selectedStlIds.size} 个`
|
||||
: $("fusionMeta").textContent;
|
||||
}
|
||||
|
||||
async function loadFusion() {
|
||||
const version = ++state.fusionVersion;
|
||||
const selected = currentSeries();
|
||||
@@ -634,9 +732,12 @@ async function loadFusion() {
|
||||
if (version !== state.fusionVersion) return;
|
||||
await buildStlModels(volume);
|
||||
if (version !== state.fusionVersion) return;
|
||||
applyFusionMode();
|
||||
fitCamera();
|
||||
$("fusionStatus").textContent = `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)} 张`;
|
||||
$("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size} 个`;
|
||||
renderDicomAnnotation();
|
||||
updateDicomPreview();
|
||||
} catch (error) {
|
||||
clearFusion();
|
||||
$("fusionStatus").textContent = error.message;
|
||||
@@ -682,6 +783,7 @@ async function buildDicomVolume(volume) {
|
||||
const lines = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x1cd9c7, transparent: true, opacity: 0.42 }));
|
||||
state.dicomGroup.add(lines);
|
||||
state.baseModelScale = sceneScale;
|
||||
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup);
|
||||
}
|
||||
|
||||
async function buildStlModels(volume) {
|
||||
@@ -723,9 +825,9 @@ async function buildStlModels(volume) {
|
||||
applyPose();
|
||||
}
|
||||
|
||||
function applyPose() {
|
||||
function applyPose(poseInput = state.pose) {
|
||||
if (!state.modelGroup) return;
|
||||
const pose = { ...DEFAULT_POSE, ...state.pose };
|
||||
const pose = { ...DEFAULT_POSE, ...poseInput };
|
||||
state.modelGroup.rotation.set(
|
||||
THREE.MathUtils.degToRad(Number(pose.rotateX) || 0),
|
||||
THREE.MathUtils.degToRad(Number(pose.rotateY) || 0),
|
||||
@@ -751,6 +853,120 @@ function fitCamera() {
|
||||
state.controls.update();
|
||||
}
|
||||
|
||||
function resetPose(kind) {
|
||||
if (kind === "rotation") {
|
||||
state.pose = { ...state.pose, rotateX: 0, rotateY: 0, rotateZ: 0 };
|
||||
} else if (kind === "transform") {
|
||||
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1 };
|
||||
} else if (kind === "flip") {
|
||||
state.pose = { ...state.pose, flipX: false, flipY: false, flipZ: false };
|
||||
} else {
|
||||
state.pose = { ...DEFAULT_POSE };
|
||||
}
|
||||
renderPoseControls();
|
||||
applyPose();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function suggestBoneSelection() {
|
||||
const boneKeys = ["rib", "vertebra", "sternum", "bone", "spine", "肋", "椎", "骨", "胸骨"];
|
||||
const next = new Set();
|
||||
state.stlFiles.forEach((file) => {
|
||||
const haystack = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase();
|
||||
if (boneKeys.some((key) => haystack.includes(key))) next.add(Number(file.id));
|
||||
});
|
||||
if (!next.size) {
|
||||
state.stlFiles.slice(0, Math.min(6, state.stlFiles.length)).forEach((file) => next.add(Number(file.id)));
|
||||
}
|
||||
state.selectedStlIds = next;
|
||||
renderStl();
|
||||
markDirty();
|
||||
loadFusion();
|
||||
}
|
||||
|
||||
function candidateScore(pose) {
|
||||
if (!state.dicomSceneBox || !state.modelGroup || !state.rawModelGroup?.children.length) return -Infinity;
|
||||
const current = { ...state.pose };
|
||||
applyPose(pose);
|
||||
const modelBox = new THREE.Box3().setFromObject(state.modelGroup);
|
||||
applyPose(current);
|
||||
const overlap = modelBox.clone().intersect(state.dicomSceneBox);
|
||||
const overlapSize = new THREE.Vector3();
|
||||
overlap.getSize(overlapSize);
|
||||
const modelSize = new THREE.Vector3();
|
||||
const dicomSize = new THREE.Vector3();
|
||||
modelBox.getSize(modelSize);
|
||||
state.dicomSceneBox.getSize(dicomSize);
|
||||
const overlapVolume = Math.max(0, overlapSize.x) * Math.max(0, overlapSize.y) * Math.max(0, overlapSize.z);
|
||||
const modelVolume = Math.max(modelSize.x * modelSize.y * modelSize.z, 0.001);
|
||||
const modelCenter = new THREE.Vector3();
|
||||
const dicomCenter = new THREE.Vector3();
|
||||
modelBox.getCenter(modelCenter);
|
||||
state.dicomSceneBox.getCenter(dicomCenter);
|
||||
const centerPenalty = modelCenter.distanceTo(dicomCenter) / Math.max(dicomSize.length(), 0.001);
|
||||
const scalePenalty = Math.abs((pose.scale || 1) - 1) * 0.03;
|
||||
return overlapVolume / modelVolume - centerPenalty - scalePenalty;
|
||||
}
|
||||
|
||||
function runAutoCoarse() {
|
||||
if (!state.activeCase || !currentSeries() || !state.rawModelGroup?.children.length) {
|
||||
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
||||
return;
|
||||
}
|
||||
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1 };
|
||||
renderPoseControls();
|
||||
applyPose();
|
||||
fitCamera();
|
||||
state.autoResult = { score: candidateScore(state.pose), pose: { ...state.pose }, evaluated: 1 };
|
||||
setAutoState("粗配准完成", "ok");
|
||||
$("autoResult").textContent = `已按 DICOM 物理尺寸复位平移/缩放,score ${state.autoResult.score.toFixed(4)}`;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function runAutoFine() {
|
||||
if (!state.activeCase || !currentSeries() || !state.rawModelGroup?.children.length) {
|
||||
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
||||
return;
|
||||
}
|
||||
setAutoState("运行中", "warn");
|
||||
$("autoResult").textContent = "正在进行几何重叠微调...";
|
||||
const adjustable = {
|
||||
translateX: $("autoX").checked,
|
||||
translateY: $("autoY").checked,
|
||||
translateZ: $("autoZ").checked,
|
||||
scale: $("autoScale").checked,
|
||||
};
|
||||
let best = { pose: { ...state.pose }, score: candidateScore(state.pose), mode: "初始位姿" };
|
||||
let evaluated = 1;
|
||||
const rounds = [
|
||||
{ move: 0.34, scale: 0.12 },
|
||||
{ move: 0.18, scale: 0.06 },
|
||||
{ move: 0.08, scale: 0.025 },
|
||||
{ move: 0.035, scale: 0.01 },
|
||||
];
|
||||
for (const round of rounds) {
|
||||
const candidates = [];
|
||||
if (adjustable.translateX) candidates.push(["translateX", round.move], ["translateX", -round.move]);
|
||||
if (adjustable.translateY) candidates.push(["translateY", round.move], ["translateY", -round.move]);
|
||||
if (adjustable.translateZ) candidates.push(["translateZ", round.move], ["translateZ", -round.move]);
|
||||
if (adjustable.scale) candidates.push(["scale", round.scale], ["scale", -round.scale]);
|
||||
for (const [key, delta] of candidates) {
|
||||
const pose = { ...best.pose, [key]: key === "scale" ? Math.max(0.2, Math.min(3, best.pose.scale + delta)) : best.pose[key] + delta };
|
||||
const score = candidateScore(pose);
|
||||
evaluated += 1;
|
||||
if (score > best.score) best = { pose, score, mode: `${key} ${delta > 0 ? "+" : "-"}` };
|
||||
}
|
||||
}
|
||||
state.pose = { ...best.pose };
|
||||
state.autoResult = { ...best, evaluated };
|
||||
renderPoseControls();
|
||||
applyPose();
|
||||
fitCamera();
|
||||
setAutoState("微调完成", "ok");
|
||||
$("autoResult").textContent = `最佳候选:${best.mode};score ${best.score.toFixed(4)};评估 ${evaluated} 个候选。`;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
async function saveRegistration(nextStatus = null) {
|
||||
if (!state.activeCase || !currentSeries() || state.saving) return;
|
||||
state.saving = true;
|
||||
@@ -792,6 +1008,8 @@ async function saveRegistration(nextStatus = null) {
|
||||
algorithm_model: state.algorithmModel,
|
||||
selected_count: selectedFiles.length,
|
||||
families: [...new Set(selectedFiles.map((file) => file.family).filter(Boolean))],
|
||||
fusion_mode: state.fusionMode,
|
||||
auto_result: state.autoResult,
|
||||
},
|
||||
notes: $("notes").value,
|
||||
}),
|
||||
@@ -846,16 +1064,42 @@ function openLinkedApp(kind) {
|
||||
function wireEvents() {
|
||||
$("loginForm").addEventListener("submit", login);
|
||||
$("logoutBtn").addEventListener("click", () => logout());
|
||||
$("caseToggleBtn").addEventListener("click", () => {
|
||||
state.caseCollapsed = !state.caseCollapsed;
|
||||
document.querySelector(".workspace").classList.toggle("case-collapsed", state.caseCollapsed);
|
||||
$("caseToggleBtn").classList.toggle("active", state.caseCollapsed);
|
||||
window.setTimeout(() => {
|
||||
state.resizeScene?.();
|
||||
fitCamera();
|
||||
}, 230);
|
||||
});
|
||||
$("refreshBtn").addEventListener("click", async () => {
|
||||
await loadStatus();
|
||||
await loadCases(false);
|
||||
});
|
||||
$("relationBtn").addEventListener("click", () => openLinkedApp("relation"));
|
||||
$("viewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
|
||||
$("openViewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
|
||||
$("reloadFusionBtn").addEventListener("click", () => loadFusion());
|
||||
$("fitBtn").addEventListener("click", fitCamera);
|
||||
$("saveBtn").addEventListener("click", () => saveRegistration());
|
||||
$("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered"));
|
||||
$("notes").addEventListener("input", markDirty);
|
||||
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
|
||||
$("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
|
||||
$("resetFlipBtn").addEventListener("click", () => resetPose("flip"));
|
||||
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
|
||||
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
|
||||
$("autoFineBtn").addEventListener("click", runAutoFine);
|
||||
$("dicomPreview").addEventListener("load", () => $("dicomLoading").classList.add("hidden"));
|
||||
$("dicomPreview").addEventListener("error", () => $("dicomLoading").classList.add("hidden"));
|
||||
document.querySelectorAll("[data-tool-tab]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.activeToolTab = button.dataset.toolTab || "series";
|
||||
document.querySelectorAll("[data-tool-tab]").forEach((item) => item.classList.toggle("active", item === button));
|
||||
document.querySelectorAll("[data-tool-pane]").forEach((pane) => pane.classList.toggle("active", pane.dataset.toolPane === state.activeToolTab));
|
||||
});
|
||||
});
|
||||
$("caseSearch").addEventListener("input", () => {
|
||||
state.search = $("caseSearch").value.trim();
|
||||
window.clearTimeout(state.searchTimer);
|
||||
@@ -875,16 +1119,26 @@ function wireEvents() {
|
||||
await loadCases(false);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll(".segmented button").forEach((button) => {
|
||||
document.querySelectorAll("[data-window]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
state.windowMode = button.dataset.window || "default";
|
||||
document.querySelectorAll(".segmented button").forEach((item) => item.classList.toggle("active", item === button));
|
||||
document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item === button));
|
||||
await loadFusion();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll("[data-fusion-mode]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
state.fusionMode = button.dataset.fusionMode || "fusion";
|
||||
document.querySelectorAll("[data-fusion-mode]").forEach((item) => item.classList.toggle("active", item === button));
|
||||
applyFusionMode();
|
||||
fitCamera();
|
||||
});
|
||||
});
|
||||
$("sliceSlider").addEventListener("input", () => {
|
||||
state.sliceIndex = Number($("sliceSlider").value || 0);
|
||||
updateSliceControl();
|
||||
renderDicomAnnotation();
|
||||
updateDicomPreview();
|
||||
});
|
||||
$("sliceSlider").addEventListener("change", () => loadFusion());
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button id="caseToggleBtn" class="icon-btn" type="button" title="收起/展开完整关联 CT">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
<button id="relationBtn" class="ghost-btn accent" type="button">数据库关联可视化</button>
|
||||
<button id="viewerBtn" class="ghost-btn" type="button">DICOM 阅片系统</button>
|
||||
<span id="dbStatus" class="status-pill">数据库</span>
|
||||
@@ -81,17 +84,90 @@
|
||||
<div id="activeTags" class="tag-line"></div>
|
||||
</section>
|
||||
|
||||
<section class="work-grid">
|
||||
<aside class="series-panel panel">
|
||||
<div class="panel-head compact">
|
||||
<h2>DICOM 序列</h2>
|
||||
<span id="seriesCount">0</span>
|
||||
<section class="registration-board">
|
||||
<aside class="tool-dock panel">
|
||||
<div class="tool-tabs">
|
||||
<button class="active" data-tool-tab="series">序列</button>
|
||||
<button data-tool-tab="models">组织分割</button>
|
||||
<button data-tool-tab="pose">标记</button>
|
||||
</div>
|
||||
<div id="seriesList" class="series-list"></div>
|
||||
|
||||
<section class="tool-pane active" data-tool-pane="series">
|
||||
<div class="patient-card">
|
||||
<strong id="patientSummary">未选择 CT</strong>
|
||||
<dl>
|
||||
<div><dt>检查号</dt><dd id="summaryCt">-</dd></div>
|
||||
<div><dt>患者号</dt><dd id="summaryPatientId">-</dd></div>
|
||||
<div><dt>检查日期</dt><dd id="summaryStudyTime">-</dd></div>
|
||||
<div><dt>检查部位</dt><dd id="summaryBodyPart">-</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="tool-section-title">
|
||||
<span>3D 重建所需序列</span>
|
||||
<button id="reloadFusionBtn" type="button">重选序列</button>
|
||||
</div>
|
||||
<div id="matchedSeries" class="matched-series"></div>
|
||||
<div class="tool-section-title">
|
||||
<span>全部检查和序列</span>
|
||||
<em id="seriesCount">0</em>
|
||||
</div>
|
||||
<div id="seriesList" class="series-list compact-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="tool-pane" data-tool-pane="models">
|
||||
<div class="tool-section-title">
|
||||
<span>STL 模型</span>
|
||||
<em id="stlCount">0</em>
|
||||
</div>
|
||||
<div id="modelRail" class="chip-rail"></div>
|
||||
<div id="stlList" class="stl-list compact-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="tool-pane" data-tool-pane="pose">
|
||||
<div class="tool-section-title">
|
||||
<span>位姿手动调整</span>
|
||||
<em id="saveState">未保存</em>
|
||||
</div>
|
||||
<div class="pose-actions">
|
||||
<button id="resetRotationBtn" type="button">重置旋转</button>
|
||||
<button id="resetTransformBtn" type="button">重置平移缩放</button>
|
||||
<button id="resetFlipBtn" type="button">重置镜像</button>
|
||||
</div>
|
||||
<div class="flip-row">
|
||||
<button data-flip="flipX">镜像 X</button>
|
||||
<button data-flip="flipY">镜像 Y</button>
|
||||
<button data-flip="flipZ">镜像 Z</button>
|
||||
</div>
|
||||
<div id="poseGrid" class="pose-grid"></div>
|
||||
|
||||
<div class="tool-section-title">
|
||||
<span>位姿自动调整</span>
|
||||
<em id="autoState">未运行</em>
|
||||
</div>
|
||||
<div class="auto-box">
|
||||
<div class="auto-options">
|
||||
<label><input id="autoX" type="checkbox" checked /> X</label>
|
||||
<label><input id="autoY" type="checkbox" checked /> Y</label>
|
||||
<label><input id="autoZ" type="checkbox" checked /> Z</label>
|
||||
<label><input id="autoScale" type="checkbox" checked /> 缩放</label>
|
||||
</div>
|
||||
<div class="pose-actions">
|
||||
<button id="suggestBoneBtn" type="button">建议骨骼模型</button>
|
||||
<button id="autoCoarseBtn" type="button">自动粗配准</button>
|
||||
<button id="autoFineBtn" type="button">自动微调</button>
|
||||
</div>
|
||||
<div id="autoResult" class="auto-result">选择 DICOM 序列和 STL 后可运行。</div>
|
||||
</div>
|
||||
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="viewer-panel panel">
|
||||
<section class="viewer-panel fusion-panel panel">
|
||||
<div class="viewer-head">
|
||||
<div class="segmented">
|
||||
<button data-fusion-mode="fusion" class="active">融合结果</button>
|
||||
<button data-fusion-mode="model">单独模型</button>
|
||||
</div>
|
||||
<div class="segmented">
|
||||
<button data-window="default" class="active">默认</button>
|
||||
<button data-window="bone">骨窗</button>
|
||||
@@ -119,30 +195,28 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="right-panel">
|
||||
<section class="panel stl-panel">
|
||||
<div class="panel-head compact">
|
||||
<h2>STL 模型</h2>
|
||||
<span id="stlCount">0</span>
|
||||
<section class="viewer-panel dicom-panel panel">
|
||||
<div class="viewer-head">
|
||||
<div>
|
||||
<h2>DICOM 标注</h2>
|
||||
<p id="dicomPanelMeta">当前序列切片</p>
|
||||
</div>
|
||||
<div id="modelRail" class="chip-rail"></div>
|
||||
<div id="stlList" class="stl-list"></div>
|
||||
<button id="openViewerBtn" class="ghost-btn" type="button">阅片系统</button>
|
||||
</div>
|
||||
<div class="dicom-stage">
|
||||
<img id="dicomPreview" alt="DICOM 切片" />
|
||||
<div class="crosshair horizontal"></div>
|
||||
<div class="crosshair vertical"></div>
|
||||
<div class="dicom-overlay top-left" id="dicomInfoLeft">未选择序列</div>
|
||||
<div class="dicom-overlay top-right" id="dicomInfoRight"></div>
|
||||
<div class="dicom-overlay bottom-left" id="dicomInfoBottom">Img: -</div>
|
||||
<div id="dicomLoading" class="fusion-loading hidden">
|
||||
<span>正在加载 DICOM 标注视图</span>
|
||||
<div><i></i></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="annotationTags" class="annotation-tags"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel pose-panel">
|
||||
<div class="panel-head compact">
|
||||
<h2>位姿参数</h2>
|
||||
<span id="saveState">未保存</span>
|
||||
</div>
|
||||
<div id="poseGrid" class="pose-grid"></div>
|
||||
<div class="flip-row">
|
||||
<button data-flip="flipX">镜像 X</button>
|
||||
<button data-flip="flipY">镜像 Y</button>
|
||||
<button data-flip="flipZ">镜像 Z</button>
|
||||
</div>
|
||||
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -111,6 +111,50 @@ button {
|
||||
grid-template-columns: 338px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
transition: grid-template-columns 0.22s ease;
|
||||
}
|
||||
|
||||
.workspace.case-collapsed {
|
||||
grid-template-columns: 0 minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workspace.case-collapsed .case-panel {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateX(-18px);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 38px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
grid-template-columns: 8px 1fr;
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
gap: 4px;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.38);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, rgba(25, 38, 56, 0.95), rgba(9, 15, 23, 0.95));
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.icon-btn span {
|
||||
border: 2px solid #afbfd2;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.icon-btn span:first-child {
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
|
||||
.icon-btn span:nth-child(2),
|
||||
.icon-btn span:nth-child(3) {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.icon-btn.active {
|
||||
border-color: rgba(25, 214, 195, 0.66);
|
||||
background: rgba(20, 184, 166, 0.18);
|
||||
}
|
||||
|
||||
.panel {
|
||||
@@ -153,6 +197,8 @@ button {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.search-input,
|
||||
@@ -179,6 +225,11 @@ button {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.tool-pane .notes {
|
||||
width: 100%;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.filter-row,
|
||||
.part-filter,
|
||||
.chip-rail,
|
||||
@@ -189,6 +240,10 @@ button {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tool-pane .chip-rail {
|
||||
padding: 0 0 10px;
|
||||
}
|
||||
|
||||
.filter,
|
||||
.chip,
|
||||
.tag,
|
||||
@@ -376,6 +431,148 @@ button {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.registration-board {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 360px minmax(520px, 1.08fr) minmax(420px, 0.92fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tool-dock {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: 44px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(6, 10, 15, 0.55);
|
||||
}
|
||||
|
||||
.tool-tabs button {
|
||||
border: 0;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(18, 24, 36, 0.72);
|
||||
color: #c5d3e5;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.tool-tabs button.active {
|
||||
background: linear-gradient(180deg, rgba(75, 86, 111, 0.88), rgba(30, 39, 58, 0.9));
|
||||
color: #ffffff;
|
||||
box-shadow: inset 0 -2px 0 rgba(25, 214, 195, 0.75);
|
||||
}
|
||||
|
||||
.tool-pane {
|
||||
min-height: 0;
|
||||
display: none;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.tool-pane.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.patient-card {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, rgba(28, 35, 49, 0.92), rgba(15, 21, 31, 0.92));
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.patient-card strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.patient-card dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 9px 14px;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.patient-card div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.patient-card dt {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.patient-card dd {
|
||||
margin: 3px 0 0;
|
||||
overflow: hidden;
|
||||
color: #edf5ff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-section-title {
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
margin: 8px -10px 8px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||
background: rgba(24, 31, 45, 0.9);
|
||||
color: #f1f7ff;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.tool-section-title button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #c4d7ee;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.tool-section-title em {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.matched-series {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.matched-series .series-card {
|
||||
border-color: rgba(25, 214, 195, 0.75);
|
||||
background: rgba(20, 184, 166, 0.12);
|
||||
}
|
||||
|
||||
.compact-list {
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tool-pane .compact-list {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.series-panel,
|
||||
.right-panel {
|
||||
min-height: 0;
|
||||
@@ -402,6 +599,10 @@ button {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dicom-panel {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.viewer-head {
|
||||
min-height: 58px;
|
||||
display: flex;
|
||||
@@ -410,7 +611,23 @@ button {
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fusion-panel .viewer-head {
|
||||
align-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.viewer-head h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.viewer-head p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
@@ -506,15 +723,18 @@ button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#saveState.ok {
|
||||
#saveState.ok,
|
||||
#autoState.ok {
|
||||
color: #b8f7da;
|
||||
}
|
||||
|
||||
#saveState.warn {
|
||||
#saveState.warn,
|
||||
#autoState.warn {
|
||||
color: #ffe1a6;
|
||||
}
|
||||
|
||||
#saveState.error {
|
||||
#saveState.error,
|
||||
#autoState.error {
|
||||
color: #fecdd3;
|
||||
}
|
||||
|
||||
@@ -628,6 +848,94 @@ button {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dicom-stage {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.dicom-stage img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
.crosshair {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.crosshair.horizontal {
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: rgba(52, 211, 153, 0.72);
|
||||
}
|
||||
|
||||
.crosshair.vertical {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
background: rgba(248, 113, 113, 0.72);
|
||||
}
|
||||
|
||||
.dicom-overlay {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
max-width: 48%;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
text-shadow: 0 1px 2px #000;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.dicom-overlay.top-left {
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.dicom-overlay.top-right {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dicom-overlay.bottom-left {
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.annotation-tags {
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.annotation-tags span {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(25, 214, 195, 0.55);
|
||||
border-radius: 999px;
|
||||
background: rgba(20, 184, 166, 0.12);
|
||||
color: #ccfbf1;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.slice-row span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
@@ -663,13 +971,13 @@ input[type="range"] {
|
||||
.pose-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 0 12px 12px;
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
|
||||
.pose-control {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr) 72px;
|
||||
gap: 8px;
|
||||
grid-template-columns: 56px 26px minmax(0, 1fr) 26px 64px;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -690,11 +998,44 @@ input[type="range"] {
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
.pose-control button,
|
||||
.pose-actions button,
|
||||
.auto-box button {
|
||||
min-height: 28px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(8, 13, 20, 0.82);
|
||||
color: #c6d4e5;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.pose-control button:hover,
|
||||
.pose-actions button:hover,
|
||||
.auto-box button:hover {
|
||||
border-color: rgba(25, 214, 195, 0.65);
|
||||
color: #ccfbf1;
|
||||
}
|
||||
|
||||
.quick-row {
|
||||
grid-column: 2 / span 3;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pose-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.flip-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
padding: 0 12px 12px;
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
|
||||
.flip-row button {
|
||||
@@ -712,6 +1053,45 @@ input[type="range"] {
|
||||
background: rgba(20, 184, 166, 0.16);
|
||||
}
|
||||
|
||||
.auto-box {
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 10px;
|
||||
background: rgba(8, 13, 20, 0.62);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.auto-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.auto-options label {
|
||||
min-height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 22, 32, 0.82);
|
||||
color: #c7d6e8;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.auto-result {
|
||||
min-height: 36px;
|
||||
margin-top: 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(7, 10, 15, 0.78);
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 22px;
|
||||
color: var(--muted);
|
||||
@@ -776,4 +1156,15 @@ input[type="range"] {
|
||||
.work-grid {
|
||||
grid-template-columns: 270px minmax(360px, 1fr) 300px;
|
||||
}
|
||||
|
||||
.registration-board {
|
||||
grid-template-columns: 320px minmax(460px, 1fr) minmax(360px, 0.8fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1250px) {
|
||||
.registration-board {
|
||||
overflow-x: auto;
|
||||
grid-template-columns: 320px 560px 420px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user