Explorar o código

feat: 完成态按钮按来源区分 + 导入文件管理新增 sheet 清洗方式/备注

进度页(cleanProgress.vue):
- 去掉完成后自动跳转;完成态恢复两个按钮
- 「查看清洗日志」+ 按来源二选一:自主清洗=「查看结果」跳清洗结果页(mode=manual);
  智能清洗=「开始分析」跳案件研判页(/workbench?newChat=1 直达新对话草稿)
- 完成面板新增「未清洗(表)」统计:AI 无法清洗同属失败语义,单独明示

导入文件管理(importList.vue):
- 开启树表(childrenColumnName=children),文件行可展开 sheet 子行,sheet 行显示工作表名
- 新增「清洗方式」(AI清洗/系统清洗)与「备注」(AI 匹配模板/新建表名、清洗失败原因)
  两列,仅 sheet 行有值;数据由 getFiles 按最新 ai_clean_job 回填瞬态字段
- 修复整页刷新直达 ?tab=files 列表不加载(onMounted 补首次 load)

实测:文件A 展开=「AI清洗|AI 新建表:表为机动车登记信息…」;文件B=三sheet「系统清洗|-」;
历史失败行=「AI清洗|清洗失败:该表块需要结构归一…」;自主/智能两种完成态按钮与跳转均验证通过。
回归:aiclean+dm 115 单测全绿。
cc hai 5 días
pai
achega
caf975d618

+ 37 - 20
ai-electron/frontend/src/case/views/data/cleanProgress.vue

@@ -117,9 +117,21 @@
                 </span>
                 <span class="stat-label">导入失败(条)</span>
               </div>
+              <!-- AI 无法清洗的表:同属失败语义,单独明示(此前只藏在处理日志里) -->
+              <template v-if="unableCount > 0">
+                <div class="stat-divider"></div>
+                <div class="stat-item">
+                  <span class="stat-value is-fail">{{ unableCount }}</span>
+                  <span class="stat-label">未清洗(表)</span>
+                </div>
+              </template>
             </div>
             <div class="complete-actions">
-              <Button v-if="showErrorLogAction" class="complete-log" @click="handleViewLog">查看错误日志</Button>
+              <Button class="complete-log" @click="handleViewLog">查看清洗日志</Button>
+              <Button v-if="isFused" type="primary" class="complete-result" @click="handleStartAnalysis">
+                开始分析
+              </Button>
+              <Button v-else type="primary" class="complete-result" @click="handleViewResult">查看结果</Button>
             </div>
           </div>
         </template>
@@ -221,7 +233,6 @@
     if (isComplete.value) return governTaskFinished.value ? '数据治理完成' : '清洗入库流程已完成';
     return `当前正在执行${currentStepLabel.value}`;
   });
-  const showErrorLogAction = computed(() => Number(displayFailCount.value ?? 0) > 0);
   const completeDesc = computed(() =>
     governTaskFinished.value ? '数据治理已完成,治理结果已写入案件数据。' : '清洗与入库流程已执行完毕。',
   );
@@ -347,11 +358,29 @@
   };
 
   const handleViewLog = () => {
-    if (!showErrorLogAction.value) return;
     refreshCurrentCleanBatchId();
     logModalOpen.value = true;
   };
 
