cc 3 veckor sedan
förälder
incheckning
4aa193f372

+ 89 - 0
.workbuddy/memory/2026-09-15.md

@@ -518,3 +518,92 @@ PG `192.168.0.109:5432` 与 Redis 都可达,于是把 ai-server 真跑起来
 - 下次要跑:从仓库根 `mvn -pl ai-server spring-boot:run -Dspring-boot.run.workingDirectory=E:/workspace/zsjz-ai`,
   启动后**必须重新 `POST /js/a/case/open`**(JSON body)注册 slave 数据源。
 
+---
+
+## 移除「聊天历史」菜单(2026-09-16 09:45,用户要求)
+
+用户要求去掉独立的「聊天历史」菜单。共改 4 处:
+1. `core/store/modules/menu.json`:删掉 AI 分组(`/10263`)下的 `/aiChatHistory/index` 子项
+   (分组 redirect 仍是 `/aiAnalysis/index`,未受影响)
+2. `core/router/helper/routeHelper.ts`:删掉 `'/aiChatHistory/index'` 的组件映射
+   (**AI 模块的视图只走这张映射表,不走通用 glob**,删了就不会再有引用)
+3. **删除** `ai/views/aiChatHistory/index.vue`(该文件已被 git 跟踪,已 git rm)
+4. `ai/views/aiAnalysis/index.vue`:删掉因此失效的 `openFromQuery()`(读 `?sessionId=` 定位会话)
+   以及随之无用的 `useRoute` 导入与 `route` 变量
+
+**验证**:`menu.json` JSON 合法且 AI 菜单变为 `['AI 数据分析','模型管理']`;
+全仓 `grep aiChatHistory|聊天历史` **0 命中**;`i-mdi:history` 无残留引用;
+`src/ai/**` 类型检查与 ESLint **0 错误**;改动的 3 个文件均不在 `src/core` 既有类型报错列表中。
+
+**注意**:`pnpm type:check` 全量跑会报出 **`src/core/**` 的既有错误**(`getToken`/`getUserInfo`/
+`getSessionTimeout` 等在用户体系移除后失配,约 10+ 处),这是**历史遗留、非本次引入**;
+检查时用 `grep "^src/ai/"` 过滤即可。
+
+---
+
+## ★★★ 案件数据上传:Electron 本地路径 → 纯 Web multipart 上传(2026-09-16)
+
+用户要求:移除 Electron 相关内容,改纯 Web 上传(多选文件 + 文件夹,xls/xlsx/csv,带进度),
+后端改**单文件**上传并解析,解析完查结果**复用原结果页**,后续流程与参数保持不变。
+
+### 改造前的关键事实(决定了方案)
+- 原链路前端只把**本地绝对路径数组**发给 `POST /dm/preFile`(`{filePath: string[], isDir}`),
+  后端拿路径读**服务端/客户端本机文件** → Web 化必须真正上传字节。
+- **`filePath` 在清洗阶段会被再次读取**(`CleanTask:87` 打开文件、`DmService.hasSourceFile` 过滤)
+  → 上传文件**必须落盘持久化**,且 `filePath` 要回填为服务端可读的绝对 Path。
+- 清洗完成后会**递归删除整个 `QingJian/tmp`** → 上传文件**绝不能放 tmp 下**,
+  否则「重新清洗」会失效。落盘目录:`QingJian/workspace/{caseId}/upload/{batchId}/`。
+- `PreTask`/`PreDataListener` 本来就接收 `Path` 入参 → **落盘成 Path 即可整套复用解析逻辑**。
+- ⚠️ `DmController` **所有 POST 端点都缺 `@RequestBody`**(Solon→Spring 迁移遗留)
+  → 现有 `preFile` 本来就是坏的,必须先补。
+
+### 后端改动(ai-server)
+| 文件 | 改动 |
+| --- | --- |
+| `DmController.java` | 给 preFile/doClean/getFiles/deletedFiles/saveGovernCon 补 `@RequestBody`;**新增 `POST /dm/preFileUpload`**(单文件 MultipartFile) |
+| `common/model/dm/dto/DeletedFilesDTO.java` | **新增**:`deletedFiles` 前端发 `{ids:[]}`,原签名是简单参数绑定不上 |
+| `DmService.java` | 新增上传落盘 + 调 PreTask 解析的方法;后缀白名单 xls/xlsx/csv;`filePath` 回填服务端绝对路径、`fileSize` 回填真实大小 |
+| `StateManager.java` | 新增 `isCaseOpened()`(避免在业务代码里硬编码哨兵值 `getCaseId()==888888`) |
+
+**注意**:`getCaseId()` 返回 `Integer`,**未开案时是哨兵值 888888 而非 null**。
+
+### 前端改动(ai-frontend)
+| 文件 | 改动 |
+| --- | --- |
+| `case/api/govern/governApi.ts` | 新增 `preFileUpload(file, onProgress, data)`,走 `defHttp.uploadFile`(底层 axios,可传 `onUploadProgress`) |
+| `case/views/data/index.vue` | 上传弹窗整体重写(见下) |
+
+`index.vue` 具体改法:
+- **删掉全部 Electron 依赖**:`extractUploadFilePath` / `extractUploadFolderPath`(读 `window.electron.getPathForFile`)、
+  `handleSelectFile`(`ipcRenderer.invoke` 选文件夹)、`beforeUpload` / `beforeUploadFolder`、
+  `processUploadFilePaths` / `queueUploadFilePaths` / `normalizeUploadPaths` / `pendingUploadPaths` / `uploadBatchTimer` /
+  `selecting` / `uploadProcessing`。
+- 把 `Upload.Dragger` 换成**原生拖拽区**(`@dragover/@dragleave/@drop`)+ 两个隐藏 input
+  (`[multiple][accept=.xls,.xlsx,.csv]` 与 `[webkitdirectory]`)。
+- 文件夹:选择走 `webkitdirectory`;**拖拽走 `webkitGetAsEntry()` 递归遍历** `createReader().readEntries()`
+  (注意 readEntries 每次只返回最多 100 条,要循环读到空为止)。
+- 后缀校验 `isValidUploadFile`,不合规的过滤掉并提示条数。
+- 进度:`uploadProgressMap`(单文件 %)+ `overallProgress`(按 (各文件进度和)/总数 计算)+
+  `uploadStatuses`(pending/uploading/done/error)。
+- **逐文件串行调用** `preFileUpload`,累加返回的 `FileInfo[]`,全部结束后
+  `sessionStorage.setItem('case_clean_file_infos', ...)` → `emitter.emit(CASE_IMPORT_FILE_REFRESH_EVENT)`
+  → `go(PageEnum.BASE_MATCH)` —— **下游(结果页/匹配页)完全没动**。
+
+### 验证结果
+- 后端 `mvn compile` + `mvn package -DskipTests` **均 BUILD SUCCESS**
+- 前端 `pnpm type:check`:`index.vue` / `governApi.ts` **无新增错误**
+  (残留 3 处是既有:`CheckedType`×2、`Type instantiation`×1)
+- 前端 ESLint(`--max-warnings 0`)**0 错误**(prettier/vue 格式问题已用 `--fix` 修掉)
+- 测试进程已全部清理(端口 8980 释放,无 java / 无头 Chrome 残留)
+
+### 本次踩到的环境坑(复用价值高)
+1. **Git Bash 后台跑 `spring-boot:run` 会随 shell 退出被杀**:日志显示已到
+   `Tomcat started on port 8980` + `Started App` + `ACCEPTING_TRAFFIC`,但 `netstat` 查不到、
+   进程也没了。**改用 `mvn package -DskipTests` 打 jar 后 `java -jar` 启动**即可正常监听
+   (但同样要避免后台被杀,测试完及时清理)。
+2. `mvn` 在本机 Git Bash 下直接用会报
+   `找不到或无法加载主类 org.codehaus.plexus.classworlds.launcher.Launcher`
+   → 必须用 `bash /c/Users/cc/AppData/Local/Temp/cptest/mvn-run.sh`(它显式传 classworlds jar 与 `-Dmaven.home`)。
+3. `pip install openpyxl` 装到了系统 Python,但 `python` 命令走的是另一个解释器 →
+   用 **`python -m pip install`** 才装到当前 `python` 上。
+

