2026-05-03-23-22-10 修复阅览切换和下载触发
This commit is contained in:
@@ -346,6 +346,7 @@ function OverviewDicomThumbnail({ item }: { item: LibraryItem }) {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const restoredDeformationJob = useRef(readStoredDeformationJob()).current;
|
const restoredDeformationJob = useRef(readStoredDeformationJob()).current;
|
||||||
|
const downloadedZipJobIds = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
// --- Authentication State ---
|
// --- Authentication State ---
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
@@ -381,6 +382,7 @@ export default function App() {
|
|||||||
const [viewerPlane, setViewerPlane] = useState('coronal');
|
const [viewerPlane, setViewerPlane] = useState('coronal');
|
||||||
const [viewerWindow, setViewerWindow] = useState('default');
|
const [viewerWindow, setViewerWindow] = useState('default');
|
||||||
const [viewerSliceIndex, setViewerSliceIndex] = useState<number | 'middle'>('middle');
|
const [viewerSliceIndex, setViewerSliceIndex] = useState<number | 'middle'>('middle');
|
||||||
|
const [debouncedViewerSliceIndex, setDebouncedViewerSliceIndex] = useState<number | 'middle'>('middle');
|
||||||
const [viewerPreview, setViewerPreview] = useState<LibraryViewerPreview | null>(null);
|
const [viewerPreview, setViewerPreview] = useState<LibraryViewerPreview | null>(null);
|
||||||
const [isViewerLoading, setIsViewerLoading] = useState(false);
|
const [isViewerLoading, setIsViewerLoading] = useState(false);
|
||||||
const [viewerError, setViewerError] = useState('');
|
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 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;
|
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 = 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');
|
const link = document.createElement('a');
|
||||||
link.href = fileUrl(path);
|
link.href = directUrl;
|
||||||
if (name) link.download = name;
|
if (name) link.download = name;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
@@ -739,8 +759,11 @@ export default function App() {
|
|||||||
: job;
|
: job;
|
||||||
setZipJobs(current => ({ ...current, [target]: displayJob }));
|
setZipJobs(current => ({ ...current, [target]: displayJob }));
|
||||||
if (job.status === 'completed') {
|
if (job.status === 'completed') {
|
||||||
triggerDownload(job.result?.file?.path, job.result?.file?.name);
|
if (!downloadedZipJobIds.current.has(job.id)) {
|
||||||
showToast('ZIP 打包完成,已开始下载');
|
downloadedZipJobIds.current.add(job.id);
|
||||||
|
await triggerDownload(job.result?.file?.path, job.result?.file?.name);
|
||||||
|
showToast('ZIP 打包完成,已开始下载');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (job.status === 'failed') {
|
if (job.status === 'failed') {
|
||||||
showToast(job.error || 'ZIP 打包失败');
|
showToast(job.error || 'ZIP 打包失败');
|
||||||
@@ -903,6 +926,7 @@ export default function App() {
|
|||||||
setViewerPlane('coronal');
|
setViewerPlane('coronal');
|
||||||
setViewerWindow('default');
|
setViewerWindow('default');
|
||||||
setViewerSliceIndex('middle');
|
setViewerSliceIndex('middle');
|
||||||
|
setDebouncedViewerSliceIndex('middle');
|
||||||
setViewerPreview(null);
|
setViewerPreview(null);
|
||||||
setViewerError('');
|
setViewerError('');
|
||||||
};
|
};
|
||||||
@@ -914,6 +938,13 @@ export default function App() {
|
|||||||
setIsViewerLoading(false);
|
setIsViewerLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setDebouncedViewerSliceIndex(viewerSliceIndex);
|
||||||
|
}, typeof viewerSliceIndex === 'number' ? 180 : 0);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [viewerSliceIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!libraryViewerItem) return;
|
if (!libraryViewerItem) return;
|
||||||
|
|
||||||
@@ -921,7 +952,7 @@ export default function App() {
|
|||||||
setIsViewerLoading(true);
|
setIsViewerLoading(true);
|
||||||
setViewerError('');
|
setViewerError('');
|
||||||
fetch(
|
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 }
|
{ signal: controller.signal }
|
||||||
)
|
)
|
||||||
.then(async response => {
|
.then(async response => {
|
||||||
@@ -937,7 +968,7 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [libraryViewerItem?.id, viewerPlane, viewerSliceIndex, viewerWindow]);
|
}, [libraryViewerItem?.id, viewerPlane, debouncedViewerSliceIndex, viewerWindow]);
|
||||||
|
|
||||||
const changePassword = (userId: string, newPass: string) => {
|
const changePassword = (userId: string, newPass: string) => {
|
||||||
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
|
setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u));
|
||||||
@@ -1953,6 +1984,7 @@ export default function App() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setViewerPlane(option.key);
|
setViewerPlane(option.key);
|
||||||
setViewerSliceIndex('middle');
|
setViewerSliceIndex('middle');
|
||||||
|
setDebouncedViewerSliceIndex('middle');
|
||||||
}}
|
}}
|
||||||
className={`py-3 rounded-xl text-xs font-black transition-all ${
|
className={`py-3 rounded-xl text-xs font-black transition-all ${
|
||||||
viewerPlane === option.key
|
viewerPlane === option.key
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ PORT = 8787
|
|||||||
JOBS = {}
|
JOBS = {}
|
||||||
JOBS_LOCK = threading.Lock()
|
JOBS_LOCK = threading.Lock()
|
||||||
DICOM_FILE_CACHE = {}
|
DICOM_FILE_CACHE = {}
|
||||||
|
DICOM_VOLUME_CACHE = {}
|
||||||
|
DICOM_VOLUME_CACHE_LOCK = threading.Lock()
|
||||||
|
DICOM_VOLUME_CACHE_LIMIT = 2
|
||||||
LIBRARY_DIR = APP_DIR / "web_library"
|
LIBRARY_DIR = APP_DIR / "web_library"
|
||||||
LIBRARY_META = LIBRARY_DIR / "library.json"
|
LIBRARY_META = LIBRARY_DIR / "library.json"
|
||||||
RESULT_DIR = APP_DIR / "web_results"
|
RESULT_DIR = APP_DIR / "web_results"
|
||||||
@@ -178,6 +181,56 @@ def sorted_dicom_files(dicom_dir):
|
|||||||
return sorted_files
|
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):
|
def find_library_item(item_id):
|
||||||
return next((item for item in list_library() if item["id"] == item_id), None)
|
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"
|
plane = plane if plane in {"coronal", "sagittal"} else "coronal"
|
||||||
window = window if window in VIEWER_WINDOWS else "default"
|
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):
|
def normalize_reformat_index(raw_index, count):
|
||||||
if str(raw_index) == "middle":
|
if str(raw_index) == "middle":
|
||||||
@@ -1156,7 +1209,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
write_library_meta(remaining)
|
write_library_meta(remaining)
|
||||||
upload_root = Path(target["dicomPath"]).resolve().parent
|
upload_root = Path(target["dicomPath"]).resolve().parent
|
||||||
if upload_root.exists() and upload_root.is_relative_to(LIBRARY_DIR.resolve()):
|
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)
|
shutil.rmtree(upload_root)
|
||||||
preview_cache = PREVIEW_CACHE_DIR / item_id
|
preview_cache = PREVIEW_CACHE_DIR / item_id
|
||||||
if preview_cache.exists():
|
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-Disposition", f'attachment; filename="{file_path.name}"')
|
||||||
self.send_header("Content-Length", str(file_path.stat().st_size))
|
self.send_header("Content-Length", str(file_path.stat().st_size))
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
with file_path.open("rb") as file_handle:
|
try:
|
||||||
shutil.copyfileobj(file_handle, self.wfile)
|
with file_path.open("rb") as file_handle:
|
||||||
|
shutil.copyfileobj(file_handle, self.wfile)
|
||||||
|
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
||||||
|
return
|
||||||
|
|
||||||
def send_cors_headers(self):
|
def send_cors_headers(self):
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
|||||||
55
工程分析/实现方案-2026-05-03-23-22-10.md
Normal file
55
工程分析/实现方案-2026-05-03-23-22-10.md
Normal 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. 提交并推送 Gitea,commit 信息使用 `2026-05-03-23-22-10 修复阅览切换和下载触发`。
|
||||||
|
9. 重新部署到 `http://192.168.3.11:3005/`。
|
||||||
|
|
||||||
|
## 回滚思路
|
||||||
|
|
||||||
|
若缓存或 blob 下载出现问题,可回滚本次 `web_backend.py` 和 `WebSite/src/App.tsx` 修改,恢复直接读取体数据和直接 URL 下载方式。
|
||||||
|
|
||||||
|
## 风险控制
|
||||||
|
|
||||||
|
- 体数据缓存只影响阅览预览接口,不影响真实形变输出。
|
||||||
|
- 缓存使用目录签名,影像库数据变化后会重新读取。
|
||||||
|
- 删除影像库时同步清除 DICOM 文件缓存和体数据缓存。
|
||||||
|
- 前端保留下载回退路径,避免 blob 下载失败后无法下载。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
用户已明确本次不需要人工二次确认,直接执行。
|
||||||
78
工程分析/测试方案-2026-05-03-23-22-10.md
Normal file
78
工程分析/测试方案-2026-05-03-23-22-10.md
Normal 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 部署。
|
||||||
|
|
||||||
|
## 人工审核状态
|
||||||
|
|
||||||
|
用户已明确本次不需要人工二次确认,直接执行。
|
||||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -55,3 +55,21 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
已有后端能力应先通过前端入口复用,不重复实现打包逻辑。DICOM 阅览类预览需要使用缓存,避免每次切换切片或窗宽窗位都重复生成相同图像;新增缓存应继续放在现有 `_preview_cache` 下,避免污染仓库。
|
已有后端能力应先通过前端入口复用,不重复实现打包逻辑。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,避免同一个完成状态在后续渲染或轮询中重复触发。
|
||||||
|
|||||||
55
工程分析/需求分析-2026-05-03-23-22-10.md
Normal file
55
工程分析/需求分析-2026-05-03-23-22-10.md
Normal 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 部署。
|
||||||
|
|
||||||
|
## 待确认事项
|
||||||
|
|
||||||
|
用户已明确本次不需要二次人工确认,因此本次文档写完后直接执行实现和测试。
|
||||||
Reference in New Issue
Block a user