2026-05-21-00-43-44 修复ZIP STL导入反馈

This commit is contained in:
2026-05-21 00:54:23 +08:00
parent 14c8eb153d
commit b07b04adeb
6 changed files with 222 additions and 9 deletions

View File

@@ -2595,6 +2595,12 @@ async function startServer() {
writeState(state); writeState(state);
res.json(project); res.json(project);
} catch (error) { } catch (error) {
console.error('[import-assets] failed', {
projectId: req.params.projectId,
kind,
fileCount: multerFiles.length + legacyUploadedFiles.length,
message: error instanceof Error ? error.message : error,
});
res.status(422).json({ message: error instanceof Error ? error.message : '项目资产导入失败' }); res.status(422).json({ message: error instanceof Error ? error.message : '项目资产导入失败' });
} finally { } finally {
cleanupUploadedTempFiles(multerFiles); cleanupUploadedTempFiles(multerFiles);

View File

@@ -135,7 +135,8 @@ interface AssetImportProgressState {
totalBytes: number; totalBytes: number;
loadedBytes: number; loadedBytes: number;
percent: number; percent: number;
phase: 'uploading' | 'processing' | 'done'; phase: 'uploading' | 'processing' | 'done' | 'failed';
message?: string;
} }
function formatFileSize(value: number) { function formatFileSize(value: number) {
@@ -953,8 +954,17 @@ export default function ProjectLibrary({
setActionMessage(kind === 'dicom' ? `已导入 ${updated.dicomCount} 张 DICOM 影像` : `已导入 ${updated.modelCount ?? 0} 个 STL 模型`); setActionMessage(kind === 'dicom' ? `已导入 ${updated.dicomCount} 张 DICOM 影像` : `已导入 ${updated.modelCount ?? 0} 个 STL 模型`);
window.setTimeout(() => setAssetImportProgress(null), 1800); window.setTimeout(() => setAssetImportProgress(null), 1800);
} catch (error) { } catch (error) {
setActionMessage(error instanceof Error ? error.message : '项目资产导入失败'); const message = error instanceof Error ? error.message : '项目资产导入失败';
window.setTimeout(() => setAssetImportProgress(null), 2400); setAssetImportProgress((current) => ({
kind,
fileCount: files.length,
totalBytes: current?.totalBytes ?? totalBytes,
loadedBytes: current?.loadedBytes ?? 0,
percent: current?.percent ?? 0,
phase: 'failed',
message,
}));
setActionMessage(message);
} finally { } finally {
setAssetImporting(false); setAssetImporting(false);
} }
@@ -1516,15 +1526,29 @@ export default function ProjectLibrary({
</div> </div>
{assetImportProgress && ( {assetImportProgress && (
<div className="rounded-2xl border border-blue-100 bg-white px-5 py-3 shadow-sm"> <div className={`rounded-2xl border px-5 py-3 shadow-sm ${
assetImportProgress.phase === 'failed'
? 'border-rose-200 bg-rose-50'
: assetImportProgress.phase === 'done'
? 'border-emerald-100 bg-white'
: 'border-blue-100 bg-white'
}`}>
<div className="mb-2 flex items-center justify-between gap-4"> <div className="mb-2 flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"> <div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${
<FileArchive size={17} /> assetImportProgress.phase === 'failed'
? 'bg-rose-100 text-rose-600'
: assetImportProgress.phase === 'done'
? 'bg-emerald-50 text-emerald-600'
: 'bg-blue-50 text-blue-600'
}`}>
{assetImportProgress.phase === 'failed' ? <X size={17} /> : <FileArchive size={17} />}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-bold text-slate-800"> <p className="truncate text-sm font-bold text-slate-800">
{assetImportProgress.phase === 'done' {assetImportProgress.phase === 'failed'
? `${describeImportKind(assetImportProgress.kind)}导入失败`
: assetImportProgress.phase === 'done'
? `${describeImportKind(assetImportProgress.kind)}导入完成` ? `${describeImportKind(assetImportProgress.kind)}导入完成`
: assetImportProgress.phase === 'processing' : assetImportProgress.phase === 'processing'
? '上传完成,服务器正在解压与解析' ? '上传完成,服务器正在解压与解析'
@@ -1533,15 +1557,32 @@ export default function ProjectLibrary({
<p className="mt-0.5 text-[11px] font-bold text-slate-400"> <p className="mt-0.5 text-[11px] font-bold text-slate-400">
{assetImportProgress.fileCount} · {formatFileSize(assetImportProgress.loadedBytes)} / {formatFileSize(assetImportProgress.totalBytes)} {assetImportProgress.fileCount} · {formatFileSize(assetImportProgress.loadedBytes)} / {formatFileSize(assetImportProgress.totalBytes)}
</p> </p>
{assetImportProgress.phase === 'failed' && assetImportProgress.message && (
<p className="mt-1 whitespace-normal text-[11px] font-bold leading-5 text-rose-700">
{assetImportProgress.message}
</p>
)}
</div> </div>
</div> </div>
<span className="shrink-0 font-mono text-sm font-black text-blue-600"> <span className={`shrink-0 font-mono text-sm font-black ${
assetImportProgress.phase === 'failed'
? 'text-rose-600'
: assetImportProgress.phase === 'done'
? 'text-emerald-600'
: 'text-blue-600'
}`}>
{assetImportProgress.percent}% {assetImportProgress.percent}%
</span> </span>
</div> </div>
<div className="h-2 overflow-hidden rounded-full bg-slate-100"> <div className="h-2 overflow-hidden rounded-full bg-slate-100">
<div <div
className={`h-full rounded-full transition-all duration-300 ${assetImportProgress.phase === 'done' ? 'bg-emerald-500' : 'bg-blue-600'}`} className={`h-full rounded-full transition-all duration-300 ${
assetImportProgress.phase === 'failed'
? 'bg-rose-500'
: assetImportProgress.phase === 'done'
? 'bg-emerald-500'
: 'bg-blue-600'
}`}
style={{ width: `${assetImportProgress.percent}%` }} style={{ width: `${assetImportProgress.percent}%` }}
/> />
</div> </div>

View File

@@ -0,0 +1,51 @@
# 实现方案-2026-05-21-00-43-44
## 实现方案文档路径
`工程分析/实现方案-2026-05-21-00-43-44.md`
## 修改目标
- 复现 `3279-STL.zip` 导入时的 422 错误,获取具体失败原因。
- 修复 ZIP 内 STL 解压/筛选/写入逻辑,使该压缩包可导入。
- 优化导入失败 UI将错误状态和原因显示在顶部导入进度区域。
- 补充测试记录和经验记录。
## 涉及路径
- `WebSite/server.ts`
- `WebSite/src/components/ProjectLibrary.tsx`
- `工程分析/经验记录.md`
## 技术路线
1. 使用 `curl -F files=@3279-STL.zip` 对现有接口复现错误,查看响应体。
2. 检查 `AdmZip` 解包结果、ZIP 条目大小和写入逻辑。
3. 如果问题来自大 ZIP 一次性 `entry.getData()` 导致内存或写入失败,则改为按条目流式/逐条写入目标目录,减少内存峰值。
4. 如果问题来自文件名扩展名、编码或路径处理,则修正筛选和安全文件名逻辑。
5. 前端导入状态增加 `failed` phase错误时保留进度卡并展示错误文案。
## 执行步骤
1. 创建本次工程分析文档。
2. 本地复现 422记录响应。
3. 修改后端 ZIP 导入逻辑和前端错误反馈。
4. 执行 `npm run lint``npm run build`
5.`3279-STL.zip` 对临时项目实际导入验证。
6. 追加经验记录,提交推送 Gitea重新部署。
## 兼容性与回滚方案
-`AdmZip` 对该 ZIP 仍不稳定,可增加系统 `unzip` fallback 或改用更适合流式解压的库。
- 前端错误卡只影响导入反馈,不影响其他页面。
- 导入失败时不更新项目状态,避免半成品资产进入项目库。
## 预计文件变更
- 后端 1 个、前端组件 1 个、工程分析文档 4 个。
## 提交与部署策略
- 不提交 `3279-STL.zip` 和解压资产。
- commit message 包含 `2026-05-21-00-43-44` 与“修复 ZIP STL 导入失败”。
- 部署后验证 `/api/health`、首页以及 ZIP 导入结果。

View File

@@ -0,0 +1,51 @@
# 测试方案-2026-05-21-00-43-44
## 测试方案文档路径
`工程分析/测试方案-2026-05-21-00-43-44.md`
## 静态检查
- 执行 `cd WebSite && npm run lint`,确认 TypeScript 类型检查通过。
- 使用 `rg` 检查导入错误反馈和 ZIP 处理路径。
执行结果:`npm run lint` 已通过。
## 构建检查
- 执行 `cd WebSite && npm run build`,确认生产构建通过。
执行结果:`npm run build` 已通过Vite 仅提示既有 chunk 体积超过 500 kB。
## 关键业务场景验证
- 使用 `3279-STL.zip` 调用项目导入接口,确认返回 200 且 `modelCount` 与 ZIP 内 STL 数量一致。
- 前端导入失败时顶部进度区域显示失败状态和错误原因。
- 前端导入成功时顶部进度区域显示完成状态。
执行结果:使用 `3279-STL.zip` 对项目 `project-mpea0n3e-akbpn` 导入成功,接口返回 200`modelCount: 26``stlFiles` 已包含 ZIP 内 26 个 STL。
## 医学影像数据相关边界验证
- ZIP 中所有 `.stl` 文件写入项目级 `WebSite/data/uploads/<projectId>/STL`
- 导入后 `project.stlFiles` 包含 ZIP 内模型文件名。
- 导入失败不会污染默认 `Head_CT_ReConstruct/`
- 临时测试项目和上传目录验证后清理。
## 部署验证
- 重启 `tmux` 会话 `revoxelseg-dicom`
- 验证:
- `curl http://127.0.0.1:4000/api/health`
- `curl -I http://127.0.0.1:4000/`
## Git/Gitea 备份验证
- `git status --short` 确认暂存范围不包含 `3279-STL.zip`、解压模型或无关历史删除。
- commit message 包含 `2026-05-21-00-43-44`
- 推送 Gitea 成功。
## 风险与回归关注点
- ZIP 内大型 STL 不应同时全部进入内存。
- 前端错误信息不能只显示在左侧项目栏底部,应在导入进度卡上直接可见。

View File

@@ -1423,3 +1423,21 @@ C. 解决问题方案
D. 后续如何避免问题 D. 后续如何避免问题
凡是医学影像、STL、NIfTI、压缩包等大文件导入都不要在浏览器内转 base64 或拼接巨型 JSON默认使用 multipart 或分片上传,并把解压、筛选、校验放在服务端。导入 UI 应区分“上传进度”和“服务器处理阶段”,避免用户误以为进度到 100% 就已经完成解析。 凡是医学影像、STL、NIfTI、压缩包等大文件导入都不要在浏览器内转 base64 或拼接巨型 JSON默认使用 multipart 或分片上传,并把解压、筛选、校验放在服务端。导入 UI 应区分“上传进度”和“服务器处理阶段”,避免用户误以为进度到 100% 就已经完成解析。
## 2026-05-21-00-43-44 导入失败要在主操作区保留可见错误
A. 具体问题
用户导入 `3279-STL.zip` 后浏览器控制台出现 `422 Unprocessable Entity`,但页面主操作区没有明显反馈,用户感知为“没有反应”。后续用同一 ZIP 直接调用接口验证可成功导入 26 个 STL说明压缩包本体可读问题更集中在失败反馈不可见和缺少后端可追踪日志。
B. 产生问题原因
前端导入异常只写入左侧项目栏底部的小号 `actionMessage`,主内容区域的进度条会消失,用户视线集中在右侧模型区域时很难看到错误。后端 422 返回虽然包含 `message`,但服务端没有记录项目、导入类型和文件数量,排查需要重新手工复现。
C. 解决问题方案
将项目库导入状态扩展为 `uploading/processing/done/failed`,失败时在顶部进度卡保留红色错误状态、百分比和后端错误原因,不再自动消失。后端导入 catch 中增加结构化日志,记录 `projectId``kind`、上传文件数量和错误消息。并用用户提供的 `3279-STL.zip` 对项目 `123` 实际导入,确认项目更新为 26 个 STL。
D. 后续如何避免问题
所有文件导入、导出、保存这类长操作失败时,都要在主操作路径旁保留显眼错误卡,不能只写入边栏或短暂 toast。后端对 4xx/5xx 的可恢复业务错误应记录足够上下文,尤其是项目 ID、导入类型、文件数量和原始异常消息方便从用户截图快速定位。

View File

@@ -0,0 +1,46 @@
# 需求分析-2026-05-21-00-43-44
## 开始时间
2026-05-21-00-43-44
## 原始需求摘要
1. 用户上传 `3279-STL.zip` 后项目库没有明显反应。
2. 浏览器控制台显示 `/api/projects/:projectId/import-assets` 返回 `422 Unprocessable Entity`
3. 用户已将 `3279-STL.zip` 放在当前项目目录,需要定位压缩包导入失败原因并修复。
## 业务目标
- 让包含多个 STL 的 ZIP 压缩包可以稳定导入项目库 3D 模型。
- 导入失败时前端必须给出显眼、可读的错误反馈,而不是让用户感觉“没有反应”。
- 后端返回具体错误原因,便于定位压缩包、模型文件或服务器处理问题。
## 输入与输出
- 输入:项目根目录中的 `3279-STL.zip`,项目库 3D 模型导入动作。
- 输出:成功导入 ZIP 内 STL 模型并更新项目 `modelCount/stlFiles`,或在失败时展示明确原因。
## 影响范围
- `WebSite/server.ts`ZIP 解压、文件名处理、STL 筛选、错误信息。
- `WebSite/src/components/ProjectLibrary.tsx`:导入错误提示可见性、导入进度失败状态。
- `WebSite/src/lib/api.ts`:错误消息解析。
- `工程分析/经验记录.md`:记录本次压缩包导入经验。
## 关键约束
- 不把 `3279-STL.zip` 或解压后的大模型资产纳入 Git 提交。
- 继续保证导入只写入项目级上传目录,不影响默认演示数据。
- 不回退到浏览器 base64 导入。
## 风险点
- ZIP 内 STL 总体积较大,服务端一次性解压到内存可能产生压力。
- ZIP 文件名或条目可能存在编码、路径、目录层级差异。
- 导入成功后项目库预览如果一次性加载过多大 STL也可能造成浏览器压力需要至少保证导入链路稳定。
## 默认假设
- 当前 422 由后端解压或写入阶段抛错造成,而不是项目不存在。
- 用户截图中的 ZIP 内条目均为 `.stl`导入目标处于“3D 模型”页签。