+  /**
+   * 自主清洗的完成出口「查看结果」:跳清洗结果统计页(导入文件列表带数据量/成功失败条数)。
+   * 智能清洗的对应出口是「开始分析」(见 {@link handleStartAnalysis})。
+   */
+  const handleViewResult = async () => {
+    if (!shouldRunGovernAfterClean.value) {
+      createMessage.warning('数据治理已关闭,本次导入未自动治理,可在数据页手动执行');
+    }
+    await navigateToImportResult();
+  };
+
+  /** 智能清洗的完成出口「开始分析」:直接进案件研判(智能工作台)新对话草稿 */
+  const handleStartAnalysis = async () => {
+    if (!shouldRunGovernAfterClean.value) {
+      createMessage.warning('数据治理已关闭,本次导入未自动治理,可在数据页手动执行');
+    }
+    await go({ path: PageEnum.BASE_WORKBENCH, query: { newChat: '1' } }, true);
+  };
+
   const navigateToImportResult = async () => {
     sessionStorage.removeItem(IMPORT_FILE_INFO_STORAGE_KEY);
     sessionStorage.removeItem(RECLEAN_CONTEXT_STORAGE_KEY);
@@ -866,8 +895,7 @@
   /**
    * 清洗阶段完成判定:非融合模式看 SSE 进度;融合模式看 AI 批次终态 —— 后端在
    * 1/2/3 级全部结束后推终态消息(cmd 103,stage=DONE/PARTIAL/FAILED)。纯 AI/4级
-   * 批次没有 doClean,SSE 进度不会走到 100%,不能拿 progress 当完成依据,
-   * 否则完成面板与自动跳转永远不会触发(2026-10-11 实测卡在「任务执行中」)。
+   * 批次没有 doClean,SSE 进度不会走到 100%,不能拿 progress 当完成依据。
    */
   const cleanPhaseDone = computed(() => (isFused.value ? aiStageFinished.value : progress.value >= 100));
   const isComplete = computed(
@@ -875,27 +903,16 @@
   );
 
   /**
-   * 进度到 100%(清洗/治理全部完成)后自动跳转到清洗结果统计页(案件数据 / 导入文件,
-   * 列表带每个文件的数据量与成功/失败条数),停留片刻让用户看到完成状态。
-   * isComplete 可能多次翻转 true(governTaskFinished 等),用标记防重入。
+   * 完成时提示「另有 N 个表 AI 无法清洗」——只在完成那一刻提醒一次。
+   * 完成后<b>不自动跳转</b>(2026-10-11 需求):跳转由完成面板上的按钮触发,
+   * 自主清洗给「查看结果」,智能清洗给「开始分析」。
    */
-  const autoJumped = ref(false);
   watch(isComplete, (done) => {
-    if (!done || autoJumped.value) return;
-    autoJumped.value = true;
-    createMessage.success('清洗导入完成,正在进入导入文件统计…');
+    if (!done) return;
     const unable = isFused.value ? (aiStage.value?.needReviewCount ?? 0) : 0;
     if (unable > 0) {
       createMessage.warning(`另有 ${unable} 个表 AI 无法清洗,详情见处理日志;可用「手动清洗」处理。`);
     }
-    window.setTimeout(() => {
-      // 自动跳转不弹「治理关闭」确认框(弹窗会卡住自动流程),改用提示说明;
-      // 结果统计页顶栏本来就有「数据治理」按钮,用户可在那里手动治理。
-      if (!shouldRunGovernAfterClean.value) {
-        createMessage.warning('数据治理已关闭,本次导入未自动治理,可在数据页手动执行');
-      }
-      void navigateToImportResult();
-    }, 1800);
   });
 
   /**

+ 87 - 10
ai-electron/frontend/src/case/views/data/importList.vue

@@ -175,15 +175,14 @@
       width: 240,
       align: 'center',
       ellipsis: true,
-      customRender: ({ record }: { record: FileInfo }) =>
-        h(
-          'span',
-          {
-            class: 'file-name-text',
-            title: getText(record.fileName || record.sheetName, '未命名文件'),
-          },
-          getText(record.fileName || record.sheetName, '未命名文件'),
-        ),
+      customRender: ({ record }: { record: FileInfo }) => {
+        // sheet 行优先显示工作表名(sheet 记录的 fileName 也是文件名,会与父行重复分不清)
+        const isSheet = !(record.pid == null || record.pid === '0' || record.pid === 0);
+        const label = isSheet
+          ? getText(record.sheetName, getText(record.fileName, '未命名工作表'))
+          : getText(record.fileName || record.sheetName, '未命名文件');
+        return h('span', { class: 'file-name-text', title: label }, label);
+      },
     },
     {
       title: '导入成功',
@@ -219,6 +218,35 @@
       customRender: ({ value }: { value?: string | null }) =>
         h('span', { class: 'govern-status-text' }, getGovernStatusLabel(value)),
     },
+    {
+      // 仅 sheet 行有意义:文件行留空。值由后端 getFiles 按 AI 任务记录回填
+      title: '清洗方式',
+      dataIndex: 'cleanMode',
+      key: 'cleanMode',
+      width: 110,
+      align: 'center',
+      customRender: ({ record }: { record: FileInfo }) => {
+        if (record.pid == null || record.pid === '0' || record.pid === 0) return '';
+        const mode = getText(record.cleanMode, '');
+        if (!mode) return h('span', { class: 'clean-mode-text is-empty' }, '-');
+        return h('span', { class: ['clean-mode-text', mode === 'AI清洗' ? 'is-ai' : 'is-system'] }, mode);
+      },
+    },
+    {
+      // 仅 sheet 行有意义:AI 匹配结果(模板名/新建表名)或失败原因
+      title: '备注',
+      dataIndex: 'cleanNote',
+      key: 'cleanNote',
+      width: 220,
+      align: 'left',
+      ellipsis: true,
+      customRender: ({ record }: { record: FileInfo }) => {
+        if (record.pid == null || record.pid === '0' || record.pid === 0) return '';
+        const note = getText(record.cleanNote, '');
+        if (!note) return h('span', { class: 'clean-note-text is-empty' }, '-');
+        return h('span', { class: 'clean-note-text', title: note }, note);
+      },
+    },
     {
       title: '上传时间',
       dataIndex: 'createDateTime',
@@ -290,6 +318,10 @@
     rowSelection,
     defaultRowSelection: rowSelection,
     pagination: { position: ['none'] },
+    // BasicTable 默认 childrenColumnName='childList',这里的数据是 file_info 原生 children:
+    // 开树表 + 显式改回 'children',文件行才能展开出 sheet 子行(清洗方式/备注只挂在 sheet 行上)
+    isTreeTable: true,
+    childrenColumnName: 'children',
   });
   const { clearSelectedRowKeys, reload, redoHeight } = tableAction;
 
@@ -339,12 +371,18 @@
       }
       syncTableLayout();
     },
-    { flush: 'post' },
+    // immediate:整页刷新直达 ?tab=files 时组件创建即为 active,不补这一枪首次永不加载(列表空)
+    { flush: 'post', immediate: true },
   );
 
   onMounted(() => {
     emitter.on(CASE_DATA_REFRESH_EVENT, handleImportListRefresh);
     emitter.on(CASE_IMPORT_FILE_REFRESH_EVENT, handleImportListRefresh);
+    // 整页刷新直达 ?tab=files 时组件创建即为 active:watch 捕获不到「变化」,
+    // 在这里补首次加载,否则列表停在空态(2026-10-13 实测)
+    if (props.active && !hasLoadedOnActivate.value) {
+      handleImportListRefresh();
+    }
   });
 
   onBeforeUnmount(() => {
@@ -604,6 +642,45 @@
       }
     }
 
+    /* sheet 行的清洗方式/备注 */
+    .clean-mode-text,
+    .clean-note-text {
+      display: inline-block;
+      max-width: 100%;
+      color: #374151;
+      font-size: 12px;
+      line-height: 1.5;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      vertical-align: middle;
+    }
+
+    .clean-mode-text {
+      padding: 0 6px;
+      border-radius: 6px;
+
+      &.is-ai {
+        color: #6d28d9;
+        background: #f5f3ff;
+      }
+
+      &.is-system {
+        color: #0e7490;
+        background: #ecfeff;
+      }
+
+      &.is-empty {
+        padding: 0;
+        background: none;
+        color: #9ca3af;
+      }
+    }
+
+    .clean-note-text.is-empty {
+      color: #9ca3af;
+    }
+
     .ant-form {
       padding: 12px 16px 0;
       border: 1px solid #e5e7eb;

+ 4 - 0
ai-electron/frontend/src/types/api.d.ts

@@ -37,6 +37,10 @@ export interface FileInfo {
   hasNew?: boolean;
   classify?: ApiText | null;
   funcRegx?: ApiText | null;
+  /** 清洗方式(仅 sheet 行有值):「AI清洗」/「系统清洗」,后端按 AI 任务记录回填 */
+  cleanMode?: ApiText | null;
+  /** 清洗备注(仅 sheet 行有值):AI 匹配结果(模板名/新建表名)或失败原因 */
+  cleanNote?: ApiText | null;
 }
 
 /**

+ 15 - 0
ai-server/src/main/java/com/zsjz/ai/common/model/dm/entity/FileInfo.java

@@ -178,4 +178,19 @@ public class FileInfo implements Serializable {
 
     @TableField(exist = false)
     private Integer mainId;
+
+    /**
+     * 清洗方式(仅 sheet 记录有意义,由 getFiles 组装时按 AI 任务记录回填):
+     * 「AI清洗」= 经 AI 判定后入库(含 AI 命中既有模板与 AI 新建表);
+     * 「系统清洗」= 上传直配模板、未经过 AI 的表
+     */
+    @TableField(exist = false)
+    private String cleanMode;
+
+    /**
+     * 清洗备注(仅 sheet 记录):AI 匹配结果(模板名/新建表名)或失败原因
+     * (如「清洗失败:该表块需要结构归一…」)
+     */
+    @TableField(exist = false)
+    private String cleanNote;
 }

+ 79 - 0
ai-server/src/main/java/com/zsjz/ai/module/dm/service/DmService.java

@@ -98,6 +98,11 @@ public class DmService {
     /** 非表格文件的 AI 识别档案:抽样本、建档、提交后台识别 */
     @Autowired
     private FileAiProfileService fileAiProfileService;
+    /** 清洗方式/备注回填:按 sheet 查 AI 任务与新建表元数据 */
+    @Autowired
+    private com.zsjz.ai.module.aiclean.mapper.AiCleanJobMapper aiCleanJobMapper;
+    @Autowired
+    private com.zsjz.ai.module.aiclean.mapper.AiCleanTemplateMapper aiCleanTemplateMapper;
 
     /**
      * 预览文件内容
@@ -889,6 +894,8 @@ public class DmService {
             fileInfo.setDataNum(dataNum);
             fileInfo.setFailNum(failNum);
             fileInfo.setSuccessNum(dataNum - failNum);
+            // sheet 行展示「清洗方式 / 备注」:按 AI 任务记录回填瞬态字段
+            fillSheetCleanInfo(childrens);
         }
         return rootFiles
                 .stream()
@@ -897,6 +904,78 @@ public class DmService {
                 .toList();
     }
 
+    /**
+     * 回填 sheet 的清洗方式与备注(瞬态字段,供「导入文件管理」列表展示):
+     * <ul>
+     *   <li>有 AI 任务 → 「AI清洗」;备注 = AI 匹配结果(匹配到的模板名 / 新建表名)
+     *       或失败原因(无法清洗/失败/跳过的原因,无法清洗同属失败语义)</li>
+     *   <li>无 AI 任务但已配模板(上传直配或手动清洗)→ 「系统清洗」</li>
+     *   <li>都没有 → 留空(前端显示 -)</li>
+     * </ul>
+     * sheet 可能被多次清洗(多条 job),取最新一条(雪花 id 降序首个)。
+     */
+    private void fillSheetCleanInfo(List<FileInfo> sheets) {
+        if (CollUtil.isEmpty(sheets)) {
+            return;
+        }
+        List<Long> sheetIds = sheets.stream().map(FileInfo::getId).filter(Objects::nonNull).toList();
+        Map<Long, com.zsjz.ai.module.aiclean.entity.AiCleanJob> latestJob = new HashMap<>();
+        if (CollUtil.isNotEmpty(sheetIds)) {
+            for (com.zsjz.ai.module.aiclean.entity.AiCleanJob job : aiCleanJobMapper.selectList(
+                    Wrappers.<com.zsjz.ai.module.aiclean.entity.AiCleanJob>lambdaQuery()
+                            .in(com.zsjz.ai.module.aiclean.entity.AiCleanJob::getSheetId, sheetIds)
+                            .orderByDesc(com.zsjz.ai.module.aiclean.entity.AiCleanJob::getId))) {
+                latestJob.putIfAbsent(job.getSheetId(), job);
+            }
+        }
+        Map<Long, String> aiTableNames = new HashMap<>();
+        for (FileInfo sheet : sheets) {
+            com.zsjz.ai.module.aiclean.entity.AiCleanJob job = latestJob.get(sheet.getId());
+            if (job == null) {
+                if (sheet.getTemplateId() != null) {
+                    sheet.setCleanMode("系统清洗");
+                }
+                continue;
+            }
+            sheet.setCleanMode("AI清洗");
+            sheet.setCleanNote(cleanNoteOf(job, aiTableNames));
+        }
+    }
+
+    private String cleanNoteOf(com.zsjz.ai.module.aiclean.entity.AiCleanJob job, Map<Long, String> aiTableNames) {
+        String status = StrUtil.nullToEmpty(job.getStatus() == null ? null : job.getStatus().name());
+        String reason = StrUtil.maxLength(StrUtil.nullToEmpty(job.getFailReason()), 120);
+        return switch (status) {
+            // 无法清洗同属失败语义(用户明确要求):统一带「清洗失败」前缀,原因如实带出
+            case "NEED_REVIEW", "FAIL" -> "清洗失败:" + StrUtil.blankToDefault(reason, "原因见处理日志");
+            case "SKIP" -> "已跳过:" + StrUtil.blankToDefault(reason, "源文件不可用");
+            default -> {
+                if ("A".equals(job.getExitCode())) {
+                    String name = job.getRefTemplateId() == null ? null
+                            : GlobalCache.idNameZhMap().get(job.getRefTemplateId());
+                    yield "AI 匹配模板:" + StrUtil.blankToDefault(name,
+                            job.getRefTemplateId() == null ? "未知模板" : String.valueOf(job.getRefTemplateId()));
+                }
+                if ("B".equals(job.getExitCode())) {
+                    yield "AI 新建表:" + StrUtil.blankToDefault(
+                            aiTableName(job.getAiTemplateId(), aiTableNames),
+                            job.getAiTemplateId() == null ? "AI 表" : "ai_t" + job.getAiTemplateId());
+                }
+                yield "";   // 判定进行中,暂无备注
+            }
+        };
+    }
+
+    private String aiTableName(Long aiTemplateId, Map<Long, String> cache) {
+        if (aiTemplateId == null) {
+            return null;
+        }
+        return cache.computeIfAbsent(aiTemplateId, id -> {
+            com.zsjz.ai.module.aiclean.entity.AiCleanTemplate tpl = aiCleanTemplateMapper.selectById(id);
+            return tpl == null ? null : tpl.getTableNameCn();
+        });
+    }
+
     public void deletedFiles(List<Long> ids) {
         if (CollUtil.isEmpty(ids)) {
             return;

+ 42 - 0
smart-clean-test-cases.md

@@ -180,3 +180,45 @@
 - **秒完批次**(C+E3,均为头部指纹直通复用 AI 模板):提交 → 进度页 → 自动跳回统计页。✅
 - **指纹复用跨批次**:C 与 G 同表头,G 批次建的 AI 模板被 C 秒级复用(HEADER_CACHE,零模型调用)。✅
 - 回归:aiclean 模块 76 个单测全绿(静默趟断言更新为「两趟全静默」)。
+
+---
+
+## 文件A「清洗成功但没进数据表」修复 + 完成态按钮 + 文件管理字段(2026-10-13)
+
+### 问题一:标题行文件被误判「无法清洗」(用户实测报告)
+文件A(`周海--车辆登记信息.xls`)是「单标题行 + 表头行」结构(表头在第 2 行),
+智能清洗两次都被标 NEED_REVIEW「该表块需要结构归一」——**完成面板看起来"成功",但数据没进表**。
+
+根因:`AiCleanService.needsStructure` 把 `headerRow > 1`(表头不在首行)当硬失败,
+且该检查在候选匹配**之前**——降级链 1→2→3 根本没走完就被拦截。
+而实践中结构探测(BlockDetector 取第一个密集行)已正确定位标题行下的真实表头,
+数据读取一律按探测行号切,**这类文件完全可以清洗**;旧条件拦下的恰恰全是可清洗文件。
+
+修复:`needsStructure` 只保留 `primaryMergeCount() > 0`(只有合并单元格会真正读错位)。
+实测(浏览器 + 接口):文件A → AI 判定(LLM,WEAK)→ **3级自建表 ai_t,3 行车辆数据全部入库**。✅
+
+### 问题二:完成态按钮按来源模式区分(需求)+ 去掉自动跳转
+- **去掉完成后自动跳转**(此前 1.8s 自动跳结果页)。
+- 完成态恢复两个按钮:
+  - 「查看清洗日志」→ 打开清洗日志弹窗(不再限失败数>0 才显示);
+  - 第二个按钮按来源:**自主清洗** =「查看结果」→ 清洗结果页(`/data/index?tab=files&mode=manual`);
+    **智能清洗** =「开始分析」→ 案件研判页(`/workbench?newChat=1`,直达新对话草稿)。
+- 完成面板新增「未清洗(表)」统计:AI 无法清洗的表同属失败语义,单独明示(此前只藏在处理日志)。
+
+实测:智能模式完成态 =「查看清洗日志」+「开始分析」→ 点击进 `/workbench?newChat=1` ✅;
+自主模式(文件B 手动清洗 272 条成功)=「查看清洗日志」+「查看结果」→ 点击进 `/data/index?tab=files&mode=manual` ✅。
+
+### 问题三:「导入文件管理」新增 sheet 级字段(需求)
+- 列表支持**展开文件行显示 sheet 子行**(开启树表 + childrenColumnName='children';
+  BasicTable 默认 childList 需显式改回),sheet 行名称显示工作表名。
+- 新增两列(仅 sheet 行有值,后端 `getFiles` 按 AI 任务记录回填瞬态字段):
+  - **清洗方式**:「AI清洗」(经 AI 判定的,含命中既有模板与新建表)/「系统清洗」(上传直配模板,未经 AI);
+  - **备注**:AI 匹配结果(「AI 匹配模板:xxx」/「AI 新建表:xxx」)或失败原因(「清洗失败:原因」,无法清洗同属失败语义)。
+- 顺带修复:整页刷新直达 `?tab=files` 时列表永不加载(watch 捕获不到初始 active,onMounted 补首次加载)。
+
+实测:文件A 展开 →「AI清洗|AI 新建表:表为机动车登记信息,…」;文件B 展开 →
+三 sheet「系统清洗|-」;历史失败行 →「AI清洗|清洗失败:该表块需要结构归一…」。✅
+
+### 回归
+- aiclean + dm 模块 115 个单测全绿。
+- 手动清洗复测:文件B 272 条全部成功(上午 09:44 一次失败未能复现,当时为 IDEA 重启后的瞬态环境)。