2026-05-03-22-36-18 增加DICOM阅览和单项下载

This commit is contained in:
2026-05-03 22:46:46 +08:00
parent 525c2c1dda
commit bff7eead08
6 changed files with 509 additions and 6 deletions

View File

@@ -93,6 +93,19 @@ const VIDEO_SOURCE_OPTIONS = [
const PREVIEW_ALGORITHM_OPTIONS = VIDEO_SOURCE_OPTIONS;
const VIEWER_PLANE_OPTIONS = [
{ key: 'coronal', label: '冠状位' },
{ key: 'sagittal', label: '矢状位' },
];
const VIEWER_WINDOW_OPTIONS = [
{ key: 'default', label: '默认' },
{ key: 'bone', label: '骨窗' },
{ key: 'soft_tissue', label: '软组织' },
{ key: 'brain', label: '脑窗' },
{ key: 'lung', label: '肺窗' },
];
type LibraryItem = {
id: string;
patientId: string;
@@ -116,6 +129,16 @@ type LibraryInfo = {
}[];
};
type LibraryViewerPreview = {
imageUrl: string;
index: number;
count: number;
plane: string;
window: string;
windowLabel: string;
patientId: string;
};
type StoredDeformationJob = {
job: BackendJob;
progress: number;
@@ -354,6 +377,13 @@ export default function App() {
const [isUploadingDicom, setIsUploadingDicom] = useState(false);
const [libraryInfo, setLibraryInfo] = useState<LibraryInfo | null>(null);
const [isLibraryInfoLoading, setIsLibraryInfoLoading] = useState(false);
const [libraryViewerItem, setLibraryViewerItem] = useState<LibraryItem | null>(null);
const [viewerPlane, setViewerPlane] = useState('coronal');
const [viewerWindow, setViewerWindow] = useState('default');
const [viewerSliceIndex, setViewerSliceIndex] = useState<number | 'middle'>('middle');
const [viewerPreview, setViewerPreview] = useState<LibraryViewerPreview | null>(null);
const [isViewerLoading, setIsViewerLoading] = useState(false);
const [viewerError, setViewerError] = useState('');
const folderUploadInputRef = useRef<HTMLInputElement | null>(null);
const zipUploadInputRef = useRef<HTMLInputElement | null>(null);
@@ -868,6 +898,47 @@ export default function App() {
}
};
const openLibraryViewer = (item: LibraryItem) => {
setLibraryViewerItem(item);
setViewerPlane('coronal');
setViewerWindow('default');
setViewerSliceIndex('middle');
setViewerPreview(null);
setViewerError('');
};
const closeLibraryViewer = () => {
setLibraryViewerItem(null);
setViewerPreview(null);
setViewerError('');
setIsViewerLoading(false);
};
useEffect(() => {
if (!libraryViewerItem) return;
const controller = new AbortController();
setIsViewerLoading(true);
setViewerError('');
fetch(
`${API_BASE}/api/library/reformat-preview?id=${encodeURIComponent(libraryViewerItem.id)}&plane=${encodeURIComponent(viewerPlane)}&index=${encodeURIComponent(String(viewerSliceIndex))}&window=${encodeURIComponent(viewerWindow)}`,
{ signal: controller.signal }
)
.then(async response => {
const data = await response.json();
if (!response.ok) throw new Error(data.error || '阅览图像生成失败');
setViewerPreview(data);
})
.catch(error => {
if ((error as Error).name !== 'AbortError') setViewerError((error as Error).message);
})
.finally(() => {
if (!controller.signal.aborted) setIsViewerLoading(false);
});
return () => controller.abort();
}, [libraryViewerItem?.id, viewerPlane, viewerSliceIndex, viewerWindow]);
const changePassword = (userId: string, newPass: string) => {
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
setPwChangeInput('');
@@ -1198,7 +1269,7 @@ export default function App() {
<Download size={14} />
{zipJobs.all?.status === 'running'
? `四状态 ZIP 打包中 ${formatProgress(progressFromJob(zipJobs.all, 0))}%`
: '下载四状态 ZIP'}
: '下载结果'}
</button>
)}
{zipJobs.all?.status === 'failed' && <p className="text-[10px] text-red-500 font-bold mt-2 break-all">{zipJobs.all.error || '四状态 ZIP 打包失败'}</p>}
@@ -1369,7 +1440,7 @@ export default function App() {
<Download size={14} />
{zipJobs.all?.status === 'running'
? `打包中 ${formatProgress(progressFromJob(zipJobs.all, 0))}%`
: '下载四状态 DICOM ZIP'}
: '下载结果'}
</button>
</div>
<div className="grid grid-cols-4 gap-4 p-4">
@@ -1384,9 +1455,27 @@ export default function App() {
return (
<div key={t.key} className="min-w-0">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center gap-2 min-w-0">
<span className={`text-[10px] font-bold px-2 py-0.5 rounded transition-colors ${t.key === 'original' ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-500'}`}>{t.label}</span>
<button
onClick={() => handlePackageDownload(t.key)}
disabled={deformationJob?.status !== 'completed' || !deformationJob.result?.outputs || zipJobs[t.key]?.status === 'running'}
className="w-7 h-7 rounded-lg bg-slate-100 text-slate-500 hover:bg-green-600 hover:text-white transition-all flex items-center justify-center disabled:opacity-40 disabled:hover:bg-slate-100 disabled:hover:text-slate-500"
title={`下载${t.label} DICOM`}
>
<Download size={13} />
</button>
</div>
<span className="text-[8px] font-mono text-slate-300">{t.sub}</span>
</div>
{zipJobs[t.key]?.status === 'running' && (
<p className="mb-2 text-[9px] font-bold text-green-600">
{formatProgress(progressFromJob(zipJobs[t.key], 0))}%
</p>
)}
{zipJobs[t.key]?.status === 'failed' && (
<p className="mb-2 text-[9px] font-bold text-red-500 break-all">{zipJobs[t.key].error || '本状态 DICOM ZIP 打包失败'}</p>
)}
<div className="h-44 bg-slate-900 rounded-xl relative flex justify-center items-center overflow-hidden border border-slate-800">
{imagePath ? (
<img src={fileUrl(imagePath)} className="w-full h-full object-contain" />
@@ -1519,7 +1608,7 @@ export default function App() {
<span>{item.fileCount || 0} </span>
</div>
<div className="grid grid-cols-3 gap-3 mt-2">
<div className="grid grid-cols-4 gap-2 mt-2">
<button
onClick={() => {
if (item.status === 'processed') {
@@ -1535,11 +1624,17 @@ export default function App() {
}}
className={`py-2.5 text-[11px] font-black rounded-xl transition-all ${item.status === 'processed' ? 'bg-blue-600 text-white hover:bg-black shadow-lg shadow-blue-500/20' : 'bg-slate-100 text-slate-300 cursor-not-allowed'}`}
>
</button>
<button
onClick={() => item.status === 'processed' ? openLibraryViewer(item) : showToast('该影像尚在处理队列中,无法阅览')}
className={`py-2.5 text-[11px] font-black rounded-xl transition-all flex items-center justify-center gap-1 ${item.status === 'processed' ? 'bg-slate-900 text-white hover:bg-blue-600' : 'bg-slate-100 text-slate-300 cursor-not-allowed'}`}
>
<Eye size={12} />
</button>
<button
onClick={() => showLibraryInfo(item)}
className="py-2.5 bg-slate-900 text-white text-[11px] font-black rounded-xl hover:bg-blue-600 transition-all flex items-center justify-center gap-1"
className="py-2.5 bg-slate-100 text-slate-500 text-[11px] font-black rounded-xl hover:bg-blue-50 hover:text-blue-600 transition-all flex items-center justify-center gap-1"
>
<Info size={12} />
</button>
@@ -1831,6 +1926,112 @@ export default function App() {
</div>
)}
{libraryViewerItem && (
<div className="fixed inset-0 bg-slate-950/55 backdrop-blur-sm z-40 flex items-center justify-center p-6">
<div className="w-full max-w-6xl max-h-[90vh] bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden flex flex-col">
<div className="px-7 py-5 border-b flex items-center justify-between gap-5">
<div className="min-w-0">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">DICOM </p>
<h3 className="text-xl font-black text-slate-800 mt-1 truncate">{libraryViewerItem.patientId}</h3>
</div>
<button
onClick={closeLibraryViewer}
className="w-10 h-10 rounded-xl bg-slate-50 text-slate-400 hover:bg-red-50 hover:text-red-500 flex items-center justify-center transition-all shrink-0"
>
<X size={20} />
</button>
</div>
<div className="p-6 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-6 overflow-y-auto min-h-0">
<div className="space-y-5">
<div>
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3"></p>
<div className="grid grid-cols-2 gap-2">
{VIEWER_PLANE_OPTIONS.map(option => (
<button
key={option.key}
onClick={() => {
setViewerPlane(option.key);
setViewerSliceIndex('middle');
}}
className={`py-3 rounded-xl text-xs font-black transition-all ${
viewerPlane === option.key
? 'bg-blue-600 text-white shadow-lg shadow-blue-500/20'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
{option.label}
</button>
))}
</div>
</div>
<div>
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-3"></p>
<select
value={viewerWindow}
onChange={event => setViewerWindow(event.target.value)}
className="w-full px-3 py-3 bg-slate-50 border border-slate-200 rounded-xl text-xs font-bold text-slate-700 outline-none focus:ring-2 focus:ring-blue-500"
>
{VIEWER_WINDOW_OPTIONS.map(option => (
<option key={option.key} value={option.key}>{option.label}</option>
))}
</select>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest"></p>
<span className="text-[10px] font-mono font-black text-blue-600">
{(viewerPreview?.index ?? 0) + 1} / {viewerPreview?.count || 1}
</span>
</div>
<input
type="range"
min="0"
max={Math.max(0, (viewerPreview?.count || 1) - 1)}
value={viewerPreview?.index ?? 0}
onChange={event => setViewerSliceIndex(parseInt(event.target.value, 10))}
className="w-full h-1.5 accent-blue-600 cursor-pointer"
/>
</div>
<div className="rounded-2xl bg-slate-50 border border-slate-100 p-4 space-y-3">
<div className="flex items-center justify-between text-xs">
<span className="font-bold text-slate-400"></span>
<span className="font-black text-slate-700">{VIEWER_PLANE_OPTIONS.find(option => option.key === viewerPlane)?.label}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="font-bold text-slate-400"></span>
<span className="font-black text-slate-700">{viewerPreview?.windowLabel || VIEWER_WINDOW_OPTIONS.find(option => option.key === viewerWindow)?.label}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="font-bold text-slate-400"></span>
<span className="font-black text-slate-700">{libraryViewerItem.fileCount || 0} </span>
</div>
</div>
</div>
<div className="min-h-[360px] lg:min-h-[560px] bg-slate-950 rounded-2xl border border-slate-900 overflow-hidden flex items-center justify-center relative">
{viewerPreview?.imageUrl && !viewerError ? (
<img src={`${API_BASE}${viewerPreview.imageUrl}`} className="w-full h-full object-contain" />
) : (
<div className="text-center text-white/35">
<ImageIcon size={46} className="mx-auto mb-3" />
<p className="text-xs font-bold">{viewerError || '等待影像载入'}</p>
</div>
)}
{isViewerLoading && (
<div className="absolute top-4 right-4 px-3 py-1.5 rounded-lg bg-black/60 text-white text-[10px] font-black">
...
</div>
)}
</div>
</div>
</div>
</div>
)}
{(libraryInfo || isLibraryInfoLoading) && (
<div className="fixed inset-0 bg-slate-950/45 backdrop-blur-sm z-40 flex items-center justify-center p-6">
<div className="w-full max-w-4xl max-h-[85vh] bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden">

View File

@@ -47,6 +47,13 @@ RESULT_DIR = APP_DIR / "web_results"
JOBS_META = RESULT_DIR / "jobs.json"
USER_TASKS_META = RESULT_DIR / "user_tasks.json"
PREVIEW_CACHE_DIR = LIBRARY_DIR / "_preview_cache"
VIEWER_WINDOWS = {
"default": {"label": "默认", "low": -500, "high": 1200},
"bone": {"label": "骨窗", "low": -500, "high": 1800},
"soft_tissue": {"label": "软组织", "low": -160, "high": 240},
"brain": {"label": "脑窗", "low": 0, "high": 80},
"lung": {"label": "肺窗", "low": -1000, "high": 400},
}
def json_default(value):
@@ -213,6 +220,54 @@ def make_library_slice_preview(item_id, index):
}
def make_library_reformat_preview(item_id, plane, index, window):
item = find_library_item(item_id)
if not item:
raise RuntimeError("影像库中没有找到该数据。")
plane = plane if plane in {"coronal", "sagittal"} else "coronal"
window = window if window in VIEWER_WINDOWS else "default"
volume = load_dicom_volume(item["dicomPath"])
def normalize_reformat_index(raw_index, count):
if str(raw_index) == "middle":
return count // 2
try:
return int(raw_index)
except Exception:
return count // 2
if plane == "coronal":
count = volume.shape[1]
index = normalize_reformat_index(index, count)
index = max(0, min(index, count - 1))
image = volume[:, index, :]
else:
count = volume.shape[2]
index = normalize_reformat_index(index, count)
index = max(0, min(index, count - 1))
image = volume[:, :, index]
cache_dir = PREVIEW_CACHE_DIR / item_id / "reformat"
safe_mkdir(cache_dir)
preview_path = cache_dir / f"{plane}_{window}_{index:04d}.png"
if not preview_path.exists():
preset = VIEWER_WINDOWS[window]
preview = Image.fromarray(ct_window(image, preset["low"], preset["high"])).convert("RGB")
preview = fit_image(preview, 960, 720)
preview.save(preview_path, format="PNG")
return {
"imageUrl": f"/api/file?path={quote(str(preview_path.resolve()), safe='')}",
"index": index,
"count": count,
"plane": plane,
"window": window,
"windowLabel": VIEWER_WINDOWS[window]["label"],
"patientId": item["patientId"],
}
def dicom_value(ds, name, fallback="-"):
value = getattr(ds, name, fallback)
if value in [None, ""]:
@@ -881,6 +936,15 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(make_library_slice_preview(item_id, index))
return
if parsed.path == "/api/library/reformat-preview":
params = parse_qs(parsed.query)
item_id = params.get("id", [""])[0]
plane = params.get("plane", ["coronal"])[0]
index = params.get("index", ["0"])[0]
window = params.get("window", ["default"])[0]
self.send_json(make_library_reformat_preview(item_id, plane, index, window))
return
if parsed.path == "/api/library/info":
params = parse_qs(parsed.query)
item_id = params.get("id", [""])[0]

View File

@@ -0,0 +1,78 @@
# 实现方案
开始时间2026-05-03-22-36-18
## 本次方案路径
`工程分析/实现方案-2026-05-03-22-36-18.md`
## 实现目标
完成下载文案、四状态单项 DICOM 下载、影像库阅览入口和冠状/矢状 DICOM 显示模式切换。
## 涉及文件
- `WebSite/src/App.tsx`
- `web_backend.py`
- `工程分析/经验记录.md`
## 执行步骤
1. 前端文案调整:
- 将左侧工作站区域的“下载四状态ZIP”改为“下载结果”。
- 将影像库卡片中的“调阅”改为“变换”。
2. 四状态单项下载:
- 在四状态结果卡片中,为原始序列、硬边界、高斯平滑、软过渡重建标题旁增加下载图标按钮。
- 点击按钮调用已有 `handlePackageDownload(t.key)`,复用后端现有 `prepare_deformation_zip(job_id, target)`
- 按钮在形变任务未完成、对应状态 ZIP 正在打包时禁用,并显示简短状态。
3. 后端新增影像库重建阅览接口:
- 新增 `/api/library/reformat-preview`,参数包含 `id``plane``index``window`
- `plane` 支持 `coronal``sagittal`
- `window` 支持 `default``bone``soft_tissue``brain``lung` 等固定窗宽窗位模式。
- 读取影像库 DICOM 序列为体数据,根据平面切片后用窗宽窗位转 PNG缓存到 `web_library/_preview_cache/<item_id>/reformat/`
4. 前端新增影像库阅览弹层:
- 增加 `libraryViewerItem``viewerPlane``viewerWindow``viewerSliceIndex``viewerPreview` 等状态。
- 新增“阅览”按钮,打开弹层。
- 弹层提供冠状位/矢状位分段按钮、显示模式选择、切片滑杆、当前切片计数和预览图。
- 按切片、平面、显示模式请求 `/api/library/reformat-preview`
5. 保持现有“信息”“删除”“变换”行为不变,避免影响数据选择和工作站流程。
6. 执行测试方案。
7. 将关键问题和解决方案追加到 `工程分析/经验记录.md`
8. 提交并推送 Giteacommit 信息使用 `2026-05-03-22-36-18 增加DICOM阅览和单项下载`
9. 重新部署到 `http://192.168.3.11:3005/`
## 预期实现细节
- 单项下载 target 使用现有键:
- `original`
- `hard_boundary`
- `gaussian_smooth`
- `soft_transition`
- 阅览接口返回:
- `imageUrl`
- `index`
- `count`
- `plane`
- `window`
- `patientId`
- 窗宽窗位建议:
- default沿用现有 `ct_window` 默认范围。
- bone骨窗。
- soft_tissue软组织窗。
- brain脑窗。
- lung肺窗。
## 回滚思路
如阅览接口或 UI 不符合预期,可回滚 `web_backend.py` 的新接口和 `WebSite/src/App.tsx` 中的阅览弹层及按钮;四状态单项下载和文案调整可单独保留或回退。
## 风险控制
- 不引入新的前端依赖。
- 不修改真实 DICOM 形变与输出目录结构。
- 后端预览只输出 PNG不暴露额外 DICOM 文件写操作。
- 缓存文件写入现有 `_preview_cache` 下,删除影像库条目时继续随缓存目录清理。
## 人工审核状态
待用户二次人工审核确认。未经确认不得修改业务代码。

View File

@@ -0,0 +1,87 @@
# 测试方案
开始时间2026-05-03-22-36-18
## 本次方案路径
`工程分析/测试方案-2026-05-03-22-36-18.md`
## 测试范围
- 前端文案是否正确调整。
- 四状态输出区每个状态是否都有独立下载按钮。
- 单状态 DICOM ZIP 下载是否能触发、轮询并下载。
- 数据影像库“变换”“阅览”“信息”“删除”按钮是否正常布局和工作。
- 阅览弹层是否能显示冠状位、矢状位。
- 阅览弹层是否能切换 DICOM/CT 显示模式和切片。
- 前端类型检查、构建和 Python 语法检查是否通过。
- 重新部署后目标地址是否可访问。
## 测试命令
Python 语法检查:
```bash
python -m py_compile web_backend.py head_extension_app.py
```
前端类型检查:
```bash
cd WebSite
npm run lint
```
前端构建:
```bash
cd WebSite
npm run build
```
后端阅览接口验证:
```bash
curl -s 'http://127.0.0.1:8787/api/library/reformat-preview?id=<影像库ID>&plane=coronal&index=0&window=bone'
```
```bash
curl -s 'http://127.0.0.1:8787/api/library/reformat-preview?id=<影像库ID>&plane=sagittal&index=0&window=soft_tissue'
```
部署验证:
```bash
curl -I --max-time 5 http://192.168.3.11:3005/
```
## 手工验证点
- 左侧按钮显示“下载结果”。
- 四状态卡片中原始序列、硬边界、高斯平滑、软过渡重建旁边均有下载按钮。
- 点击单状态下载按钮后,能下载对应 DICOM 序列 ZIP。
- 数据影像库卡片中“调阅”改为“变换”。
- 点击“变换”仍能进入影像变换工作站并选择该数据。
- 点击“阅览”打开弹层,能查看冠状位和矢状位。
- 切换显示模式后图像灰度/对比度发生变化。
- 调节切片滑杆后图像更新。
## 验收标准
- 所有用户提出的文字和功能入口均出现。
- 单状态 DICOM ZIP 下载可用。
- 阅览弹层可用,冠状位/矢状位和显示模式切换可用。
- `python -m py_compile web_backend.py head_extension_app.py` 通过。
- `npm run lint` 通过。
- `npm run build` 通过。
- Gitea commit 和 push 完成。
- 项目重新部署后 `http://192.168.3.11:3005/` 返回 `200 OK`
## 无法测试或残余风险
- 若没有真实业务 DICOM 数据,使用仓库样例或当前影像库数据验证;不同扫描协议下的窗宽窗位显示效果可能需用户进一步微调。
- 冠状/矢状重建为轻量预览,不替代专业 DICOM 阅片器的诊断级 MPR 功能。
## 人工审核状态
待用户二次人工审核确认。未经确认不得修改业务代码。

View File

@@ -37,3 +37,21 @@ C. 解决问题方案
D. 后续如何避免问题
预览标注层、参考线、箭头等非 CT 内容应优先在形变结果上单独叠加,不要和医学影像像素一起参与形变采样。若必须参与形变,应先确认该标注能承受硬边界或快速变化权重造成的撕裂效果。
## 2026-05-03-22-36-18 增加 DICOM 阅览和单项下载
A. 具体问题
四状态结果区只有总 ZIP 下载入口,用户无法直接下载单个状态的 DICOM 序列;影像库卡片只能进入变换工作站或查看信息,缺少冠状位、矢状位阅览能力和 CT 显示模式切换。
B. 产生问题原因
后端已经具备单状态 DICOM ZIP 打包能力,但前端未暴露单状态按钮。影像库已有轴位缩略图接口,但该接口只读取单张 DICOM未提供基于整套 DICOM 体数据的冠状/矢状重建预览。
C. 解决问题方案
前端在四状态卡片标题旁增加单状态下载按钮,复用现有 ZIP job 轮询和下载逻辑。后端新增影像库冠状/矢状重建预览接口,并按平面、切片和窗宽窗位缓存 PNG。前端新增影像库阅览弹层支持冠状位、矢状位、切片滑杆和显示模式切换。
D. 后续如何避免问题
已有后端能力应先通过前端入口复用不重复实现打包逻辑。DICOM 阅览类预览需要使用缓存,避免每次切换切片或窗宽窗位都重复生成相同图像;新增缓存应继续放在现有 `_preview_cache` 下,避免污染仓库。

View File

@@ -0,0 +1,55 @@
# 需求分析
开始时间2026-05-03-22-36-18
## 原始需求
用户提出三项修改:
1. 左侧“下载四状态ZIP”描述变为“下载结果”。
2. “四状态 DICOM 输出结果”中,原始序列、硬边界、高斯平滑、软过渡重建旁边都增加下载按钮,可以下载对应的 DICOM 文件。
3. 在“数据影像库”中,“调阅”描述变为“变换”;右侧增加“阅览”按钮,可以看 CT 冠状位、矢状位,同时可以调节 DCM 影像显示模式。
## 目标
- 调整已有按钮文案,使下载入口和变换入口更符合用户表达。
- 在四状态结果区为每个状态提供独立 DICOM 下载入口。
- 在影像库卡片中新增阅览入口,打开 DICOM 阅览弹层。
- 阅览弹层支持冠状位、矢状位切换,并支持常见 DICOM/CT 显示窗宽窗位模式切换。
## 影响范围
- `WebSite/src/App.tsx`
- 修改按钮文字。
- 增加四状态单项下载按钮。
- 增加影像库阅览弹层状态、UI 和请求逻辑。
- `web_backend.py`
- 复用或扩展现有 DICOM 读取、窗宽窗位、预览缓存能力。
- 增加影像库冠状位/矢状位阅览预览接口。
- `工程分析/经验记录.md`
- 完成后追加关键问题和解决方案。
## 当前定位
- 四状态单状态 ZIP 下载能力后端已存在:`prepare_deformation_zip(job_id, target)` 支持 `original``hard_boundary``gaussian_smooth``soft_transition`
- 前端现有 `handlePackageDownload(target)` 已支持传入 target并会轮询 ZIP job 完成后下载文件;当前仅全量 DICOM ZIP 按钮显式暴露。
- 影像库目前只有轴位切片缩略预览接口 `/api/library/preview`,使用单张 DICOM 像素生成 PNG。
- 影像库信息弹层已存在,可在其旁边新增独立阅览弹层,不必复用信息弹层。
## 约束
- 修改业务代码前必须先等待用户确认实现方案和测试方案。
- 不改变 DICOM 形变算法。
- 单状态下载应下载对应状态完整 DICOM 序列的 ZIP而不是仅下载截图。
- 阅览功能应尽量轻量,不引入大型医学影像前端库。
## 风险点
- 冠状位/矢状位预览需要读取整套 DICOM 体数据,可能比单张轴位预览更耗时;需要做缓存。
- DICOM 切片排序、窗宽窗位和方向显示若处理粗糙,可能导致阅览体验不稳定。
- 四状态卡片空间有限,新增下载按钮需要避免文字拥挤或遮挡。
- 单状态 ZIP job 与全量 ZIP job 共用 `zipJobs` 状态,需避免 target key 冲突。
## 待确认事项
建议实现为:四状态卡片标题旁增加图标下载按钮;影像库卡片按钮区从“调阅/信息/删除”调整为“变换/阅览/信息/删除”,阅览弹层支持冠状位、矢状位,以及默认/骨窗/软组织/脑窗/肺窗等显示模式。待用户确认后执行。