cc 3 weeks ago
parent
commit
7951be10a2

+ 161 - 0
.trae/documents/case-data-fullscreen-upload.md

@@ -0,0 +1,161 @@
+# 案件数据 · 选择文件 → 全屏上传页改造方案
+
+## Summary
+
+将案件数据页(`index.vue`)头部「选择文件」按钮当前弹出的**上传弹框(BasicModal)**,改为跳转一个**全屏页面**(`/data/upload`)完成文件上传,并引入**批次(batch)生命周期**管理:
+
+- **进入页面即生成批次 id**:`upload.vue` 挂载时前端生成一个 Long 型 `batchId`(不依赖首次上传响应),本页所有上传文件统一携带该 `batchId` 上传(后端 `preFileUpload` 收到非空 batchId 会直接复用)。
+- **左侧区域**:顶部「选择文件 / 选择文件夹」模式切换 + 选择文件按钮;中间已选文件列表(每个文件的待上传/解析中 n%/成功/失败状态);底部「开始上传」按钮与整体进度条。
+- **右侧区域**:**treetable(树表格)**,实时展示每份文件解析完成后后端返回的解析结果树(文件 → sheet,列含状态 / 数据量 / 成功 / 失败 / 大小)。
+- **离开本页**:上传解析进行中(或有未处理文件)时返回 → **弹确认提示**;确认离开且未进入清洗 → **调用后端清理本批次的内容和文件**(已上传文件 + 解析缓存 + 本批次记录)。
+- **设计主题与现有案件数据页一致**(主色 `#2a50ec`、白底面板 `#e2e8f0` 边框 12px 圆角、渐变 `#316dff→#4f8bff`、页面底色 `#f0f2f5`、暗色主题适配),UI 复用 `index.vue` 既有上传样式与 `match.vue` 全屏页头样式。
+
+## Current State Analysis
+
+### 前端现状
+- 入口:[index.vue](file:///e:/workspace/zsjz-ai/ai-frontend/src/case/views/data/index.vue#L28-L30) 头部「选择文件」按钮 → `handleUploadNavigate('single')` → `openUpload('single')` 打开 `BasicModal`(模板 L141-L261)。
+- 弹框内 UI(整体移植到新页):模式切换 L153-L171、隐藏 `<input type="file" multiple webkitdirectory>` L175-L190、拖拽区 L192-L215、文件列表+状态/进度 L218-L253、「开始上传并解析」L254-L258。
+- 上传逻辑 `startUpload`(L2604-L2665):逐个 `preFileUpload(file, onProgress, { batchId })`(POST `api/dm/preFileUpload` multipart)返回 `List<FileInfo>` **树**(根节点 pid=0=文件、children=各 sheet);全部完成后写 `sessionStorage('case_clean_file_infos')` 并 `go(PageEnum.BASE_MATCH)`。当前 batchId 只在**首次上传成功后**从响应里回填,后续沿用——不满足“进页即生成”。
+- 解析结果字段([FileInfo.java](file:///e:/workspace/zsjz-ai/ai-server/src/main/java/com/zsjz/ai/common/model/dm/entity/FileInfo.java)):`id/pid/fileName/sheetName/tableName/fileStatus/dataNum/fileSize/successNum/failNum/batchId/createDateTime/children`;`fileStatus` 含读取失败/格式不正确/读取完成/加密/模板匹配失败/模板匹配成功等。
+- 路由:[routes/index.ts](file:///e:/workspace/zsjz-ai/ai-frontend/src/core/router/routes/index.ts#L38-L98) `DataRoute.children` 注册全屏页(match/cleaning 用 `meta.hideMenu/hideTab/currentActiveMenu=BASE_HOME`);[pageEnum.ts](file:///e:/workspace/zsjz-ai/ai-frontend/src/core/enums/pageEnum.ts) 已有 `BASE_MATCH='/data/match'`。
+- 树表格:`BasicTable` 支持 `isTreeTable`,children 字段名由 `childrenColumnName` 指定(默认 `'childList'`,需显式传 `'children'`),`row-key='id'`。
+- 主题 token:主色 `#2a50ec`;成功 `#16a34a`、失败 `#dc2626`、文字 `#111827/#475569/#64748b`、面板 `#fff`+`#e2e8f0` 边框+12px 圆角+`0 8px 24px rgba(15,23,42,0.04)` 阴影;暗色 `html[data-theme='dark']`(背景 `#0f172a/#111827`、边框 `#1f2937`)。
+
+### 后端批次现状
+- `preProcessUploadedFile(MultipartFile, Long batchId, Integer reClean)`([DmService.java](file:///e:/workspace/zsjz-ai/ai-server/src/main/java/com/zsjz/ai/module/dm/service/DmService.java#L140-L167)):`batchId != null` 时直接复用,否则雪花生成;上传文件落盘 `PathConst.WORKSPACE/{caseId}/upload/{batchId}/`(L173-L198);解析缓存 `PreDataListener` 写 `PathConst.TMP_PATH/{batchId}/{fileId}_{sheetName}.json`;解析结果先驻内存,DB `file_info` 入库发生在后续 `doClean` 清洗环节,**废弃批次通常无 DB 行**。
+- `cancelClean(batchId)`(L552-L559):仅删 `TMP_PATH/{batchId}` 缓存,**不删上传文件、不删 DB 记录**。
+- **缺口**:现有接口无法“清理本批次的内容和文件”(上传文件目录 + 缓存 + 本批次 `file_info` 记录),需新增。
+
+## Proposed Changes
+
+### 1. 后端:新增「清理废弃批次」接口
+
+**文件**:`e:\workspace\zsjz-ai\ai-server\src\main\java\com\zsjz\ai\module\dm\service\DmService.java`
+- 新增方法:
+
+```java
+/** 清理废弃批次:删除本批次已上传文件、解析缓存与 file_info 记录(仅用于未进入清洗的批次) */
+public void clearUploadBatch(Long batchId) {
+    if (batchId == null) {
+        return;
+    }
+    // 1) 解析缓存 tmp/{batchId}
+    try {
+        FileUtil.del(PathConst.TMP_PATH.resolve(batchId.toString()));
+    } catch (Exception e) {
+        log.warn("清理批次解析缓存失败, batchId={}", batchId, e);
+    }
+    // 2) 已上传文件 workspace/{caseId}/upload/{batchId}
+    try {
+        if (StateManager.instance().isCaseOpened()) {
+            Long caseId = StateManager.instance().getCaseId().longValue();
+            FileUtil.del(PathConst.WORKSPACE.resolve(String.valueOf(caseId))
+                    .resolve(UPLOAD_DIR_NAME).resolve(batchId.toString()));
+        }
+    } catch (Exception e) {
+        log.warn("清理批次上传文件失败, batchId={}", batchId, e);
+    }
+    // 3) 本批次 file_info 记录(安全兜底;正常废弃批次无记录)
+    fileInfoMapper.delete(Wrappers.lambdaQuery(FileInfo.class).eq(FileInfo::getBatchId, batchId));
+}
+```
+
+**文件**:`e:\workspace\zsjz-ai\ai-server\src\main\java\com\zsjz\ai\module\dm\controller\DmController.java`
+- 新增(参照既有 `cancelClean` GET 风格,幂等):
+
+```java
+/** 清理废弃批次:删除本批次已上传文件、解析缓存与记录(上传页放弃离开时调用) */
+@GetMapping("/clearUploadBatch")
+public Result<Void> clearUploadBatch(Long batchId) {
+    dmService.clearUploadBatch(batchId);
+    return Result.succeed();
+}
+```
+
+**文件**:`e:\workspace\zsjz-ai\ai-frontend\src\case\api\govern\governApi.ts`
+- 新增(放在 `cancelClean` 附近):
+
+```ts
+/** 清理废弃批次:删除本批次已上传文件、解析缓存与记录(未进入清洗而离开上传页时调用) */
+export const clearUploadBatch = (params: { batchId?: ApiId | number }) =>
+  defHttp.get<void>({ url: adminPath + '/dm/clearUploadBatch', params });
+```
+
+### 2. 新增全屏上传页 `upload.vue`
+
+**文件**:新建 `e:\workspace\zsjz-ai\ai-frontend\src\case\views\data\upload.vue`(组件命名 `useDesign('case-upload')`,prefix `jeesite-case-upload`)
+
+**批次生命周期(核心新增逻辑)**:
+- 挂载时生成批次 id(“进页即生成,交给后端”):不依赖后端响应,本地生成 Long:
+
+```ts
+const createUploadBatchId = () => Date.now() * 1000000 + Math.floor(Math.random() * 1000000); // 约 1.7e18,Long 安全
+```
+
+  `startUpload` 中**每个文件**统一传 `{ batchId: uploadBatchId.value }`,保证本页全部文件同批(后端对非空 batchId 复用)。删除原“首次响应回填 batchId”逻辑。
+- 离开拦截:
+  - 返回按钮:若 `isUploading || selectedFiles.length > 0 || resultTrees.length > 0`(本页有上传活动),用 `Modal.confirm` 提示「上传解析进行中/存在未处理文件,离开将清理本批次已上传文件与解析结果,是否继续?」;取消 → 留在本页;确认 → `await clearUploadBatch({ batchId })`(失败仅 warning 不阻塞)→ 清空 `sessionStorage('case_clean_file_infos')`(仅当其中结果全属本批次)→ `go(PageEnum.BASE_HOME, true)` + `emitter.emit('case-data:refresh')`。
+  - `onBeforeRouteLeave` 守卫兜底浏览器返回/标签页关闭:仅当 `allowedToLeave` 为 `true`(点「下一步」或已确认清理后的程序化跳转)时放行,否则中止并弹同类确认。
+  - 「下一步:数据匹配」(≥1 个文件解析成功才可点):设 `allowedToLeave=true`,写 `sessionStorage('case_clean_file_infos')` + `emitter.emit('case-import-file:refresh')` + `go(PageEnum.BASE_MATCH)`,**不调用清理接口**。
+
+**页面结构**:
+- 页头(仿 `match.vue`):左=`LeftOutlined` 返回按钮 + 标题「文件上传」+ 副标题「上传并解析 xls / xlsx / csv 文件,解析结果实时展示在右侧」;右上=主按钮「下一步:数据匹配」(无成功解析时 disabled)。
+- 左右布局:页面容器沿用 `index.vue` 全出血样式(`background:#f0f2f5; width:calc(100% + 24px); margin:0 -12px; padding:12px`),内部 `display:flex; gap:12px; height:100%; min-height:0`。
+  - **左面板**(`flex:0 0 460px` 白底卡片):
+    - 顶部:胶囊切换「选择文件 / 选择文件夹」(复用 `.upload-mode-switch` 样式,默认取路由 query `mode`,缺省 `single`)+ 「选择文件/选择文件夹」主按钮触发隐藏 input;保留拖拽区(复用 `.upload-dropzone`)。
+    - 中部:`.upload-file-list`(复用原样式)文件列表:文件名 + 大小 + 状态徽标(待上传 / 解析中 n% / ✓ / ✗)。
+    - 底部:整体进度条 `.upload-overall-progress` + 「开始上传」主按钮(无文件或上传中禁用)+ 「清空」链接。
+  - **右面板**(`flex:1` 白底卡片):标题「解析结果(实时)」+ `BasicTable`:
+    - `:is-tree-table="true"`、`:children-column-name="'children'"`、`row-key="id"`、`:pagination="false"`、`:data-source="resultTrees"`。
+    - 列:名称(根行=fileName,子行=sheetName/tableName,有子节点自动显示展开箭头)、大小(根行 fileSize)、数据量 dataNum、成功 successNum、失败 failNum、状态(`fileStatus` 映射彩色标签:成功 `#16a34a`/失败 `#dc2626`/其他灰)。
+
+**逻辑(从 `index.vue` 移植并改造)**:
+- 状态:`uploadMode`、`uploadBatchId`、`selectedFiles`、`uploadStatuses/uploadProgressMap/overallProgress/isUploading/dragOver`、`resultTrees`(实时结果数组)、`allowedToLeave`。
+- 文件工具:移植 `formatFileSize/isValidUploadFile/filterValidFiles/handleDragOver/handleDragLeave/collectFilesFromDataTransfer/traverseDirectory/handleFileInputChange/handleFolderInputChange`。
+- `startUpload`:逐个 `preFileUpload(file, onProgress, { batchId: uploadBatchId.value })`;每份文件返回的 `FileInfo[]` 追加到 `resultTrees`(右侧实时刷新)并同步累积写 `sessionStorage('case_clean_file_infos')`;整体进度 = 已完成文件数占比;全部结束显示「下一步」。
+- `mapStatus`:从 `match.vue` L185 移植(`SUCCESS/COMPLETE→成功`、`FAIL→失败`、`PASSWORD/FORMAT/EMPTY→对应文案`)。
+
+**样式**:复用 `index.vue` L5810-L6060 上传样式(upload-mode-switch / upload-dropzone / upload-file-list / upload-file-item / upload-overall-progress),补充双栏布局与右面板表格样式;沿用 L6076 起 `html[data-theme='dark']` 暗色覆盖写法。
+
+### 3. 注册路由与页面枚举
+
+- **`e:\workspace\zsjz-ai\ai-frontend\src\core\enums\pageEnum.ts`**:新增 `BASE_UPLOAD = '/data/upload'`。
+- **`e:\workspace\zsjz-ai\ai-frontend\src\core\router\routes\index.ts`**:`DataRoute.children` 新增:
+
+```ts
+{
+  path: 'upload',
+  name: 'CaseDataUpload',
+  component: () => import('@/case/views/data/upload.vue'),
+  meta: { title: '文件上传', hideMenu: true, hideTab: true, currentActiveMenu: PageEnum.BASE_HOME },
+},
+```
+
+### 4. 改造 `index.vue`:去掉弹框、改为跳转
+
+- `handleUploadNavigate`(L920-L926)改为 `go({ path: PageEnum.BASE_UPLOAD, query: { mode: mode || 'single' } })`;删除 `openUpload/handleUploadModeChange/handleUploadChange/handleUploadOk/handleUploadCancel`。
+- 删除模板弹框块 L141-L261(BasicModal)及 `.upload-modal-body` 相关样式(L5810-L6060、L6063-L6074 媒体查询)。
+- 删除脚本上传状态与函数:`uploadVisible/uploadMode/uploadBizKey/uploadLoadTime/fileInputRef/folderInputRef/selectedFiles/uploadResults/uploadProgressMap/uploadStatuses/isUploading/overallProgress/dragOver/registerUploadModal`、`formatFileSize/isValidUploadFile/filterValidFiles/handleDragOver/handleDragLeave/traverseDirectory/collectFilesFromDataTransfer/handleDrop/handleFileInputChange/handleFolderInputChange/handleTriggerFileSelect/clearSelectedFiles/startUpload`、`VALID_UPLOAD_EXTS`。
+- 清理因此未再使用的导入:`preFileUpload`(governApi)、`Upload`(antd)、`BasicModal, useModal`、`FileInfo` 类型。**保留** `buildUUID`(L1604/L1674 仍用)、`createMessage`、`Modal`(模板创建弹框用)。
+
+## Assumptions & Decisions
+
+1. **批次生成**:前端进入上传页即用时间戳+随机数生成 Long `batchId` 并在每个文件请求中回传;后端非空即复用,无需新增“创建批次”接口。生成值上限约 `1.7e18 < Long.MAX`,满足 Long 转换。
+2. **清理时机**:仅当用户在本页确认离开/放弃且**未进入清洗**(未点「下一步」)时调用 `clearUploadBatch`;点「下一步」进入 `/data/match` 是正常流程,不清理。
+3. **保留文件夹上传**:原弹框「选择文件 / 选择文件夹」能力在新页左侧顶部保留,默认 `single`。
+4. **不再自动跳转**:解析完成后由页头「下一步:数据匹配」手动进入 match 页,右侧可先行查看解析结果(有成功解析才可点)。
+5. **“实时”语义**:解析为逐文件接口(无流式中间态),右侧 treetable 在每份文件解析完成瞬间追加对应结果树即满足“实时显示解析结果”;上传中进度在左侧列表实时显示。
+6. **树结构**:后端已返回 pid=0 根 + children sheet 的树;前端直接追加为表格树节点(`childrenColumnName='children'`、`row-key='id'`)。
+7. 暗色主题照抄 `index.vue` 写法;不动既有 `/dm/preFileUpload` 接口语义。
+
+## Verification
+
+1. 后端:`mvn compile -f pom.xml` 编译通过;`GET /dm/clearUploadBatch?batchId=xxx` 返回成功,且对应 `workspace/{caseId}/upload/{batchId}`、`tmp/{batchId}` 与 `file_info` 记录被清除(清理前后比对目录与 DB)。
+2. 前端:用 IDEA MCP 编译验证(`tools.build_project` 或 `npm run build`),无未使用导入告警。
+3. 手工验证:
+   - 案件数据页点击「选择文件」→ 打开 `/data/upload` 全屏页(不再弹框),进入即生成批次 id(可经网络请求确认所有文件携带同一 `batchId`)。
+   - 左侧选多个 xls/xlsx/csv → 文件列表与大小正确、状态徽标实时变化(待上传→n%→✓/✗)、整体进度到 100%;文件夹模式递归收集正常。
+   - 每完成一个文件,右侧 treetable 立即出现该文件根节点与 sheet 子节点(可展开),状态/数据量/成功/失败列正确;失败文件仍展示且状态为失败。
+   - 上传中(或已选未上传)点返回 → 弹确认提示;确认后调用 `clearUploadBatch` 并返回 `/data/index`,后端目录与记录被清理;取消则留在本页。
+   - 全部完成后点「下一步:数据匹配」→ 进入 `/data/match`,结果与改动前一致,且**不触发清理**。
+   - 亮/暗主题、≥1280px 双栏布局正常。

+ 0 - 51
QingJian/agent/AGENTS.md

@@ -1,51 +0,0 @@
-# Name: 清鉴
-
-# Role: 智能反贪数据研判专家
-
-## Profile
-
-你是一名专为纪检监察机关和经侦部门设计的AI数据研判专家。你的核心能力是处理和分析海量的**银行账单、第三方支付流水、通讯话单(CDR)、基站轨迹
-**等多源异构数据。你擅长利用图算法、时序分析和异常检测模型,从杂乱无章的数据中挖掘出隐蔽的贿赂线索、利益输送链条和攻守同盟关系。
-当前时间:2026年。
-
-## 核心规则 (Action Logic)
-
-1. **先思后行**:你采用“先思后行”的决策机制。
-2. **严禁伪造**:禁止在回复中模拟或伪造工具的执行过程;当你没找到可用工具时,请直接使用sql_expert进行sql查询。
-3. **自然回复**:任务完成后,请以自然语言直接回复(不要有表情符),无需输出 `Final Answer:` 等标签。
-4.
-
-## Operational Principles
-
-- **数据驱动**:所有结论必须基于输入的数据特征,严禁无中生有。使用“高度疑似”、“异常指数XX%”、“关联度强”等专业表述,而非直接断言“就是受贿”。
-- **隐私与安全**:在处理数据前,自动提示用户确认数据来源的合法性(如已获审批手续)。不存储原始敏感数据,仅在内存中进行计算分析。
-- **证据链导向**:分析结果应直接服务于司法取证,指出哪些数据点可以作为关键证据,哪些需要进一步补强。
-- **抗干扰能力**:能够识别并过滤正常的亲友往来、工资发放等噪音数据,聚焦异常点。
-
-## Analysis Workflow
-
-1. **数据预处理**:自动清洗格式,统一时间戳,标记缺失值。
-2. **特征提取**:提取金额、频率、时间、地点、对象等关键特征。
-3. **模型匹配**:调用内置的“贿赂交易模型”、“洗钱模型”、“串供模型”进行匹配。
-4. **可视化输出**:生成文字版的拓扑结构描述、异常时间轴、资金链路图。
-5. **研判建议**:给出下一步调查方向(如:建议调取某账户的IP登录日志、建议讯问某中间人)。
-
-## Tone & Style
-
-- 专业、简练、冷峻、逻辑性强。
-- 多用数据术语:如“资金回流”、“闭环交易”、“通联异常”、“时空伴随”。
-- 拒绝情感化表达,直击问题核心。
-
-## Constraints
-
-- 若数据量不足或特征不明显,如实报告“暂无显著异常”,不强行分析。
-- 严禁提供如何规避侦查的建议。
-- 对于涉及国家机密或极度敏感的案件细节,提醒用户注意保密等级。
-
-## Example Output Format
-
-- **【异常概览】**:(一句话总结核心发现)
-- **【资金链路分析】**:(详细描述资金流转路径,标注异常节点)
-- **【通联行为画像】**:(描述通话规律及可疑联系人)
-- **【时空碰撞结果】**:(如有基站数据,分析轨迹重合情况)
-- **【研判结论与建议】**:(给出嫌疑指数及下一步侦查建议)

+ 0 - 54
QingJian/agent/skills/holder/SKILL.md

@@ -1,54 +0,0 @@
----
-name: bankHolder
-description: 面向贪污受贿案件,用于智能识别银行卡代持行为。通过资金特征、交易习惯、账户行为、关联关系,自动筛查由他人实际控制、供被调查人使用的代持银行卡、影子账户、隐蔽资金通道。
-tools: [ "selectTransOrCallLocal", "selectSalary", "selectSameMacOrIp", "selectBankBindPhone" ]
----
-
-# 1. 触发条件
-
-用户请求涉及**银行卡代持、影子账户、实际控制人、隐蔽资金、借用账户**等贪污受贿资金隐蔽场景。
-
-## 代持特征关键词
-
-银行卡代持、借用账户、他人卡、实际控制、影子账户、隐蔽账户、资金中转、异常交易
-
-## 标准办案问句
-
-- 请排查被调查人可能持有的他人代持银行卡。
-- 分析哪些账户存在代持特征,疑似由嫌疑人实际控制。
-- 识别与被调查人资金高度关联、交易行为异常的代持账户。
-- 检索贪污受贿案件中可能用于隐蔽资金的代持卡。
-
-# 2. 执行逻辑(内部封装,禁止拆解)
-
-1. **资金特征识别**
-   筛查小额零散转入、集中大额转出、固定时间交易、无合理消费、快进快出等代持典型特征。
-2. **关联关系匹配**
-   匹配账户持有人与被调查人是否存在亲属、同事、同学、密切通信、同行、碰面等关联。
-3. **交易行为验证**
-   验证交易地点、设备信息、消费习惯是否与被调查人高度重合。
-4. **代持账户输出**
-   输出高风险代持银行卡清单,标注风险等级、代持特征、关联证据。
-
-# 3. 输入参数
-
-- personName(string, 必填):被调查对象姓名
-
-# 4. 输出格式
-
-| 账户姓名 | 银行卡号         | 代持特征        | 风险等级 | 关联说明      |
-|------|--------------|-------------|------|-----------|
-| 刘某   | 6228****1234 | 快进快出、无合理消费  | 高    | 密切通信+基站重合 |
-| 陈某   | 6222****5678 | 固定时间转入、大额转出 | 高    | 亲属关系+共同出行 |
-| 赵某   | 6217****9012 | 交易地点与嫌疑人重合  | 中    | 同事关系      |
-
-# 5. 执行约束
-
-1. 严禁手动拆解流程,禁止单独调用工具后自行判断。
-2. 必须基于资金特征+关系特征+行为特征综合判定。
-3. 输出必须结构化、可直接用于案件研判。
-4. 结果返回后,主动提供下一步核查建议。
-
-# 6. 下一步研判建议
-
-是否需要对上述高风险代持账户进行资金溯源、交易对手扩线、开户资料核验,固定贪污受贿、隐蔽资金证据?

+ 0 - 63
QingJian/agent/skills/miss/SKILL.md

@@ -1,63 +0,0 @@
----
-name: missData
-description: 自动化排查与被调查对象存在紧密关联(高频通信、同行、基站碰面),但在本端交易记录中缺失交易的隐形关系人,自动完成关系发现→资金核查→差异输出全流程
-tools: [ "statCallOtherName", "statTransData", "statTogetherTravel", "statTransBigAmount", "statExpressInfo", "statMeet" ]
----
-
-# 一、技能触发条件
-
-当用户请求**同时包含强关联行为 + 资金记录缺失**两类语义要素时,立即启用本技能。
-
-## 强关联行为标识
-
-通信频繁、高频通话、经常联系、同行出行、共同差旅、同驻一地、基站重合、位置碰面、轨迹交集
-
-## 资金记录缺失标识
-
-无交易记录、无转账流水、无资金往来、无账单交互、资金链路缺失、交易台账空白、未查询到转账
-
-## 标准用户请求示例
-
-1. 协助排查与目标对象高频通话,但无任何资金交易记录的人员。
-2. 识别与被调查人共同差旅、同行出行,但在本端流水无资金往来的关联方。
-3. 分析与目标对象基站位置重合、存在碰面行为,但无资金交互的人员。
-4. 检索与被调查人存在紧密关联,但缺失交易记录的隐形关联人员。
-
----
-
-# 二、技能执行逻辑(内部封装,禁止拆解执行)
-
-1. **多维关联发现**
-   并行采集被调查对象三类强关联名单:高频通信Top10、同行出行Top5、基站碰面Top5。
-2. **资金穿透核验**
-   自动提取关联名单主体信息,调用资金查询工具逐一对本端交易记录进行全量匹配与校验。
-3. **隐形关系输出**
-   筛选并返回**存在强关联行为但无任何资金交易**的关联方清单,包含身份信息、关联类型、关联频次。
-
----
-
-# 三、输入参数定义
-
-- **personName**(string, 必填):被调查对象标准姓名,支持全称精准检索
-- **otherNames**(string, 可选):被调查对象对手姓名,根据工具定义的参数决定是否启用
-
----
-
-# 四、结果输出规范
-
-以标准化结构化表格输出。
-
----
-
-# 五、执行约束条款
-
-1. 严禁人工/手动拆解技能执行流程,禁止单独调用工具后进行逻辑判断。
-2. 关联发现、资金核验、结果输出必须由技能内部闭环完成。
-3. 必须以结构化表格形式呈现,保证可读性与合规性。
-4. 输出结果后,须主动提供下一步侦查建议。
-
----
-
-# 六、后续侦查建议
-
-是否需要对上述隐形关系人开展快递信息检索、大额交易回溯、关联轨迹扩线等研判,以完善人物画像与资金证据链?

+ 0 - 6
ai-frontend/src/App.vue

@@ -9,11 +9,9 @@
 </template>
 <script lang="ts" setup>
   import { computed, onMounted, unref, watch } from 'vue';
-  import { useRoute } from 'vue-router';
   import { StyleProvider, legacyLogicalPropertiesTransformer, ConfigProvider, theme } from 'ant-design-vue';
   import { authorizationInfoApi } from '@/core/api/sys/login';
   import { AppProvider } from '@/core/components/Application';
-  import { promptCaseConfigGuideOnce } from '@/core/logics/caseConfigGuide';
   import { useRootSetting } from '@/core/hooks/setting/useRootSetting';
   import { useUserStore } from '@/core/store/modules/case';
   import { ThemeEnum } from '@/core/enums/appEnum';
@@ -27,7 +25,6 @@
   // support Multi-language
   const { getAntdLocale } = useLocale();
   const { getDarkMode, getThemeColor } = useRootSetting();
-  const route = useRoute();
   const userStore = useUserStore();
   const { setWatermark, clear } = useWatermark();
   const authorizationUnit = computed(() => String(userStore.getPageCacheByKey('authorizationUnit', '') || '').trim());
@@ -73,9 +70,6 @@
 
   onMounted(() => {
     void ensureAuthorizationUnit();
-    if (route.path.startsWith('/data/')) {
-      void promptCaseConfigGuideOnce();
-    }
   });
 </script>
 <style lang="less">

+ 16 - 3
ai-frontend/src/case/api/govern/governApi.ts

@@ -6,7 +6,7 @@ import { CalcTaskDTO, DataTableDTO, DoCleanDTO, GovernTree, PrepareProcessFileDT
 import { Query } from '@/types';
 import { GovernTreeQuery } from './types/query';
 
-const { adminPath } = useGlobSetting();
+const { ctxPath, adminPath } = useGlobSetting();
 const DEFAULT_ELECTRON_API_URL = 'http://localhost:8980';
 
 const trimRightSlash = (value = '') => value.replace(/\/+$/, '');
@@ -161,15 +161,21 @@ export const preFile = (data: PrepareProcessFileDTO) =>
 
 /**
  * 单文件上传并解析(Web 端 multipart 方式)
+ *
+ * 注意:defHttp.uploadFile 不走 beforeRequestHook/transformRequestHook:
+ * 1. 不会自动补齐 urlPrefix/apiUrl,必须像其他上传接口一样手动拼接 ctxPath(如 /js)+ adminPath(如 /a);
+ * 2. 不回自动解包,返回原始 AxiosResponse,业务数据在 res.data.data。
+ *
+ * 后端返回单个文件节点 FileInfo(文件根内嵌 children sheet 列表);重复文件或无结果时 data 为 null。
  */
 export const preFileUpload = (
   file: File,
   onProgress?: (percent: number) => void,
   data?: { batchId?: string | number; reClean?: number },
 ) => {
-  return defHttp.uploadFile<FileInfo[]>(
+  return defHttp.uploadFile<Result<FileInfo>>(
     {
-      url: adminPath + '/dm/preFileUpload',
+      url: ctxPath + adminPath + '/dm/preFileUpload',
       onUploadProgress: (progressEvent: any) => {
         if (!onProgress || !progressEvent.total) return;
         const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
@@ -191,6 +197,13 @@ export const preFileUpload = (
 export const cancelClean = (params: { batchId?: ApiId | number }) =>
   defHttp.get<void>({ url: adminPath + '/dm/cancelClean', params });
 
+/**
+ * 清理废弃批次
+ * 删除本批次已上传文件、解析缓存与记录(未进入清洗而离开上传页时调用)
+ */
+export const clearUploadBatch = (params: { batchId?: ApiId | number }) =>
+  defHttp.get<void>({ url: adminPath + '/dm/clearUploadBatch', params });
+
 /**
  * 已导入文件列表
  * @param data

+ 1 - 660
ai-frontend/src/case/views/data/index.vue

@@ -138,128 +138,6 @@
       </div>
     </div>
 
-    <BasicModal
-      v-model:open="uploadVisible"
-      :title="uploadMode === 'single' ? '选择文件' : '选择文件夹'"
-      width="760px"
-      :show-cancel-btn="false"
-      :show-ok-btn="false"
-      wrapClassName="case-upload-modal"
-      @register="registerUploadModal"
-      @ok="handleUploadOk"
-      @cancel="handleUploadCancel"
-    >
-      <div class="upload-modal-body">
-        <div class="upload-modal-header">
-          <div class="upload-mode-switch" role="tablist" aria-label="上传类型切换">
-            <button
-              type="button"
-              :class="['upload-mode-switch__item', { 'is-active': uploadMode === 'single' }]"
-              :disabled="isUploading"
-              @click="handleUploadModeChange('single')"
-            >
-              选择文件
-            </button>
-            <button
-              type="button"
-              :class="['upload-mode-switch__item', { 'is-active': uploadMode === 'folder' }]"
-              :disabled="isUploading"
-              @click="handleUploadModeChange('folder')"
-            >
-              选择文件夹
-            </button>
-          </div>
-        </div>
-
-        <!-- 隐藏的文件输入 -->
-        <input
-          ref="fileInputRef"
-          type="file"
-          multiple
-          accept=".xls,.xlsx,.csv"
-          style="display: none"
-          @change="handleFileInputChange"
-        />
-        <input
-          ref="folderInputRef"
-          type="file"
-          webkitdirectory
-          directory
-          style="display: none"
-          @change="handleFolderInputChange"
-        />
-
-        <div
-          class="upload-dropzone"
-          :class="{ 'is-dragover': dragOver }"
-          @dragover.prevent="handleDragOver"
-          @dragleave="handleDragLeave"
-          @drop.prevent="handleDrop"
-        >
-          <div class="upload-dragger-content">
-            <div class="upload-dragger-icon">⬆</div>
-            <div class="upload-dragger-title">
-              {{ uploadMode === 'single' ? '拖拽文件到这里,或点击选择文件' : '拖拽文件夹到这里,或点击选择文件夹' }}
-            </div>
-            <div class="upload-dragger-desc">
-              {{
-                uploadMode === 'single'
-                  ? '支持批量选择 xls / xlsx / csv 文件'
-                  : '支持选择整个文件夹,系统会自动读取符合条件的文件'
-              }}
-            </div>
-            <Button type="primary" class="upload-action-btn" :disabled="isUploading" @click="handleTriggerFileSelect">
-              {{ uploadMode === 'single' ? '选择文件' : '选择文件夹' }}
-            </Button>
-          </div>
-        </div>
-
-        <!-- 已选文件列表 -->
-        <div v-if="selectedFiles.length" class="upload-file-list">
-          <div class="upload-file-list__header">
-            <span>已选择 {{ selectedFiles.length }} 个文件</span>
-            <Button type="link" size="small" :disabled="isUploading" @click="clearSelectedFiles"> 清空 </Button>
-          </div>
-          <div class="upload-file-list__body">
-            <div v-for="(file, idx) in selectedFiles" :key="file.name + '-' + idx" class="upload-file-item">
-              <div class="upload-file-item__info">
-                <span class="upload-file-item__name" :title="file.name">{{ file.name }}</span>
-                <span class="upload-file-item__size">{{ formatFileSize(file.size) }}</span>
-              </div>
-              <div class="upload-file-item__status">
-                <template v-if="uploadStatuses.get(file.name + '-' + idx) === 'done'">
-                  <span class="upload-file-item__success">✓</span>
-                </template>
-                <template v-else-if="uploadStatuses.get(file.name + '-' + idx) === 'error'">
-                  <span class="upload-file-item__error">✗</span>
-                </template>
-                <template v-else-if="uploadStatuses.get(file.name + '-' + idx) === 'uploading'">
-                  <span class="upload-file-item__progress-text"
-                    >{{ uploadProgressMap.get(file.name + '-' + idx) || 0 }}%</span
-                  >
-                </template>
-                <template v-else>
-                  <span class="upload-file-item__pending">待上传</span>
-                </template>
-              </div>
-            </div>
-          </div>
-          <!-- 整体进度 -->
-          <div v-if="isUploading" class="upload-overall-progress">
-            <div class="upload-overall-progress__bar">
-              <div class="upload-overall-progress__fill" :style="{ width: overallProgress + '%' }"></div>
-            </div>
-            <span class="upload-overall-progress__text">总进度 {{ overallProgress }}%</span>
-          </div>
-          <div class="upload-file-list__actions">
-            <Button type="primary" :loading="isUploading" :disabled="selectedFiles.length === 0" @click="startUpload">
-              {{ isUploading ? '上传解析中...' : '开始上传并解析' }}
-            </Button>
-          </div>
-        </div>
-      </div>
-    </BasicModal>
-
     <QueryTableModal
       v-model:open="templateVisible"
       title="模板库"
@@ -701,7 +579,6 @@
     Pagination,
     Input,
     Switch,
-    Upload,
     Modal,
     Radio,
     Tooltip,
@@ -714,7 +591,6 @@
   import TableSetting from '@/core/components/Table/src/components/settings/index.vue';
   import { createTableContext } from '@/core/components/Table/src/hooks/useTableContext';
   import { BasicTree } from '@/core/components/Tree';
-  import { BasicModal, useModal } from '@/core/components/Modal';
   import { Icon } from '@/core/components/Icon';
   import { useDesign } from '@/core/hooks/web/useDesign';
   import { useGo } from '@/core/hooks/web/usePage';
@@ -728,7 +604,6 @@
   import CustomSearch from './components/CustomSearch.vue';
   import GovernProgressModal from './components/GovernProgressModal.vue';
   import { getActiveImportProgress } from './importProgressSession';
-  import type { FileInfo } from '@/types/api';
   import CallRecordModal from '@/call/views/components/CallRecordModal.vue';
   import TransRecordModal from '@/trans/views/components/TransRecordModal.vue';
   import {
@@ -756,7 +631,6 @@
     getTreeCallPersonPage,
     getTreeCallOperPage,
     preFile,
-    preFileUpload,
     saveGovernConf,
   } from '@/case/api/govern/governApi';
   import type { GovernConfItem } from '@/case/api/govern/governApi';
@@ -842,7 +716,6 @@
 
   type DataPageTabKey = 'import' | 'files';
   const activeTab = ref<DataPageTabKey>('import');
-  const uploadVisible = ref(false);
   const templateVisible = ref(false);
   const templateCatalogLoading = ref(false);
   const templateCreateVisible = ref(false);
@@ -866,25 +739,10 @@
   });
   const governConfig = ref<GovernConfigFormState>(createDefaultGovernConfig());
   const governConfigIdMap = ref<Record<string, string | number | undefined>>({});
-  const uploadMode = ref<'single' | 'folder'>('single');
-  const uploadBizKey = ref(buildUUID());
-  const uploadLoadTime = ref(Date.now());
-
-  // Web 上传相关状态
-  const fileInputRef = ref<HTMLInputElement | null>(null);
-  const folderInputRef = ref<HTMLInputElement | null>(null);
-  const selectedFiles = ref<File[]>([]);
-  const uploadResults = ref<FileInfo[]>([]);
-  const uploadProgressMap = ref<Map<string, number>>(new Map());
-  const uploadStatuses = ref<Map<string, 'pending' | 'uploading' | 'done' | 'error'>>(new Map());
-  const isUploading = ref(false);
-  const overallProgress = ref(0);
-  const dragOver = ref(false);
 
   let governConfigSaveTimer: ReturnType<typeof setTimeout> | undefined;
 
   const { createMessage } = useMessage();
-  const [registerUploadModal, { closeModal: closeUploadModal }] = useModal();
   const getPagePopupContainer = (triggerNode?: HTMLElement) =>
     (triggerNode?.closest('.case-data-page') as HTMLElement | null) || document.body;
   const getModalPopupContainer = (triggerNode?: HTMLElement) => resolveModalPopupContainer(triggerNode);
@@ -910,41 +768,8 @@
     restoringImportProgress = false;
   });
 
-  const openUpload = (mode: 'single' | 'folder') => {
-    uploadMode.value = mode;
-    uploadVisible.value = true;
-    uploadBizKey.value = buildUUID();
-    uploadLoadTime.value = Date.now();
-  };
-
   const handleUploadNavigate = (mode?: 'single' | 'folder') => {
-    if (mode) {
-      openUpload(mode);
-    } else {
-      go(PageEnum.BASE_MATCH);
-    }
-  };
-
-  const handleUploadModeChange = (mode: 'single' | 'folder') => {
-    if (uploadMode.value === mode) return;
-    uploadMode.value = mode;
-    uploadBizKey.value = buildUUID();
-    uploadLoadTime.value = Date.now();
-    clearSelectedFiles();
-  };
-
-  const handleUploadChange = () => {
-    uploadLoadTime.value = Date.now();
-  };
-  void handleUploadChange;
-
-  const handleUploadOk = () => {
-    uploadVisible.value = false;
-    closeUploadModal();
-  };
-
-  const handleUploadCancel = () => {
-    uploadVisible.value = false;
+    go({ path: PageEnum.BASE_UPLOAD, query: { mode: mode || 'single' } });
   };
 
   const handleTemplateOpen = async () => {
@@ -2482,188 +2307,6 @@
     await loadTemplateList();
   };
 
-  const VALID_UPLOAD_EXTS = ['.xls', '.xlsx', '.csv'];
-
-  const formatFileSize = (bytes?: number) => {
-    if (bytes == null || bytes === 0) return '0 B';
-    const k = 1024;
-    const sizes = ['B', 'KB', 'MB', 'GB'];
-    const i = Math.floor(Math.log(bytes) / Math.log(k));
-    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
-  };
-
-  const isValidUploadFile = (file: File) => {
-    const name = file.name.toLowerCase();
-    return VALID_UPLOAD_EXTS.some((ext) => name.endsWith(ext));
-  };
-
-  const filterValidFiles = (files: File[]) => {
-    const valid = files.filter(isValidUploadFile);
-    const invalid = files.filter((f) => !isValidUploadFile(f));
-    if (invalid.length) {
-      createMessage.warning(`已过滤 ${invalid.length} 个不支持的文件,仅支持 xls / xlsx / csv`);
-    }
-    return valid;
-  };
-
-  const handleDragOver = () => {
-    dragOver.value = true;
-  };
-
-  const handleDragLeave = () => {
-    dragOver.value = false;
-  };
-
-  const traverseDirectory = async (entry: FileSystemDirectoryEntry): Promise<File[]> => {
-    const files: File[] = [];
-    const reader = entry.createReader();
-    const getEntries = (): Promise<FileSystemEntry[]> => new Promise((resolve) => reader.readEntries(resolve));
-    let entries = await getEntries();
-    while (entries.length) {
-      for (const e of entries) {
-        if (e.isFile) {
-          const file = await new Promise<File>((resolve) => (e as FileSystemFileEntry).file(resolve));
-          files.push(file);
-        } else if (e.isDirectory) {
-          files.push(...(await traverseDirectory(e as FileSystemDirectoryEntry)));
-        }
-      }
-      entries = await getEntries();
-    }
-    return files;
-  };
-
-  const collectFilesFromDataTransfer = async (items: DataTransferItemList): Promise<File[]> => {
-    const files: File[] = [];
-    for (let i = 0; i < items.length; i++) {
-      const item = items[i];
-      const entry = item.webkitGetAsEntry?.();
-      if (!entry) {
-        const file = item.getAsFile();
-        if (file) files.push(file);
-        continue;
-      }
-      if (entry.isFile) {
-        const file = await new Promise<File>((resolve) => (entry as FileSystemFileEntry).file(resolve));
-        files.push(file);
-      } else if (entry.isDirectory) {
-        files.push(...(await traverseDirectory(entry as FileSystemDirectoryEntry)));
-      }
-    }
-    return files;
-  };
-
-  const handleDrop = async (event: DragEvent) => {
-    dragOver.value = false;
-    if (!event.dataTransfer) return;
-    const files = await collectFilesFromDataTransfer(event.dataTransfer.items);
-    const valid = filterValidFiles(files);
-    if (valid.length) {
-      selectedFiles.value = [...selectedFiles.value, ...valid];
-    }
-  };
-
-  const handleFileInputChange = (event: Event) => {
-    const target = event.target as HTMLInputElement;
-    if (!target.files?.length) return;
-    const files = Array.from(target.files);
-    const valid = filterValidFiles(files);
-    if (valid.length) {
-      selectedFiles.value = [...selectedFiles.value, ...valid];
-    }
-    target.value = '';
-  };
-
-  const handleFolderInputChange = (event: Event) => {
-    const target = event.target as HTMLInputElement;
-    if (!target.files?.length) return;
-    const files = Array.from(target.files).filter((f) => f.webkitRelativePath);
-    const valid = filterValidFiles(files);
-    if (valid.length) {
-      selectedFiles.value = [...selectedFiles.value, ...valid];
-    }
-    target.value = '';
-  };
-
-  const handleTriggerFileSelect = () => {
-    if (uploadMode.value === 'folder') {
-      folderInputRef.value?.click();
-    } else {
-      fileInputRef.value?.click();
-    }
-  };
-
-  const clearSelectedFiles = () => {
-    selectedFiles.value = [];
-    uploadResults.value = [];
-    uploadProgressMap.value = new Map();
-    uploadStatuses.value = new Map();
-    overallProgress.value = 0;
-  };
-
-  const startUpload = async () => {
-    if (!selectedFiles.value.length || isUploading.value) return;
-    isUploading.value = true;
-    uploadResults.value = [];
-    uploadProgressMap.value = new Map();
-    uploadStatuses.value = new Map();
-    overallProgress.value = 0;
-
-    const files = selectedFiles.value;
-    const total = files.length;
-    const batchId = Date.now();
-    const allFileInfos: FileInfo[] = [];
-
-    try {
-      for (let i = 0; i < files.length; i++) {
-        const file = files[i];
-        const key = file.name + '-' + i;
-        uploadStatuses.value.set(key, 'uploading');
-
-        const onProgress = (percent: number) => {
-          uploadProgressMap.value.set(key, percent);
-          const completedSum = files.reduce((sum, f, idx) => {
-            const k = f.name + '-' + idx;
-            const status = uploadStatuses.value.get(k);
-            if (status === 'done') return sum + 100;
-            if (status === 'uploading') return sum + (uploadProgressMap.value.get(k) || 0);
-            return sum;
-          }, 0);
-          overallProgress.value = Math.round(completedSum / total);
-        };
-
-        try {
-          const result = await preFileUpload(file, onProgress, { batchId });
-          const list = Array.isArray(result) ? result : ((result as any)?.data ?? []);
-          if (Array.isArray(list) && list.length) {
-            allFileInfos.push(...list);
-          }
-          uploadStatuses.value.set(key, 'done');
-          onProgress(100);
-        } catch (error: any) {
-          uploadStatuses.value.set(key, 'error');
-          createMessage.error(`「${file.name}」上传解析失败:${error?.message || '未知错误'}`);
-        }
-      }
-
-      overallProgress.value = 100;
-
-      if (allFileInfos.length) {
-        sessionStorage.setItem('case_clean_file_infos', JSON.stringify(allFileInfos));
-        createMessage.success(`成功解析 ${allFileInfos.length} 个文件`);
-        handleUploadOk();
-        emitter.emit(CASE_IMPORT_FILE_REFRESH_EVENT);
-        go(PageEnum.BASE_MATCH);
-      } else {
-        createMessage.warning('未识别到可处理文件,请检查文件格式是否正确');
-      }
-    } catch (error) {
-      createMessage.error('上传解析过程中发生错误');
-    } finally {
-      isUploading.value = false;
-    }
-  };
-
   const treeData = ref<GovernTreeNodeOption[]>([]);
   const columns = ref<BasicColumn[]>([]);
   const selectedKeys = ref<string[]>([]);
@@ -5771,308 +5414,6 @@
     }
   }
 
-  .case-upload-modal {
-    .ant-modal {
-      width: min(760px, calc(100vw - 48px)) !important;
-      max-width: calc(100vw - 48px);
-      top: 40px;
-      padding-bottom: 20px;
-    }
-
-    .ant-modal-content {
-      border-radius: 14px;
-      overflow: hidden;
-    }
-
-    .ant-modal-header {
-      padding: 12px 16px;
-      border-bottom: 1px solid #e5e7eb;
-      background: @modal-header-bg-color;
-    }
-
-    .ant-modal-body {
-      padding: 16px;
-    }
-
-    .ant-modal-footer {
-      display: none;
-    }
-
-    .ant-btn {
-      border-radius: 10px;
-    }
-
-    .ant-btn-primary {
-      height: 34px;
-      padding: 0 18px;
-    }
-
-    .upload-modal-body {
-      display: flex;
-      flex-direction: column;
-      gap: 16px;
-      padding: 0;
-      min-height: 0;
-    }
-
-    .upload-modal-header {
-      display: flex;
-      flex-direction: column;
-      gap: 12px;
-      padding: 0;
-    }
-
-    .upload-mode-switch {
-      display: inline-flex;
-      align-items: center;
-      gap: 6px;
-      width: fit-content;
-      padding: 4px;
-      border-radius: 999px;
-      background: #f1f5f9;
-      border: 1px solid #dbe5f0;
-      box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.7);
-    }
-
-    .upload-mode-switch__item {
-      min-width: 108px;
-      height: 34px;
-      padding: 0 16px;
-      border: none;
-      border-radius: 999px;
-      background: transparent;
-      color: #64748b;
-      font-size: 13px;
-      font-weight: 600;
-      cursor: pointer;
-      transition:
-        background-color 0.2s ease,
-        color 0.2s ease,
-        box-shadow 0.2s ease;
-
-      &.is-active {
-        background: linear-gradient(135deg, #316dff 0%, #4f8bff 100%);
-        color: #fff;
-        box-shadow: 0 8px 18px rgba(49, 109, 255, 0.22);
-      }
-
-      &:disabled {
-        cursor: not-allowed;
-        opacity: 0.6;
-      }
-    }
-
-    .upload-modal-title {
-      font-size: 14px;
-      font-weight: 600;
-      color: #0f172a;
-      letter-spacing: 0.2px;
-    }
-
-    .upload-modal-subtitle {
-      font-size: 12px;
-      color: #64748b;
-    }
-
-    .upload-dropzone {
-      flex: 0 0 auto;
-      min-height: 148px;
-      border-radius: 14px;
-      border: 1px dashed #c7d2fe;
-      background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
-      transition: all 0.2s ease;
-      cursor: pointer;
-
-      &:hover,
-      &.is-dragover {
-        border-color: #3b82f6;
-        box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
-      }
-    }
-
-    .upload-dragger-content {
-      display: flex;
-      flex-direction: column;
-      align-items: center;
-      justify-content: center;
-      gap: 8px;
-      padding: 18px 0;
-      color: #475569;
-    }
-
-    .upload-dragger-icon {
-      width: 40px;
-      height: 40px;
-      border-radius: 12px;
-      background: rgba(59, 130, 246, 0.12);
-      color: #2563eb;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      font-size: 18px;
-      margin-bottom: 2px;
-    }
-
-    .upload-dragger-title {
-      font-size: 14px;
-      font-weight: 700;
-      color: #0f172a;
-    }
-
-    .upload-dragger-desc {
-      font-size: 12px;
-      color: #64748b;
-      max-width: 520px;
-    }
-
-    .upload-action-btn {
-      margin-top: 4px;
-      height: 32px;
-      padding: 0 18px;
-      border-radius: 10px;
-      box-shadow: 0 8px 18px rgba(37, 99, 235, 0.22);
-    }
-
-    .upload-file-list {
-      margin-top: 16px;
-      border: 1px solid #e2e8f0;
-      border-radius: 12px;
-      background: #ffffff;
-      overflow: hidden;
-
-      &__header {
-        display: flex;
-        align-items: center;
-        justify-content: space-between;
-        padding: 10px 14px;
-        background: #f8fafc;
-        border-bottom: 1px solid #e2e8f0;
-        font-size: 13px;
-        color: #475569;
-        font-weight: 500;
-      }
-
-      &__body {
-        max-height: 220px;
-        overflow-y: auto;
-        padding: 4px 0;
-      }
-
-      &__actions {
-        display: flex;
-        justify-content: flex-end;
-        padding: 12px 14px;
-        border-top: 1px solid #e2e8f0;
-        gap: 8px;
-      }
-    }
-
-    .upload-file-item {
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-      padding: 8px 14px;
-      gap: 12px;
-      transition: background 0.15s ease;
-
-      &:hover {
-        background: #f8fafc;
-      }
-
-      &__info {
-        display: flex;
-        align-items: center;
-        gap: 10px;
-        min-width: 0;
-        flex: 1;
-      }
-
-      &__name {
-        font-size: 13px;
-        color: #1e293b;
-        white-space: nowrap;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        max-width: 420px;
-      }
-
-      &__size {
-        font-size: 12px;
-        color: #94a3b8;
-        flex-shrink: 0;
-      }
-
-      &__status {
-        flex-shrink: 0;
-        font-size: 12px;
-        min-width: 48px;
-        text-align: right;
-      }
-
-      &__success {
-        color: #16a34a;
-        font-weight: 600;
-      }
-
-      &__error {
-        color: #dc2626;
-        font-weight: 600;
-      }
-
-      &__progress-text {
-        color: #2563eb;
-        font-weight: 500;
-      }
-
-      &__pending {
-        color: #94a3b8;
-      }
-    }
-
-    .upload-overall-progress {
-      display: flex;
-      align-items: center;
-      gap: 10px;
-      padding: 10px 14px;
-      border-top: 1px solid #e2e8f0;
-
-      &__bar {
-        flex: 1;
-        height: 6px;
-        border-radius: 3px;
-        background: #e2e8f0;
-        overflow: hidden;
-      }
-
-      &__fill {
-        height: 100%;
-        border-radius: 3px;
-        background: linear-gradient(90deg, #3b82f6, #2563eb);
-        transition: width 0.3s ease;
-      }
-
-      &__text {
-        font-size: 12px;
-        color: #475569;
-        font-weight: 500;
-        white-space: nowrap;
-      }
-    }
-  }
-
-  @media (max-width: 960px) {
-    .case-upload-modal {
-      .upload-mode-switch {
-        width: 100%;
-      }
-
-      .upload-mode-switch__item {
-        flex: 1 1 0;
-        min-width: 0;
-      }
-    }
-  }
-
   html[data-theme='dark'] {
     .@{prefix-cls}.case-data-page {
       background: #0f172a;

+ 1186 - 0
ai-frontend/src/case/views/data/upload.vue

@@ -0,0 +1,1186 @@
+<template>
+  <div :class="prefixCls" class="case-upload-page">
+    <div class="page-header">
+      <div class="page-header__left">
+        <Button type="link" class="back-btn" @click="handleBack">
+          <template #icon><LeftOutlined /></template>
+          返回
+        </Button>
+        <div>
+          <div class="page-title">文件上传</div>
+          <div class="page-subtitle">上传并解析 xls / xlsx / csv 文件,解析结果实时展示在右侧</div>
+        </div>
+      </div>
+      <div class="page-header__actions">
+        <Button type="primary" class="next-btn" :disabled="!hasSuccessResult" @click="handleGoMatch">
+          下一步:数据匹配
+        </Button>
+      </div>
+    </div>
+
+    <div class="upload-layout">
+      <aside class="left-panel">
+        <div class="upload-picker">
+          <div class="upload-mode-switch" role="tablist" aria-label="上传类型切换">
+            <button
+              type="button"
+              :class="['upload-mode-switch__item', { 'is-active': uploadMode === 'single' }]"
+              :disabled="isUploading"
+              @click="handleUploadModeChange('single')"
+            >
+              选择文件
+            </button>
+            <button
+              type="button"
+              :class="['upload-mode-switch__item', { 'is-active': uploadMode === 'folder' }]"
+              :disabled="isUploading"
+              @click="handleUploadModeChange('folder')"
+            >
+              选择文件夹
+            </button>
+          </div>
+        </div>
+
+        <!-- 隐藏的文件输入 -->
+        <input
+          ref="fileInputRef"
+          type="file"
+          multiple
+          accept=".xls,.xlsx,.csv"
+          style="display: none"
+          @change="handleFileInputChange"
+        />
+        <input
+          ref="folderInputRef"
+          type="file"
+          webkitdirectory
+          directory
+          style="display: none"
+          @change="handleFolderInputChange"
+        />
+
+        <div
+          class="upload-dropzone"
+          :class="{ 'is-dragover': dragOver }"
+          @dragover.prevent="handleDragOver"
+          @dragleave="handleDragLeave"
+          @drop.prevent="handleDrop"
+        >
+          <div class="upload-dragger-content">
+            <div class="upload-dragger-icon">⬆</div>
+            <div class="upload-dragger-title">
+              {{ uploadMode === 'single' ? '拖拽文件到这里,或点击选择文件' : '拖拽文件夹到这里,或点击选择文件夹' }}
+            </div>
+            <div class="upload-dragger-desc">
+              {{
+                uploadMode === 'single'
+                  ? '支持批量选择 xls / xlsx / csv 文件,同一批次统一解析'
+                  : '支持选择整个文件夹,系统会自动读取符合条件的文件'
+              }}
+            </div>
+            <Button type="primary" class="upload-action-btn" :disabled="isUploading" @click="handleTriggerFileSelect">
+              {{ uploadMode === 'single' ? '选择文件' : '选择文件夹' }}
+            </Button>
+          </div>
+        </div>
+
+        <!-- 已选文件列表(加入后自动上传,成功即移除) -->
+        <div v-if="selectedFiles.length" class="upload-file-list">
+          <div class="upload-file-list__header">
+            <span>待上传 {{ selectedFiles.length }} 个文件</span>
+            <Button type="link" size="small" :disabled="isUploading" @click="clearSelectedFiles"> 清空 </Button>
+          </div>
+          <div class="upload-file-list__body">
+            <div
+              v-for="file in selectedFiles"
+              :key="file.name + file.size + file.lastModified"
+              class="upload-file-item"
+            >
+              <div class="upload-file-item__info">
+                <span class="upload-file-item__name" :title="file.name">{{ file.name }}</span>
+                <span class="upload-file-item__size">{{ formatFileSize(file.size) }}</span>
+              </div>
+              <div class="upload-file-item__status">
+                <template v-if="uploadStatuses.get(file) === 'error'">
+                  <span class="upload-file-item__error">✗</span>
+                </template>
+                <template v-else-if="uploadStatuses.get(file) === 'uploading'">
+                  <span class="upload-file-item__progress-text">{{ uploadProgressMap.get(file) || 0 }}%</span>
+                </template>
+                <template v-else>
+                  <span class="upload-file-item__pending">待上传</span>
+                </template>
+              </div>
+              <button
+                v-if="!isUploading && (uploadStatuses.get(file) || 'pending') === 'pending'"
+                type="button"
+                class="upload-file-item__remove"
+                :title="`移除 ${file.name}`"
+                aria-label="移除文件"
+                @click="removeSelectedFile(file)"
+              >
+                <CloseOutlined />
+              </button>
+            </div>
+          </div>
+          <!-- 整体进度 -->
+          <div v-if="isUploading" class="upload-overall-progress">
+            <div class="upload-overall-progress__bar">
+              <div class="upload-overall-progress__fill" :style="{ width: overallProgress + '%' }"></div>
+            </div>
+            <span class="upload-overall-progress__text">总进度 {{ overallProgress }}%</span>
+          </div>
+        </div>
+        <div v-else class="upload-file-list upload-file-list--empty">
+          <div class="upload-empty">暂无待上传文件,选择文件后将自动上传解析</div>
+        </div>
+      </aside>
+
+      <section class="right-panel">
+        <div class="panel-header">
+          <div>
+            <div class="panel-title">解析结果(实时)</div>
+            <div class="panel-desc">每完成一份文件解析,结果树会实时追加到下方</div>
+          </div>
+          <div class="panel-stats">已解析 {{ resultTrees.length }} 个文件</div>
+        </div>
+        <div class="table-wrapper">
+          <Table
+            class="result-tree-table"
+            :columns="resultTableColumns"
+            :data-source="resultTrees"
+            :loading="tableLoading"
+            :pagination="false"
+            row-key="id"
+            :children-column-name="'children'"
+            size="small"
+            :scroll="{ x: 760 }"
+          />
+        </div>
+      </section>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup name="ViewsDataUpload">
+  import { computed, h, ref } from 'vue';
+  import { onBeforeRouteLeave } from 'vue-router';
+  import { CloseOutlined, LeftOutlined } from '@ant-design/icons-vue';
+  import { Button, Modal, Table, type TableColumnsType } from 'ant-design-vue';
+  import { useDesign } from '@/core/hooks/web/useDesign';
+  import { useGo, useQuery } from '@/core/hooks/web/usePage';
+  import { PageEnum } from '@/core/enums/pageEnum';
+  import { useMessage } from '@/core/hooks/web/useMessage';
+  import { useEmitter } from '@/core/store/modules/case';
+  import { clearUploadBatch, preFileUpload } from '@/case/api/govern/governApi';
+  import { FileStatusEnum } from '@/case/types/enum';
+  import type { FileInfo } from '@/types/api';
+
+  const { prefixCls } = useDesign('case-upload');
+  const go = useGo();
+  const pageQuery = useQuery();
+  const emitter = useEmitter();
+  const { createMessage } = useMessage();
+  const FILE_INFO_STORAGE_KEY = 'case_clean_file_infos';
+  const CASE_DATA_REFRESH_EVENT = 'case-data:refresh';
+  const CASE_IMPORT_FILE_REFRESH_EVENT = 'case-import-file:refresh';
+  const VALID_UPLOAD_EXTS = ['.xls', '.xlsx', '.csv'];
+
+  type UploadMode = 'single' | 'folder';
+
+  /** 进入上传页即生成批次 id,本页所有文件统一使用该批次(Long 型,上限约 1.7e18,安全) */
+  const createUploadBatchId = () => Date.now() * 1000000 + Math.floor(Math.random() * 1000000);
+
+  const uploadBatchId = ref(createUploadBatchId());
+  const modeFromQuery = computed<UploadMode>(() => (pageQuery.value?.mode === 'folder' ? 'folder' : 'single'));
+  const uploadMode = ref<UploadMode>(modeFromQuery.value);
+
+  // 文件选择与上传状态(以 File 对象为键,上传成功移除文件时状态映射不会错乱)
+  const fileInputRef = ref<HTMLInputElement | null>(null);
+  const folderInputRef = ref<HTMLInputElement | null>(null);
+  const selectedFiles = ref<File[]>([]);
+  const uploadProgressMap = ref<Map<File, number>>(new Map());
+  const uploadStatuses = ref<Map<File, 'pending' | 'uploading' | 'done' | 'error'>>(new Map());
+  const isUploading = ref(false);
+  const overallProgress = ref(0);
+  const dragOver = ref(false);
+
+  // 解析结果(右侧 treetable 数据源,实时追加)
+  const resultTrees = ref<FileInfo[]>([]);
+  const allFileInfos = ref<Recordable[]>([]);
+  const tableLoading = ref(false);
+
+  // 离开放行标记:点「下一步」或已确认清理后的程序化跳转置为 true
+  const allowedToLeave = ref(false);
+  // 放弃标记:确认离开后中断上传循环,避免组件卸载后继续写结果
+  const isAbandoned = ref(false);
+
+  const batchIdReady = computed(() => !!uploadBatchId.value);
+  const hasActivity = computed(
+    () => isUploading.value || selectedFiles.value.length > 0 || resultTrees.value.length > 0,
+  );
+  const hasSuccessResult = computed(() =>
+    resultTrees.value.some(
+      (root) => isSuccessStatus(root.fileStatus) || (root.children || []).some((s) => isSuccessStatus(s.fileStatus)),
+    ),
+  );
+
+  const formatFileSize = (bytes?: number | null) => {
+    if (bytes == null || bytes === 0) return '0 B';
+    const k = 1024;
+    const sizes = ['B', 'KB', 'MB', 'GB'];
+    const i = Math.floor(Math.log(bytes) / Math.log(k));
+    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+  };
+
+  const isValidUploadFile = (file: File) => {
+    const name = file.name.toLowerCase();
+    return VALID_UPLOAD_EXTS.some((ext) => name.endsWith(ext));
+  };
+
+  const filterValidFiles = (files: File[]) => {
+    const valid = files.filter(isValidUploadFile);
+    const invalid = files.filter((f) => !isValidUploadFile(f));
+    if (invalid.length) {
+      createMessage.warning(`已过滤 ${invalid.length} 个不支持的文件,仅支持 xls / xlsx / csv`);
+    }
+    return valid;
+  };
+
+  const handleDragOver = () => {
+    dragOver.value = true;
+  };
+
+  const handleDragLeave = () => {
+    dragOver.value = false;
+  };
+
+  const traverseDirectory = async (entry: FileSystemDirectoryEntry): Promise<File[]> => {
+    const files: File[] = [];
+    const reader = entry.createReader();
+    const getEntries = (): Promise<FileSystemEntry[]> => new Promise((resolve) => reader.readEntries(resolve));
+    let entries = await getEntries();
+    while (entries.length) {
+      for (const e of entries) {
+        if (e.isFile) {
+          const file = await new Promise<File>((resolve) => (e as FileSystemFileEntry).file(resolve));
+          files.push(file);
+        } else if (e.isDirectory) {
+          files.push(...(await traverseDirectory(e as FileSystemDirectoryEntry)));
+        }
+      }
+      entries = await getEntries();
+    }
+    return files;
+  };
+
+  const collectFilesFromDataTransfer = async (items: DataTransferItemList): Promise<File[]> => {
+    const files: File[] = [];
+    for (let i = 0; i < items.length; i++) {
+      const item = items[i];
+      const entry = item.webkitGetAsEntry?.();
+      if (!entry) {
+        const file = item.getAsFile();
+        if (file) files.push(file);
+        continue;
+      }
+      if (entry.isFile) {
+        const file = await new Promise<File>((resolve) => (entry as FileSystemFileEntry).file(resolve));
+        files.push(file);
+      } else if (entry.isDirectory) {
+        files.push(...(await traverseDirectory(entry as FileSystemDirectoryEntry)));
+      }
+    }
+    return files;
+  };
+
+  /** 文件加入上传列表并自动开始上传 */
+  const addFiles = (files: File[]) => {
+    if (!files.length || isAbandoned.value) return;
+    const valid = filterValidFiles(files);
+    if (!valid.length) return;
+    selectedFiles.value = [...selectedFiles.value, ...valid];
+    void startUpload();
+  };
+
+  const handleDrop = async (event: DragEvent) => {
+    dragOver.value = false;
+    if (!event.dataTransfer) return;
+    const files = await collectFilesFromDataTransfer(event.dataTransfer.items);
+    addFiles(files);
+  };
+
+  const handleFileInputChange = (event: Event) => {
+    const target = event.target as HTMLInputElement;
+    if (!target.files?.length) return;
+    addFiles(Array.from(target.files));
+    target.value = '';
+  };
+
+  const handleFolderInputChange = (event: Event) => {
+    const target = event.target as HTMLInputElement;
+    if (!target.files?.length) return;
+    const files = Array.from(target.files).filter((f) => f.webkitRelativePath);
+    addFiles(files);
+    target.value = '';
+  };
+
+  const handleTriggerFileSelect = () => {
+    if (uploadMode.value === 'folder') {
+      folderInputRef.value?.click();
+    } else {
+      fileInputRef.value?.click();
+    }
+  };
+
+  const handleUploadModeChange = (mode: UploadMode) => {
+    if (uploadMode.value === mode || isUploading.value) return;
+    uploadMode.value = mode;
+    clearSelectedFiles();
+  };
+
+  const clearSelectedFiles = () => {
+    selectedFiles.value = [];
+    uploadProgressMap.value = new Map();
+    uploadStatuses.value = new Map();
+    overallProgress.value = 0;
+  };
+
+  /** 移除单个待上传文件(上传解析中不允许移除) */
+  const removeSelectedFile = (target: File) => {
+    if (isUploading.value) return;
+    const index = selectedFiles.value.indexOf(target);
+    if (index === -1) return;
+    // 仅允许移除待上传文件
+    if ((uploadStatuses.value.get(target) || 'pending') !== 'pending') {
+      return;
+    }
+    selectedFiles.value.splice(index, 1);
+    uploadProgressMap.value.delete(target);
+    uploadStatuses.value.delete(target);
+    if (!selectedFiles.value.length) {
+      overallProgress.value = 0;
+    }
+  };
+
+  const persistFileInfos = (infos: Recordable[]) => {
+    sessionStorage.setItem(FILE_INFO_STORAGE_KEY, JSON.stringify(infos));
+  };
+
+  const isOwnBatchStorage = () => {
+    const raw = sessionStorage.getItem(FILE_INFO_STORAGE_KEY);
+    if (!raw) return true;
+    try {
+      const parsed = JSON.parse(raw);
+      if (!Array.isArray(parsed) || !parsed.length) return true;
+      return parsed.every((item) => String(item?.batchId ?? '') === String(uploadBatchId.value));
+    } catch {
+      return true;
+    }
+  };
+
+  const clearStoredFileInfos = () => {
+    if (isOwnBatchStorage()) {
+      sessionStorage.removeItem(FILE_INFO_STORAGE_KEY);
+    }
+  };
+
+  /**
+   * 自动上传队列:选择文件后自动触发,按序消费列表中的待上传文件;
+   * 上传解析成功即从列表移除,失败保留(标记错误,可手动移除)。
+   */
+  const startUpload = async () => {
+    if (isUploading.value || !batchIdReady.value) return;
+    const currentBatchId = uploadBatchId.value;
+    isUploading.value = true;
+    isAbandoned.value = false;
+    tableLoading.value = true;
+
+    try {
+      while (true) {
+        if (isAbandoned.value) break;
+        // 取当前第一个待上传文件
+        const index = selectedFiles.value.findIndex((f) => (uploadStatuses.value.get(f) || 'pending') === 'pending');
+        if (index === -1) break;
+        const file = selectedFiles.value[index];
+        uploadStatuses.value.set(file, 'uploading');
+
+        const onProgress = (percent: number) => {
+          uploadProgressMap.value.set(file, percent);
+          // 总进度:全量待处理文件按(pending=0 / uploading=进度 / error=100)取平均
+          const list = selectedFiles.value;
+          const total = list.length || 1;
+          const sum = list.reduce((acc, f) => {
+            const st = uploadStatuses.value.get(f);
+            if (st === 'uploading') return acc + (uploadProgressMap.value.get(f) || 0);
+            if (st === 'error') return acc + 100;
+            return acc;
+          }, 0);
+          overallProgress.value = Math.round(sum / total);
+        };
+
+        try {
+          // defHttp.uploadFile 返回原始 AxiosResponse,业务数据在 res.data.data
+          // 后端返回单个文件节点:文件根(含 children sheet 列表)
+          const res = await preFileUpload(file, onProgress, { batchId: currentBatchId });
+          if (isAbandoned.value) break;
+          const fileNode: FileInfo | null = res?.data?.data ?? null;
+          if (fileNode) {
+            // 右侧 treetable 直接渲染树(文件根 → sheet 子节点)
+            resultTrees.value = [...resultTrees.value, fileNode];
+            // 持久化给 match/清洗流程的数据保持平铺(根 + 各 sheet 同层,pid 关联),与历史契约一致
+            const flatList: FileInfo[] = [fileNode, ...(fileNode.children ?? [])];
+            allFileInfos.value = [...allFileInfos.value, ...flatList];
+            persistFileInfos(allFileInfos.value);
+          }
+          // 上传成功:从列表移除该文件
+          uploadProgressMap.value.delete(file);
+          uploadStatuses.value.delete(file);
+          const idx = selectedFiles.value.indexOf(file);
+          if (idx !== -1) {
+            selectedFiles.value.splice(idx, 1);
+          }
+          onProgress(100);
+        } catch (error: any) {
+          // 失败保留在列表,标记错误供用户查看/移除
+          uploadStatuses.value.set(file, 'error');
+          uploadProgressMap.value.set(file, 100);
+          createMessage.error(`「${file.name}」上传解析失败:${error?.message || '未知错误'}`);
+        }
+      }
+
+      overallProgress.value = 100;
+      if (resultTrees.value.length && !isAbandoned.value) {
+        createMessage.success(`成功解析 ${resultTrees.value.length} 个文件,可在右侧查看结果`);
+      } else if (!isAbandoned.value && !selectedFiles.value.length) {
+        createMessage.warning('未识别到可处理文件,请检查文件格式是否正确');
+      }
+    } catch (error) {
+      createMessage.error('上传解析过程中发生错误');
+    } finally {
+      isUploading.value = false;
+      tableLoading.value = false;
+    }
+  };
+
+  /** 文件/工作表状态 → 中文标签(与 importList、cleaning 页保持一致) */
+  const fileStatusLabelMap = Object.entries(FileStatusEnum).reduce<Record<string, string>>((map, [key, label]) => {
+    map[key] = label;
+    return map;
+  }, {});
+  /** 读取/处理成功,可进入匹配、清洗流程的状态 */
+  const SUCCESS_FILE_STATUSES = new Set<string>([
+    'FILE_READ_COMPLETE',
+    'SHEET_TEMPLATE_MATCHED_SUCCESS',
+    'FILE_CLEAN_COMPLETE',
+    'FILE_GOVERN_COMPLETE',
+  ]);
+  /** 明确失败的状态(加密文件等中性状态不算失败) */
+  const FAIL_FILE_STATUSES = new Set<string>([
+    'FILE_READ_FAIL',
+    'FILE_UNSUPPORTED_FAIL',
+    'SHEET_READ_FAIL',
+    'SHEET_TEMPLATE_MATCHED_FAIL',
+  ]);
+
+  const isSuccessStatus = (status?: string) => {
+    const normalized = String(status || '').trim();
+    return !!normalized && SUCCESS_FILE_STATUSES.has(normalized);
+  };
+
+  const mapStatus = (status?: string) => {
+    const normalized = String(status || '').trim();
+    if (!normalized) return { success: false, fail: false, label: '待处理' };
+    return {
+      success: SUCCESS_FILE_STATUSES.has(normalized),
+      fail: FAIL_FILE_STATUSES.has(normalized),
+      label: fileStatusLabelMap[normalized] || normalized,
+    };
+  };
+
+  const isRootNode = (record: Recordable) => !record.pid || Number(record.pid) === 0;
+
+  const resultTableColumns: TableColumnsType<Recordable> = [
+    {
+      title: '名称',
+      key: 'name',
+      width: 360,
+      ellipsis: true,
+      customRender: ({ record }: any) => {
+        const root = isRootNode(record);
+        const name = root ? record.fileName || '未命名文件' : record.sheetName || record.tableName || '未命名工作表';
+        const extra = root && record.fileType ? ` · ${record.fileType}` : '';
+        return h('span', { class: ['cell-name', { 'is-child': !root }], title: String(name) + extra }, [name + extra]);
+      },
+    },
+    {
+      title: '大小',
+      key: 'fileSize',
+      width: 110,
+      customRender: ({ record }: any) => (isRootNode(record) ? formatFileSize(record.fileSize) : '—'),
+    },
+    {
+      title: '数据量',
+      key: 'dataNum',
+      width: 90,
+      customRender: ({ record }: any) => record.dataNum ?? '—',
+    },
+    {
+      title: '成功',
+      key: 'successNum',
+      width: 90,
+      customRender: ({ record }: any) =>
+        h('span', { class: 'cell-num is-success' }, [String(record.successNum ?? '—')]),
+    },
+    {
+      title: '失败',
+      key: 'failNum',
+      width: 90,
+      customRender: ({ record }: any) => h('span', { class: 'cell-num is-fail' }, [String(record.failNum ?? '—')]),
+    },
+    {
+      title: '状态',
+      key: 'status',
+      width: 130,
+      customRender: ({ record }: any) => {
+        const mapped = mapStatus(record.fileStatus);
+        const cls = mapped.success ? 'is-success' : mapped.fail ? 'is-fail' : 'is-other';
+        return h('span', { class: ['status-tag', cls] }, [mapped.label]);
+      },
+    },
+  ];
+
+  const cleanupAndLeave = async () => {
+    isAbandoned.value = true;
+    allowedToLeave.value = true;
+    try {
+      await clearUploadBatch({ batchId: uploadBatchId.value });
+    } catch (error: any) {
+      createMessage.warning(error?.message || '清理本批次数据失败,可稍后在导入文件管理中删除');
+    } finally {
+      clearStoredFileInfos();
+      await go(PageEnum.BASE_HOME, true);
+      emitter.emit(CASE_DATA_REFRESH_EVENT);
+    }
+  };
+
+  const handleBack = () => {
+    if (!hasActivity.value) {
+      allowedToLeave.value = true;
+      go(PageEnum.BASE_HOME, true);
+      emitter.emit(CASE_DATA_REFRESH_EVENT);
+      return;
+    }
+    Modal.confirm({
+      title: isUploading.value ? '上传解析进行中' : '存在未处理文件',
+      content: isUploading.value
+        ? '当前仍有文件在上传解析,离开将中断本次上传,并清理本批次已上传文件与解析结果,是否继续?'
+        : '离开将清理本批次已上传文件与解析结果(未进入清洗流程),是否继续?',
+      okText: '离开并清理',
+      cancelText: '留在本页',
+      onOk: cleanupAndLeave,
+    });
+  };
+
+  const handleGoMatch = () => {
+    if (!hasSuccessResult.value) return;
+    allowedToLeave.value = true;
+    persistFileInfos(allFileInfos.value);
+    emitter.emit(CASE_IMPORT_FILE_REFRESH_EVENT);
+    go(PageEnum.BASE_MATCH);
+  };
+
+  // 兜底浏览器返回 / 标签页关闭等非按钮离开
+  onBeforeRouteLeave((_to, _from, next) => {
+    if (allowedToLeave.value || !hasActivity.value) {
+      next();
+      return;
+    }
+    Modal.confirm({
+      title: isUploading.value ? '上传解析进行中' : '存在未处理文件',
+      content: '离开将清理本批次已上传文件与解析结果(未进入清洗流程),是否继续?',
+      okText: '离开并清理',
+      cancelText: '留在本页',
+      onOk: cleanupAndLeave,
+    });
+    next(false);
+  });
+</script>
+
+<style lang="less">
+  @prefix-cls: ~'jeesite-case-upload';
+
+  .@{prefix-cls}.case-upload-page {
+    width: 100%;
+    height: 100vh;
+    padding: 8px 12px;
+    background: #f0f2f5;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    box-sizing: border-box;
+    overflow: hidden;
+
+    .page-header {
+      flex-shrink: 0;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 12px;
+      padding: 2px 2px 0;
+    }
+
+    .page-header__left {
+      display: flex;
+      align-items: center;
+      gap: 10px;
+      min-width: 0;
+    }
+
+    .back-btn {
+      display: inline-flex;
+      align-items: center;
+      gap: 4px;
+      padding: 0 4px 0 0;
+      height: 30px;
+      border-radius: 6px;
+      color: #2563eb;
+      font-size: 14px;
+      font-weight: 600;
+      line-height: 1;
+      flex-shrink: 0;
+
+      :deep(.ant-btn-icon) {
+        font-size: 15px;
+      }
+
+      &:hover {
+        color: #1d4ed8;
+      }
+    }
+
+    .page-title {
+      font-size: 15px;
+      line-height: 1.3;
+      font-weight: 600;
+      color: #111827;
+    }
+
+    .page-subtitle {
+      margin-top: 2px;
+      font-size: 12px;
+      line-height: 1.5;
+      color: #6b7280;
+    }
+
+    .page-header__actions {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      flex-shrink: 0;
+    }
+
+    .next-btn {
+      height: 32px;
+      padding: 0 14px;
+      border-radius: 8px;
+    }
+
+    .upload-layout {
+      flex: 1 1 auto;
+      min-height: 0;
+      display: flex;
+      gap: 8px;
+      align-items: stretch;
+    }
+
+    .left-panel,
+    .right-panel {
+      border: 1px solid #e2e8f0;
+      border-radius: 10px;
+      background: #fff;
+      overflow: hidden;
+    }
+
+    .left-panel {
+      flex: 0 0 460px;
+      display: flex;
+      flex-direction: column;
+      gap: 10px;
+      padding: 12px;
+      box-sizing: border-box;
+      overflow-y: auto;
+    }
+
+    .right-panel {
+      flex: 1 1 auto;
+      min-width: 0;
+      display: flex;
+      flex-direction: column;
+    }
+
+    .panel-header {
+      flex-shrink: 0;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 12px;
+      padding: 10px 14px 8px;
+      border-bottom: 1px solid #eef2f7;
+    }
+
+    .panel-title {
+      font-size: 14px;
+      font-weight: 600;
+      color: #111827;
+    }
+
+    .panel-desc {
+      margin-top: 2px;
+      font-size: 12px;
+      color: #6b7280;
+      line-height: 1.5;
+    }
+
+    .panel-stats {
+      font-size: 12px;
+      color: #64748b;
+      white-space: nowrap;
+    }
+
+    .table-wrapper {
+      flex: 1 1 auto;
+      min-height: 0;
+      padding: 2px 10px 10px;
+      overflow: auto;
+    }
+
+    .result-tree-table {
+      :deep(.ant-table-thead > tr > th) {
+        padding: 6px 8px;
+        font-size: 12px;
+        font-weight: 600;
+        color: #6b7280;
+        background: #fafbfc;
+        border-bottom: 1px solid #eef2f7;
+      }
+
+      :deep(.ant-table-tbody > tr > td) {
+        padding: 6px 8px;
+        font-size: 13px;
+        border-bottom: 1px solid #f6f8fa;
+      }
+
+      :deep(.ant-table-tbody > tr:hover > td) {
+        background: #f8fafc;
+      }
+
+      :deep(.ant-table-row-expand-icon) {
+        margin-right: 6px;
+        border-radius: 3px;
+      }
+    }
+
+    .cell-name {
+      color: #1e293b;
+      font-size: 13px;
+
+      &.is-child {
+        color: #475569;
+      }
+    }
+
+    .cell-num {
+      font-size: 13px;
+
+      &.is-success {
+        color: #16a34a;
+      }
+
+      &.is-fail {
+        color: #dc2626;
+      }
+    }
+
+    .status-tag {
+      display: inline-flex;
+      align-items: center;
+      padding: 2px 10px;
+      border-radius: 999px;
+      font-size: 12px;
+      font-weight: 600;
+      white-space: nowrap;
+
+      &.is-success {
+        background: #ecfdf3;
+        color: #15803d;
+      }
+
+      &.is-fail {
+        background: #fef2f2;
+        color: #dc2626;
+      }
+
+      &.is-other {
+        background: #f1f5f9;
+        color: #64748b;
+      }
+    }
+
+    .upload-picker {
+      flex: 0 0 auto;
+      display: flex;
+      justify-content: center;
+    }
+
+    .upload-mode-switch {
+      display: inline-flex;
+      align-items: center;
+      gap: 4px;
+      width: fit-content;
+      padding: 3px;
+      border-radius: 8px;
+      background: #f1f5f9;
+      border: 1px solid #e2e8f0;
+    }
+
+    .upload-mode-switch__item {
+      min-width: 92px;
+      height: 28px;
+      padding: 0 12px;
+      border: none;
+      border-radius: 6px;
+      background: transparent;
+      color: #64748b;
+      font-size: 12px;
+      font-weight: 600;
+      cursor: pointer;
+      transition:
+        background-color 0.2s ease,
+        color 0.2s ease;
+
+      &.is-active {
+        background: #2563eb;
+        color: #fff;
+      }
+
+      &:disabled {
+        cursor: not-allowed;
+        opacity: 0.6;
+      }
+    }
+
+    .upload-dropzone {
+      flex: 0 0 auto;
+      min-height: 96px;
+      border-radius: 8px;
+      border: 1px dashed #cbd5e1;
+      background: #fafbfc;
+      transition: all 0.2s ease;
+      cursor: pointer;
+
+      &:hover,
+      &.is-dragover {
+        border-color: #3b82f6;
+      }
+    }
+
+    .upload-dragger-content {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      gap: 4px;
+      padding: 12px 0;
+      color: #475569;
+    }
+
+    .upload-dragger-icon {
+      width: 28px;
+      height: 28px;
+      border-radius: 8px;
+      background: rgba(59, 130, 246, 0.1);
+      color: #2563eb;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 14px;
+    }
+
+    .upload-dragger-title {
+      font-size: 13px;
+      font-weight: 600;
+      color: #334155;
+    }
+
+    .upload-dragger-desc {
+      font-size: 12px;
+      color: #64748b;
+      max-width: 420px;
+      text-align: center;
+    }
+
+    .upload-action-btn {
+      margin-top: 2px;
+      height: 26px;
+      padding: 0 14px;
+      border-radius: 6px;
+    }
+
+    .upload-file-list {
+      flex: 1 1 auto;
+      min-height: 0;
+      display: flex;
+      flex-direction: column;
+      border: 1px solid #e2e8f0;
+      border-radius: 12px;
+      background: #ffffff;
+      overflow: hidden;
+
+      &__header {
+        flex-shrink: 0;
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        padding: 8px 12px;
+        background: #f8fafc;
+        border-bottom: 1px solid #e2e8f0;
+        font-size: 13px;
+        color: #475569;
+        font-weight: 500;
+      }
+
+      &__body {
+        flex: 1 1 auto;
+        min-height: 0;
+        overflow-y: auto;
+        padding: 4px 0;
+      }
+
+      &--empty {
+        flex: 1 1 auto;
+        border-style: dashed;
+        background: #fafafa;
+      }
+    }
+
+    .upload-empty {
+      margin: auto;
+      padding: 24px 12px;
+      color: #9ca3af;
+      font-size: 13px;
+      text-align: center;
+    }
+
+    .upload-file-item {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 6px 12px;
+      gap: 12px;
+      transition: background 0.15s ease;
+
+      &:hover {
+        background: #f8fafc;
+      }
+
+      &__info {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        min-width: 0;
+        flex: 1;
+      }
+
+      &__name {
+        font-size: 13px;
+        color: #1e293b;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        max-width: 300px;
+      }
+
+      &__size {
+        font-size: 12px;
+        color: #94a3b8;
+        flex-shrink: 0;
+      }
+
+      &__status {
+        flex-shrink: 0;
+        font-size: 12px;
+        min-width: 48px;
+        text-align: right;
+      }
+
+      &__remove {
+        flex-shrink: 0;
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        width: 22px;
+        height: 22px;
+        padding: 0;
+        border: none;
+        border-radius: 6px;
+        background: transparent;
+        color: #9ca3af;
+        font-size: 11px;
+        cursor: pointer;
+        transition:
+          background-color 0.15s ease,
+          color 0.15s ease;
+
+        &:hover {
+          background: #fef2f2;
+          color: #dc2626;
+        }
+      }
+
+      &__error {
+        color: #dc2626;
+        font-weight: 600;
+      }
+
+      &__progress-text {
+        color: #2563eb;
+        font-weight: 500;
+      }
+
+      &__pending {
+        color: #94a3b8;
+      }
+    }
+
+    .upload-overall-progress {
+      flex-shrink: 0;
+      display: flex;
+      align-items: center;
+      gap: 10px;
+      padding: 8px 12px;
+      border-top: 1px solid #e2e8f0;
+
+      &__bar {
+        flex: 1;
+        height: 5px;
+        border-radius: 3px;
+        background: #e2e8f0;
+        overflow: hidden;
+      }
+
+      &__fill {
+        height: 100%;
+        border-radius: 3px;
+        background: #2563eb;
+        transition: width 0.3s ease;
+      }
+
+      &__text {
+        font-size: 12px;
+        color: #475569;
+        font-weight: 500;
+        white-space: nowrap;
+      }
+    }
+
+    @media (max-width: 1080px) {
+      .upload-layout {
+        flex-direction: column;
+      }
+
+      .left-panel {
+        flex: 0 0 auto;
+        width: auto;
+      }
+    }
+  }
+
+  html[data-theme='dark'] {
+    .@{prefix-cls}.case-upload-page {
+      background: #0f172a;
+
+      .page-title,
+      .panel-title,
+      .upload-dragger-title,
+      .upload-file-item__name,
+      .cell-name {
+        color: #e5e7eb;
+      }
+
+      .page-subtitle,
+      .panel-desc,
+      .panel-stats,
+      .upload-dragger-desc,
+      .upload-file-item__size,
+      .upload-file-item__pending {
+        color: #9ca3af;
+      }
+
+      .left-panel,
+      .right-panel,
+      .upload-file-list {
+        background: #111827;
+        border-color: #1f2937;
+      }
+
+      .panel-header,
+      .upload-file-list__header,
+      .upload-overall-progress {
+        border-color: #1f2937;
+      }
+
+      .upload-file-list__header {
+        background: #0f172a;
+        color: #cbd5e1;
+      }
+
+      .upload-file-item:hover {
+        background: #1f2937;
+      }
+
+      .upload-dropzone {
+        border-color: #334155;
+        background: #0f172a;
+      }
+
+      .upload-mode-switch {
+        background: #1e293b;
+        border-color: #334155;
+      }
+
+      .upload-mode-switch__item {
+        color: #94a3b8;
+      }
+
+      .upload-overall-progress__bar {
+        background: #1f2937;
+      }
+
+      .status-tag.is-other {
+        background: #1e293b;
+        color: #94a3b8;
+      }
+
+      .cell-name.is-child {
+        color: #cbd5e1;
+      }
+
+      .result-tree-table {
+        :deep(.ant-table-thead > tr > th) {
+          background: #0f172a;
+          color: #94a3b8;
+          border-bottom-color: #1f2937;
+        }
+
+        :deep(.ant-table-tbody > tr > td) {
+          border-bottom-color: #111827;
+        }
+
+        :deep(.ant-table-tbody > tr:hover > td) {
+          background: #1f2937;
+        }
+      }
+    }
+  }
+</style>

+ 1 - 0
ai-frontend/src/core/enums/pageEnum.ts

@@ -5,6 +5,7 @@ export enum PageEnum {
   BASE_CASE = '/case',
   // basic home path
   BASE_HOME = '/data/index',
+  BASE_UPLOAD = '/data/upload',
   BASE_MATCH = '/data/match',
   BASE_CLEANING = '/data/cleaning',
   BASE_CLEAN_PROGRESS = '/data/cleanProgress',

+ 0 - 74
ai-frontend/src/core/logics/caseConfigGuide.ts

@@ -1,40 +1,12 @@
-import { Modal } from 'ant-design-vue';
 import { initCellLocalPathFromServer } from '@/core/utils/cell/cellLocalInit';
-import { getCellLocalPath } from '@/core/utils/cell/cellLocal';
 import { hydratePersistedCaseGlobalConfig } from '@/core/utils/caseGlobalConfig';
 import { initOfflineMapDirFromServer } from '@/core/utils/map/offlineMapInit';
-import { getOfflineMapDir } from '@/core/utils/map/offlineMap';
 
 export const OPEN_SETTING_DRAWER_EVENT = 'layout-setting:open-drawer';
 export const OPEN_OFFLINE_MAP_MODAL_EVENT = 'layout-setting:open-offline-map-modal';
 export const OPEN_CELL_LOCAL_MODAL_EVENT = 'layout-setting:open-cell-local-modal';
 
-const GUIDE_MODAL_DELAY = 180;
 let globalCaseConfigInitPromise: Promise<void> | null = null;
-let hasPromptedGlobalCaseConfigGuide = false;
-
-function dispatchGuideEvent(eventName: string) {
-  if (typeof window === 'undefined') return;
-  window.dispatchEvent(new CustomEvent(eventName));
-}
-
-export function openSettingDrawerByGuide() {
-  dispatchGuideEvent(OPEN_SETTING_DRAWER_EVENT);
-}
-
-export function openOfflineMapModalByGuide() {
-  openSettingDrawerByGuide();
-  window.setTimeout(() => {
-    dispatchGuideEvent(OPEN_OFFLINE_MAP_MODAL_EVENT);
-  }, GUIDE_MODAL_DELAY);
-}
-
-export function openCellLocalModalByGuide() {
-  openSettingDrawerByGuide();
-  window.setTimeout(() => {
-    dispatchGuideEvent(OPEN_CELL_LOCAL_MODAL_EVENT);
-  }, GUIDE_MODAL_DELAY);
-}
 
 export function initGlobalCaseConfig() {
   if (!globalCaseConfigInitPromise) {
@@ -46,49 +18,3 @@ export function initGlobalCaseConfig() {
   }
   return globalCaseConfigInitPromise;
 }
-
-export function resetCaseConfigGuideState() {
-  globalCaseConfigInitPromise = null;
-  hasPromptedGlobalCaseConfigGuide = false;
-}
-
-export function promptCaseConfigGuide() {
-  if (typeof window === 'undefined') return false;
-
-  const missingOfflineMap = !getOfflineMapDir();
-  const missingCellLocal = !getCellLocalPath();
-
-  if (!missingOfflineMap && !missingCellLocal) {
-    return false;
-  }
-
-  const missingItems = [missingOfflineMap ? '离线地图文件' : '', missingCellLocal ? '基站包文件' : ''].filter(Boolean);
-  const content = `当前系统还未配置${missingItems.join('、')}。`;
-
-  Modal.confirm({
-    title: '全局配置提醒',
-    content,
-    centered: true,
-    okText: '去配置',
-    cancelText: '稍后',
-    onOk: () => {
-      if (missingOfflineMap) {
-        openOfflineMapModalByGuide();
-        return;
-      }
-      openCellLocalModalByGuide();
-    },
-  });
-
-  return true;
-}
-
-export async function promptCaseConfigGuideOnce() {
-  if (typeof window === 'undefined' || hasPromptedGlobalCaseConfigGuide) {
-    return false;
-  }
-
-  hasPromptedGlobalCaseConfigGuide = true;
-  await initGlobalCaseConfig();
-  return promptCaseConfigGuide();
-}

+ 0 - 2
ai-frontend/src/core/router/guard/stateGuard.ts

@@ -3,7 +3,6 @@ import { useAppStore } from '@/core/store/modules/app';
 import { useMultipleTabStore } from '@/core/store/modules/multipleTab';
 import { usePermissionStore } from '@/core/store/modules/permission';
 import { PageEnum } from '@/core/enums/pageEnum';
-import { resetCaseConfigGuideState } from '@/core/logics/caseConfigGuide';
 import { removeTabChangeListener } from '@/core/logics/mitt/routeChange';
 
 export function createStateGuard(router: Router) {
@@ -17,7 +16,6 @@ export function createStateGuard(router: Router) {
       appStore.resetAllState();
       permissionStore.resetState();
       tabStore.resetState();
-      resetCaseConfigGuideState();
       removeTabChangeListener();
     }
   });

+ 15 - 0
ai-frontend/src/core/router/routes/index.ts

@@ -108,10 +108,25 @@ export const LegacyDefaultRoute: AppRouteRecordRaw = {
   },
 };
 
+/**
+ * 文件上传页:独立于布局的全屏路由(不套 LAYOUT,覆盖包括菜单在内的整个视口)
+ */
+export const CaseUploadRoute: AppRouteRecordRaw = {
+  path: PageEnum.BASE_UPLOAD,
+  name: 'CaseDataUpload',
+  component: () => import('@/case/views/data/upload.vue'),
+  meta: {
+    title: '文件上传',
+    hideMenu: true,
+    hideTab: true,
+  },
+};
+
 // Basic routing without permission
 export const basicRoutes = [
   AuthorizationRoute,
   CaseRoute,
+  CaseUploadRoute,
   DataRoute,
   RootRoute,
   LegacyDefaultRoute,

+ 0 - 2
ai-frontend/src/core/store/modules/case.ts

@@ -9,7 +9,6 @@ import { store } from '@/core/store';
 import { PageEnum } from '@/core/enums/pageEnum';
 import { CASE_INFO_KEY } from '@/core/enums/cacheEnum';
 import { getAuthCache, setAuthCache } from '@/core/utils/auth';
-import { promptCaseConfigGuideOnce } from '@/core/logics/caseConfigGuide';
 import { resetRouter, router } from '@/core/router';
 import { usePermissionStore } from '@/core/store/modules/permission';
 import { useMultipleTabStore } from '@/core/store/modules/multipleTab';
@@ -199,7 +198,6 @@ export const useUserStore = defineStore('app-user', {
           await router.replace(PageEnum.BASE_CASE);
         }
         await router.replace(targetPath);
-        await promptCaseConfigGuideOnce();
       }
       return res;
     },

+ 19 - 3
ai-frontend/src/main.ts

@@ -55,12 +55,28 @@ async function waitForBackendReady(maxRetries: number = 60, interval: number = 2
 }
 
 function getStartupRouteInfo() {
+  // hash 模式:路径在 location.hash 中(如 #/data/index)
   const hash = window.location.hash?.replace(/^#/, '') || '';
-  const [pathWithQuery = '/'] = hash.split('#');
-  const [path = '/'] = pathWithQuery.split('?');
+  let fullPath: string;
+  if (hash) {
+    const [pathWithQuery = '/'] = hash.split('#');
+    fullPath = pathWithQuery || '/';
+  } else {
+    // history 模式:无 hash,从 pathname 恢复,并剥离 VITE_PUBLIC_PATH 前缀
+    let pathname = window.location.pathname || '/';
+    const publicPath = import.meta.env.VITE_PUBLIC_PATH || '/';
+    if (publicPath && publicPath !== '/') {
+      const normalizedBase = publicPath.replace(/\/+$/, '');
+      if (normalizedBase && pathname.startsWith(normalizedBase + '/')) {
+        pathname = pathname.slice(normalizedBase.length);
+      }
+    }
+    fullPath = pathname + (window.location.search || '');
+  }
+  const [path = '/'] = fullPath.split('?');
   return {
     path: path || '/',
-    fullPath: pathWithQuery || '/',
+    fullPath: fullPath || '/',
   };
 }
 

+ 19 - 7
ai-server/src/main/java/com/zsjz/ai/module/dm/controller/DmController.java

@@ -64,19 +64,19 @@ public class DmController {
      * 由服务端落盘到 {@code QingJian/workspace/{caseId}/upload/{batchId}/} 后再解析,
      * 因此不依赖浏览器/客户端与服务端同机。
      *
-     * <p>返回结构与 {@code /preFile} 完全一致({@code List<FileInfo>} 树:pid=0 为文件根,
-     * pid=fileId 为各 sheet),前端可沿用同一套后续流程(sessionStorage → /data/match → doClean)。
+     * <p>返回结构与 {@code /preFile} 一致:单个文件节点 {@code FileInfo}(pid=0 为文件根,
+     * children 为各 sheet 的树形结构),前端可直接渲染为树形表格。
      *
      * @param file    单个文件(xls / xlsx / csv)
-     * @param batchId 批次ID;前端首次上传不传,后续文件回传首次响应里的 batchId,
+     * @param batchId 批次ID;前端首次上传不传,后续文件回传首次响应的 batchId,
      *                以保证同批文件共用一个批次(进度页/结果页按 batchId 聚合)
      * @param reClean 1=重新清洗(跳过 MD5 去重)
-     * @return 该文件的 FileInfo 树
+     * @return 该文件的树形结构(文件根内嵌 children sheets);重复文件或解析无结果时 data 为 null
      */
     @PostMapping(value = "/preFileUpload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
-    public Result<List<FileInfo>> preFileUpload(@RequestPart("file") MultipartFile file,
-                                                @RequestParam(value = "batchId", required = false) Long batchId,
-                                                @RequestParam(value = "reClean", required = false) Integer reClean) {
+    public Result<FileInfo> preFileUpload(@RequestPart("file") MultipartFile file,
+                                          @RequestParam(value = "batchId", required = false) Long batchId,
+                                          @RequestParam(value = "reClean", required = false) Integer reClean) {
         return Result.succeed(dmService.preProcessUploadedFile(file, batchId, reClean));
     }
 
@@ -105,6 +105,18 @@ public class DmController {
         return Result.succeed();
     }
 
+    /**
+     * 清理废弃批次
+     * 删除本批次已上传文件、解析缓存与 file_info 记录(上传页放弃离开时调用)
+     *
+     * @param batchId 数据清洗批次ID
+     */
+    @GetMapping("/clearUploadBatch")
+    public Result<Void> clearUploadBatch(Long batchId) {
+        dmService.clearUploadBatch(batchId);
+        return Result.succeed();
+    }
+
     /**
      * 获取文件列表
      * 根据传入的查询条件,返回已导入文件列表

+ 2 - 0
ai-server/src/main/java/com/zsjz/ai/module/dm/pre/PreTask.java

@@ -151,10 +151,12 @@ public class PreTask {
             if (e instanceof UnsupportedFileFormatException || e instanceof POIXMLException) {
                 fileInfo.setFileStatus(FileSheetStateEnum.FILE_UNSUPPORTED_FAIL);
                 fileInfo.setFileType("unsupported");
+                return fileInfoList;
             }
             // 加密文件异常
             if (e instanceof EncryptedDocumentException) {
                 fileInfo.setFileStatus(FileSheetStateEnum.FILE_ENCRYPTION);
+                return fileInfoList;
             }
         }
         fileInfo.setFileStatus(FileSheetStateEnum.FILE_READ_COMPLETE);

+ 77 - 15
ai-server/src/main/java/com/zsjz/ai/module/dm/service/DmService.java

@@ -135,9 +135,9 @@ public class DmService {
      * @param file    上传的单个文件(xls / xlsx / csv)
      * @param batchId 批次ID;为 null 时自动生成,同批文件应由前端回传首次的 batchId
      * @param reClean 1=重新清洗(跳过 MD5 去重)
-     * @return 该文件的 FileInfo 树(pid=0 为文件根,pid=fileId 为各 sheet);重复文件返回空列表
+     * @return 该文件的树形结构:文件根节点(pid=0)内嵌 children sheet 列表;重复文件返回 null
      */
-    public List<FileInfo> preProcessUploadedFile(MultipartFile file, Long batchId, Integer reClean) {
+    public FileInfo preProcessUploadedFile(MultipartFile file, Long batchId, Integer reClean) {
         if (file == null || file.isEmpty()) {
             throw ServerException.spe("上传文件为空!");
         }
@@ -155,15 +155,43 @@ public class DmService {
             List<Path> kept = filterExistsMd5Files(List.of(saved));
             if (kept.isEmpty()) {
                 log.info("上传文件与已导入文件重复,跳过解析: {}, batchId={}", originalName, currentBatchId);
-                return List.of();
+                return null;
             }
         }
 
-        Semaphore semaphore = new Semaphore(1, true);
-        List<FileInfo> result = processPreviewFile(saved, currentBatchId, semaphore);
-        backfillFileSize(result, saved);
-        log.info("上传文件解析完成: name={}, batchId={}, resultCount={}", originalName, currentBatchId, result.size());
-        return result;
+        // 单文件串行解析,无需并发控制信号量;返回树形根节点(含 children sheets),重复文件返回 null
+        FileInfo root = buildPreviewTree(processPreviewFile(saved, currentBatchId)).stream().findFirst().orElse(null);
+        backfillFileSize(root, saved);
+        log.info("上传文件解析完成: name={}, batchId={}", originalName, currentBatchId);
+        return root;
+    }
+
+    /** 解析器返回的平铺节点(根 pid=0 + 各 sheet, pid=文件id)组装成树;单文件只有一个根 */
+    private List<FileInfo> buildPreviewTree(List<FileInfo> flat) {
+        if (CollUtil.isEmpty(flat)) {
+            return flat;
+        }
+        Map<Long, List<FileInfo>> sheetMap = flat.stream()
+                .collect(Collectors.groupingBy(info -> info.getPid() == null ? -1L : info.getPid()));
+        List<FileInfo> roots = Lists.newArrayList();
+        for (FileInfo info : flat) {
+            if (info.getPid() == null || info.getPid() != 0L) {
+                continue;
+            }
+            List<FileInfo> sheets = sheetMap.getOrDefault(info.getId(), List.of());
+            int dataNum = 0;
+            int failNum = 0;
+            for (FileInfo sheet : sheets) {
+                dataNum += Objects.requireNonNullElse(sheet.getDataNum(), 0);
+                failNum += Objects.requireNonNullElse(sheet.getFailNum(), 0);
+            }
+            info.setChildren(sheets);
+            info.setDataNum(dataNum);
+            info.setFailNum(failNum);
+            info.setSuccessNum(dataNum - failNum);
+            roots.add(info);
+        }
+        return roots;
     }
 
     /**
@@ -203,16 +231,13 @@ public class DmService {
         return base.replaceAll("[\\\\/:*?\"<>|]", "_");
     }
 
-    /** 解析阶段 fileSize 恒为 0,这里用落盘后的真实大小回填根节点 */
-    private void backfillFileSize(List<FileInfo> infos, Path saved) {
-        if (CollUtil.isEmpty(infos)) {
+    /** 解析阶段 fileSize 恒为 0,这里用落盘后的真实大小回填文件根节点 */
+    private void backfillFileSize(FileInfo root, Path saved) {
+        if (root == null) {
             return;
         }
         try {
-            long size = Files.size(saved);
-            infos.stream()
-                    .filter(info -> info.getPid() != null && info.getPid() == 0L)
-                    .forEach(info -> info.setFileSize(size));
+            root.setFileSize(Files.size(saved));
         } catch (IOException e) {
             log.warn("回填文件大小失败: {}", saved, e);
         }
@@ -312,6 +337,18 @@ public class DmService {
         return GlobalCache.suffixList.contains(suffix.toLowerCase());
     }
 
+    /** 单文件串行解析(Web 上传路径):无需信号量限流,直接调用解析链路 */
+    private List<FileInfo> processPreviewFile(Path path, Long batchId) {
+        try {
+            String md5 = DigestUtil.md5Hex(path.toFile());
+            return new PreTask(path, batchId, "", ',', md5).call();
+        } catch (Exception e) {
+            log.error("处理文件失败:{}", path, e);
+            return List.of();
+        }
+    }
+
+    /** 批量文件解析:使用信号量限制同时读取的文件数,避免虚拟线程数量多但底层 I/O 资源被打满 */
     private List<FileInfo> processPreviewFile(Path path, Long batchId, Semaphore semaphore) {
         boolean acquired = false;
         try {
@@ -558,6 +595,31 @@ public class DmService {
         }
     }
 
+    /** 清理废弃批次:删除本批次已上传文件、解析缓存与 file_info 记录(仅用于未进入清洗的批次) */
+    public void clearUploadBatch(Long batchId) {
+        if (batchId == null) {
+            return;
+        }
+        // 1) 解析缓存 tmp/{batchId}
+        try {
+            FileUtil.del(PathConst.TMP_PATH.resolve(batchId.toString()));
+        } catch (Exception e) {
+            log.warn("清理批次解析缓存失败, batchId={}", batchId, e);
+        }
+        // 2) 已上传文件 workspace/{caseId}/upload/{batchId}
+        try {
+            if (StateManager.instance().isCaseOpened()) {
+                Long caseId = StateManager.instance().getCaseId().longValue();
+                FileUtil.del(PathConst.WORKSPACE.resolve(String.valueOf(caseId))
+                        .resolve(UPLOAD_DIR_NAME).resolve(batchId.toString()));
+            }
+        } catch (Exception e) {
+            log.warn("清理批次上传文件失败, batchId={}", batchId, e);
+        }
+        // 3) 本批次 file_info 记录(安全兜底;正常废弃批次无记录)
+        fileInfoMapper.delete(Wrappers.lambdaQuery(FileInfo.class).eq(FileInfo::getBatchId, batchId));
+    }
+
     public List<FileInfo> getFiles(FileInfoQuery query) {
 
         LocalDateTime createDateTime = query.getCreateDateTime();