Przeglądaj źródła

revert: 还原任务日志入口与本地快照

撤销 d9c02b3、5688dbd:
- 恢复导入文件页「任务日志」按钮与 TaskLogDrawer
- 恢复 cleanProgress 的任务日志快照落盘与结果页回看
- 移除临时的 TaskLogModal
- 文档同步为原始的日志展示行为
cc 5 dni temu
rodzic
commit
bdb6de16c1

+ 96 - 12
ai-electron/frontend/src/case/views/data/cleanProgress.vue

@@ -216,17 +216,17 @@
   import type { FileInfo } from '@/types/api';
   import {
     clearImportProgress,
-    clearLegacyTaskLogStorage,
     getActiveImportProgress,
     getCleanFlowSource,
     getImportProgressEventSource,
-    getTaskLogMemory,
+    getTaskLogSnapshot,
     resetImportProgressRuntimeState,
     saveImportProgress,
-    saveTaskLogMemory,
+    saveTaskLogSnapshot,
     setImportProgressEventSource,
     useImportProgressRuntimeState,
     type TaskLogItem,
+    type TaskLogStatus,
   } from './importProgressSession';
 
   const { prefixCls } = useDesign('case-clean-progress');
@@ -264,10 +264,12 @@
   } = useImportProgressRuntimeState();
   const sseMessages = ref<string[]>([]);
   /**
-   * 处理日志:清洗 + 治理统一成一条时间线,只保存在模块内存里。
-   * 进度页与导入文件页可以互相切换回看;刷新/关闭页面即丢失,不落 localStorage。
+   * 处理日志:清洗 + 治理统一成一条时间线。
+   * 实时写模块状态供页面渲染,同时按案件/批次落一份 localStorage 快照,
+   * 完成后结果页仍可回看(见 importProgressSession 的 TaskLogSnapshot)。
    */
   const taskLog = ref<TaskLogItem[]>([]);
+  const taskLogStatus = ref<TaskLogStatus>('running');
   const taskLogBatchId = ref('');
   const governFailed = ref(false);
   let taskLogSeq = 0;
@@ -456,6 +458,7 @@
     const batchId = currentCleanBatchId.value || refreshCurrentCleanBatchId();
     if (taskLogBatchId.value && batchId && batchId !== taskLogBatchId.value) {
       taskLog.value = [];
+      taskLogStatus.value = 'running';
       governFailed.value = false;
     }
     taskLogBatchId.value = batchId || taskLogBatchId.value;
@@ -469,7 +472,7 @@
     if (taskLog.value.length > 200) {
       taskLog.value.length = 200;
     }
-    saveTaskLogMemory(taskLog.value, taskLogBatchId.value, getCurrentCaseId());
+    persistTaskLog();
   };
 
   const findBatchId = (items?: FileInfo[] | null): string => {
@@ -504,6 +507,76 @@
     return currentCleanBatchId.value;
   };
 
