Răsfoiți Sursa

feat(case): 清洗治理日志与结果页展示,补齐无文件拦截

- 清洗/治理任务日志:SSE 原文展示并落 localStorage 快照,进度页、结果页和导入文件页均可查看
- 进度页与结果页布局重做:卡片式页头、左侧状态/步骤、右侧日志控制台、完成概览与治理失败态
- 完成后刷新可恢复结果页;无文件或无可清洗工作表时禁止进入进度页并退回上传页
- 后端补充治理阶段日志消息,不改变原有阶段顺序与判定
- 查询弹窗与清洗日志弹窗移除渐变背景,统一实色样式
cc 3 zile în urmă
părinte
comite
f5178de1d5

Fișier diff suprimat deoarece este prea mare
+ 670 - 228
ai-electron/frontend/src/case/views/data/cleanProgress.vue


+ 5 - 0
ai-electron/frontend/src/case/views/data/cleaning.vue

@@ -1072,6 +1072,11 @@
       createMessage.warning('未找到可导入的数据,请先上传并完成模板匹配');
       return;
     }
+    // 只有文件根、没有任何工作表时,后端不会产生清洗任务;不要跳到进度页
+    if (!sanitizedFileInfos.some((item) => (item.children || []).length > 0)) {
+      createMessage.warning('未找到可清洗文件,请先上传并匹配文件');
+      return;
+    }
     const matchedSheetsWithoutHeaderRow = collectMatchedSheetsWithoutHeaderRow(sanitizedFileInfos);
     if (matchedSheetsWithoutHeaderRow.length) {
       const previewNames = matchedSheetsWithoutHeaderRow.slice(0, 3).join('、');

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

@@ -757,7 +757,7 @@
 
     .ant-modal-content {
       border-radius: 14px;
-      background: linear-gradient(180deg, #fdfefe 0%, #ffffff 100%);
+      background: #fff;
       box-shadow: 0 18px 42px rgba(15, 23, 42, 0.14);
     }
 
@@ -785,7 +785,7 @@
     }
 
     .query-table-search {
-      background: linear-gradient(180deg, #fafcff 0%, #ffffff 100%);
+      background: #fff;
       border-color: #e2e8f0;
     }
 

+ 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>

+ 339 - 0
ai-electron/frontend/src/case/views/data/components/TaskLogList.vue

@@ -0,0 +1,339 @@
+<template>
+  <div class="task-log-list" :class="{ 'is-dense': dense, 'is-console': consoleStyle, 'is-fill': fill }">
+    <div class="task-log-list__header">
+      <div class="task-log-list__heading">
+        <span class="task-log-list__title">{{ title }}</span>
+        <span v-if="live" class="task-log-list__live"><i></i>实时</span>
+      </div>
+      <span class="task-log-list__total">共 {{ logs.length }} 条</span>
+    </div>
+
+    <div class="task-log-list__body" :style="{ maxHeight: resolvedMaxHeight }">
+      <div v-if="!logs.length" class="task-log-list__empty">{{ emptyText }}</div>
+      <div v-for="(item, index) in logs" :key="item.id || `${item.time}-${index}`" class="task-log-row">
+        <span v-if="consoleStyle" class="task-log-row__time">{{ item.time }}</span>
+        <span v-else class="task-log-row__rail" aria-hidden="true"><span class="task-log-row__dot"></span></span>
+        <span class="task-log-row__text">{{ item.text }}</span>
+        <span v-if="!consoleStyle" class="task-log-row__time">{{ item.time }}</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup name="TaskLogList">
+  /**
+   * 清洗 / 治理任务日志时间线。
+   *
+   * <p>三处复用:进度页执行中面板、完成态统计页右侧、结果页日志抽屉。
+   * 日志直接显示后端原文(含 [数据清洗] / [数据治理] 前缀),不做级别判断或阶段筛选。
+   */
+  import { computed } from 'vue';
+  import type { TaskLogItem } from '../importProgressSession';
+
+  const props = withDefaults(
+    defineProps<{
+      logs?: TaskLogItem[];
+      title?: string;
+      /** 执行中:标题旁显示呼吸的「实时」标记 */
+      live?: boolean;
+      /** 紧凑模式:进度页内嵌面板使用 */
+      dense?: boolean;
+      /** 控制台样式:时间列在前,适合大段密集日志 */
+      consoleStyle?: boolean;
+      /** 由外层 flex 容器分配高度,列表撑满并内部滚动 */
+      fill?: boolean;
+      maxHeight?: number | string;
+      emptyText?: string;
+    }>(),
+    {
+      logs: () => [],
+      title: '处理日志',
+      live: false,
+      dense: false,
+      consoleStyle: false,
+      fill: false,
+      maxHeight: 240,
+      emptyText: '暂无日志',
+    },
+  );
+
+  const resolvedMaxHeight = computed(() =>
+    typeof props.maxHeight === 'number' ? `${props.maxHeight}px` : props.maxHeight,
+  );
+</script>
+
+<style lang="less">
+  .task-log-list {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+    min-height: 0;
+
+    &__header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      gap: 12px;
+    }
+
+    &__heading {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      min-width: 0;
+    }
+
+    &__title {
+      font-size: 13px;
+      font-weight: 600;
+      line-height: 20px;
+      color: #0f172a;
+    }
+
+    &__live {
+      display: inline-flex;
+      align-items: center;
+      gap: 5px;
+      padding: 1px 8px;
+      border-radius: 999px;
+      background: #ecfdf5;
+      color: #15803d;
+      font-size: 11px;
+      font-weight: 600;
+      line-height: 18px;
+
+      i {
+        width: 6px;
+        height: 6px;
+        border-radius: 50%;
+        background: #22c55e;
+        box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.14);
+        animation: task-log-live 1.5s ease-out infinite;
+      }
+    }
+
+    &__total {
+      flex: none;
+      font-size: 12px;
+      color: #94a3b8;
+      font-variant-numeric: tabular-nums;
+    }
+
+    &__body {
+      display: flex;
+      flex-direction: column;
+      gap: 1px;
+      min-height: 0;
+      overflow: auto;
+      overscroll-behavior: contain;
+
+      &::-webkit-scrollbar {
+        width: 6px;
+      }
+
+      &::-webkit-scrollbar-thumb {
+        border-radius: 999px;
+        background: #dbe3ee;
+      }
+
+      &::-webkit-scrollbar-track {
+        background: transparent;
+      }
+    }
+
+    &__empty {
+      padding: 18px 8px;
+      text-align: center;
+      font-size: 12.5px;
+      color: #94a3b8;
+    }
+  }
+
+  .task-log-list.is-fill {
+    height: 100%;
+
+    .task-log-list__body {
+      flex: 1 1 auto;
+      max-height: none !important;
+    }
+  }
+
+  .task-log-list.is-console {
+    .task-log-list__body {
+      gap: 0;
+    }
+
+    .task-log-row {
+      display: grid;
+      grid-template-columns: 72px minmax(0, 1fr);
+      align-items: start;
+      gap: 10px;
+      padding: 7px 10px;
+      border-radius: 0;
+      border-bottom: 1px solid #f1f5f9;
+      font-size: 12.5px;
+      line-height: 20px;
+
+      &:hover {
+        background: #f8fafc;
+      }
+
+      &:last-child {
+        border-bottom: 0;
+      }
+    }
+
+    .task-log-row__time {
+      padding-top: 0;
+      font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+      font-size: 11.5px;
+      color: #94a3b8;
+      letter-spacing: 0.01em;
+    }
+
+    .task-log-row__text {
+      color: #334155;
+      word-break: break-word;
+    }
+  }
+
+  .task-log-row {
+    display: flex;
+    align-items: flex-start;
+    gap: 9px;
+    padding: 6px 8px;
+    border-radius: 8px;
+    font-size: 13px;
+    line-height: 20px;
+    transition: background-color 0.15s ease;
+
+    &:hover {
+      background: rgba(241, 245, 249, 0.9);
+    }
+
+    &__rail {
+      position: relative;
+      flex: none;
+      display: flex;
+      justify-content: center;
+      width: 8px;
+      align-self: stretch;
+
+      &::after {
+        content: '';
+        position: absolute;
+        top: 17px;
+        bottom: -7px;
+        left: 50%;
+        width: 1px;
+        background: #e2e8f0;
+        transform: translateX(-50%);
+      }
+    }
+
+    &:last-child &__rail::after {
+      display: none;
+    }
+
+    &__dot {
+      position: relative;
+      z-index: 1;
+      width: 7px;
+      height: 7px;
+      margin-top: 7px;
+      border-radius: 50%;
+      background: #94a3b8;
+      box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.12);
+    }
+
+    &__text {
+      flex: 1 1 auto;
+      min-width: 0;
+      color: #334155;
+      word-break: break-all;
+    }
+
+    &__time {
+      flex: none;
+      padding-top: 1px;
+      font-size: 11.5px;
+      color: #94a3b8;
+      font-variant-numeric: tabular-nums;
+    }
+  }
+
+  .task-log-list.is-dense {
+    gap: 8px;
+
+    .task-log-list__title {
+      font-size: 12.5px;
+    }
+
+    .task-log-row {
+      gap: 8px;
+      padding: 4px 6px;
+      font-size: 12.5px;
+      line-height: 19px;
+    }
+
+    .task-log-row__time {
+      font-size: 11px;
+    }
+
+    .task-log-row__dot {
+      margin-top: 6px;
+    }
+  }
+
+  html[data-theme='dark'] {
+    .task-log-list__title {
+      color: #e5e7eb;
+    }
+
+    .task-log-list__total,
+    .task-log-row__time,
+    .task-log-list__empty {
+      color: #94a3b8;
+    }
+
+    .task-log-row__text {
+      color: #cbd5e1;
+    }
+
+    .task-log-row:hover {
+      background: #1e293b;
+    }
+
+    .task-log-row__rail::after {
+      background: #334155;
+    }
+
+    .task-log-list.is-console {
+      .task-log-row {
+        border-bottom-color: #1f2937;
+
+        &:hover {
+          background: #1e293b;
+        }
+      }
+
+      .task-log-row__text {
+        color: #cbd5e1;
+      }
+    }
+  }
+
+  @keyframes task-log-live {
+    0% {
+      box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.35);
+    }
+
+    70% {
+      box-shadow: 0 0 0 6px rgba(34, 197, 94, 0);
+    }
+
+    100% {
+      box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
+    }
+  }
+</style>

