Kaynağa Gözat

fix(case): 恢复查看日志弹框并与手动清洗共用表格展示

- 导入文件页恢复「查看日志」入口,弹框按序号/类型/日志内容/时间展示
- 清洗、AI、治理日志写入同一份模块内存缓存,切页签可回看
- 日志不落 localStorage,刷新/关闭页面即清空;按案件隔离
- 补充 E2E 日志弹框截图与说明
cc 1 hafta önce
ebeveyn
işleme
d9c02b39ce

+ 10 - 9
ai-electron/frontend/src/case/views/data/cleanProgress.vue

@@ -220,8 +220,10 @@
     getActiveImportProgress,
     getCleanFlowSource,
     getImportProgressEventSource,
+    getTaskLogMemory,
     resetImportProgressRuntimeState,
     saveImportProgress,
+    saveTaskLogMemory,
     setImportProgressEventSource,
     useImportProgressRuntimeState,
     type TaskLogItem,
@@ -262,8 +264,8 @@
   } = useImportProgressRuntimeState();
   const sseMessages = ref<string[]>([]);
   /**
-   * 处理日志:清洗 + 治理统一成一条时间线,只存在本页内存里。
-   * 页面退出即清空;不落 localStorage,也不在导入文件页提供历史日志入口。
+   * 处理日志:清洗 + 治理统一成一条时间线,只保存在模块内存里。
+   * 进度页与导入文件页可以互相切换回看;刷新/关闭页面即丢失,不落 localStorage。
    */
   const taskLog = ref<TaskLogItem[]>([]);
   const taskLogBatchId = ref('');
@@ -467,6 +469,7 @@
     if (taskLog.value.length > 200) {
       taskLog.value.length = 200;
     }
