Add admin DICOM export controls
This commit is contained in:
@@ -8,10 +8,12 @@ import json
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -24,6 +26,7 @@ from fastapi.responses import FileResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
|
|
||||||
|
|
||||||
APP_DIR = Path(__file__).resolve().parent
|
APP_DIR = Path(__file__).resolve().parent
|
||||||
@@ -70,7 +73,7 @@ BODY_PARTS = set(BODY_PART_ORDER)
|
|||||||
PHASES = {"plain", "arterial", "portal_venous", "delayed", "unknown", ""}
|
PHASES = {"plain", "arterial", "portal_venous", "delayed", "unknown", ""}
|
||||||
CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""}
|
CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""}
|
||||||
ROLES = {
|
ROLES = {
|
||||||
"管理员": ["查看DICOM", "编辑标注", "AI识别", "用户创建", "权限控制", "系统设置"],
|
"管理员": ["查看DICOM", "编辑标注", "AI识别", "影像导出", "批量导出", "用户创建", "权限控制", "系统设置"],
|
||||||
"阅片员": ["查看DICOM", "编辑标注", "AI识别"],
|
"阅片员": ["查看DICOM", "编辑标注", "AI识别"],
|
||||||
"访客": ["查看DICOM"],
|
"访客": ["查看DICOM"],
|
||||||
}
|
}
|
||||||
@@ -121,6 +124,10 @@ class StudyCompletionIn(BaseModel):
|
|||||||
completed: bool = False
|
completed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ExportStudiesIn(BaseModel):
|
||||||
|
ct_numbers: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class UserIn(BaseModel):
|
class UserIn(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
@@ -619,6 +626,85 @@ def resolve_study_root(study: dict[str, Any]) -> Path:
|
|||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def safe_archive_name(value: Any, fallback: str = "study") -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
cleaned = "".join("_" if char in '<>:"/\\|?*\0' else char for char in text)
|
||||||
|
cleaned = cleaned.strip().strip(".")
|
||||||
|
return cleaned[:160] or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def study_export_folder_name(study: dict[str, Any]) -> str:
|
||||||
|
ct_number = safe_archive_name(study.get("ct_number"), "study")
|
||||||
|
name = safe_archive_name(study.get("source_patient_name") or study.get("patient_name_dicom") or "", "")
|
||||||
|
return f"{ct_number}_{name}" if name else ct_number
|
||||||
|
|
||||||
|
|
||||||
|
def add_directory_to_zip(zip_file: zipfile.ZipFile, root: Path, prefix: str) -> int:
|
||||||
|
root = root.resolve()
|
||||||
|
count = 0
|
||||||
|
for path in root.rglob("*"):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
relative = path.resolve().relative_to(root)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
zip_file.write(path, Path(prefix) / relative)
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def remove_file(path: str) -> None:
|
||||||
|
try:
|
||||||
|
Path(path).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def build_study_export_zip(ct_numbers: list[str]) -> tuple[str, str]:
|
||||||
|
unique_numbers = []
|
||||||
|
for ct_number in ct_numbers:
|
||||||
|
value = str(ct_number or "").strip()
|
||||||
|
if value and value not in unique_numbers:
|
||||||
|
unique_numbers.append(value)
|
||||||
|
if not unique_numbers:
|
||||||
|
raise HTTPException(status_code=400, detail="请选择需要导出的检查")
|
||||||
|
if len(unique_numbers) > 200:
|
||||||
|
raise HTTPException(status_code=400, detail="单次最多导出 200 个检查")
|
||||||
|
|
||||||
|
temp = tempfile.NamedTemporaryFile(prefix="pacs_dicom_export_", suffix=".zip", delete=False)
|
||||||
|
temp_path = temp.name
|
||||||
|
temp.close()
|
||||||
|
total_files = 0
|
||||||
|
notes = []
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(temp_path, mode="w", compression=zipfile.ZIP_STORED, allowZip64=True) as zip_file:
|
||||||
|
for ct_number in unique_numbers:
|
||||||
|
study = get_study_record(ct_number)
|
||||||
|
root = resolve_study_root(study)
|
||||||
|
if not root.exists():
|
||||||
|
notes.append(f"{ct_number}: DICOM 路径不存在 - {root}")
|
||||||
|
continue
|
||||||
|
folder = study_export_folder_name(study)
|
||||||
|
file_count = add_directory_to_zip(zip_file, root, folder)
|
||||||
|
total_files += file_count
|
||||||
|
if file_count == 0:
|
||||||
|
notes.append(f"{ct_number}: 未找到可导出的文件 - {root}")
|
||||||
|
if notes:
|
||||||
|
zip_file.writestr("导出说明.txt", "\n".join(notes))
|
||||||
|
if total_files == 0:
|
||||||
|
remove_file(temp_path)
|
||||||
|
raise HTTPException(status_code=404, detail="没有找到可导出的 DICOM 文件")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
remove_file(temp_path)
|
||||||
|
raise HTTPException(status_code=500, detail=f"导出失败:{exc}") from exc
|
||||||
|
|
||||||
|
filename = f"PACS_DICOM_{safe_archive_name(unique_numbers[0])}.zip" if len(unique_numbers) == 1 else f"PACS_DICOM_batch_{time.strftime('%Y%m%d_%H%M%S')}.zip"
|
||||||
|
return temp_path, filename
|
||||||
|
|
||||||
|
|
||||||
def read_header(path: Path) -> dict[str, str]:
|
def read_header(path: Path) -> dict[str, str]:
|
||||||
tags = [
|
tags = [
|
||||||
"SeriesInstanceUID",
|
"SeriesInstanceUID",
|
||||||
@@ -1129,6 +1215,24 @@ def update_study_completion(ct_number: str, data: StudyCompletionIn, user: str =
|
|||||||
return {"ok": True, "ct_number": ct_number, **study_completion_fields(ct_number)}
|
return {"ok": True, "ct_number": ct_number, **study_completion_fields(ct_number)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/studies/export")
|
||||||
|
def export_studies_get(ct_numbers: list[str] = Query(default=[]), _: str = Depends(require_admin)) -> FileResponse:
|
||||||
|
path, filename = build_study_export_zip(ct_numbers)
|
||||||
|
return FileResponse(path, media_type="application/zip", filename=filename, background=BackgroundTask(remove_file, path))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/studies/{ct_number}/export")
|
||||||
|
def export_study(ct_number: str, _: str = Depends(require_admin)) -> FileResponse:
|
||||||
|
path, filename = build_study_export_zip([ct_number])
|
||||||
|
return FileResponse(path, media_type="application/zip", filename=filename, background=BackgroundTask(remove_file, path))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/studies/export")
|
||||||
|
def export_studies(data: ExportStudiesIn, _: str = Depends(require_admin)) -> FileResponse:
|
||||||
|
path, filename = build_study_export_zip(data.ct_numbers)
|
||||||
|
return FileResponse(path, media_type="application/zip", filename=filename, background=BackgroundTask(remove_file, path))
|
||||||
|
|
||||||
|
|
||||||
def get_series_files(ct_number: str, series_uid: str) -> list[Path]:
|
def get_series_files(ct_number: str, series_uid: str) -> list[Path]:
|
||||||
data = scan_study(ct_number)
|
data = scan_study(ct_number)
|
||||||
files = data["files"].get(series_uid)
|
files = data["files"].get(series_uid)
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const app = {
|
|||||||
studySortDir: "desc",
|
studySortDir: "desc",
|
||||||
studyFilter: "all",
|
studyFilter: "all",
|
||||||
studyPartFilter: "all",
|
studyPartFilter: "all",
|
||||||
|
exportMode: false,
|
||||||
|
exportSelection: new Set(),
|
||||||
aiEnabled: true,
|
aiEnabled: true,
|
||||||
seriesSortField: "time",
|
seriesSortField: "time",
|
||||||
seriesSortDir: "asc",
|
seriesSortDir: "asc",
|
||||||
@@ -201,6 +203,12 @@ function isAdmin() {
|
|||||||
|
|
||||||
function applyPermissions() {
|
function applyPermissions() {
|
||||||
$("settingsBtn").classList.toggle("hidden", !isAdmin());
|
$("settingsBtn").classList.toggle("hidden", !isAdmin());
|
||||||
|
$("exportToolbar").classList.toggle("hidden", !isAdmin());
|
||||||
|
if (!isAdmin()) {
|
||||||
|
app.exportMode = false;
|
||||||
|
app.exportSelection.clear();
|
||||||
|
}
|
||||||
|
updateExportToolbar();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshStatus() {
|
async function refreshStatus() {
|
||||||
@@ -313,6 +321,59 @@ function refreshStudyAnnotationSummary() {
|
|||||||
if (study) Object.assign(study, summary);
|
if (study) Object.assign(study, summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateExportToolbar() {
|
||||||
|
const toolbar = $("exportToolbar");
|
||||||
|
if (!toolbar) return;
|
||||||
|
toolbar.classList.toggle("hidden", !isAdmin());
|
||||||
|
$("toggleExportMode").classList.toggle("active", app.exportMode);
|
||||||
|
$("toggleExportMode").textContent = app.exportMode ? "退出批量" : "批量导出";
|
||||||
|
$("exportSelected").disabled = !app.exportMode || app.exportSelection.size === 0;
|
||||||
|
$("exportSelectionCount").textContent = `${app.exportSelection.size} 个`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportDownloadUrl(ctNumbers) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const ctNumber of ctNumbers) params.append("ct_numbers", ctNumber);
|
||||||
|
params.set("access_token", app.token);
|
||||||
|
return `/api/studies/export?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDownload(url) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.rel = "noopener";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportStudy(ctNumber, event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!isAdmin()) return;
|
||||||
|
triggerDownload(`/api/studies/${encodeURIComponent(ctNumber)}/export?access_token=${encodeURIComponent(app.token)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExportMode() {
|
||||||
|
app.exportMode = !app.exportMode;
|
||||||
|
if (!app.exportMode) app.exportSelection.clear();
|
||||||
|
renderStudies();
|
||||||
|
updateExportToolbar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setExportSelection(ctNumber, checked) {
|
||||||
|
if (checked) {
|
||||||
|
app.exportSelection.add(ctNumber);
|
||||||
|
} else {
|
||||||
|
app.exportSelection.delete(ctNumber);
|
||||||
|
}
|
||||||
|
updateExportToolbar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportSelectedStudies() {
|
||||||
|
if (!isAdmin() || app.exportSelection.size === 0) return;
|
||||||
|
triggerDownload(exportDownloadUrl(Array.from(app.exportSelection)));
|
||||||
|
}
|
||||||
|
|
||||||
async function loadStudies() {
|
async function loadStudies() {
|
||||||
const q = encodeURIComponent($("studySearch").value.trim());
|
const q = encodeURIComponent($("studySearch").value.trim());
|
||||||
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
|
app.studies = sortStudies(await json(`/api/studies?q=${q}&limit=500`));
|
||||||
@@ -325,21 +386,33 @@ function renderStudies() {
|
|||||||
app.studies = sortStudies(app.studies);
|
app.studies = sortStudies(app.studies);
|
||||||
updateSortButtons();
|
updateSortButtons();
|
||||||
const studies = visibleStudies();
|
const studies = visibleStudies();
|
||||||
|
for (const ctNumber of Array.from(app.exportSelection)) {
|
||||||
|
if (!studies.some((study) => study.ct_number === ctNumber)) app.exportSelection.delete(ctNumber);
|
||||||
|
}
|
||||||
$("studyCount").textContent = studies.length === app.studies.length ? String(studies.length) : `${studies.length}/${app.studies.length}`;
|
$("studyCount").textContent = studies.length === app.studies.length ? String(studies.length) : `${studies.length}/${app.studies.length}`;
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
for (const study of studies) {
|
for (const study of studies) {
|
||||||
const button = document.createElement("button");
|
const card = document.createElement("div");
|
||||||
const status = studyStatus(study);
|
const status = studyStatus(study);
|
||||||
const nextStatusLabel = status === "complete" ? "待处理" : "已完成";
|
const nextStatusLabel = status === "complete" ? "待处理" : "已完成";
|
||||||
button.className = "study-card";
|
card.className = "study-card";
|
||||||
button.classList.toggle("active", app.study?.ct_number === study.ct_number);
|
card.tabIndex = 0;
|
||||||
button.classList.toggle("complete", status === "complete");
|
card.setAttribute("role", "button");
|
||||||
|
card.classList.toggle("active", app.study?.ct_number === study.ct_number);
|
||||||
|
card.classList.toggle("complete", status === "complete");
|
||||||
|
card.classList.toggle("export-mode", app.exportMode && isAdmin());
|
||||||
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
|
const name = study.source_patient_name || study.patient_name_dicom || "无姓名";
|
||||||
const partTags = asList(study.body_parts)
|
const partTags = asList(study.body_parts)
|
||||||
.filter((part) => BODY_PARTS.includes(part))
|
.filter((part) => BODY_PARTS.includes(part))
|
||||||
.map((part) => `<em>${escapeHtml(partLabel(part))}</em>`)
|
.map((part) => `<em>${escapeHtml(partLabel(part))}</em>`)
|
||||||
.join("");
|
.join("");
|
||||||
button.innerHTML = `
|
const exportControls = isAdmin()
|
||||||
|
? `
|
||||||
|
${app.exportMode ? `<label class="study-batch-check"><input type="checkbox" ${app.exportSelection.has(study.ct_number) ? "checked" : ""} />选择</label>` : ""}
|
||||||
|
<span class="study-export-btn" role="button" tabindex="0" title="导出该检查影像">下载</span>
|
||||||
|
`
|
||||||
|
: "";
|
||||||
|
card.innerHTML = `
|
||||||
<div class="study-card-status-stack">
|
<div class="study-card-status-stack">
|
||||||
<i class="study-status-badge status-${status}" title="点击切换为${nextStatusLabel}">${studyStatusLabel(status)}</i>
|
<i class="study-status-badge status-${status}" title="点击切换为${nextStatusLabel}">${studyStatusLabel(status)}</i>
|
||||||
<div class="study-part-tags">${partTags}</div>
|
<div class="study-part-tags">${partTags}</div>
|
||||||
@@ -348,11 +421,25 @@ function renderStudies() {
|
|||||||
<span>${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}</span>
|
<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>${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张</span>
|
||||||
<span>${Number(study.annotated_series || 0)} 个序列已标注,${Number(study.unannotated_series || 0)} 个序列未标注</span>
|
<span>${Number(study.annotated_series || 0)} 个序列已标注,${Number(study.unannotated_series || 0)} 个序列未标注</span>
|
||||||
|
${exportControls}
|
||||||
`;
|
`;
|
||||||
button.onclick = () => selectStudy(study.ct_number);
|
card.onclick = () => selectStudy(study.ct_number);
|
||||||
button.querySelector(".study-status-badge").onclick = (event) => toggleStudyCompletion(study.ct_number, event);
|
card.onkeydown = (event) => {
|
||||||
list.appendChild(button);
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
selectStudy(study.ct_number);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
card.querySelector(".study-status-badge").onclick = (event) => toggleStudyCompletion(study.ct_number, event);
|
||||||
|
card.querySelector(".study-export-btn")?.addEventListener("click", (event) => exportStudy(study.ct_number, event));
|
||||||
|
card.querySelector(".study-export-btn")?.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") exportStudy(study.ct_number, event);
|
||||||
|
});
|
||||||
|
card.querySelector(".study-batch-check")?.addEventListener("click", (event) => event.stopPropagation());
|
||||||
|
card.querySelector(".study-batch-check input")?.addEventListener("change", (event) => setExportSelection(study.ct_number, event.target.checked));
|
||||||
|
list.appendChild(card);
|
||||||
}
|
}
|
||||||
|
updateExportToolbar();
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyStudyCompletion(data) {
|
function applyStudyCompletion(data) {
|
||||||
@@ -1263,6 +1350,8 @@ function wire() {
|
|||||||
$("backToViewer").addEventListener("click", () => showViewerPage(true));
|
$("backToViewer").addEventListener("click", () => showViewerPage(true));
|
||||||
$("infoBtn").addEventListener("click", openInfo);
|
$("infoBtn").addEventListener("click", openInfo);
|
||||||
$("aiClassify").addEventListener("click", runAI);
|
$("aiClassify").addEventListener("click", runAI);
|
||||||
|
$("toggleExportMode").addEventListener("click", toggleExportMode);
|
||||||
|
$("exportSelected").addEventListener("click", exportSelectedStudies);
|
||||||
$("zoomIn").addEventListener("click", () => changeZoom(1.18));
|
$("zoomIn").addEventListener("click", () => changeZoom(1.18));
|
||||||
$("zoomOut").addEventListener("click", () => changeZoom(1 / 1.18));
|
$("zoomOut").addEventListener("click", () => changeZoom(1 / 1.18));
|
||||||
$("overlayToggle").addEventListener("click", () => {
|
$("overlayToggle").addEventListener("click", () => {
|
||||||
|
|||||||
@@ -63,6 +63,11 @@
|
|||||||
<button data-study-part-filter="lower_abdomen">下腹部</button>
|
<button data-study-part-filter="lower_abdomen">下腹部</button>
|
||||||
<button data-study-part-filter="pelvis">盆腔</button>
|
<button data-study-part-filter="pelvis">盆腔</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="exportToolbar" class="export-toolbar hidden">
|
||||||
|
<button id="toggleExportMode" type="button">批量导出</button>
|
||||||
|
<button id="exportSelected" type="button" disabled>导出所选</button>
|
||||||
|
<span id="exportSelectionCount">0 个</span>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ button:disabled {
|
|||||||
.series-pane {
|
.series-pane {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto auto auto auto 1fr;
|
grid-template-rows: auto auto auto auto auto 1fr;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +366,40 @@ button:disabled {
|
|||||||
background: rgba(25, 212, 194, 0.08);
|
background: rgba(25, 212, 194, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-toolbar button {
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #0b0f16;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-toolbar button.active,
|
||||||
|
.export-toolbar button:not(:disabled):hover {
|
||||||
|
border-color: rgba(52, 116, 246, 0.7);
|
||||||
|
color: #dbe8ff;
|
||||||
|
background: rgba(52, 116, 246, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-toolbar button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-toolbar span {
|
||||||
|
min-width: 36px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.study-list,
|
.study-list,
|
||||||
.series-grid {
|
.series-grid {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -376,13 +410,14 @@ button:disabled {
|
|||||||
.study-card {
|
.study-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 112px 12px 12px;
|
padding: 12px 112px 42px 12px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #0b0f16;
|
background: #0b0f16;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.study-card-status-stack {
|
.study-card-status-stack {
|
||||||
@@ -468,6 +503,47 @@ button:disabled {
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.study-export-btn,
|
||||||
|
.study-batch-check {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
height: 24px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(21, 28, 39, 0.92);
|
||||||
|
color: #dbe8ff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.study-export-btn {
|
||||||
|
right: 10px;
|
||||||
|
min-width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.study-export-btn:hover {
|
||||||
|
border-color: rgba(25, 212, 194, 0.68);
|
||||||
|
color: #baf8ee;
|
||||||
|
background: rgba(25, 212, 194, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.study-batch-check {
|
||||||
|
left: 12px;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.study-batch-check input {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
accent-color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
.series-grid {
|
.series-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user