+ 0 - 13
ai-frontend/src/ai/views/aiAnalysis/index.vue

@@ -109,7 +109,6 @@
    */
   import { computed, nextTick, onMounted, ref, watch } from 'vue';
   import { Button, Tooltip, Modal } from 'ant-design-vue';
-  import { useRoute } from 'vue-router';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import { useUserStore } from '@/core/store/modules/case';
   import { useMessage } from '@/core/hooks/web/useMessage';
@@ -125,7 +124,6 @@
 
   const store = useChatStreamStore();
   const userStore = useUserStore();
-  const route = useRoute();
   const { showMessage } = useMessage();
 
   const scrollRef = ref<HTMLElement>();
@@ -159,21 +157,10 @@
   onMounted(async () => {
     if (workspaceId.value) {
       await store.init(workspaceId.value);
-      await openFromQuery();
     }
     await loadModels();
   });
 
-  /** 从聊天历史页跳转过来时,按 ?sessionId= 定位会话 */
-  async function openFromQuery() {
-    const raw = route.query.sessionId;
-    const id = Number(Array.isArray(raw) ? raw[0] : raw);
-    if (!id || Number.isNaN(id)) return;
-    if (store.sessions.some((s) => s.id === id)) {
-      await onSelect(id);
-    }
-  }
-
   // 切换案件时重置会话上下文
   watch(workspaceId, async (id, old) => {
     if (id === old) return;

+ 0 - 358
ai-frontend/src/ai/views/aiChatHistory/index.vue

@@ -1,358 +0,0 @@
-<template>
-  <div class="ai-page ai-history">
-    <header class="ai-topbar">
-      <div class="ai-topbar__title">
-        聊天历史
-        <span class="ai-topbar__meta">{{ summary }}</span>
-      </div>
-      <span class="ai-topbar__spacer"></span>
-      <div class="ai-topbar__actions">
-        <a-input v-model:value="keyword" class="ai-history__search" size="small" allow-clear placeholder="按标题搜索">
-          <template #prefix><Icon icon="mdi:magnify" :size="14" /></template>
-        </a-input>
-        <a-button size="small" :loading="loading" @click="load">
-          <template #icon><Icon icon="mdi:refresh" :size="14" /></template>
-          刷新
-        </a-button>
-      </div>
-    </header>
-
-    <div class="ai-scroll ai-history__body">
-      <div class="ai-history__inner">
-        <a-table
-          class="ai-history__table"
-          size="middle"
-          :columns="columns"
-          :data-source="filtered"
-          :loading="loading"
-          :pagination="pagination"
-          row-key="id"
-          :row-class-name="() => 'ai-history__row'"
-          @change="onTableChange"
-        >
-          <template #bodyCell="{ column, record }">
-            <template v-if="column.key === 'title'">
-              <div class="ai-history__title">
-                <span v-if="record.pinned" class="ai-history__pin">置顶</span>
-                <a class="ai-history__title-text" @click="openPreview(record)">{{ record.title || '新对话' }}</a>
-              </div>
-            </template>
-
-            <template v-else-if="column.key === 'messageCount'">
-              <span class="ai-history__mono">{{ record.messageCount ?? 0 }} 条</span>
-            </template>
-
-            <template v-else-if="column.key === 'lastMessageAt'">
-              <span class="ai-history__mono">{{ record.lastMessageAt || record.updateAt || '—' }}</span>
-            </template>
-
-            <template v-else-if="column.key === 'createAt'">
-              <span class="ai-history__mono">{{ record.createAt || '—' }}</span>
-            </template>
-
-            <template v-else-if="column.key === 'action'">
-              <div class="ai-history__ops">
-                <a-button type="link" size="small" @click="openChat(record)">继续对话</a-button>
-                <a-button type="link" size="small" @click="openPreview(record)">预览</a-button>
-                <a-button type="link" size="small" @click="onRename(record)">重命名</a-button>
-                <a-button type="link" size="small" @click="onTogglePin(record)">
-                  {{ record.pinned ? '取消置顶' : '置顶' }}
-                </a-button>
-                <a-button type="link" size="small" danger @click="onDelete(record)">删除</a-button>
-              </div>
-            </template>
-          </template>
-
-          <template #emptyText>
-            <div class="ai-empty-tip">暂无聊天记录。前往「AI 数据分析」开始第一次提问。</div>
-          </template>
-        </a-table>
-      </div>
-    </div>
-
-    <!-- 会话预览抽屉:完整还原消息渲染(表格/图表/图谱) -->
-    <a-drawer
-      v-model:open="previewOpen"
-      :width="860"
-      placement="right"
-      :title="previewSession?.title || '会话预览'"
-      :body-style="{ padding: '0 16px 24px', background: 'var(--ai-bg-page)' }"
-    >
-      <template #extra>
-        <a-button size="small" :disabled="!previewMessages.length" @click="exportPreview"> 导出 Markdown </a-button>
-        <a-button size="small" type="primary" @click="previewSession && openChat(previewSession)">继续对话</a-button>
-      </template>
-
-      <div v-if="previewLoading" class="ai-history__preview-loading">
-        <div v-for="n in 4" :key="n" class="ai-skeleton" :style="{ height: '16px', marginBottom: '10px' }"></div>
-      </div>
-      <div v-else-if="!previewMessages.length" class="ai-empty-tip">该会话暂无消息</div>
-      <div v-else class="ai-history__preview">
-        <ChatMessageItem
-          v-for="msg in previewMessages"
-          :key="msg.id"
-          :message="msg"
-          :title="previewSession?.title || ''"
-          @star="() => {}"
-          @remove="() => {}"
-          @regenerate="() => {}"
-          @pick="() => {}"
-        />
-      </div>
-    </a-drawer>
-  </div>
-</template>
-
-<script lang="ts" setup name="AiChatHistoryIndex">
-  /**
-   * 聊天历史页
-   *
-   * 以表格列出当前案件下的全部会话,支持搜索、重命名、置顶、删除、导出,
-   * 并通过抽屉复用聊天页的消息渲染组件做完整预览(含结果表/图表/图谱)。
-   */
-  import { computed, h, onMounted, ref, watch } from 'vue';
-  import { Button, Drawer, Input, Modal, Table } from 'ant-design-vue';
-  import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue';
-  import { useRouter } from 'vue-router';
-  import Icon from '@/core/components/Icon/src/Icon.vue';
-  import { useUserStore } from '@/core/store/modules/case';
-  import { useMessage } from '@/core/hooks/web/useMessage';
-  import * as chatApi from '../../api/chatApi';
-  import type { ChatSession } from '../../api/types';
-  import { useChatStreamStore, type ChatMessage } from '../../store/chatStream';
-  import { parseToolEvents, parseMessageBlocksIncremental } from '../../utils/parseMessageContent';
-  import { exportConversationMarkdown } from '../../utils/reportExport';
-  import ChatMessageItem from '../../components/ChatMessageItem.vue';
-
-  const AButton = Button;
-  const AInput = Input;
-  const ATable = Table;
-  const ADrawer = Drawer;
-
-  const router = useRouter();
-  const store = useChatStreamStore();
-  const userStore = useUserStore();
-  const { showMessage } = useMessage();
-
-  const keyword = ref('');
-  const loading = ref(false);
-  const page = ref(1);
-  const pageSize = ref(20);
-
-  const previewOpen = ref(false);
-  const previewSession = ref<ChatSession | null>(null);
-  const previewMessages = ref<ChatMessage[]>([]);
-  const previewLoading = ref(false);
-
-  const workspaceId = computed(() => Number(userStore.getCaseInfo?.id || 0));
-
-  const columns: TableColumnsType = [
-    { title: '会话标题', key: 'title', ellipsis: true },
-    { title: '消息数', key: 'messageCount', width: 100 },
-    { title: '最后消息', key: 'lastMessageAt', width: 180 },
-    { title: '创建时间', key: 'createAt', width: 180 },
-    { title: '操作', key: 'action', width: 300, fixed: 'right' },
-  ];
-
-  const filtered = computed(() => {
-    const kw = keyword.value.trim().toLowerCase();
-    const list = store.sessions || [];
-    if (!kw) return list;
-    return list.filter((s) => (s.title || '').toLowerCase().includes(kw));
-  });
-
-  const pagination = computed<TablePaginationConfig>(() => ({
-    current: page.value,
-    pageSize: pageSize.value,
-    total: filtered.value.length,
-    showSizeChanger: true,
-    pageSizeOptions: ['10', '20', '50'],
-    showTotal: (total: number) => `共 ${total} 个会话`,
-    size: 'small',
-  }));
-
-  const summary = computed(() => {
-    const total = store.sessions.length;
-    if (!total) return '当前案件暂无会话';
-    const pinned = store.sessions.filter((s) => s.pinned).length;
-    return `${total} 个会话${pinned ? ` · ${pinned} 个置顶` : ''}`;
-  });
-
-  onMounted(async () => {
-    if (!workspaceId.value) return;
-    await store.init(workspaceId.value);
-  });
-
-  watch(workspaceId, async (id) => {
-    if (!id) return;
-    await store.init(id, true);
-  });
-
-  async function load() {
-    loading.value = true;
-    try {
-      await store.loadSessions();
-    } catch (e: any) {
-      showMessage(e?.message || '加载失败', 'error');
-    } finally {
-      loading.value = false;
-    }
-  }
-
-  function onTableChange(pager: TablePaginationConfig) {
-    page.value = pager.current || 1;
-    pageSize.value = pager.pageSize || 20;
-  }
-
-  /** 打开聊天页并定位到指定会话(a-table 插槽 record 为宽松类型,故此处放宽入参) */
-  function openChat(session: any) {
-    previewOpen.value = false;
-    router.push({ path: '/aiAnalysis/index', query: { sessionId: String(session.id) } });
-  }
-
-  async function openPreview(session: any) {
-    previewSession.value = session as ChatSession;
-    previewOpen.value = true;
-    previewLoading.value = true;
-    previewMessages.value = [];
-    try {
-      const list = await chatApi.listMessages(session.id);
-      previewMessages.value = (list || []).map((m) => ({
-        ...m,
-        toolSteps: parseToolEvents(m.toolEvents),
-        blocks: parseMessageBlocksIncremental(undefined, m.content || '').blocks,
-      }));
-    } catch (e: any) {
-      showMessage(e?.message || '加载会话消息失败', 'error');
-    } finally {
-      previewLoading.value = false;
-    }
-  }
-
-  function exportPreview() {
-    if (!previewMessages.value.length) return;
-    const name = exportConversationMarkdown(previewMessages.value, previewSession.value?.title || 'AI 分析');
-    showMessage(`已导出 ${name}`, 'success');
-  }
-
-  function onRename(session: any) {
-    let draft = session.title || '';
-    Modal.confirm({
-      title: '重命名会话',
-      content: () =>
-        h(Input, {
-          value: draft,
-          'onUpdate:value': (v: string) => (draft = v),
-          placeholder: '输入新的会话标题',
-          maxlength: 100,
-        }),
-      okText: '保存',
-      cancelText: '取消',
-      onOk: async () => {
-        const title = (draft || '').trim();
-        if (!title) return;
-        await store.renameSession(session.id, title);
-        showMessage('已保存', 'success');
-      },
-    });
-  }
-
-  async function onTogglePin(session: any) {
-    try {
-      await store.togglePin(session.id);
-    } catch (e: any) {
-      showMessage(e?.message || '操作失败', 'error');
-    }
-  }
-
-  function onDelete(session: any) {
-    Modal.confirm({
-      title: '删除会话',
-      content: `确认删除「${session.title || '新对话'}」?消息记录将一并删除且不可恢复。`,
-      okText: '删除',
-      okType: 'danger',
-      cancelText: '取消',
-      onOk: async () => {
-        await store.removeSession(session.id);
-        showMessage('已删除', 'success');
-      },
-    });
-  }
-</script>
-
-<style lang="less" scoped>
-  .ai-history {
-    &__search {
-      width: 200px;
-    }
-
-    &__body {
-      flex: 1 1 auto;
-      min-height: 0;
-      overflow-y: auto;
-    }
-
-    &__inner {
-      max-width: 1280px;
-      margin: 0 auto;
-      padding: var(--ai-sp-4) var(--ai-sp-5) var(--ai-sp-5);
-    }
-
-    &__table {
-      background: var(--ai-bg);
-    }
-
-    &__title {
-      display: flex;
-      align-items: center;
-      gap: var(--ai-sp-2);
-      min-width: 0;
-    }
-
-    &__pin {
-      flex: 0 0 auto;
-      padding: 0 6px;
-      font-size: 11px;
-      line-height: 17px;
-      color: var(--ai-primary);
-      background: var(--ai-primary-soft);
-      border-radius: var(--ai-radius-sm);
-    }
-
-    &__title-text {
-      font-size: 13.5px;
-      color: var(--ai-text);
-      overflow: hidden;
-      white-space: nowrap;
-      text-overflow: ellipsis;
-
-      &:hover {
-        color: var(--ai-primary);
-      }
-    }
-
-    &__mono {
-      font-family: 'SFMono-Regular', Consolas, monospace;
-      font-size: 12px;
-      color: var(--ai-text-2);
-    }
-
-    &__ops {
-      display: flex;
-      align-items: center;
-      gap: var(--ai-sp-1);
-      flex-wrap: wrap;
-    }
-
-    &__preview-loading {
-      padding: var(--ai-sp-4) 0;
-    }
-
-    &__preview {
-      background: var(--ai-bg);
-      border: 1px solid var(--ai-border);
-      border-radius: var(--ai-radius);
-      padding: 0 var(--ai-sp-4);
-    }
-  }
-</style>

+ 27 - 1
ai-frontend/src/case/api/govern/governApi.ts

@@ -154,11 +154,37 @@ export const getTreeCallOperUseOpenInfoPage = (data: GovernTreeQuery) =>
 export const doClean = (data: DoCleanDTO) => defHttp.postJson<Recordable>({ url: adminPath + '/dm/doClean', data });
 
 /**
- * 文件预处理
+ * 文件预处理(Electron 本地路径方式,保留兼容)
  */
 export const preFile = (data: PrepareProcessFileDTO) =>
   defHttp.postJson<FileInfo[]>({ url: adminPath + '/dm/preFile', data });
 
+/**
+ * 单文件上传并解析(Web 端 multipart 方式)
+ */
+export const preFileUpload = (
+  file: File,
+  onProgress?: (percent: number) => void,
+  data?: { batchId?: string | number; reClean?: number },
+) => {
+  return defHttp.uploadFile<FileInfo[]>(
+    {
+      url: adminPath + '/dm/preFileUpload',
+      onUploadProgress: (progressEvent: any) => {
+        if (!onProgress || !progressEvent.total) return;
+        const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
+        onProgress(percent);
+      },
+    },
+    {
+      name: 'file',
+      file,
+      filename: file.name,
+      data: data as any,
+    },
+  );
+};
+
 /**
  * 取消清洗任务
  */

+ 396 - 159
ai-frontend/src/case/views/data/index.vue

@@ -155,7 +155,7 @@
             <button
               type="button"
               :class="['upload-mode-switch__item', { 'is-active': uploadMode === 'single' }]"
-              :disabled="uploadProcessing"
+              :disabled="isUploading"
               @click="handleUploadModeChange('single')"
             >
               选择文件
@@ -163,46 +163,99 @@
             <button
               type="button"
               :class="['upload-mode-switch__item', { 'is-active': uploadMode === 'folder' }]"
-              :disabled="uploadProcessing"
+              :disabled="isUploading"
               @click="handleUploadModeChange('folder')"
             >
               选择文件夹
             </button>
           </div>
-          <!-- <div class="upload-modal-title">{{ uploadMode === 'single' ? '选择文件' : '选择文件夹' }}</div> -->
-          <!-- <div class="upload-modal-subtitle">支持拖拽文件到这里,或点击选择文件或选择文件夹,系统会自动识别处理</div> -->
         </div>
-        <div class="upload-dropzone">
-          <Spin :spinning="uploadProcessing" tip="解析中">
-            <Upload.Dragger
-              :directory="uploadMode === 'folder'"
-              :multiple="true"
-              :disabled="uploadProcessing"
-              :before-upload="uploadMode === 'folder' ? beforeUploadFolder : beforeUpload"
-              :show-upload-list="false"
-            >
-              <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' ? '支持批量选择文件' : '支持选择整个文件夹,系统会自动读取文件' }}
-                </div>
-                <Button
-                  type="primary"
-                  class="upload-action-btn"
-                  :loading="selecting"
-                  :disabled="uploadProcessing"
-                  @click="handleSelectFile"
-                >
-                  {{ uploadMode === 'single' ? '选择文件' : '选择文件夹' }}
-                </Button>
+
+        <!-- 隐藏的文件输入 -->
+        <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>
-            </Upload.Dragger>
-          </Spin>
+            </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>
@@ -675,6 +728,7 @@
   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 {
@@ -702,6 +756,7 @@
     getTreeCallPersonPage,
     getTreeCallOperPage,
     preFile,
+    preFileUpload,
     saveGovernConf,
   } from '@/case/api/govern/governApi';
   import type { GovernConfItem } from '@/case/api/govern/governApi';
@@ -814,8 +869,18 @@
   const uploadMode = ref<'single' | 'folder'>('single');
   const uploadBizKey = ref(buildUUID());
   const uploadLoadTime = ref(Date.now());
-  const pendingUploadPaths = ref<string[]>([]);
-  let uploadBatchTimer: ReturnType<typeof setTimeout> | null = null;
+
+  // 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();
@@ -865,6 +930,7 @@
     uploadMode.value = mode;
     uploadBizKey.value = buildUUID();
     uploadLoadTime.value = Date.now();
+    clearSelectedFiles();
   };
 
   const handleUploadChange = () => {
@@ -2416,128 +2482,188 @@
     await loadTemplateList();
   };
 
-  const selecting = ref(false);
-  const uploadProcessing = ref(false);
-  const PRE_FILE_EMPTY_MESSAGE = '未识别到可处理文件,可能是文件重复、所有文件不存在,或文件格式不正确';
+  const VALID_UPLOAD_EXTS = ['.xls', '.xlsx', '.csv'];
 
-  const normalizeUploadPaths = (filePaths: Array<string | undefined | null>) =>
-    filePaths.map((item) => String(item ?? '').trim()).filter((item) => !!item);
+  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 extractUploadFilePath = (file: File) => {
-    const electronApi = window.electron;
-    const resolvedPath = electronApi?.getPathForFile?.(file) || (file as File & { path?: string })?.path;
-    return normalizeUploadPaths([resolvedPath])[0];
+  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 splitUploadPath = (value: string) => value.replace(/\\/g, '/').split('/').filter(Boolean);
+  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 extractUploadFolderPath = (file: File) => {
-    const filePath = extractUploadFilePath(file);
-    if (!filePath) return '';
+  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 relativePath = String((file as File & { webkitRelativePath?: string }).webkitRelativePath || '').trim();
-    const relativeSegments = splitUploadPath(relativePath);
-    if (relativeSegments.length <= 1) return '';
+  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 fileSegments = splitUploadPath(filePath);
-    const rootSegmentCount = fileSegments.length - relativeSegments.length + 1;
-    if (rootSegmentCount <= 0) return '';
+  const handleTriggerFileSelect = () => {
+    if (uploadMode.value === 'folder') {
+      folderInputRef.value?.click();
+    } else {
+      fileInputRef.value?.click();
+    }
+  };
 
-    const separator = filePath.includes('\\') ? '\\' : '/';
-    return fileSegments.slice(0, rootSegmentCount).join(separator);
+  const clearSelectedFiles = () => {
+    selectedFiles.value = [];
+    uploadResults.value = [];
+    uploadProgressMap.value = new Map();
+    uploadStatuses.value = new Map();
+    overallProgress.value = 0;
   };
 
-  const processUploadFilePaths = async (filePaths: string[]) => {
-    const normalizedPaths = normalizeUploadPaths(filePaths);
-    if (!normalizedPaths.length || uploadProcessing.value) return;
+  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[] = [];
 
-    uploadProcessing.value = true;
-    selecting.value = true;
     try {
-      const isDir = uploadMode.value === 'folder';
-      const response = await preFile({ filePath: normalizedPaths, isDir });
-      const fileInfos = Array.isArray(response) ? response : ((response as any)?.data ?? []);
-      if (Array.isArray(fileInfos) && fileInfos.length) {
-        sessionStorage.setItem('case_clean_file_infos', JSON.stringify(fileInfos));
-        createMessage.success('文件预处理成功');
+      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(PRE_FILE_EMPTY_MESSAGE);
-      }
-    } catch (error) {
-      createMessage.error('文件预处理失败');
-    } finally {
-      uploadProcessing.value = false;
-      selecting.value = false;
-    }
-  };
-
-  const queueUploadFilePaths = (filePaths: string[]) => {
-    const normalizedPaths = normalizeUploadPaths(filePaths);
-    if (!normalizedPaths.length) return;
-    pendingUploadPaths.value.push(...normalizedPaths);
-    if (uploadBatchTimer) clearTimeout(uploadBatchTimer);
-    uploadBatchTimer = setTimeout(() => {
-      const batchPaths = Array.from(new Set(pendingUploadPaths.value));
-      pendingUploadPaths.value = [];
-      uploadBatchTimer = null;
-      void processUploadFilePaths(batchPaths);
-    }, 0);
-  };
-
-  const handleSelectFile = async (event: MouseEvent) => {
-    event.stopPropagation();
-    if (selecting.value || uploadProcessing.value) return;
-    selecting.value = true;
-    const isDir = uploadMode.value === 'folder';
-    const ipcRenderer = (window as any)?.electron?.ipcRenderer;
-    if (!ipcRenderer?.invoke) {
-      createMessage.error('无法获取文件路径,请在 Electron 环境下选择文件');
-      selecting.value = false;
-      return;
-    }
-    try {
-      const channel = isDir ? 'controller/os/selectFolder' : 'controller/effect/selectFile';
-      const resolvedPath = await ipcRenderer.invoke(channel);
-      const filePaths = Array.isArray(resolvedPath)
-        ? resolvedPath.filter((item: unknown): item is string => typeof item === 'string' && !!item.trim())
-        : typeof resolvedPath === 'string' && resolvedPath.trim()
-          ? [resolvedPath]
-          : [];
-      if (!filePaths.length) {
-        selecting.value = false;
-        return;
+        createMessage.warning('未识别到可处理文件,请检查文件格式是否正确');
       }
-      await processUploadFilePaths(filePaths);
     } catch (error) {
-      createMessage.error('文件预处理失败');
+      createMessage.error('上传解析过程中发生错误');
     } finally {
-      selecting.value = false;
+      isUploading.value = false;
     }
   };
 
-  const beforeUpload = async (file: File) => {
-    const filePath = extractUploadFilePath(file);
-    if (!filePath) {
-      createMessage.warning('当前拖拽文件未获取到绝对路径,请重新拖拽或点击选择文件');
-      return false;
-    }
-    queueUploadFilePaths([filePath]);
-    return false;
-  };
-
-  const beforeUploadFolder = async (file: File) => {
-    const folderPath = extractUploadFolderPath(file);
-    if (!folderPath) {
-      createMessage.warning('当前拖拽文件夹未获取到目录路径,请重新拖拽或点击选择文件夹');
-      return false;
-    }
-    queueUploadFilePaths([folderPath]);
-    return false;
-  };
-
   const treeData = ref<GovernTreeNodeOption[]>([]);
   const columns = ref<BasicColumn[]>([]);
   const selectedKeys = ref<string[]>([]);
@@ -3638,7 +3764,6 @@
   });
 
   onBeforeUnmount(() => {
-    if (uploadBatchTimer) clearTimeout(uploadBatchTimer);
     emitter.off(CASE_DATA_REFRESH_EVENT, handleCaseDataRefresh, true);
     emitter.off(HEADER_TEMPLATE_LIBRARY_OPEN_EVENT, handleTemplateOpen, true);
   });
@@ -5751,32 +5876,18 @@
 
     .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;
 
-      .ant-spin-nested-loading,
-      .ant-spin-container {
-        display: block;
-      }
-
-      .ant-upload-drag {
-        min-height: 148px;
-        border-radius: 14px;
-        border: 1px dashed #c7d2fe;
-        background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
-        transition: all 0.2s ease;
-      }
-
-      .ant-upload-drag:hover {
+      &:hover,
+      &.is-dragover {
         border-color: #3b82f6;
         box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
       }
-
-      .ant-upload.ant-upload-drag {
-        padding: 0;
-      }
-
-      .ant-upload-drag-container {
-        height: 100%;
-      }
     }
 
     .upload-dragger-content {
@@ -5821,6 +5932,132 @@
       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) {

+ 0 - 1
ai-frontend/src/core/router/helper/routeHelper.ts

@@ -58,7 +58,6 @@ const explicitDynamicViewMap: Record<string, () => Promise<Recordable>> = {
   // AI 数据分析应用(模块自持视图,不走通用 glob 匹配,避免与 node_modules 内同名 views 冲突)
   '/aiAnalysis/index': () => import('@/ai/views/aiAnalysis/index.vue'),
   '/aiModel/index': () => import('@/ai/views/aiModel/index.vue'),
-  '/aiChatHistory/index': () => import('@/ai/views/aiChatHistory/index.vue'),
 };
 
 function normalizeViewModuleKey(key: string) {

+ 0 - 16
ai-frontend/src/core/store/modules/menu.json

@@ -653,22 +653,6 @@
         "leaf": false,
         "url": "/aiModel/index",
         "target": ""
-      },
-      {
-        "path": "/aiChatHistory/index",
-        "component": "/aiChatHistory/index",
-        "children": [],
-        "meta": {
-          "icon": "i-mdi:history",
-          "hideMenu": false,
-          "color": "",
-          "title": "聊天历史"
-        },
-        "name": "ViewsAiChatHistoryIndex",
-        "id": "10266",
-        "leaf": false,
-        "url": "/aiChatHistory/index",
-        "target": ""
       }
     ]
   }