+  /** 把当前日志序列落一份快照:完成后进度快照会清掉,但任务日志仍可在结果页回看 */
+  const persistTaskLog = (status: TaskLogStatus = taskLogStatus.value) => {
+    const caseId = getCurrentCaseId();
+    if (!caseId) return;
+
+    const batchId = taskLogBatchId.value || currentCleanBatchId.value || refreshCurrentCleanBatchId() || '';
+    const updatedAt = Date.now();
+    taskLogStatus.value = status;
+    saveTaskLogSnapshot({
+      caseId: String(caseId),
+      batchId,
+      status,
+      logs: taskLog.value,
+      successCount: successCount.value,
+      failCount: failCount.value,
+      updatedAt,
+    });
+  };
+
+  /** 刷新恢复执行中的页面时,把同一批次已产生的日志接回来 */
+  const restoreTaskLog = () => {
+    const snapshot = getTaskLogSnapshot(getCurrentCaseId());
+    if (!snapshot) return;
+
+    const currentBatch = currentCleanBatchId.value || refreshCurrentCleanBatchId();
+    if (currentBatch && snapshot.batchId && snapshot.batchId !== currentBatch) return;
+
+    taskLog.value = snapshot.logs.slice();
+    taskLogBatchId.value = snapshot.batchId;
+    taskLogStatus.value = snapshot.status;
+    if (snapshot.status === 'failed') {
+      governFailed.value = true;
+    }
+  };
+
+  /**
+   * 完成 / 失败后刷新进度页:进度快照已在完成时清掉,但任务日志快照还在。
+   * 同一批次的终态直接恢复成结果视图,避免刷新后又重新跑一遍清洗。
+   */
+  const restoreCompletedTaskLog = () => {
+    const snapshot = getTaskLogSnapshot(getCurrentCaseId());
+    if (!snapshot || (snapshot.status !== 'completed' && snapshot.status !== 'failed')) return false;
+
+    const currentBatch = currentCleanBatchId.value || refreshCurrentCleanBatchId();
+    if (currentBatch && snapshot.batchId && snapshot.batchId !== currentBatch) return false;
+
+    taskLog.value = snapshot.logs.slice();
+    taskLogBatchId.value = snapshot.batchId;
+    taskLogStatus.value = snapshot.status;
+    currentCleanBatchId.value = snapshot.batchId || currentCleanBatchId.value;
+    successCount.value = snapshot.successCount ?? successCount.value;
+    failCount.value = snapshot.failCount ?? failCount.value;
+    cleanStatsSnapshot.value = {
+      ...cleanStatsSnapshot.value,
+      success: snapshot.successCount,
+      fail: snapshot.failCount,
+    };
+
+    const hasGovernLogs = snapshot.logs.some((item) => item.text.includes('[数据治理]'));
+    shouldRunGovernAfterClean.value = hasGovernLogs;
+    governTaskStarted.value = hasGovernLogs;
+    governTaskFinished.value = hasGovernLogs && snapshot.status === 'completed';
+    governFailed.value = snapshot.status === 'failed';
+    cleanTaskStarted.value = true;
+    cleanTaskFinished.value = true;
+    activeStep.value = 2;
+    progress.value = 100;
+    return true;
+  };
+
   const handleViewLog = () => {
     refreshCurrentCleanBatchId();
     logModalOpen.value = true;
@@ -708,6 +781,7 @@
     activeStep.value = 2;
     governTaskFinished.value = true;
     closeSse();
+    persistTaskLog('completed');
     clearImportProgress(getCurrentCaseId());
     safeEmitImportRefresh();
     if (!governCompleteNotified.value) {
@@ -749,6 +823,7 @@
       governFailed.value = true;
       progress.value = 100;
       addTaskLog(`[数据治理]${message}`);
+      persistTaskLog('failed');
       closeSse();
       createMessage.error(message);
     }
@@ -811,7 +886,9 @@
         const message = String(payload.message || '').trim();
         if (/治理失败|治理异常/.test(message)) {
           governFailed.value = true;
+          taskLogStatus.value = 'failed';
           progress.value = 100;
+          persistTaskLog('failed');
           closeSse();
           createMessage.error(message || '数据治理失败,请查看任务日志');
           return;
@@ -1143,6 +1220,7 @@
    */
   watch(isComplete, (done) => {
     if (!done) return;
+    persistTaskLog(governFailed.value ? 'failed' : 'completed');
     const unable = isFused.value ? (aiStage.value?.needReviewCount ?? 0) : 0;
     if (unable > 0) {
       createMessage.warning(`另有 ${unable} 个表 AI 无法清洗,详情见处理日志;可用「手动清洗」处理。`);
@@ -1328,14 +1406,15 @@
   };
 
   const initializeProgress = async () => {
-    // 任务日志在模块内存里:进度页与导入文件页可来回查看;刷新/关闭页面自然丢失
-    const cachedTaskLog = getTaskLogMemory(undefined, getCurrentCaseId());
-    taskLog.value = cachedTaskLog.logs;
-    taskLogBatchId.value = cachedTaskLog.batchId;
+    // 每次进入都先清掉上一次留在组件里的任务日志,再按快照恢复
+    taskLog.value = [];
+    taskLogBatchId.value = '';
+    taskLogStatus.value = 'running';
     governFailed.value = false;
-    clearLegacyTaskLogStorage();
 
     if (restoreImportProgress()) {
+      // 刷新恢复:把同一批次已产生的清洗/治理日志接回来,避免页面空白
+      restoreTaskLog();
       // 刷新恢复:智能入口仍强制自动治理,避免旧快照把治理开关带回配置值
       if (isSmartFlowEntry()) {
         shouldRunGovernAfterClean.value = true;
@@ -1351,6 +1430,12 @@
       return;
     }
 
+    // 完成/失败后刷新:进度快照已清,但任务日志快照还在,直接恢复结果视图
+    if (restoreCompletedTaskLog()) {
+      closeSse();
+      return;
+    }
+
     resetImportProgressRuntimeState();
 
     await loadGovernSwitch();
@@ -1371,7 +1456,6 @@
 
   onBeforeUnmount(() => {
     clearCompletionFallbackTimer();
-    // 日志保留在模块内存里供导入文件页回看;不落 localStorage,刷新/关闭页面即丢失
     if (isComplete.value) {
       closeSse();
     } else {

+ 194 - 0
ai-electron/frontend/src/case/views/data/components/TaskLogDrawer.vue

@@ -0,0 +1,194 @@
+<template>
+  <Drawer
+    v-model:open="open"
+    placement="right"
+    :width="680"
+    :body-style="{ padding: '16px 20px 20px' }"
+    wrap-class-name="case-task-log-drawer"
+  >
+    <template #title>
+      <div class="task-log-drawer__title">
+        <span class="task-log-drawer__title-text">任务日志</span>
+        <span class="task-log-drawer__status" :class="`is-${displayStatus}`">{{ statusText }}</span>
+      </div>
+    </template>
+
+    <div class="task-log-drawer">
+      <div class="task-log-drawer__meta">
+        <span class="task-log-drawer__meta-item">
+          <span class="label">批次</span>
+          <span class="value">{{ batchId || '-' }}</span>
+        </span>
+        <span class="task-log-drawer__meta-item">
+          <span class="label">更新于</span>
+          <span class="value">{{ updatedText }}</span>
+        </span>
+      </div>
+
+      <TaskLogList
+        :logs="logs"
+        title="最近一次任务"
+        :max-height="'calc(100vh - 224px)'"
+        empty-text="还没有任务日志,完成一次清洗后会在这里显示"
+      />
+    </div>
+  </Drawer>
+</template>
+
+<script lang="ts" setup name="TaskLogDrawer">
+  /**
+   * 结果页的任务日志抽屉。
+   *
+   * <p>数据来自 importProgressSession 落盘的任务日志快照(清洗 + 治理同一条时间线),
+   * 即使已经离开进度页,仍可回看最近一次任务的完整执行记录。
+   */
+  import { computed } from 'vue';
+  import { Drawer } from 'ant-design-vue';
+  import TaskLogList from './TaskLogList.vue';
+  import type { TaskLogItem, TaskLogStatus } from '../importProgressSession';
+
+  const open = defineModel<boolean>('open', { default: false });
+
+  const props = withDefaults(
+    defineProps<{
+      logs?: TaskLogItem[];
+      status?: TaskLogStatus | '';
+      batchId?: string | number;
+      updatedAt?: number | null;
+    }>(),
+    {
+      logs: () => [],
+      status: '',
+      batchId: '',
+      updatedAt: null,
+    },
+  );
+
+  const displayStatus = computed(() => {
+    if (props.status) return props.status;
+    return props.logs.length ? 'completed' : 'empty';
+  });
+
+  const statusText = computed(() => {
+    switch (displayStatus.value) {
+      case 'running':
+        return '进行中';
+      case 'completed':
+        return '已完成';
+      case 'failed':
+        return '存在失败';
+      default:
+        return '暂无记录';
+    }
+  });
+
+  const updatedText = computed(() => {
+    if (!props.updatedAt) return '-';
+    return new Date(props.updatedAt).toLocaleString('zh-CN', { hour12: false });
+  });
+</script>
+
+<style lang="less">
+  .task-log-drawer {
+    display: flex;
+    flex-direction: column;
+    gap: 14px;
+    min-height: 0;
+    height: 100%;
+
+    &__title {
+      display: flex;
+      align-items: center;
+      gap: 10px;
+    }
+
+    &__title-text {
+      font-size: 16px;
+      font-weight: 600;
+      color: #0f172a;
+    }
+
+    &__status {
+      display: inline-flex;
+      align-items: center;
+      height: 22px;
+      padding: 0 9px;
+      border-radius: 999px;
+      font-size: 11.5px;
+      font-weight: 600;
+      line-height: 22px;
+
+      &.is-running {
+        color: #1d4ed8;
+        background: #eff6ff;
+      }
+
+      &.is-completed {
+        color: #15803d;
+        background: #ecfdf5;
+      }
+
+      &.is-failed {
+        color: #b91c1c;
+        background: #fef2f2;
+      }
+
+      &.is-empty {
+        color: #64748b;
+        background: #f1f5f9;
+      }
+    }
+
+    &__meta {
+      display: flex;
+      align-items: center;
+      gap: 22px;
+      flex-wrap: wrap;
+      padding: 10px 12px;
+      border: 1px solid #eef2f7;
+      border-radius: 10px;
+      background: #f8fafc;
+    }
+
+    &__meta-item {
+      display: inline-flex;
+      align-items: center;
+      gap: 7px;
+      min-width: 0;
+      font-size: 12px;
+
+      .label {
+        flex: none;
+        color: #94a3b8;
+      }
+
+      .value {
+        max-width: 320px;
+        overflow: hidden;
+        color: #334155;
+        font-weight: 500;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+    }
+  }
+
+  html[data-theme='dark'] {
+    .task-log-drawer__title-text {
+      color: #e5e7eb;
+    }
+
+    .task-log-drawer__meta {
+      border-color: #1f2937;
+      background: #111827;
+    }
+
+    .task-log-drawer__meta-item .label {
+      color: #94a3b8;
+    }
+
+    .task-log-drawer__meta-item .value {
+      color: #cbd5e1;
+    }
+  }
+</style>

+ 2 - 3
ai-electron/frontend/src/case/views/data/components/TaskLogList.vue

@@ -24,9 +24,8 @@
   /**
    * 清洗 / 治理任务日志时间线。
    *
-   * <p>两处复用:进度页执行中面板、完成态统计页右侧。日志只存在于当前进度页内存,
-   * 退出即清空,不落 localStorage。日志直接显示后端原文(含 [数据清洗] / [数据治理] 前缀),
-   * 不做级别判断或阶段筛选。
+   * <p>三处复用:进度页执行中面板、完成态统计页右侧、结果页日志抽屉。
+   * 日志直接显示后端原文(含 [数据清洗] / [数据治理] 前缀),不做级别判断或阶段筛选。
    */
   import { computed } from 'vue';
   import type { TaskLogItem } from '../importProgressSession';

+ 0 - 207
ai-electron/frontend/src/case/views/data/components/TaskLogModal.vue

@@ -1,207 +0,0 @@
-<template>
-  <BasicModal
-    :open="open"
-    title="清洗日志"
-    :width="1000"
-    :showOkBtn="false"
-    cancelText="关闭"
-    wrapClassName="case-task-log-modal"
-    @update:open="handleOpenChange"
-    @cancel="handleOpenChange(false)"
-  >
-    <div class="task-log-summary">
-      <span class="task-log-summary__meta">批次 {{ batchId || '-' }}</span>
-      <span class="task-log-summary__meta">更新于 {{ updatedText }}</span>
-      <span class="task-log-summary__total">共 {{ logs.length }} 条</span>
-    </div>
-
-    <Table
-      class="task-log-table"
-      :columns="columns"
-      :data-source="rows"
-      :pagination="pagination"
-      :locale="{ emptyText: '本次任务暂无日志' }"
-      row-key="id"
-      size="small"
-    />
-  </BasicModal>
-</template>
-
-<script lang="ts" setup name="TaskLogModal">
-  /**
-   * 清洗 / 治理任务日志弹框。
-   *
-   * <p>与手动清洗的「清洗日志」保持一致:用表格按时间展示本次任务的执行记录。
-   * 数据只来自 importProgressSession 的内存缓存,不落 localStorage;刷新或关闭页面即丢失。
-   */
-  import { computed, h } from 'vue';
-  import { Table } from 'ant-design-vue';
-  import { BasicModal } from '@/core/components/Modal';
-  import type { TaskLogItem } from '../importProgressSession';
-
-  const props = withDefaults(
-    defineProps<{
-      open: boolean;
-      logs?: TaskLogItem[];
-      batchId?: string | number;
-      updatedAt?: number | null;
-    }>(),
-    {
-      logs: () => [],
-      batchId: '',
-      updatedAt: null,
-    },
-  );
-
-  const emit = defineEmits<{
-    (e: 'update:open', value: boolean): void;
-  }>();
-
-  interface LogRow {
-    id: string;
-    index: number;
-    type: string;
-    content: string;
-    time: string;
-  }
-
-  const typeOf = (text: string) => {
-    if (text.startsWith('[数据治理]')) return '数据治理';
-    if (text.startsWith('[数据清洗]')) return '数据清洗';
-    return '其他';
-  };
-
-  const contentOf = (text: string) => text.replace(/^\[(数据清洗|数据治理)\]/, '');
-
-  const rows = computed<LogRow[]>(() =>
-    props.logs.map((item, index) => ({
-      id: item.id,
-      index: index + 1,
-      type: typeOf(item.text),
-      content: contentOf(item.text),
-      time: item.time,
-    })),
-  );
-
-  const pagination = computed(() => ({
-    pageSize: 10,
-    showSizeChanger: false,
-    showTotal: (total: number) => `共 ${total} 条`,
-  }));
-
-  const updatedText = computed(() => {
-    if (!props.updatedAt) return '-';
-    return new Date(props.updatedAt).toLocaleString('zh-CN', { hour12: false });
-  });
-
-  const renderType = (type: string) =>
-    h(
-      'span',
-      { class: ['task-log-type', type === '数据治理' ? 'is-govern' : type === '数据清洗' ? 'is-clean' : ''] },
-      type,
-    );
-
-  const columns = [
-    { title: '序号', dataIndex: 'index', key: 'index', width: 68, align: 'center' as const },
-    {
-      title: '类型',
-      dataIndex: 'type',
-      key: 'type',
-      width: 110,
-      customRender: ({ text }: { text: string }) => renderType(text),
-    },
-    {
-      title: '日志内容',
-      dataIndex: 'content',
-      key: 'content',
-      customRender: ({ text }: { text: string }) => h('span', { class: 'task-log-content' }, text || '-'),
-    },
-    { title: '记录时间', dataIndex: 'time', key: 'time', width: 120 },
-  ];
-
-  const handleOpenChange = (value: boolean) => {
-    emit('update:open', value);
-  };
-</script>
-
-<style lang="less">
-  .case-task-log-modal {
-    .ant-modal-content {
-      border-radius: 14px;
-      overflow: hidden;
-    }
-
-    .ant-modal-header {
-      padding: 12px 16px;
-      background: @modal-header-bg-color;
-      border-bottom-color: #e5e7eb;
-    }
-
-    .ant-modal-title {
-      color: #111827;
-      font-size: 16px;
-      font-weight: 600;
-      line-height: 24px;
-    }
-
-    .ant-modal-body {
-      padding: 16px;
-    }
-
-    .ant-modal-footer {
-      padding: 8px 16px 10px;
-      border-top-color: #e5e7eb;
-      background: #fff;
-    }
-  }
-
-  .task-log-summary {
-    display: flex;
-    align-items: center;
-    gap: 18px;
-    margin-bottom: 12px;
-    color: #475569;
-    font-size: 13px;
-
-    &__total {
-      margin-left: auto;
-      color: #94a3b8;
-    }
-  }
-
-  .task-log-table {
-    .ant-table-thead > tr > th {
-      background: #f8fafc;
-      color: #334155;
-    }
-
-    .ant-table-tbody > tr > td {
-      vertical-align: top;
-    }
-  }
-
-  .task-log-type {
-    display: inline-flex;
-    padding: 1px 8px;
-    border-radius: 10px;
-    background: #f1f5f9;
-    color: #64748b;
-    font-size: 12px;
-    line-height: 20px;
-
-    &.is-clean {
-      background: #eff6ff;
-      color: #1d4ed8;
-    }
-
-    &.is-govern {
-      background: #ecfdf5;
-      color: #047857;
-    }
-  }
-
-  .task-log-content {
-    white-space: pre-wrap;
-    word-break: break-word;
-  }
-</style>

+ 26 - 21
ai-electron/frontend/src/case/views/data/importList.vue

@@ -10,7 +10,7 @@
           <div class="table-toolbar__actions">
             <a-button class="task-log-btn" type="default" @click="openTaskLog">
               <Icon icon="i-ant-design:file-text-outlined" />
-              查看日志
+              任务日志
               <span v-if="taskLogCount" class="task-log-btn__count">{{ taskLogCount }}</span>
             </a-button>
             <Popconfirm title="是否确认删除选中的导入记录?" @confirm="handleDeleteSelected()">
@@ -41,13 +41,6 @@
       :initial-sheet-id="currentLogSheetId"
     />
 
-    <TaskLogModal
-      v-model:open="taskLogModalOpen"
-      :logs="taskLogMemory.logs"
-      :batch-id="taskLogMemory.batchId"
-      :updated-at="taskLogMemory.updatedAt"
-    />
-
     <AiRejectModal
       v-model:open="aiRejectOpen"
       :job-id="aiRejectRecord?.aiJobId ?? null"
@@ -56,6 +49,14 @@
       :success-num="Number(aiRejectRecord?.successNum ?? 0)"
       :fail-num="Number(aiRejectRecord?.failNum ?? 0)"
     />
+
+    <TaskLogDrawer
+      v-model:open="taskLogDrawerOpen"
+      :logs="taskLogSnapshot?.logs || []"
+      :status="taskLogSnapshot?.status || ''"
+      :batch-id="taskLogSnapshot?.batchId || ''"
+      :updated-at="taskLogSnapshot?.updatedAt || null"
+    />
   </div>
 </template>
 <script lang="ts" setup name="ViewsSysEmpUserList">
@@ -78,12 +79,12 @@
   import componentSetting from '@/core/settings/componentSetting';
   import CleanLogModal from './components/CleanLogModal.vue';
   import AiRejectModal from './components/AiRejectModal.vue';
-  import TaskLogModal from './components/TaskLogModal.vue';
+  import TaskLogDrawer from './components/TaskLogDrawer.vue';
   import {
-    getTaskLogMemory,
+    getTaskLogSnapshot,
     resetImportProgressSession,
     setCleanFlowSource,
-    type TaskLogMemory,
+    type TaskLogSnapshot,
   } from './importProgressSession';
 
   const props = defineProps({
@@ -111,18 +112,20 @@
   const logModalOpen = ref(false);
   const currentLogFileInfos = ref<FileInfo[]>([]);
   const currentLogSheetId = ref<string>('');
-  /** 最近一次清洗/治理任务日志(模块内存,不落存储) */
-  const taskLogModalOpen = ref(false);
-  const readTaskLogMemory = () => getTaskLogMemory(undefined, userStore.getCaseInfo?.id);
-  const taskLogMemory = ref<TaskLogMemory>(readTaskLogMemory());
-  const taskLogCount = computed(() => taskLogMemory.value.logs.length);
-  const openTaskLog = () => {
-    taskLogMemory.value = readTaskLogMemory();
-    taskLogModalOpen.value = true;
-  };
   /** AI 清洗失败明细:按 sheet 最新 job 查 ai_clean_reject */
   const aiRejectOpen = ref(false);
   const aiRejectRecord = ref<Partial<FileInfo> | null>(null);
+  /** 最近一次清洗任务日志(进度页在任务结束时落盘,结果页只读) */
+  const taskLogDrawerOpen = ref(false);
+  const taskLogSnapshot = ref<TaskLogSnapshot | null>(null);
+  const taskLogCount = computed(() => taskLogSnapshot.value?.logs?.length ?? 0);
+  const refreshTaskLogSnapshot = () => {
+    taskLogSnapshot.value = getTaskLogSnapshot(userStore.getCaseInfo?.id);
+  };
+  const openTaskLog = () => {
+    refreshTaskLogSnapshot();
+    taskLogDrawerOpen.value = true;
+  };
   const hasLoadedOnActivate = ref(false);
   const recleaningFileIds = ref<string[]>([]);
   const paginationLoading = ref(false);
@@ -412,7 +415,7 @@
 
   const handleImportListRefresh = () => {
     hasLoadedOnActivate.value = true;
-    taskLogMemory.value = readTaskLogMemory();
+    refreshTaskLogSnapshot();
     syncTableLayout();
     void reload();
   };
@@ -421,6 +424,7 @@
     () => props.active,
     (active) => {
       if (!active) return;
+      refreshTaskLogSnapshot();
       if (!hasLoadedOnActivate.value) {
         handleImportListRefresh();
         return;
@@ -432,6 +436,7 @@
   );
 
   onMounted(() => {
+    refreshTaskLogSnapshot();
     emitter.on(CASE_DATA_REFRESH_EVENT, handleImportListRefresh);
     emitter.on(CASE_IMPORT_FILE_REFRESH_EVENT, handleImportListRefresh);
     // 整页刷新直达 ?tab=files 时组件创建即为 active:watch 捕获不到「变化」,

+ 56 - 32
ai-electron/frontend/src/case/views/data/importProgressSession.ts

@@ -150,8 +150,8 @@ export const getCleanFlowSource = (): CleanFlowSource | null => {
  * 清洗/治理任务日志条目。
  *
  * <p>直接保存后端 SSE 的原始文案(保留 {@code [数据清洗]} / {@code [数据治理]} 前缀),
- * 不做级别判断或阶段归类。日志只保存在模块内存,<b>不落 localStorage</b>:
- * 可以在「进度页 → 导入文件页」之间切换回看,刷新/关闭页面即丢失。
+ * 不做级别判断或阶段归类。进度页实时展示,同时落一份 localStorage 快照;
+ * 任务结束后结果页从快照里读出来,仍能查看完整日志。
  */
 export interface TaskLogItem {
   id: string;
@@ -161,47 +161,71 @@ export interface TaskLogItem {
   text: string;
 }
 
-export interface TaskLogMemory {
+export type TaskLogStatus = 'running' | 'completed' | 'failed';
+
+export interface TaskLogSnapshot {
   caseId: string;
   batchId: string;
+  status: TaskLogStatus;
   logs: TaskLogItem[];
+  /** 完成态统计:刷新完成页时不必依赖已清掉的进度快照 */
+  successCount?: number;
+  failCount?: number;
   updatedAt: number;
 }
 
-const EMPTY_TASK_LOG_MEMORY: TaskLogMemory = { caseId: '', batchId: '', logs: [], updatedAt: 0 };
+const TASK_LOG_STORAGE_KEY = 'case_clean_task_log';
+const TASK_LOG_MAX_ITEMS = 200;
+/** 只保留最近几个案件的快照,避免 localStorage 里的历史案件无限增长 */
+const TASK_LOG_MAX_CASES = 5;
 
-let taskLogMemory: TaskLogMemory = { ...EMPTY_TASK_LOG_MEMORY };
+const getTaskLogStorage = () => (typeof window === 'undefined' ? null : window.localStorage);
 
-/** 写入本次任务日志:只跨路由保留,不落任何存储 */
-export const saveTaskLogMemory = (logs: TaskLogItem[], batchId?: string | number, caseId?: string | number) => {
-  taskLogMemory = {
-    caseId: String(caseId ?? '').trim(),
-    batchId: String(batchId ?? '').trim(),
-    logs: (Array.isArray(logs) ? logs : []).slice(0, 200),
-    updatedAt: Date.now(),
+const readTaskLogMap = (): Record<string, TaskLogSnapshot> => {
+  const storage = getTaskLogStorage();
+  if (!storage) return {};
+  try {
+    const parsed = JSON.parse(storage.getItem(TASK_LOG_STORAGE_KEY) || '{}');
+    return parsed && typeof parsed === 'object' ? (parsed as Record<string, TaskLogSnapshot>) : {};
+  } catch {
+    return {};
+  }
+};
+
+export const saveTaskLogSnapshot = (snapshot: TaskLogSnapshot) => {
+  const storage = getTaskLogStorage();
+  const caseId = normalizeCaseId(snapshot.caseId);
+  if (!storage || !caseId) return;
+
+  const map = readTaskLogMap();
+  map[caseId] = {
+    ...snapshot,
+    caseId,
+    logs: (Array.isArray(snapshot.logs) ? snapshot.logs : []).slice(0, TASK_LOG_MAX_ITEMS),
+    updatedAt: snapshot.updatedAt || Date.now(),
   };
+
+  const entries = Object.entries(map)
+    .sort(([, left], [, right]) => (right.updatedAt || 0) - (left.updatedAt || 0))
+    .slice(0, TASK_LOG_MAX_CASES);
+  storage.setItem(TASK_LOG_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)));
 };
 
-/**
- * 读取最近一次任务日志;传入 caseId/batchId 时只返回同一案件、同一批次,
- * 避免切案或开始新批次后还看到上一批次的日志。
- */
-export const getTaskLogMemory = (batchId?: string | number, caseId?: string | number): TaskLogMemory => {
-  const normalizedCaseId = String(caseId ?? '').trim();
-  const normalizedBatchId = String(batchId ?? '').trim();
-  if (normalizedCaseId && taskLogMemory.caseId && normalizedCaseId !== taskLogMemory.caseId) {
-    return { ...EMPTY_TASK_LOG_MEMORY, caseId: normalizedCaseId };
-  }
-  if (normalizedBatchId && taskLogMemory.batchId && normalizedBatchId !== taskLogMemory.batchId) {
-    return { ...EMPTY_TASK_LOG_MEMORY, caseId: normalizedCaseId, batchId: normalizedBatchId };
-  }
-  return { ...taskLogMemory, logs: taskLogMemory.logs.slice() };
+export const getTaskLogSnapshot = (caseId?: string | number): TaskLogSnapshot | null => {
+  const normalizedCaseId = normalizeCaseId(caseId);
+  if (!normalizedCaseId) return null;
+  const snapshot = readTaskLogMap()[normalizedCaseId];
+  if (!snapshot || !Array.isArray(snapshot.logs)) return null;
+  return snapshot;
 };
 
-/** 旧版本把任务日志落在 localStorage;进入进度页时清掉,避免历史日志残留 */
-export const clearLegacyTaskLogStorage = () => {
-  if (typeof window === 'undefined') {
-    return;
-  }
-  window.localStorage.removeItem('case_clean_task_log');
+export const clearTaskLogSnapshot = (caseId?: string | number) => {
+  const storage = getTaskLogStorage();
+  const normalizedCaseId = normalizeCaseId(caseId);
+  if (!storage || !normalizedCaseId) return;
+
+  const map = readTaskLogMap();
+  if (!(normalizedCaseId in map)) return;
+  delete map[normalizedCaseId];
+  storage.setItem(TASK_LOG_STORAGE_KEY, JSON.stringify(map));
 };

+ 2 - 4
docs/ai-clean-e2e/README.md

@@ -154,10 +154,8 @@
   - 原值预览 `110105194912310021`(完整 18 位,无掩码)
 - 截图:[ai-reject-modal-id18.png](screenshots/ai-reject-modal-id18.png)
 - 后置:`required` 已重置为 0。
-- 清洗日志:进度页与导入文件页共用模块内存日志;导入文件页提供「查看日志」表格弹框,
-  切换页签可回看,刷新/关闭页面即清空。日志不落 localStorage,旧版本遗留的
-  `case_clean_task_log` 会在进入进度页时清除。
-- 日志弹框截图:[clean-task-log-modal.png](screenshots/clean-task-log-modal.png)
+- 任务日志:清洗/治理过程中实时展示;任务结束后按案件保留最近一次日志快照,
+  导入文件页「任务日志」抽屉可回看(`localStorage.case_clean_task_log`)。
 
 ## 5. 本次发现并修复的问题
 

BIN
docs/ai-clean-e2e/screenshots/clean-task-log-modal.png