+    saveTaskLogMemory(taskLog.value, taskLogBatchId.value, getCurrentCaseId());
   };
 
   const findBatchId = (items?: FileInfo[] | null): string => {
@@ -1325,9 +1328,10 @@
   };
 
   const initializeProgress = async () => {
-    // 任务日志只存在本页内存:进入时清空,并清掉旧版本遗留的 localStorage 快照
-    taskLog.value = [];
-    taskLogBatchId.value = '';
+    // 任务日志在模块内存里:进度页与导入文件页可来回查看;刷新/关闭页面自然丢失
+    const cachedTaskLog = getTaskLogMemory(undefined, getCurrentCaseId());
+    taskLog.value = cachedTaskLog.logs;
+    taskLogBatchId.value = cachedTaskLog.batchId;
     governFailed.value = false;
     clearLegacyTaskLogStorage();
 
@@ -1367,10 +1371,7 @@
 
   onBeforeUnmount(() => {
     clearCompletionFallbackTimer();
-    // 清洗/治理日志不跨页面保留:退出即清空内存,并清掉旧版本遗留的 localStorage
-    taskLog.value = [];
-    taskLogBatchId.value = '';
-    clearLegacyTaskLogStorage();
+    // 日志保留在模块内存里供导入文件页回看;不落 localStorage,刷新/关闭页面即丢失
     if (isComplete.value) {
       closeSse();
     } else {

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

@@ -0,0 +1,207 @@
+<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>

+ 32 - 3
ai-electron/frontend/src/case/views/data/importList.vue

@@ -8,6 +8,11 @@
             <span class="table-meta-chip is-selected">已选 {{ selectedRowKeysRef.length }} 条</span>
           </div>
           <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()">
               <a-button v-if="selectedRowKeysRef.length > 0" danger class="batch-delete-btn" type="default">
                 <Icon icon="i-ant-design:delete-outlined" color="error" /> 删除 ({{ selectedRowKeysRef.length }})
@@ -36,6 +41,13 @@
       :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"
@@ -47,7 +59,7 @@
   </div>
 </template>
 <script lang="ts" setup name="ViewsSysEmpUserList">
-  import { h, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+  import { computed, h, onBeforeUnmount, onMounted, ref, watch } from 'vue';
   import type { PropType } from 'vue';
   import { Popconfirm } from 'ant-design-vue';
   import AnalysisPagination from '@/core/components/AnalysisPagination/src/AnalysisPagination.vue';
@@ -56,7 +68,7 @@
   import { useDesign } from '@/core/hooks/web/useDesign';
   import { useGo } from '@/core/hooks/web/usePage';
   import { useMessage } from '@/core/hooks/web/useMessage';
-  import { useEmitter } from '@/core/store/modules/case';
+  import { useEmitter, useUserStore } from '@/core/store/modules/case';
   import { PageEnum } from '@/core/enums/pageEnum';
   import type { FormProps } from '@/core/components/Form';
   import type { FileInfo } from '@/types';
@@ -66,7 +78,13 @@
   import componentSetting from '@/core/settings/componentSetting';
   import CleanLogModal from './components/CleanLogModal.vue';
   import AiRejectModal from './components/AiRejectModal.vue';
-  import { resetImportProgressSession, setCleanFlowSource } from './importProgressSession';
+  import TaskLogModal from './components/TaskLogModal.vue';
+  import {
+    getTaskLogMemory,
+    resetImportProgressSession,
+    setCleanFlowSource,
+    type TaskLogMemory,
+  } from './importProgressSession';
 
   const props = defineProps({
     treeCodes: Array as PropType<string[]>,
@@ -80,6 +98,7 @@
   const { prefixCls } = useDesign('case-import-list');
   const go = useGo();
   const emitter = useEmitter();
+  const userStore = useUserStore();
   const { createMessage } = useMessage();
   const CASE_DATA_REFRESH_EVENT = 'case-data:refresh';
   const CASE_IMPORT_FILE_REFRESH_EVENT = 'case-import-file:refresh';
@@ -92,6 +111,15 @@
   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);
@@ -384,6 +412,7 @@
 
   const handleImportListRefresh = () => {
     hasLoadedOnActivate.value = true;
+    taskLogMemory.value = readTaskLogMemory();
     syncTableLayout();
     void reload();
   };

+ 39 - 2
ai-electron/frontend/src/case/views/data/importProgressSession.ts

@@ -150,8 +150,8 @@ export const getCleanFlowSource = (): CleanFlowSource | null => {
  * 清洗/治理任务日志条目。
  *
  * <p>直接保存后端 SSE 的原始文案(保留 {@code [数据清洗]} / {@code [数据治理]} 前缀),
- * 不做级别判断或阶段归类。日志只存在于进度页内存,<b>不落任何存储</b>:
- * 页面退出即清空,导入文件页也不再提供历史任务日志入口。
+ * 不做级别判断或阶段归类。日志只保存在模块内存,<b>不落 localStorage</b>:
+ * 可以在「进度页 → 导入文件页」之间切换回看,刷新/关闭页面即丢失。
  */
 export interface TaskLogItem {
   id: string;
@@ -161,6 +161,43 @@ export interface TaskLogItem {
   text: string;
 }
 
+export interface TaskLogMemory {
+  caseId: string;
+  batchId: string;
+  logs: TaskLogItem[];
+  updatedAt: number;
+}
+
+const EMPTY_TASK_LOG_MEMORY: TaskLogMemory = { caseId: '', batchId: '', logs: [], updatedAt: 0 };
+
+let taskLogMemory: TaskLogMemory = { ...EMPTY_TASK_LOG_MEMORY };
+
+/** 写入本次任务日志:只跨路由保留,不落任何存储 */
+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(),
+  };
+};
+
+/**
+ * 读取最近一次任务日志;传入 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() };
+};
+
 /** 旧版本把任务日志落在 localStorage;进入进度页时清掉,避免历史日志残留 */
 export const clearLegacyTaskLogStorage = () => {
   if (typeof window === 'undefined') {

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

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

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