2026-05-03-23-22-10 修复阅览切换和下载触发

This commit is contained in:
2026-05-03 23:26:36 +08:00
parent bff7eead08
commit 7b7c555321
6 changed files with 304 additions and 10 deletions

View File

@@ -346,6 +346,7 @@ function OverviewDicomThumbnail({ item }: { item: LibraryItem }) {
export default function App() {
const restoredDeformationJob = useRef(readStoredDeformationJob()).current;
const downloadedZipJobIds = useRef<Set<string>>(new Set());
// --- Authentication State ---
const [isLoggedIn, setIsLoggedIn] = useState(false);
@@ -381,6 +382,7 @@ export default function App() {
const [viewerPlane, setViewerPlane] = useState('coronal');
const [viewerWindow, setViewerWindow] = useState('default');
const [viewerSliceIndex, setViewerSliceIndex] = useState<number | 'middle'>('middle');
const [debouncedViewerSliceIndex, setDebouncedViewerSliceIndex] = useState<number | 'middle'>('middle');
const [viewerPreview, setViewerPreview] = useState<LibraryViewerPreview | null>(null);
const [isViewerLoading, setIsViewerLoading] = useState(false);
const [viewerError, setViewerError] = useState('');
@@ -462,10 +464,28 @@ export default function App() {
const fileUrl = (path?: string) => path ? `${API_BASE}/api/file?path=${encodeURIComponent(path)}` : '';
const triggerDownload = (path?: string, name?: string) => {
const triggerDownload = async (path?: string, name?: string) => {
if (!path) return;
const directUrl = fileUrl(path);
try {
const response = await fetch(directUrl);
if (!response.ok) throw new Error('下载文件读取失败');
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = fileUrl(path);
link.href = objectUrl;
link.download = name || path.split('/').pop() || 'download';
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
return;
} catch {
showToast('浏览器安全策略阻止直接下载时,将尝试备用下载方式');
}
const link = document.createElement('a');
link.href = directUrl;
if (name) link.download = name;
document.body.appendChild(link);
link.click();
@@ -739,9 +759,12 @@ export default function App() {
: job;
setZipJobs(current => ({ ...current, [target]: displayJob }));
if (job.status === 'completed') {
triggerDownload(job.result?.file?.path, job.result?.file?.name);
if (!downloadedZipJobIds.current.has(job.id)) {
downloadedZipJobIds.current.add(job.id);
await triggerDownload(job.result?.file?.path, job.result?.file?.name);
showToast('ZIP 打包完成,已开始下载');
}
}
if (job.status === 'failed') {
showToast(job.error || 'ZIP 打包失败');
}
@@ -903,6 +926,7 @@ export default function App() {
setViewerPlane('coronal');
setViewerWindow('default');
setViewerSliceIndex('middle');
setDebouncedViewerSliceIndex('middle');
setViewerPreview(null);
setViewerError('');
};
@@ -914,6 +938,13 @@ export default function App() {
setIsViewerLoading(false);
};
useEffect(() => {
const timer = window.setTimeout(() => {
setDebouncedViewerSliceIndex(viewerSliceIndex);
}, typeof viewerSliceIndex === 'number' ? 180 : 0);
return () => window.clearTimeout(timer);
}, [viewerSliceIndex]);
useEffect(() => {
if (!libraryViewerItem) return;
@@ -921,7 +952,7 @@ export default function App() {
setIsViewerLoading(true);
setViewerError('');
fetch(
`${API_BASE}/api/library/reformat-preview?id=${encodeURIComponent(libraryViewerItem.id)}&plane=${encodeURIComponent(viewerPlane)}&index=${encodeURIComponent(String(viewerSliceIndex))}&window=${encodeURIComponent(viewerWindow)}`,
`${API_BASE}/api/library/reformat-preview?id=${encodeURIComponent(libraryViewerItem.id)}&plane=${encodeURIComponent(viewerPlane)}&index=${encodeURIComponent(String(debouncedViewerSliceIndex))}&window=${encodeURIComponent(viewerWindow)}`,
{ signal: controller.signal }
)
.then(async response => {
@@ -937,7 +968,7 @@ export default function App() {
});
return () => controller.abort();
}, [libraryViewerItem?.id, viewerPlane, viewerSliceIndex, viewerWindow]);
}, [libraryViewerItem?.id, viewerPlane, debouncedViewerSliceIndex, viewerWindow]);
const changePassword = (userId: string, newPass: string) => {
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
@@ -1953,6 +1984,7 @@ export default function App() {
onClick={() => {
setViewerPlane(option.key);
setViewerSliceIndex('middle');
setDebouncedViewerSliceIndex('middle');
}}
className={`py-3 rounded-xl text-xs font-black transition-all ${
viewerPlane === option.key

View File

@@ -41,6 +41,9 @@ PORT = 8787
JOBS = {}
JOBS_LOCK = threading.Lock()
DICOM_FILE_CACHE = {}
DICOM_VOLUME_CACHE = {}
DICOM_VOLUME_CACHE_LOCK = threading.Lock()
DICOM_VOLUME_CACHE_LIMIT = 2
LIBRARY_DIR = APP_DIR / "web_library"
LIBRARY_META = LIBRARY_DIR / "library.json"
RESULT_DIR = APP_DIR / "web_results"
@@ -178,6 +181,56 @@ def sorted_dicom_files(dicom_dir):
return sorted_files
def dicom_dir_signature(dicom_dir):
dicom_dir = Path(dicom_dir).resolve()
files = list(dicom_dir.glob("*.dcm"))
return (
str(dicom_dir),
len(files),
max((file_path.stat().st_mtime for file_path in files), default=0),
)
def load_cached_dicom_volume(dicom_dir):
dicom_dir = Path(dicom_dir).resolve()
signature = dicom_dir_signature(dicom_dir)
cache_key = str(dicom_dir)
with DICOM_VOLUME_CACHE_LOCK:
cached = DICOM_VOLUME_CACHE.get(cache_key)
if cached and cached["signature"] == signature:
cached["last_access"] = time.time()
return cached["volume"]
volume = load_dicom_volume(dicom_dir)
DICOM_VOLUME_CACHE[cache_key] = {
"signature": signature,
"volume": volume,
"last_access": time.time(),
}
while len(DICOM_VOLUME_CACHE) > DICOM_VOLUME_CACHE_LIMIT:
oldest_key = min(
DICOM_VOLUME_CACHE,
key=lambda key: DICOM_VOLUME_CACHE[key].get("last_access", 0),
)
if oldest_key == cache_key:
break
DICOM_VOLUME_CACHE.pop(oldest_key, None)
return volume
def clear_dicom_caches(dicom_dir=None):
if dicom_dir is None:
DICOM_FILE_CACHE.clear()
with DICOM_VOLUME_CACHE_LOCK:
DICOM_VOLUME_CACHE.clear()
return
cache_key = str(Path(dicom_dir).resolve())
DICOM_FILE_CACHE.pop(cache_key, None)
with DICOM_VOLUME_CACHE_LOCK:
DICOM_VOLUME_CACHE.pop(cache_key, None)
def find_library_item(item_id):
return next((item for item in list_library() if item["id"] == item_id), None)
@@ -227,7 +280,7 @@ def make_library_reformat_preview(item_id, plane, index, window):
plane = plane if plane in {"coronal", "sagittal"} else "coronal"
window = window if window in VIEWER_WINDOWS else "default"
volume = load_dicom_volume(item["dicomPath"])
volume = load_cached_dicom_volume(item["dicomPath"])
def normalize_reformat_index(raw_index, count):
if str(raw_index) == "middle":
@@ -1156,7 +1209,7 @@ class Handler(BaseHTTPRequestHandler):
write_library_meta(remaining)
upload_root = Path(target["dicomPath"]).resolve().parent
if upload_root.exists() and upload_root.is_relative_to(LIBRARY_DIR.resolve()):
DICOM_FILE_CACHE.pop(str(Path(target["dicomPath"]).resolve()), None)
clear_dicom_caches(target["dicomPath"])
shutil.rmtree(upload_root)
preview_cache = PREVIEW_CACHE_DIR / item_id
if preview_cache.exists():
@@ -1188,8 +1241,11 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Content-Disposition", f'attachment; filename="{file_path.name}"')
self.send_header("Content-Length", str(file_path.stat().st_size))
self.end_headers()
try:
with file_path.open("rb") as file_handle:
shutil.copyfileobj(file_handle, self.wfile)
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
return
def send_cors_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")

View File

@@ -0,0 +1,55 @@
# 实现方案
开始时间2026-05-03-23-22-10
## 本次方案路径
`工程分析/实现方案-2026-05-03-23-22-10.md`
## 实现目标
修复 DICOM 阅览切片/显示模式切换导致后端连接重置的问题,并降低 ZIP 下载误触发和 HTTP 文件加载警告。
## 涉及文件
- `web_backend.py`
- `WebSite/src/App.tsx`
- `工程分析/经验记录.md`
## 执行步骤
1. 后端增加 DICOM 体数据缓存:
- 增加 `DICOM_VOLUME_CACHE` 和锁。
- 基于 DICOM 目录路径、文件数量、最新修改时间生成签名。
- `/api/library/reformat-preview` 复用缓存体数据,避免每次切片/窗位变化都重新读取完整 DICOM。
- 控制缓存规模,避免多个大体数据长期驻留。
2. 后端下载容错:
- `send_file` 捕获 `BrokenPipeError``ConnectionResetError``ConnectionAbortedError`,避免客户端中断下载时污染日志或影响服务。
3. 前端 DICOM 阅览请求防抖:
- 增加 `debouncedViewerSliceIndex`
- 切片滑杆变化后延迟短时间再请求后端,减少快速拖动时的请求数量。
- 显示模式或平面切换仍可即时重置到中间切片,但实际请求走防抖后的切片索引。
4. 前端 ZIP 下载防重复:
- 增加已下载 ZIP job id 记录,确保同一个 ZIP job 自动下载只触发一次。
5. 前端 ZIP 下载方式调整:
- 对后端文件路径优先使用 `fetch` 获取 blob再使用本地 `blob:` URL 触发下载。
- 如 blob 下载失败,回退到原有 `/api/file` URL 方式,并提示用户。
6. 执行测试方案。
7. 更新 `工程分析/经验记录.md`
8. 提交并推送 Giteacommit 信息使用 `2026-05-03-23-22-10 修复阅览切换和下载触发`
9. 重新部署到 `http://192.168.3.11:3005/`
## 回滚思路
若缓存或 blob 下载出现问题,可回滚本次 `web_backend.py``WebSite/src/App.tsx` 修改,恢复直接读取体数据和直接 URL 下载方式。
## 风险控制
- 体数据缓存只影响阅览预览接口,不影响真实形变输出。
- 缓存使用目录签名,影像库数据变化后会重新读取。
- 删除影像库时同步清除 DICOM 文件缓存和体数据缓存。
- 前端保留下载回退路径,避免 blob 下载失败后无法下载。
## 人工审核状态
用户已明确本次不需要人工二次确认,直接执行。

View File

@@ -0,0 +1,78 @@
# 测试方案
开始时间2026-05-03-23-22-10
## 本次方案路径
`工程分析/测试方案-2026-05-03-23-22-10.md`
## 测试范围
- DICOM 阅览冠状位/矢状位请求是否稳定。
- 切片滑杆快速变化是否不再导致后端被杀或连接重置。
- 显示模式切换是否正常返回图片。
- ZIP 下载 job 完成后是否只触发一次下载。
- 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
for i in 0 64 128 256 384 511; do
curl -s "http://127.0.0.1:8787/api/library/reformat-preview?id=demo_ori_head_ct&plane=coronal&index=$i&window=bone" >/dev/null
done
```
部署验证:
```bash
curl -I --max-time 5 http://192.168.3.11:3005/
curl -s --max-time 10 "http://127.0.0.1:8787/api/library/reformat-preview?id=demo_ori_head_ct&plane=coronal&index=511&window=bone"
```
## 手工验证点
- 打开数据影像库,进入 DICOM 阅览。
- 拖动切片滑杆,图像更新且控制台不再出现 `ERR_CONNECTION_RESET`
- 切换显示模式,图像更新且后端服务保持在线。
- 如有 ZIP 打包下载任务,完成后只自动下载一次。
## 验收标准
- 后端不再因为阅览切片/窗位切换被 `Killed`
- `reformat-preview` 连续请求正常返回 JSON。
- `npm run lint` 通过。
- `npm run build` 通过。
- `python -m py_compile web_backend.py head_extension_app.py` 通过。
- Gitea commit/push 完成。
- 重新部署后 `http://192.168.3.11:3005/` 返回 `200 OK`
## 残余风险
- 纯 HTTP 部署下,浏览器可能仍会对下载文件给出安全提示;本次通过 blob 下载和重复触发保护尽量降低该提示出现概率。彻底消除需要 HTTPS 部署。
## 人工审核状态
用户已明确本次不需要人工二次确认,直接执行。

View File

@@ -55,3 +55,21 @@ C. 解决问题方案
D. 后续如何避免问题
已有后端能力应先通过前端入口复用不重复实现打包逻辑。DICOM 阅览类预览需要使用缓存,避免每次切换切片或窗宽窗位都重复生成相同图像;新增缓存应继续放在现有 `_preview_cache` 下,避免污染仓库。
## 2026-05-03-23-22-10 修复阅览切换和下载触发
A. 具体问题
DICOM 阅览切片滑杆和显示模式切换时,浏览器出现 `ERR_CONNECTION_RESET`;同时 ZIP 文件下载可能在其他 UI 操作期间再次触发,并出现 HTTP 文件加载安全提示。
B. 产生问题原因
阅览重建接口每次请求都重新读取整套 DICOM 体数据,切片滑杆快速拖动和显示模式切换会并发触发多个重建请求。前端 AbortController 只能取消浏览器等待,不能停止后端已经开始的 DICOM 读取容易造成内存压力并导致后端被系统终止。ZIP 下载通过直接访问后端 `/api/file` URL 触发,且缺少已完成 ZIP job 的一次性下载保护。
C. 解决问题方案
后端为 DICOM 体数据增加基于目录签名的内存缓存,并限制缓存数量;阅览接口复用缓存体数据生成不同平面、切片和窗宽窗位的 PNG。前端为阅览切片请求增加防抖减少滑杆拖动时的请求数量。ZIP 下载增加已下载 job id 记录,并优先使用 fetch blob 转本地 `blob:` URL 触发下载,失败时再回退到直接 URL。
D. 后续如何避免问题
涉及整套 DICOM 体数据读取的接口必须考虑缓存、并发和请求节流,不能把滑杆这类高频 UI 操作直接绑定到重型后端计算。自动下载类逻辑必须记录已处理 job避免同一个完成状态在后续渲染或轮询中重复触发。

View File

@@ -0,0 +1,55 @@
# 需求分析
开始时间2026-05-03-23-22-10
## 原始需求
用户要求严格使用代码编纂工作流,并在最开始确认整体流程。本次需求分析、实现方案、测试方案和执行修改均不需要用户二次人工确认。
需要修复两个问题:
1. 点击 DICOM 阅览中的切片滚动条时出现与 ZIP 文件相关的浏览器警告:`The file at ...head_ct_morph_selected_54ed40feeb63.zip was loaded over an insecure connection. This file should be served over HTTPS.`
2. 切换 DICOM 阅览显示模式时出现请求失败:`GET /api/library/reformat-preview ... net::ERR_CONNECTION_RESET`
## 目标
- DICOM 阅览切片滚动和显示模式切换不再导致后端连接重置。
- 降低切片滑杆快速拖动时的请求风暴和后端内存压力。
- ZIP 下载完成后只触发一次下载,避免和其他 UI 操作交织造成误触发。
- 尽量避免通过直接加载 ZIP URL 的方式触发浏览器 insecure file 警告。
## 影响范围
- `web_backend.py`
- DICOM 阅览重建预览接口。
- DICOM 体数据读取和缓存策略。
- 文件下载响应的连接中断容错。
- `WebSite/src/App.tsx`
- DICOM 阅览请求节流/防抖。
- ZIP 下载触发逻辑。
- 已完成 ZIP job 的重复下载保护。
- `工程分析/经验记录.md`
## 当前定位
- 后端日志显示 `python ../web_backend.py` 被系统 `Killed`,符合快速并发读取整套 DICOM 体数据导致内存压力过大的表现。
- 当前 `/api/library/reformat-preview` 每次请求都会调用 `load_dicom_volume(item["dicomPath"])`,快速拖动滑杆或切换窗位会并发生成多个请求。
- 前端阅览 effect 在 `viewerSliceIndex``viewerWindow` 等状态变化时立即发请求AbortController 只中断浏览器端等待,不能中止后端已经开始的体数据读取。
- ZIP 下载目前通过临时 `<a>` 直接访问后端 `/api/file?path=...zip`,浏览器可能显示 HTTP 文件下载安全警告;同时需要防止同一个 ZIP job 多次自动触发下载。
## 约束
- 不需要用户二次确认,可以直接执行修改。
- 不改变真实 DICOM 形变算法。
- 不引入大型前端 DICOM 阅片库。
- 不把运行缓存图片、ZIP、DICOM 或构建产物提交到仓库。
## 风险点
- 体数据内存缓存会占用一定内存,需要按影像库目录签名复用并限制缓存规模。
- Blob 下载大 ZIP 会占用浏览器内存;本次优先用于减少直接 URL 加载警告,仍需关注超大 ZIP 的浏览器表现。
- 浏览器对 HTTP 下载的安全提示无法在纯 HTTP 部署下彻底消除;如果必须完全消除,需要 HTTPS 部署。
## 待确认事项
用户已明确本次不需要二次人工确认,因此本次文档写完后直接执行实现和测试。