+ 19 - 0
ai-server/src/main/java/com/zsjz/ai/common/model/dm/dto/DeletedFilesDTO.java

@@ -0,0 +1,19 @@
+package com.zsjz.ai.common.model.dm.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 删除文件请求 DTO
+ *
+ * <p>前端 {@code case/api/govern/governApi.ts} 的 {@code deletedFileList} 以 JSON body
+ * 提交 {@code {ids: [...]}},因此后端必须用 {@code @RequestBody} 绑定到本 DTO;
+ * 若直接写 {@code List<Long>} 则与 body 形状不匹配(即使加了 @RequestBody 也绑不上)。
+ */
+@Data
+public class DeletedFilesDTO {
+
+    /** 待删除的文件/工作表 ID 列表(file_info.id) */
+    private List<Long> ids;
+}

+ 10 - 0
ai-server/src/main/java/com/zsjz/ai/common/utils/StateManager.java

@@ -51,6 +51,16 @@ public class StateManager {
         return PathConst.WORKSPACE.resolve(getCaseId().toString());
     }
 
+    /**
+     * 是否已打开案件。
+     *
+     * <p>注意 {@link #getCaseId()} 在未开案时返回哨兵值 {@code 888888}(不是 null),
+     * 因此判断"是否已开案"必须用本方法,不要拿 caseId 与 null 比较。
+     */
+    public boolean isCaseOpened() {
+        return caseInfo != null && caseInfo.getId() != null;
+    }
+
     /**
      * 清除当前案件信息
      * <p>重置 caseInfo 为 null</p>

+ 46 - 8
ai-server/src/main/java/com/zsjz/ai/module/dm/controller/DmController.java

@@ -1,5 +1,6 @@
 package com.zsjz.ai.module.dm.controller;
 
+import com.zsjz.ai.common.model.dm.dto.DeletedFilesDTO;
 import com.zsjz.ai.common.model.dm.dto.DoCleanDTO;
 import com.zsjz.ai.common.config.Result;
 import com.zsjz.ai.common.model.dm.dto.PrepareProcessFileDTO;
@@ -12,16 +13,26 @@ import com.zsjz.ai.module.dm.service.DmService;
 import com.zsjz.ai.module.govern.serivce.GovernConfService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RequestPart;
 import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
 
 import java.util.List;
 
 /**
  * 数据管理控制器
  * 提供数据预览、清洗等功能的 HTTP 接口
+ *
+ * <p><b>入参约定</b>:前端({@code case/api/govern/governApi.ts})所有 POST 均以
+ * {@code application/json} 提交,因此对象入参**必须**加 {@code @RequestBody}。
+ * 缺少该注解时 Spring 走 {@code @ModelAttribute} 只绑定 query/form 参数,
+ * JSON body 会被忽略(Solon → Spring Boot 迁移遗留问题)。
  */
 @RestController
 @RequestMapping("/dm")