+ 75 - 7
ai-electron/frontend/src/case/views/data/importList.vue

@@ -7,11 +7,18 @@
             <span class="table-meta-chip">当前页 {{ currentPageFileCount }} 条</span>
             <span class="table-meta-chip is-selected">已选 {{ selectedRowKeysRef.length }} 条</span>
           </div>
-          <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 }})
+          <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>
+            <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 }})
+              </a-button>
+            </Popconfirm>
+          </div>
         </div>
       </template>
     </BasicTable>
@@ -33,10 +40,18 @@
       :file-infos="currentLogFileInfos"
       :initial-sheet-id="currentLogSheetId"
     />
+
+    <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">
-  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';
@@ -45,7 +60,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';
@@ -54,7 +69,13 @@
   import { FileStatusEnum } from '@/case/types/enum';
   import componentSetting from '@/core/settings/componentSetting';
   import CleanLogModal from './components/CleanLogModal.vue';
-  import { resetImportProgressSession, setCleanFlowSource } from './importProgressSession';
+  import TaskLogDrawer from './components/TaskLogDrawer.vue';
+  import {
+    getTaskLogSnapshot,
+    resetImportProgressSession,
+    setCleanFlowSource,
+    type TaskLogSnapshot,
+  } from './importProgressSession';
 
   const props = defineProps({
     treeCodes: Array as PropType<string[]>,
@@ -68,6 +89,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';
@@ -80,6 +102,17 @@
   const logModalOpen = ref(false);
   const currentLogFileInfos = ref<FileInfo[]>([]);
   const currentLogSheetId = ref<string>('');
+  /** 最近一次清洗任务日志(进度页在任务结束时落盘,结果页只读) */
+  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);
@@ -369,6 +402,7 @@
 
   const handleImportListRefresh = () => {
     hasLoadedOnActivate.value = true;
+    refreshTaskLogSnapshot();
     syncTableLayout();
     void reload();
   };
@@ -377,6 +411,7 @@
     () => props.active,
     (active) => {
       if (!active) return;
+      refreshTaskLogSnapshot();
       if (!hasLoadedOnActivate.value) {
         handleImportListRefresh();
         return;
@@ -388,6 +423,7 @@
   );
 
   onMounted(() => {
+    refreshTaskLogSnapshot();
     emitter.on(CASE_DATA_REFRESH_EVENT, handleImportListRefresh);
     emitter.on(CASE_IMPORT_FILE_REFRESH_EVENT, handleImportListRefresh);
     // 整页刷新直达 ?tab=files 时组件创建即为 active:watch 捕获不到「变化」,
@@ -601,6 +637,38 @@
       }
     }
 
+    .table-toolbar__actions {
+      display: flex;
+      align-items: center;
+      gap: 10px;
+      margin-left: auto;
+    }
+
+    .task-log-btn {
+      display: inline-flex;
+      align-items: center;
+      gap: 6px;
+      height: 34px;
+      padding: 0 14px;
+      border-radius: 8px;
+      font-weight: 500;
+    }
+
+    .task-log-btn__count {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      min-width: 18px;
+      height: 18px;
+      padding: 0 5px;
+      border-radius: 999px;
+      background: #eef4ff;
+      color: #315efb;
+      font-size: 11px;
+      font-weight: 600;
+      line-height: 18px;
+    }
+
     .batch-delete-btn {
       height: 34px;
       padding: 0 14px;

+ 86 - 0
ai-electron/frontend/src/case/views/data/importProgressSession.ts

@@ -143,3 +143,89 @@ export const getCleanFlowSource = (): CleanFlowSource | null => {
   const value = getStorage()?.getItem(CLEAN_FLOW_SOURCE_STORAGE_KEY);
   return value === 'smart' || value === 'manual' ? value : null;
 };
+
+// ==================== 清洗 / 治理任务日志 ====================
+
+/**
+ * 清洗/治理任务日志条目。
+ *
+ * <p>直接保存后端 SSE 的原始文案(保留 {@code [数据清洗]} / {@code [数据治理]} 前缀),
+ * 不做级别判断或阶段归类。进度页实时展示,同时落一份 localStorage 快照;
+ * 任务结束后结果页从快照里读出来,仍能查看完整日志。
+ */
+export interface TaskLogItem {
+  id: string;
+  /** HH:mm:ss */
+  time: string;
+  /** 后端原始文案,含 [数据清洗] / [数据治理] 前缀 */
+  text: string;
+}
+
+export type TaskLogStatus = 'running' | 'completed' | 'failed';
+
+export interface TaskLogSnapshot {
+  caseId: string;
+  batchId: string;
+  status: TaskLogStatus;
+  logs: TaskLogItem[];
+  /** 完成态统计:刷新完成页时不必依赖已清掉的进度快照 */
+  successCount?: number;
+  failCount?: number;
+  updatedAt: number;
+}
+
+const TASK_LOG_STORAGE_KEY = 'case_clean_task_log';
+const TASK_LOG_MAX_ITEMS = 200;
+/** 只保留最近几个案件的快照,避免 localStorage 里的历史案件无限增长 */
+const TASK_LOG_MAX_CASES = 5;
+
+const getTaskLogStorage = () => (typeof window === 'undefined' ? null : window.localStorage);
+
+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)));
+};
+
+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;
+};
+
+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 - 2
ai-electron/frontend/src/case/views/dm/components/QueryTableModal.vue