@@ -42,10 +53,33 @@ public class DmController {
      * @return 文件信息列表,包含文件名、路径、行数等预览信息
      */
     @PostMapping("/preFile")
-    public Result<List<FileInfo>> preProcessFile(PrepareProcessFileDTO dto) {
+    public Result<List<FileInfo>> preProcessFile(@RequestBody PrepareProcessFileDTO dto) {
         return Result.succeed(dmService.preProcessFile(dto));
     }
 
+    /**
+     * 上传单个文件并解析(Web 端上传,替代原 Electron 传本地路径的方案)
+     *
+     * <p>与 {@link #preProcessFile} 的区别:本接口接收**真实文件字节**(multipart),
+     * 由服务端落盘到 {@code QingJian/workspace/{caseId}/upload/{batchId}/} 后再解析,
+     * 因此不依赖浏览器/客户端与服务端同机。
+     *
+     * <p>返回结构与 {@code /preFile} 完全一致({@code List<FileInfo>} 树:pid=0 为文件根,
+     * pid=fileId 为各 sheet),前端可沿用同一套后续流程(sessionStorage → /data/match → doClean)。
+     *
+     * @param file    单个文件(xls / xlsx / csv)
+     * @param batchId 批次ID;前端首次上传不传,后续文件回传首次响应里的 batchId,
+     *                以保证同批文件共用一个批次(进度页/结果页按 batchId 聚合)
+     * @param reClean 1=重新清洗(跳过 MD5 去重)
+     * @return 该文件的 FileInfo 树
+     */
+    @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) {
+        return Result.succeed(dmService.preProcessUploadedFile(file, batchId, reClean));
+    }
+
     /**
      * 执行数据清洗任务
      * 根据传入的文件信息和清洗配置,执行数据清洗和入库操作
@@ -53,7 +87,7 @@ public class DmController {
      * @param dto 数据清洗请求参数,包含文件信息列表、清洗规则等
      */
     @PostMapping("/doClean")
-    public Result<Void> doClean(DoCleanDTO dto) {
+    public Result<Void> doClean(@RequestBody DoCleanDTO dto) {
         log.info("开始执行数据清洗任务参数:{}", Json.toStr(dto));
         dmService.doClean(dto);
         return Result.succeed();
@@ -73,17 +107,21 @@ public class DmController {
 
     /**
      * 获取文件列表
-     * 根据传入的文件路径,返回文件内容
-     *
+     * 根据传入的查询条件,返回已导入文件列表
      */
     @PostMapping("/getFiles")
-    public Result<List<FileInfo>> getFiles(FileInfoQuery query) {
+    public Result<List<FileInfo>> getFiles(@RequestBody FileInfoQuery query) {
         return Result.succeed(dmService.getFiles(query));
     }
 
+    /**
+     * 删除文件(含其工作表记录、业务表数据与检索文档)
+     *
+     * @param dto 待删除 ID 列表(JSON body 形状为 {@code {"ids":[1,2]}})
+     */
     @PostMapping("/deletedFiles")
-    public Result<Void> deletedFiles(List<Long> ids) {
-        dmService.deletedFiles(ids);
+    public Result<Void> deletedFiles(@RequestBody DeletedFilesDTO dto) {
+        dmService.deletedFiles(dto == null ? null : dto.getIds());
         return Result.succeed();
     }
 
@@ -101,7 +139,7 @@ public class DmController {
     }
 
     @PostMapping("/saveGovernCon")
-    public Result<Void> saveOrUpdate(List<GovernConf> governConf) {
+    public Result<Void> saveOrUpdate(@RequestBody List<GovernConf> governConf) {
         governConfService.saveOrUpdateBatch(governConf);
         return Result.succeed();
     }

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

@@ -23,6 +23,7 @@ import com.zsjz.ai.common.model.plat.dto.SseDTO;
 import com.zsjz.ai.common.model.plat.entity.TableInfo;
 import com.zsjz.ai.common.utils.LuceneManager;
 import com.zsjz.ai.common.utils.SlowDownProgress;
+import com.zsjz.ai.common.utils.StateManager;
 import com.zsjz.ai.common.utils.TowerUtils;
 import com.zsjz.ai.module.dm.clean.CleanCache;
 import com.zsjz.ai.module.dm.clean.CleanTask;
@@ -37,11 +38,14 @@ import lombok.extern.slf4j.Slf4j;
 import org.apache.lucene.index.Term;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
 
 import java.io.File;
 import java.io.IOException;
+import java.io.InputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.util.*;
@@ -60,6 +64,8 @@ public class DmService {
     private static final int PREVIEW_MAX_CONCURRENCY = 20;
     private static final long PREVIEW_ACQUIRE_TIMEOUT_MINUTES = 10;
     private static final long PREVIEW_TOTAL_TIMEOUT_MINUTES = 40;
+    /** Web 上传文件的落盘目录名(位于 QingJian/workspace/{caseId}/ 下) */
+    private static final String UPLOAD_DIR_NAME = "upload";
 
     @Autowired
     private SseService sseService;
@@ -114,6 +120,104 @@ public class DmService {
         return List.of();
     }
 
+    /**
+     * 上传单个文件并解析(Web 端上传,替代原 Electron「传本地绝对路径」的方案)。
+     *
+     * <p>与 {@link #preProcessFile} 的区别:本方法接收**真实文件字节**,先落盘到
+     * {@code QingJian/workspace/{caseId}/upload/{batchId}/},再复用同一套 {@link PreTask} 解析链。
+     * 因此不要求浏览器与服务端同机。
+     *
+     * <p><b>为什么不能落到 {@code PathConst.TMP_PATH}</b>:清洗完成回调里会
+     * {@code FileUtil.del(PathConst.TMP_PATH)} 递归删除整个 tmp 目录,而
+     * {@code doClean}({@code CleanTask})与「重新清洗」都需要按 {@code filePath}
+     * 重新打开源文件,落到 tmp 会导致清洗后源文件丢失。
+     *
+     * @param file    上传的单个文件(xls / xlsx / csv)
+     * @param batchId 批次ID;为 null 时自动生成,同批文件应由前端回传首次的 batchId
+     * @param reClean 1=重新清洗(跳过 MD5 去重)
+     * @return 该文件的 FileInfo 树(pid=0 为文件根,pid=fileId 为各 sheet);重复文件返回空列表
+     */
+    public List<FileInfo> preProcessUploadedFile(MultipartFile file, Long batchId, Integer reClean) {
+        if (file == null || file.isEmpty()) {
+            throw ServerException.spe("上传文件为空!");
+        }
+        String originalName = StrUtil.blankToDefault(file.getOriginalFilename(), "未命名文件");
+        String suffix = FileUtil.getSuffix(originalName);
+        if (suffix == null || !GlobalCache.suffixList.contains(suffix.toLowerCase())) {
+            throw ServerException.spe("不支持的文件类型:" + originalName + "(仅支持 xls / xlsx / csv)");
+        }
+
+        Long currentBatchId = batchId != null ? batchId : IdUtil.getSnowflakeNextId();
+        Path saved = saveUploadedFile(file, currentBatchId, originalName);
+
+        // 重新清洗时跳过 MD5 去重(历史文件 md5 已存在于 file_info,否则会被全部过滤)
+        if (reClean == null || reClean != 1) {
+            List<Path> kept = filterExistsMd5Files(List.of(saved));
+            if (kept.isEmpty()) {
+                log.info("上传文件与已导入文件重复,跳过解析: {}, batchId={}", originalName, currentBatchId);
+                return List.of();
+            }
+        }
+
+        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;
+    }
+
+    /**
+     * 把上传文件落盘到案件工作目录,返回保存后的绝对路径。
+     * 文件名加雪花 ID 前缀防重名,并过滤路径分隔符等危险字符防目录穿越。
+     */
+    private Path saveUploadedFile(MultipartFile file, Long batchId, String originalName) {
+        if (!StateManager.instance().isCaseOpened()) {
+            throw ServerException.spe("请先打开案件后再上传文件!");
+        }
+        Long caseId = StateManager.instance().getCaseId().longValue();
+        Path dir = PathConst.WORKSPACE
+                .resolve(String.valueOf(caseId))
+                .resolve(UPLOAD_DIR_NAME)
+                .resolve(String.valueOf(batchId));
+        try {
+            Files.createDirectories(dir);
+            String safeName = sanitizeFileName(originalName);
+            Path target = dir.resolve(IdUtil.getSnowflakeNextId() + "_" + safeName).normalize();
+            if (!target.startsWith(dir)) {
+                throw ServerException.spe("非法文件名:" + originalName);
+            }
+            try (InputStream in = file.getInputStream()) {
+                Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
+            }
+            log.info("上传文件已保存: {} -> {} ({} 字节)", originalName, target, Files.size(target));
+            return target;
+        } catch (IOException e) {
+            log.error("保存上传文件失败: {}", originalName, e);
+            throw ServerException.spe("保存上传文件失败:" + originalName);
+        }
+    }
+
+    /** 只保留文件名主体,替换路径分隔符等危险字符 */
+    private String sanitizeFileName(String name) {
+        String base = StrUtil.blankToDefault(FileUtil.getName(name), "file");
+        return base.replaceAll("[\\\\/:*?\"<>|]", "_");
+    }
+
+    /** 解析阶段 fileSize 恒为 0,这里用落盘后的真实大小回填根节点 */
+    private void backfillFileSize(List<FileInfo> infos, Path saved) {
+        if (CollUtil.isEmpty(infos)) {
+            return;
+        }
+        try {
+            long size = Files.size(saved);
+            infos.stream()
+                    .filter(info -> info.getPid() != null && info.getPid() == 0L)
+                    .forEach(info -> info.setFileSize(size));
+        } catch (IOException e) {
+            log.warn("回填文件大小失败: {}", saved, e);
+        }
+    }
+
     private List<Path> resolvePreviewFiles(PrepareProcessFileDTO dto) {
         List<String> filePathStr = dto.getFilePath();
         try {