@@ -575,7 +575,7 @@
     padding: 14px 16px;
     border: 1px solid #e2e8f0;
     border-radius: 6px;
-    background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
+    background: #fff;
     box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
     flex: 0 0 auto;
     display: flex;
@@ -763,7 +763,7 @@
     display: flex;
     align-items: center;
     justify-content: center;
-    background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
+    background: #fff;
   }
 
   .pagination-bar {

+ 9 - 0
ai-server/src/main/java/com/zsjz/ai/common/utils/SlowDownProgress.java

@@ -15,6 +15,15 @@ public class SlowDownProgress {
         stepCount.set(currentProgress);
     }
 
+    /**
+     * 当前进度(不推进)。
+     *
+     * <p>纯日志消息复用它,避免为了推一条"开始某阶段"的日志把进度条提前推到 99%。
+     */
+    public int current() {
+        return stepCount.get();
+    }
+
     /**
      * 计算下一次进度(越接近100%越慢)
      *

+ 26 - 1
ai-server/src/main/java/com/zsjz/ai/module/govern/serivce/GovernService.java

@@ -124,6 +124,19 @@ public class GovernService {
         public void sendProgress(String message) {
             sseService.sendSee(userId, new SseDTO(102, slowDownProgress.calculateNextProgress(), message, new SeeDataDTO(0, 0)));
         }
+
+        /**
+         * 只推一条治理日志,不推进进度。
+         *
+         * <p>阶段开始这类消息没有对应的工作量,若复用 {@link #sendProgress} 会虚促进度,
+         * 让用户误以为已经快结束;这里固定复用当前进度,前端只把它写进日志面板。
+         */
+        public void log(String message) {
+            // 日志消息绝不能带 100:前端把「治理 100%」视为完成态,会立刻关掉 SSE。
+            // 任务起始进度可能已经是 100(清洗刚结束),这里统一压到 99 以下。
+            int current = Math.min(99, slowDownProgress.current());
+            sseService.sendSee(userId, new SseDTO(102, current, message, new SeeDataDTO(0, 0)));
+        }
     }
 
     /**
@@ -175,25 +188,32 @@ public class GovernService {
         // ScopedValue 作用域结束绑定自动消失,无需手动清理。
         CaseContextHolder.runWith(caseId, userId, () -> {
             try {
+                GovernTaskContext context = new GovernTaskContext(sseService, new SlowDownProgress(taskProcess), caseId, userId);
+                context.log("[数据治理]治理任务已启动,正在准备执行");
+
                 // 只清本案件的治理配置缓存条目(key 已按案件拼接),绝不全表 clear——
                 // 那会把其他用户治理任务正在使用的条目也清掉
                 CaseDataCache.cleanGovernConfig(caseId);
                 log.info("【数据治理】清理缓存成功");
-                GovernTaskContext context = new GovernTaskContext(sseService, new SlowDownProgress(taskProcess), caseId, userId);
+                context.log("[数据治理]治理配置缓存已清理");
 
                 log.info("【数据治理】开始第一阶段任务:数据清洗和去重");
+                context.log("[数据治理]开始第一阶段:通话/交易去重与关系清理");
                 executeFirstStageTasks(context);
                 // 执行开户信息相关任务(如果启用)
                 if (governConfService.useOpenInfo()) {
                     log.info("【数据治理】开始开户信息任务:开始执行开户信息任务");
+                    context.log("[数据治理]开始开户信息关联任务");
                     executeOpenInfoTasks(context);
                 }
                 //第二阶段任务:计算树形模板数据量
                 log.info("【数据治理】第二阶段任务:创建时序图/计算分类树");
+                context.log("[数据治理]开始第二阶段:时序数据与分类树统计");
                 executeSecondStageTasks(context);
 
                 // 第三阶段任务:关系边计算
                 log.info("【数据治理】开始第三阶段任务:关系边计算");
+                context.log("[数据治理]开始第三阶段:关系边计算");
                 executeThreeStageTasks(context);
 
                 // 完成数据治理任务
@@ -371,12 +391,16 @@ public class GovernService {
      */
     private void executeTreeCalculationTasks(GovernTaskContext context) {
         log.info("【数据治理】开始执行分类树统计任务...");
+        context.log("[数据治理]分类树基础节点统计中");
         this.calcTreeTemplateDataCnt();
         log.info("【数据治理】分类树统计完成;开始执行人员树统计");
+        context.log("[数据治理]分类树基础节点完成,开始人员树统计");
         this.calcTreePersonData();
         log.info("【数据治理】人员树统计完成,开始执行开户信息树数据统计");
+        context.log("[数据治理]人员树统计完成,开始开户信息树统计");
         this.calcTreeCallUseOpenInfoData();
         // 既有三类节点写完之后再补 AI 节点:前面 truncate 过表,早写会被清掉
+        context.log("[数据治理]开始统计 AI 动态表治理节点");
         aiGovernService.calcAiTree();
         context.sendProgress("[数据治理]分类树统计完成!");
     }
@@ -403,6 +427,7 @@ public class GovernService {
                         // 这段直接提交到治理执行器,没走 createTaskWithTimeout 的绑定;
                         // mergeRelEdge 的 INSERT 走 @DS("slave"),线程上不绑上下文会报「请先打开案件后再操作数据」
                         CaseContextHolder.runWith(context.caseId(), context.userId(), () -> {
+                            context.log("[数据治理]开始合并关系边");
                             mergeRelEdge(context);
                             //calcRelNode(context);
                         }), GOVERN_EXECUTOR)

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff