Просмотр исходного кода

工作台文件管理视图与工具包弹框打磨

- 智能工作台新增「文件管理」菜单(顺序:对话/研判方案/插件与技能/模型管理/文件管理):
  传统侧「导入文件管理」表格的智能侧副本(操作仅 删除/下载、列宽收窄、空态/滚动/分页样式修正、进入默认查询)
- 新建工具包改为默认全屏弹框:工具卡片式勾选、分类全选、描述换 textarea、
  滚动条贴右缘不压卡片、搜索框样式补全、工具描述完整展示
- 模式切换按钮「传统」→「自主」;顶栏品牌名「清鉴 · 线索调查工具」(仅顶栏)
- uno.config safelist 补图标:database-outline / filter-variant
cc 12 часов назад
Родитель
Сommit
5cfc6c888e

+ 109 - 0
ai-electron/frontend/src/ai/views/aiCaseData/AiCaseDataPage.vue

@@ -0,0 +1,109 @@
+<template>
+  <div class="ai-casedata">
+    <ImportList :active="true" />
+  </div>
+</template>
+
+<script lang="ts" setup name="AiCaseDataPage">
+  /**
+   * 智能工作台「文件管理」视图:只承载「导入文件管理」表格
+   * (操作列仅 删除/下载,见 CaseImportList.vue 智能侧副本)。
+   *
+   * 与传统侧 case/views/data/index.vue 的整页(导入数据树 / 清洗数据 / 数据治理 / 治理设置)
+   * 无关——传统侧保持原样,这里是独立的智能侧副本。表格直接铺开,不做卡片包裹。
+   */
+  import ImportList from './CaseImportList.vue';
+</script>
+
+<style lang="less" scoped>
+  .ai-casedata {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    min-height: 0;
+    padding: var(--ai-sp-2);
+    background: var(--ai-bg-page);
+    overflow: hidden;
+
+    :deep(.case-import-list) {
+      flex: 1 1 auto;
+      min-height: 0;
+    }
+  }
+</style>
+
+<style lang="less">
+  /* 子组件内部类名只能用全局选择器,全部限定在本视图根下 */
+
+  /* 查询表单顶部 1px 描边来自核心 BasicTable 的 !important 规则,这里同强度覆盖(仅本视图) */
+  .ai-casedata .jeesite-basic-table-form-container .ant-form {
+    border-top: 0 !important;
+    background: #fff !important;
+  }
+
+  /* 表头工具条容器(当前页/已选 + 隐藏 + 设置齿轮)整体移除 */
+  .ai-casedata .jeesite-basic-table-header-container {
+    display: none;
+  }
+
+  /* 表格观感:外层不套卡片(无白底/描边/圆角);
+     表格本体 / 表头 / 滚动表体 保持组件原色,不做透明化 */
+  .ai-casedata .ant-table-wrapper {
+    background: transparent !important;
+    border: none;
+    border-radius: 0;
+    display: flex;
+    flex-direction: column;
+    flex: 1 1 auto;
+    min-height: 0;
+  }
+
+  /*
+   * 表体吃满表格区剩余高度:组件按传统布局算死的内联 max-height 在工作台里矮一截,
+   * 会在尾部留出空带 —— 用弹性高度中和(!important 盖内联样式),滚动只发生在表体内。
+   */
+  .ai-casedata .ant-table-container {
+    flex: 1 1 auto;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;
+  }
+
+  .ai-casedata .ant-table-body {
+    flex: 1 1 auto;
+    min-height: 0;
+    max-height: none !important;
+    overflow: auto !important;
+    /* 底部横向滚动条不显示(表格总宽略有溢出时也不露滚动条) */
+    scrollbar-width: none;
+  }
+
+  .ai-casedata .ant-table-body::-webkit-scrollbar {
+    height: 0;
+    width: 0;
+    display: none;
+  }
+
+  /*
+   * 空态(暂无数据):占位行要铺满整个表体——组件给 expanded-row-fixed 写死了内联高度,
+   * 会矮一截,行尾留分割线、行下留白块。这里用 :has 定位空态表体,撑满高度、
+   * 去掉行尾分割线,并把空态提示垂直居中。
+   */
+  .ai-casedata .ant-table-body table:has(.ant-table-placeholder) {
+    height: 100% !important;
+  }
+
+  .ai-casedata .ant-table-tbody:has(.ant-table-placeholder),
+  .ai-casedata .ant-table-placeholder > td {
+    height: 100% !important;
+    border-bottom: none !important;
+  }
+
+  .ai-casedata .ant-table-placeholder .ant-table-expanded-row-fixed {
+    height: 100% !important;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    overflow: visible !important;
+  }
+</style>

+ 698 - 0
ai-electron/frontend/src/ai/views/aiCaseData/CaseImportList.vue

@@ -0,0 +1,698 @@
+<template>
+  <div :class="prefixCls" class="case-import-list">
+    <BasicTable class="import-list-table" @register="registerTable" @fetch-success="handleFetchSuccess">
+      <!-- 多选删除:选中行后出现在查询表单「重置」按钮后面 -->
+      <template #form-advanceAfter>
+        <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>
+      </template>
+    </BasicTable>
+
+    <div class="pagination-bar">
+      <AnalysisPagination
+        :total="pagination.total"
+        :current="pagination.current"
+        :page-size="pagination.pageSize"
+        :page-size-options="pageSizeOptions"
+        :loading="paginationLoading"
+        @change="handlePaginationChange"
+      />
+    </div>
+
+    <CleanLogModal v-model:open="logModalOpen" title="导入失败日志" :file-infos="currentLogFileInfos" />
+  </div>
+</template>
+<script lang="ts" setup name="ViewsSysEmpUserList">
+  import { 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';
+  import { Icon } from '@/core/components/Icon';
+  import { BasicTable, BasicColumn, useTable } from '@/core/components/Table';
+  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 { PageEnum } from '@/core/enums/pageEnum';
+  import type { FormProps } from '@/core/components/Form';
+  import type { FileInfo } from '@/types';
+  import { importFileList, deletedFileList, preFile } from '@/case/api/govern/governApi';
+  import { getOpenOfficeFileErrorMessage, downloadOfficeFileById } from '@/core/utils/openOfficeFile';
+  import { FileStatusEnum } from '@/case/types/enum';
+  import componentSetting from '@/core/settings/componentSetting';
+  import CleanLogModal from '@/case/views/data/components/CleanLogModal.vue';
+  import { resetImportProgressSession } from '@/case/views/data/importProgressSession';
+
+  const props = defineProps({
+    treeCodes: Array as PropType<string[]>,
+    treeName: String,
+    active: {
+      type: Boolean,
+      default: false,
+    },
+  });
+
+  const { prefixCls } = useDesign('case-import-list');
+  const go = useGo();
+  const emitter = useEmitter();
+  const { createMessage } = useMessage();
+  const CASE_DATA_REFRESH_EVENT = 'case-data:refresh';
+  const CASE_IMPORT_FILE_REFRESH_EVENT = 'case-import-file:refresh';
+  const FILE_INFO_STORAGE_KEY = 'case_clean_file_infos';
+  const IMPORT_FILE_INFO_STORAGE_KEY = 'case_clean_import_file_infos';
+  const RECLEAN_CONTEXT_STORAGE_KEY = 'case_clean_reclean_context';
+  const PRE_FILE_EMPTY_MESSAGE = '未识别到可处理文件,可能是文件重复、所有文件不存在,或文件格式不正确';
+  const ctrlPermi = ref('');
+  const currentPageFileCount = ref(0);
+  const logModalOpen = ref(false);
+  const currentLogFileInfos = ref<FileInfo[]>([]);
+  const hasLoadedOnActivate = ref(false);
+  const recleaningFileIds = ref<string[]>([]);
+  const paginationLoading = ref(false);
+  const pagination = ref({
+    current: 1,
+    pageSize: Number(componentSetting.table.defaultPageSize),
+    total: 0,
+  });
+  const pageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({
+    label: value,
+    value: Number(value),
+  }));
+
+  const normalizeSearchParams = (params: Recordable = {}) => {
+    const nextParams: Recordable = { ...params };
+    Object.keys(nextParams).forEach((key) => {
+      const value = nextParams[key];
+      if (value === '' || value === undefined || value === null || (Array.isArray(value) && value.length === 0)) {
+        delete nextParams[key];
+      }
+    });
+    return nextParams;
+  };
+
+  const handleSearchSubmit = async () => {
+    const formValues = tableAction.getForm()?.getFieldsValue?.() || {};
+    await tableAction.reload({
+      searchInfo: normalizeSearchParams(formValues),
+      page: 1,
+    });
+  };
+
+  const searchForm: FormProps<FileInfo> = {
+    baseColProps: { md: 12, lg: 8, xl: 6 },
+    labelWidth: 90,
+    showAdvancedButton: false,
+    submitFunc: handleSearchSubmit,
+    schemas: [
+      {
+        label: '文件名称:',
+        field: 'fileName',
+        component: 'Input',
+        componentProps: {
+          placeholder: '请输入文件名称',
+        },
+      },
+      {
+        label: '上传时间:',
+        field: 'createDateTime',
+        component: 'DatePicker',
+        componentProps: {
+          format: 'YYYY-MM-DD',
+          valueFormat: 'YYYY-MM-DD',
+          placeholder: '请选择上传时间',
+        },
+      },
+    ],
+  };
+
+  const getText = (value: unknown, fallback = '-') => {
+    const text = String(value ?? '').trim();
+    return text ? text : fallback;
+  };
+
+  const getNumberText = (value: unknown) => {
+    const num = Number(value ?? 0);
+    return Number.isFinite(num) ? String(num) : '0';
+  };
+
+  const fileStatusLabelMap = Object.entries(FileStatusEnum).reduce(
+    (acc, [key, label]) => {
+      acc[key] = label;
+      return acc;
+    },
+    {} as Record<string, string>,
+  );
+
+  const getGovernStatusLabel = (status?: string | null) => {
+    const normalized = String(status || '').trim();
+    if (!normalized) return '未治理';
+    return fileStatusLabelMap[normalized] || normalized;
+  };
+
+  const getErrorMessage = (error: any, fallback = '操作失败') =>
+    error?.response?.data?.message || error?.message || fallback;
+
+  const tableColumns: BasicColumn<FileInfo>[] = [
+    {
+      title: '序号',
+      key: 'sort',
+      width: 76,
+      align: 'center',
+      customRender: ({ index }: { index: number }) => h('span', { class: 'table-cell-center' }, String(index + 1)),
+    },
+    {
+      title: '文件名称',
+      dataIndex: 'fileName',
+      key: 'fileName',
+
+      align: 'center',
+      ellipsis: true,
+      customRender: ({ record }: { record: FileInfo }) =>
+        h(
+          'span',
+          {
+            class: 'file-name-text',
+            title: getText(record.fileName || record.sheetName, '未命名文件'),
+          },
+          getText(record.fileName || record.sheetName, '未命名文件'),
+        ),
+    },
+    {
+      title: '导入成功',
+      dataIndex: 'successNum',
+      key: 'successNum',
+      // 表头文字实测 56px + 单元格内边距 16px,76 足够完整展示数字与表头
+      width: 76,
+      align: 'center',
+      customRender: ({ value }: { value?: string | number }) =>
+        h('span', { class: 'table-cell-center' }, getNumberText(value)),
+    },
+    {
+      title: '导入失败',
+      dataIndex: 'failNum',
+      key: 'failNum',
+      width: 76,
+      align: 'center',
+      customRender: ({ record }: { record: FileInfo }) =>
+        h(
+          'a',
+          {
+            class: 'fail-link',
+            onClick: (event: MouseEvent) => handleViewFailLog(event, record),
+          },
+          getNumberText(record.failNum),
+        ),
+    },
+    {
+      title: '治理状态',
+      dataIndex: 'fileStatus',
+      key: 'fileStatus',
+      width: 120,
+      align: 'center',
+      customRender: ({ value }: { value?: string | null }) =>
+        h('span', { class: 'govern-status-text' }, getGovernStatusLabel(value)),
+    },
+    {
+      title: '上传时间',
+      dataIndex: 'createDateTime',
+      key: 'createDateTime',
+      // 时间戳文本实测 109px + 单元格内边距/拖拽柄 ≈ 24px,148 保证完整展示
+      width: 148,
+      align: 'center',
+      customRender: ({ value }: { value?: string }) => h('span', { class: 'table-cell-center' }, getText(value)),
+    },
+  ];
+
+  const actionColumn: BasicColumn<FileInfo> = {
+    title: '操作',
+    width: 132,
+    // 不固定右侧(核心表格默认会给操作列 fixed:'right',这里显式覆盖为不固定)
+    fixed: undefined,
+    actions: (record: FileInfo) => [
+      {
+        label: '删除',
+        title: '删除',
+        color: 'error',
+        popConfirm: {
+          title: '是否确认删除文件',
+          confirm: handleDelete.bind(this, record),
+        },
+      },
+      {
+        label: '下载',
+        title: '下载',
+        onClick: handleDownloadFile.bind(this, record),
+      },
+    ],
+  };
+
+  const selectedRowKeysRef = ref<string[]>([]);
+  const rowSelection = {
+    type: 'checkbox',
+    columnWidth: 48,
+    onChange: (selectedRowKeys: string[]) => {
+      selectedRowKeysRef.value = selectedRowKeys;
+    },
+  };
+  const [registerTable, tableAction] = useTable<FileInfo>({
+    api: importFileList,
+    beforeFetch: (params) => {
+      const nextParams = normalizeSearchParams(params);
+      nextParams.ctrlPermi = ctrlPermi.value;
+      return nextParams;
+    },
+    afterFetch: (items: FileInfo[]) => {
+      currentPageFileCount.value = Array.isArray(items) ? items.length : 0;
+      return items;
+    },
+    immediate: false,
+    columns: tableColumns,
+    actionColumn,
+    formConfig: searchForm,
+    showTableSetting: true,
+    useSearchForm: true,
+    showSearchForm: true,
+    canResize: true,
+    isCanResizeParent: true,
+    resizeHeightOffset: 32,
+    clickToRowSelect: false,
+    rowKey: 'id',
+    rowSelection,
+    defaultRowSelection: rowSelection,
+    pagination: { position: ['none'] },
+  });
+  const { clearSelectedRowKeys, reload, redoHeight } = tableAction;
+
+  const handleFetchSuccess = ({ total }: { total?: number }) => {
+    const tablePagination = tableAction.getPagination();
+    if (typeof tablePagination === 'boolean') return;
+
+    pagination.value.current = Number(tablePagination.current) || 1;
+    pagination.value.pageSize = Number(tablePagination.pageSize || tablePagination.defaultPageSize) || 1;
+    pagination.value.total = Number(total ?? tablePagination.total) || 0;
+  };
+
+  const handlePaginationChange = async (page: number, pageSize: number) => {
+    if (paginationLoading.value || (page === pagination.value.current && pageSize === pagination.value.pageSize))
+      return;
+
+    const nextPage = pageSize === pagination.value.pageSize ? page : 1;
+    paginationLoading.value = true;
+    try {
+      tableAction.setPagination({ current: nextPage, pageSize });
+      await reload({ page: nextPage });
+    } finally {
+      paginationLoading.value = false;
+    }
+  };
+
+  const syncTableLayout = () => {
+    if (!props.active) return;
+    requestAnimationFrame(() => {
+      redoHeight();
+    });
+  };
+
+  const handleImportListRefresh = () => {
+    hasLoadedOnActivate.value = true;
+    syncTableLayout();
+    void reload();
+  };
+
+  watch(
+    () => props.active,
+    (active) => {
+      if (!active) return;
+      if (!hasLoadedOnActivate.value) {
+        handleImportListRefresh();
+        return;
+      }
+      syncTableLayout();
+    },
+    { flush: 'post' },
+  );
+
+  onMounted(() => {
+    emitter.on(CASE_DATA_REFRESH_EVENT, handleImportListRefresh);
+    emitter.on(CASE_IMPORT_FILE_REFRESH_EVENT, handleImportListRefresh);
+    // 智能侧嵌入时 active 初始即为 true,watch 只在变化时触发——挂载时直接做首查,
+    // 进入「文件管理」即默认查出数据(与传统页签激活时同一条加载路径)
+    if (props.active) {
+      handleImportListRefresh();
+    }
+  });
+
+  onBeforeUnmount(() => {
+    emitter.off(CASE_DATA_REFRESH_EVENT, handleImportListRefresh, true);
+    emitter.off(CASE_IMPORT_FILE_REFRESH_EVENT, handleImportListRefresh, true);
+  });
+
+  async function handleDelete(record: Partial<FileInfo>) {
+    const id = String(record.id ?? '').trim();
+    if (!id) {
+      createMessage.warning('未获取到文件ID,暂时无法删除');
+      return;
+    }
+    try {
+      await deletedFileList({ ids: [id] });
+      await handleSuccess('文件删除成功');
+    } catch (error: any) {
+      createMessage.error(getErrorMessage(error, '文件删除失败'));
+    }
+  }
+
+  async function handleRecognizeImport(record: Partial<FileInfo>) {
+    const filePath = String(record.filePath ?? '').trim();
+    const fileId = String(record.id ?? '').trim();
+    if (!filePath) {
+      createMessage.warning('未获取到文件路径,暂时无法重新清洗');
+      return;
+    }
+    if (!fileId) {
+      createMessage.warning('未获取到文件ID,暂时无法重新清洗');
+      return;
+    }
+    if (recleaningFileIds.value.includes(fileId)) {
+      return;
+    }
+
+    try {
+      recleaningFileIds.value = [...recleaningFileIds.value, fileId];
+      const response = await preFile({
+        filePath: [filePath],
+        isDir: false,
+        reClean: 1,
+      });
+      const fileInfos = Array.isArray(response) ? response : ((response as any)?.data ?? []);
+      if (!Array.isArray(fileInfos) || !fileInfos.length) {
+        createMessage.warning(PRE_FILE_EMPTY_MESSAGE);
+        return;
+      }
+
+      sessionStorage.setItem(FILE_INFO_STORAGE_KEY, JSON.stringify(fileInfos));
+      sessionStorage.removeItem(IMPORT_FILE_INFO_STORAGE_KEY);
+      sessionStorage.setItem(
+        RECLEAN_CONTEXT_STORAGE_KEY,
+        JSON.stringify({
+          reClean: 1,
+          fileId,
+        }),
+      );
+      resetImportProgressSession();
+      await go({
+        path: PageEnum.BASE_CLEANING,
+        query: {
+          focusKey: fileId,
+        },
+      });
+    } catch (error: any) {
+      createMessage.error(getErrorMessage(error, '进入重新清洗失败'));
+    } finally {
+      recleaningFileIds.value = recleaningFileIds.value.filter((id) => id !== fileId);
+    }
+  }
+
+  /** 下载该文件(Web 化:只传文件ID,服务端查库定位文件流,浏览器另存) */
+  async function handleDownloadFile(record: Partial<FileInfo>) {
+    const fileId = String(record.id ?? '').trim();
+    if (!fileId) {
+      createMessage.warning('未获取到文件ID,暂时无法下载文件');
+      return;
+    }
+
+    try {
+      const fileName = String(record.fileName ?? '').trim();
+      await downloadOfficeFileById(fileId, fileName || '文件');
+    } catch (error: any) {
+      createMessage.error(getOpenOfficeFileErrorMessage(error, '文件下载失败'));
+    }
+  }
+
+  function handleViewFailLog(event: MouseEvent, record: Partial<FileInfo>) {
+    event.preventDefault();
+    event.stopPropagation();
+    const failCount = Number(record.failNum ?? 0);
+    if (!Number.isFinite(failCount) || failCount <= 0) {
+      createMessage.info('当前文件暂无失败记录');
+      return;
+    }
+    const logFileInfo = { ...(record as FileInfo), pid: '0', children: [] as FileInfo[] };
+    delete (logFileInfo as Recordable).batchId;
+    currentLogFileInfos.value = [logFileInfo];
+    logModalOpen.value = true;
+  }
+
+  async function handleDeleteSelected() {
+    if (!selectedRowKeysRef.value.length) {
+      createMessage.warning('请先选择需要删除的文件');
+      return;
+    }
+    try {
+      const ids = selectedRowKeysRef.value.map((item) => String(item)).filter(Boolean);
+      await deletedFileList({ ids });
+      await handleSuccess(`已删除 ${ids.length} 条导入记录`);
+    } catch (error: any) {
+      createMessage.error(getErrorMessage(error, '批量删除失败'));
+    }
+  }
+
+  async function handleSuccess(message?: string) {
+    selectedRowKeysRef.value = [];
+    clearSelectedRowKeys();
+    if (message) {
+      createMessage.success(message);
+    }
+    await reload();
+  }
+</script>
+
+<style lang="less">
+  @prefix-cls: ~'jeesite-case-import-list';
+
+  .@{prefix-cls}.case-import-list {
+    height: 100%;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;
+
+    .import-list-table .jeesite-basic-table-header__title {
+      display: none;
+    }
+
+    .import-list-table .jeesite-basic-table-header__toolbar {
+      width: 100%;
+      justify-content: space-between;
+      margin-top: -2px;
+      margin-bottom: 8px;
+
+      > * {
+        margin-right: 0;
+      }
+    }
+
+    .import-list-table .jeesite-basic-table-header__toolbar > .ant-btn {
+      display: none;
+    }
+
+    .table-toolbar {
+      display: flex;
+      align-items: center;
+      flex: 1 1 auto;
+      gap: 10px;
+      min-width: 0;
+      flex-wrap: wrap;
+    }
+
+    .table-title-meta {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      flex-wrap: wrap;
+    }
+
+    .table-meta-chip {
+      display: inline-flex;
+      align-items: center;
+      height: 24px;
+      padding: 0 10px;
+      border-radius: 999px;
+      background: #f3f6fb;
+      color: #475569;
+      font-size: 12px;
+      line-height: 24px;
+
+      &.is-selected {
+        background: #eef4ff;
+        color: #315efb;
+      }
+    }
+
+    .batch-delete-btn {
+      height: 34px;
+      padding: 0 14px;
+      border-radius: 8px;
+    }
+
+    .import-list-table,
+    .import-list-table.jeesite-basic-table {
+      flex: 1 1 auto;
+      min-height: 0;
+      height: 100%;
+      overflow: hidden;
+      display: flex;
+      flex-direction: column;
+
+      .jeesite-basic-table {
+        height: 100%;
+        display: flex;
+        flex-direction: column;
+      }
+    }
+
+    .import-list-table .ant-table-wrapper {
+      flex: 1 1 auto;
+      min-height: 0;
+      display: flex;
+      flex-direction: column;
+      padding: 0 6px 14px !important;
+      box-sizing: border-box;
+    }
+
+    .import-list-table .ant-spin-nested-loading,
+    .import-list-table .ant-spin-container,
+    .import-list-table .ant-table,
+    .import-list-table .ant-table-container {
+      flex: 1 1 auto;
+      min-height: 0;
+      display: flex;
+      flex-direction: column;
+    }
+
+    .import-list-table .ant-table-body,
+    .import-list-table .ant-table-content {
+      flex: 1 1 auto;
+      min-height: 0;
+    }
+
+    .table-cell-center,
+    .file-name-text,
+    .govern-status-text {
+      display: inline-block;
+      max-width: 100%;
+      color: #374151;
+      font-size: 12px;
+      line-height: 1.5;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      vertical-align: middle;
+    }
+
+    .fail-link {
+      color: #315efb;
+      font-size: 12px;
+      line-height: 1.5;
+      text-decoration: underline;
+
+      &:hover {
+        color: #1d4ed8;
+      }
+    }
+
+    .ant-form {
+      padding: 12px 16px 0;
+      /* 查询表单:白底、不描边、不留底缝(智能侧视觉要求) */
+      border: none;
+      border-radius: 0;
+      background: #fff;
+      margin-bottom: 0;
+
+      .ant-form-item {
+        margin-bottom: 12px;
+      }
+
+      .ant-input,
+      .ant-picker,
+      .ant-input-affix-wrapper {
+        border-radius: 8px;
+      }
+    }
+
+    .ant-table-wrapper {
+      border-radius: 12px;
+      box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
+    }
+
+    .ant-table-container {
+      border-radius: 12px;
+      overflow: hidden;
+    }
+
+    .ant-table-thead > tr > th {
+      background: #f8fafc;
+      color: #374151;
+      font-weight: 600;
+      border-bottom-color: #e5e7eb;
+    }
+
+    .ant-table-tbody > tr > td {
+      padding-top: 10px;
+      padding-bottom: 10px;
+      color: #4b5563;
+      border-bottom-color: #eef2f7;
+      text-align: center;
+    }
+
+    .ant-table-tbody > tr:hover > td {
+      background: #f8fbff;
+    }
+
+    .jeesite-basic-table-action {
+      justify-content: center;
+      gap: 10px;
+      flex-wrap: nowrap;
+
+      .ant-btn-link,
+      .ant-btn-link.ant-btn-sm {
+        height: auto;
+        padding: 0;
+        color: #374151;
+        font-size: 12px;
+        line-height: 1.5;
+
+        &:hover {
+          color: #1d4ed8;
+        }
+      }
+
+      .ant-btn-link.ant-btn-error,
+      .ant-btn-link.ant-btn-error.ant-btn-sm {
+        color: @error-color;
+
+        &:hover,
+        &:focus {
+          color: lighten(@error-color, 10%);
+        }
+
+        &:active {
+          color: darken(@error-color, 10%);
+        }
+      }
+    }
+
+    .pagination-bar {
+      display: flex;
+      align-items: center;
+      justify-content: flex-end;
+      flex: 0 0 auto;
+      min-height: 40px;
+      /* 上下对称内边距:内容在条内垂直居中,与表格/视图底各留 8px */
+      padding: 8px 6px;
+      overflow: hidden;
+      box-sizing: border-box;
+    }
+  }
+</style>

+ 344 - 68
ai-electron/frontend/src/ai/views/aiPlugin/index.vue

@@ -78,49 +78,84 @@
           <span class="ai-plugin__tab-count">{{ packs.length }}</span>
         </template>
 
-        <!-- 工具包编辑器:新建/修改共用;工具按业务组勾选(基础工具始终可用,不进包) -->
-        <div v-if="packEditor.open" class="ai-plugin__pack-editor">
-          <div class="ai-plugin__pack-editor-title">
-            {{ packEditor.editing ? `修改工具包:${packEditor.name}` : '新建工具包' }}
+        <!-- 工具包编辑器:新建/修改共用,默认全屏弹框;工具按业务组以卡片勾选(基础工具始终可用、不进包) -->
+        <BasicModal
+          :open="packEditor.open"
+          :title="packEditor.editing ? `修改工具包:${packEditor.name}` : '新建工具包'"
+          :width="1100"
+          default-fullscreen
+          wrap-class-name="ai-pack-modal"
+          :confirm-loading="packEditor.saving"
+          ok-text="保存"
+          cancel-text="取消"
+          @ok="savePack"
+          @cancel="closePackEditor"
+        >
+          <div class="ai-plugin__pack-form">
+            <div class="ai-plugin__pack-row">
+              <label class="ai-plugin__pack-label"><i class="ai-plugin__req">*</i>工具包名</label>
+              <input
+                v-model="packEditor.name"
+                class="ai-plugin__pack-input"
+                :maxlength="100"
+                placeholder="工具包名(如:影子公司穿透)"
+              />
+            </div>
+            <div class="ai-plugin__pack-row ai-plugin__pack-row--top">
+              <label class="ai-plugin__pack-label">描述</label>
+              <textarea
+                v-model="packEditor.description"
+                class="ai-plugin__pack-input ai-plugin__pack-textarea"
+                :maxlength="500"
+                placeholder="一句话描述(可选,会出现在新建研判方案的勾选卡片上)"
+              ></textarea>
+            </div>
           </div>
-          <a-input v-model:value="packEditor.name" :maxlength="100" placeholder="工具包名(如:影子公司穿透)" />
-          <a-input
-            v-model:value="packEditor.description"
-            :maxlength="500"
-            placeholder="一句话描述(可选,会出现在新建研判方案的勾选卡片上)"
-          />
-          <div class="ai-plugin__pack-picker">
-            <div class="ai-plugin__pack-picker-head">
+
+          <div class="ai-plugin__pack-section">
+            <div class="ai-plugin__pack-section-head">
+              <span class="ai-plugin__pack-section-title">选择工具</span>
+              <span class="ai-plugin__pack-count">{{ packEditorCount ? `已选 ${packEditorCount} 个` : '未选择' }}</span>
+              <span class="ai-plugin__pack-section-sp"></span>
               <label class="ai-plugin__search">
                 <Icon icon="mdi:magnify" :size="14" />
                 <input v-model="packEditor.keyword" type="text" placeholder="搜索工具名,如 stat_trans_big" />
               </label>
-              <span class="ai-plugin__chip">已选 {{ packEditorCount }} 个工具</span>
             </div>
+            <div class="ai-plugin__pack-hint">点卡片勾选工具;基础工具(如 execute_sql)始终可用、不进工具包。</div>
+
             <div v-for="g in packToolGroups" :key="g.label" class="ai-plugin__pack-group">
               <div class="ai-plugin__pack-group-title">
                 {{ g.label }}
                 <span class="ai-plugin__chip">{{ g.tools.length }} 个</span>
+                <span class="ai-plugin__pack-group-sp"></span>
+                <button
+                  type="button"
+                  class="ai-plugin__pack-all"
+                  :class="{ 'is-on': isGroupAllOn(g) }"
+                  @click="toggleGroupAll(g)"
+                >
+                  <span class="ai-plugin__pack-ck"><Icon icon="mdi:check" :size="11" /></span>
+                  全选
+                </button>
               </div>
-              <div class="ai-plugin__pack-checks">
-                <label v-for="t in g.tools" :key="t.toolName" class="ai-plugin__pack-check">
-                  <a-checkbox
-                    :checked="!!packEditor.checked[t.toolName]"
-                    @change="packEditor.checked[t.toolName] = !packEditor.checked[t.toolName]"
-                  >
-                    <span class="ai-plugin__mono">{{ t.toolName }}</span>
-                  </a-checkbox>
-                  <div class="ai-plugin__pack-check-desc">{{ t.description || '(无说明)' }}</div>
-                </label>
+              <div class="ai-plugin__pack-cards">
+                <div
+                  v-for="t in g.tools"
+                  :key="t.toolName"
+                  class="ai-plugin__pack-card"
+                  :class="{ 'is-on': packEditor.checked[t.toolName] }"
+                  @click="packEditor.checked[t.toolName] = !packEditor.checked[t.toolName]"
+                >
+                  <span class="ai-plugin__pack-ck"><Icon icon="mdi:check" :size="11" /></span>
+                  <div class="ai-plugin__pack-card-name">{{ t.toolName }}</div>
+                  <div class="ai-plugin__pack-card-desc">{{ t.description || '(无说明)' }}</div>
+                </div>
               </div>
             </div>
             <div v-if="!packToolGroups.length" class="ai-plugin__empty">没有匹配的工具。</div>
           </div>
-          <div class="ai-plugin__pack-editor-ops">
-            <a-button @click="closePackEditor">取消</a-button>
-            <a-button type="primary" :loading="packEditor.saving" @click="savePack">保存</a-button>
-          </div>
-        </div>
+        </BasicModal>
 
         <a-spin :spinning="packsLoading">
           <div v-if="packs.length" class="ai-plugin__grid">
@@ -328,6 +363,7 @@
    */
   import { computed, onMounted, reactive, ref } from 'vue';
   import { Button, Checkbox, Drawer, Input, Modal, Select, Spin, Switch, Tabs } from 'ant-design-vue';
+  import { BasicModal } from '@/core/components/Modal';
   import Icon from '@/core/components/Icon/src/Icon.vue';
   import { useMessage } from '@/core/hooks/web/useMessage';
   import { useUserStore } from '@/core/store/modules/case';
@@ -562,6 +598,17 @@
 
   const packEditorCount = computed(() => Object.values(packEditor.checked).filter(Boolean).length);
 
+  /** 分类「全选」态:该组工具全部已勾 */
+  function isGroupAllOn(g: { tools: AgentTool[] }) {
+    return g.tools.length > 0 && g.tools.every((t) => packEditor.checked[t.toolName]);
+  }
+
+  /** 点「全选」:未全选 → 全勾;已全选 → 全不勾 */
+  function toggleGroupAll(g: { tools: AgentTool[] }) {
+    const next = !isGroupAllOn(g);
+    g.tools.forEach((t) => (packEditor.checked[t.toolName] = next));
+  }
+
   async function loadPacks() {
     packsLoading.value = true;
     try {
@@ -882,7 +929,41 @@
     }
 
     &__search {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      width: 240px;
       max-width: 300px;
+      height: 32px;
+      padding: 0 11px;
+      color: var(--ai-text-3, #9aa1ac);
+      background: var(--ai-bg-card, #fff);
+      border: 1px solid var(--ai-border-strong, #d0d7de);
+      border-radius: var(--ai-radius, 8px);
+      cursor: text;
+      transition:
+        border-color 0.15s ease,
+        box-shadow 0.15s ease;
+
+      &:focus-within {
+        border-color: var(--ai-primary, #2a50ec);
+        box-shadow: 0 0 0 3px rgba(42, 80, 236, 0.08);
+      }
+
+      input {
+        flex: 1;
+        min-width: 0;
+        font-size: 12.5px;
+        font-family: inherit;
+        color: var(--ai-text, #24292f);
+        background: transparent;
+        border: none;
+        outline: none;
+
+        &::placeholder {
+          color: var(--ai-text-3, #9aa1ac);
+        }
+      }
     }
 
     &__filter-count {
@@ -974,80 +1055,227 @@
       border-radius: var(--ai-radius);
     }
   }
-  /* ---------- 工具包:编辑器与卡片 ---------- */
-  .ai-plugin__pack-editor {
+  /* ---------- 工具包:编辑弹框(表单 + 卡片勾选) ---------- */
+  .ai-plugin__pack-form {
     display: flex;
     flex-direction: column;
-    gap: 8px;
-    margin-bottom: 12px;
-    padding: 12px;
-    border: 1px solid var(--ai-primary, #2a50ec);
-    border-radius: 8px;
-    background: var(--ai-bg-subtle, #f5f5f5);
+    gap: 16px;
+    margin-bottom: 16px;
   }
 
-  .ai-plugin__pack-editor-title {
+  .ai-plugin__pack-row {
+    display: flex;
+    align-items: center;
+    gap: 16px;
+  }
+
+  .ai-plugin__pack-label {
+    flex: 0 0 auto;
+    width: 80px;
+    font-size: 13px;
+    font-weight: 500;
+    color: var(--ai-text, #24292f);
+    text-align: right;
+  }
+
+  .ai-plugin__req {
+    margin-right: 3px;
+    font-style: normal;
+    color: #cf222e;
+  }
+
+  .ai-plugin__pack-input {
+    width: 380px;
+    height: 32px;
+    padding: 0 11px;
     font-size: 12.5px;
-    font-weight: 600;
+    font-family: inherit;
     color: var(--ai-text, #24292f);
+    background: var(--ai-bg-card, #fff);
+    border: 1px solid var(--ai-border-strong, #d0d7de);
+    border-radius: 8px;
+    outline: none;
   }
 
-  .ai-plugin__pack-picker {
-    display: flex;
-    flex-direction: column;
-    gap: 10px;
+  .ai-plugin__pack-input:focus {
+    border-color: var(--ai-primary, #2a50ec);
+    box-shadow: 0 0 0 3px rgba(42, 80, 236, 0.08);
+  }
+
+  /* 描述用多行输入 */
+  .ai-plugin__pack-textarea {
+    height: 72px;
+    padding: 8px 11px;
+    line-height: 1.6;
+    resize: vertical;
+    font-family: inherit;
+  }
+
+  .ai-plugin__pack-row--top {
+    align-items: flex-start;
+  }
+
+  .ai-plugin__pack-row--top .ai-plugin__pack-label {
+    padding-top: 8px;
   }
 
-  .ai-plugin__pack-picker-head {
+  .ai-plugin__pack-section-head {
     display: flex;
     align-items: center;
-    gap: 10px;
+    gap: 12px;
+  }
+
+  .ai-plugin__pack-section-title {
+    font-size: 15px;
+    font-weight: 700;
+    color: var(--ai-text, #24292f);
+  }
+
+  .ai-plugin__pack-count {
+    padding: 2px 10px;
+    font-size: 11.5px;
+    font-weight: 500;
+    color: var(--ai-text-2, #656d76);
+    background: var(--ai-bg-subtle, #f5f5f5);
+    border-radius: 99px;
+  }
+
+  .ai-plugin__pack-section-sp {
+    flex: 1 1 auto;
+  }
+
+  /* 分类标题行:组名 + 数量 + 全选(右侧) */
+  .ai-plugin__pack-group-sp {
+    flex: 1 1 auto;
+  }
+
+  .ai-plugin__pack-all {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    padding: 2px 10px 2px 6px;
+    font-size: 11.5px;
+    font-weight: 500;
+    font-family: inherit;
+    color: var(--ai-text-2, #656d76);
+    background: var(--ai-bg-subtle, #f5f5f5);
+    border: 1px solid var(--ai-border, #e3e7ee);
+    border-radius: 99px;
+    cursor: pointer;
+  }
+
+  .ai-plugin__pack-all .ai-plugin__pack-ck {
+    position: static;
+    width: 14px;
+    height: 14px;
+  }
+
+  .ai-plugin__pack-all.is-on {
+    color: var(--ai-primary, #2a50ec);
+    background: rgba(42, 80, 236, 0.08);
+    border-color: var(--ai-primary, #2a50ec);
+  }
+
+  .ai-plugin__pack-all.is-on .ai-plugin__pack-ck {
+    color: #fff;
+    background: var(--ai-primary, #2a50ec);
+    border-color: var(--ai-primary, #2a50ec);
+  }
+
+  /* 弹框头部搜索框:定宽、不被压缩(此前被 flex 挤变形) */
+  .ai-plugin__pack-section-head .ai-plugin__search {
+    flex: 0 0 auto;
+    width: 240px;
+    height: 32px;
+  }
+
+  .ai-plugin__pack-hint {
+    margin: 6px 0 10px;
+    font-size: 12px;
+    line-height: 1.7;
+    color: var(--ai-text-2, #656d76);
   }
 
   .ai-plugin__pack-group-title {
     display: flex;
     align-items: center;
     gap: 8px;
+    margin-top: 14px;
     font-size: 12.5px;
     font-weight: 600;
     color: var(--ai-text, #24292f);
   }
 
-  .ai-plugin__pack-checks {
-    display: flex;
-    flex-direction: column;
-    gap: 6px;
-    margin-top: 6px;
+  .ai-plugin__pack-cards {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+    gap: 12px;
+    margin-top: 8px;
   }
 
-  .ai-plugin__pack-check {
-    padding: 6px 8px;
-    border: 1px solid var(--ai-border, #d0d7de);
+  .ai-plugin__pack-card {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    padding: 14px;
+    background: var(--ai-bg-card, #fff);
+    border: 1px solid var(--ai-border, #e3e7ee);
     border-radius: 8px;
     cursor: pointer;
+    transition:
+      box-shadow 0.15s ease,
+      transform 0.15s ease,
+      border-color 0.15s ease,
+      background 0.15s ease;
   }
 
-  .ai-plugin__pack-check:hover {
-    background: var(--ai-bg-hover, #f5f5f5);
+  .ai-plugin__pack-card:hover {
+    box-shadow: 0 2px 8px rgba(27, 31, 36, 0.08);
+    transform: translateY(-1px);
   }
 
-  .ai-plugin__pack-check-desc {
-    margin-top: 2px;
-    padding-left: 22px;
-    font-size: 11.5px;
-    line-height: 1.6;
-    color: var(--ai-text-2, #656d76);
-    display: -webkit-box;
-    -webkit-line-clamp: 2;
-    -webkit-box-orient: vertical;
-    overflow: hidden;
+  .ai-plugin__pack-card.is-on {
+    border-color: var(--ai-primary, #2a50ec);
+    background: rgba(42, 80, 236, 0.03);
   }
 
-  .ai-plugin__pack-editor-ops {
+  .ai-plugin__pack-card.is-on .ai-plugin__pack-ck {
+    color: #fff;
+    background: var(--ai-primary, #2a50ec);
+    border-color: var(--ai-primary, #2a50ec);
+  }
+
+  .ai-plugin__pack-ck {
+    position: absolute;
+    top: 12px;
+    right: 12px;
     display: flex;
-    justify-content: flex-end;
-    gap: 8px;
-    padding-top: 4px;
+    align-items: center;
+    justify-content: center;
+    width: 18px;
+    height: 18px;
+    color: transparent;
+    background: var(--ai-bg-card, #fff);
+    border: 1.5px solid var(--ai-border-strong, #d0d7de);
+    border-radius: 50%;
+  }
+
+  .ai-plugin__pack-card-name {
+    overflow: hidden;
+    padding-right: 24px;
+    font-size: 13.5px;
+    font-weight: 700;
+    color: var(--ai-text, #24292f);
+    white-space: nowrap;
+    text-overflow: ellipsis;
+  }
+
+  .ai-plugin__pack-card-desc {
+    margin: 0;
+    font-size: 12.25px;
+    line-height: 1.6;
+    color: var(--ai-text-2, #656d76);
   }
 
   .ai-plugin__card-tools {
@@ -1149,3 +1377,51 @@
     overflow: hidden;
   }
 </style>
+
+<style lang="less">
+  /*
+   * 全屏态补齐(弹框挂到 body 下,只能用全局样式):antd 弹框是 position:relative,
+   * fullscreen 规则的 top/bottom 拉不开高度——这里显式补高度,正文区 flex 占满并内部滚动;
+   * 还原为窗口态时类名移除,滚动回落到组件内限高。
+   */
+  .ai-pack-modal.fullscreen-modal .ant-modal {
+    height: 100% !important;
+    margin: 0 !important;
+  }
+
+  .ai-pack-modal.fullscreen-modal .ant-modal-content {
+    display: flex;
+    flex-direction: column;
+  }
+
+  .ai-pack-modal.fullscreen-modal .ant-modal-body {
+    flex: 1 1 auto;
+    min-height: 0;
+    max-height: none !important;
+    overflow-y: auto !important;
+  }
+
+  /*
+   * 窗口态(全屏还原后):正文按视口限高、内部滚动 —— 否则内容把弹框撑出屏幕且无滚动条。
+   * 全屏态由下面 .fullscreen-modal 规则接管(高度交给 flex)。
+   */
+  .ai-pack-modal .ant-modal-body {
+    max-height: calc(100vh - 180px);
+    overflow-y: auto !important;
+  }
+
+  /*
+   * 滚动条贴弹框右缘;内容区只留刚好不被压住的小槽(卡片与滚动条间隙约 8px)。
+   */
+  .ai-pack-modal.fullscreen-modal .ant-modal-body {
+    padding-right: 0 !important;
+  }
+
+  .ai-pack-modal.fullscreen-modal .scrollbar__view {
+    padding-right: 14px;
+  }
+
+  .ai-pack-modal.fullscreen-modal .scrollbar__bar.is-vertical {
+    right: 0;
+  }
+</style>

+ 7 - 3
ai-electron/frontend/src/ai/views/aiWorkbench/index.vue

@@ -71,6 +71,7 @@
 
         <!-- ④ 插件与技能 -->
         <AiPluginPage v-else-if="view === 'plugin'" />
+        <AiCaseDataPage v-else-if="view === 'casedata'" />
       </main>
 
       <!-- 右栏:本会话产物(工具结果导出的 CSV)。可收起/拖宽,首次产出自动展开 -->
@@ -108,6 +109,7 @@
   import AiModelPage from '../aiModel/index.vue';
   import AiExpertPage from '../aiExpert/index.vue';
   import AiPluginPage from '../aiPlugin/index.vue';
+  import AiCaseDataPage from '../aiCaseData/AiCaseDataPage.vue';
   import { useAiChatPage } from '../../composables/useAiChatPage';
   import { extractArtifacts } from '../../utils/artifacts';
   import type { AiExpert } from '../../api/types';
@@ -115,8 +117,8 @@
   const userStore = useUserStore();
   const { createConfirm } = useMessage();
 
-  type WorkbenchView = 'chat' | 'model' | 'agent' | 'plugin';
-  const WORKBENCH_VIEWS: WorkbenchView[] = ['chat', 'model', 'agent', 'plugin'];
+  type WorkbenchView = 'chat' | 'casedata' | 'model' | 'agent' | 'plugin';
+  const WORKBENCH_VIEWS: WorkbenchView[] = ['chat', 'casedata', 'model', 'agent', 'plugin'];
 
   /**
    * 视图恢复:从上传流程等外部全屏页返回工作台时(/?view=xxx),按参数恢复所在视图;
@@ -132,8 +134,10 @@
     { key: 'chat', icon: 'mdi:robot-outline', label: '对话' },
     // 研判方案沿用原「模型管理」的 brain 图标;模型管理改用芯片图标(与输入框的模型 chip 同形)
     { key: 'agent', icon: 'mdi:brain', label: '研判方案' },
-    { key: 'model', icon: 'mdi:chip', label: '模型管理' },
     { key: 'plugin', icon: 'mdi:puzzle-outline', label: '插件与技能' },
+    { key: 'model', icon: 'mdi:chip', label: '模型管理' },
+    // 文件管理:把传统侧「导入文件管理」表格复制进智能侧(副本见 ai/views/aiCaseData),传统页不动
+    { key: 'casedata', icon: 'mdi:database-outline', label: '文件管理' },
   ];
 
   const {

+ 2 - 1
ai-electron/frontend/src/core/components/Application/src/AppTopBar.vue

@@ -105,7 +105,8 @@
       showCaseExit: false,
       showLogo: true,
       logoMinWidth: null,
-      logoTitle: '',
+      // 默认顶栏标题(仅顶栏 Logo 用):带「·」分隔;工作台会显式传「清鉴 · 智能工作台」覆盖
+      logoTitle: '清鉴 · 线索调查工具',
     },
   );
 

+ 2 - 2
ai-electron/frontend/src/core/components/Application/src/AppWorkbenchToggle.vue

@@ -3,11 +3,11 @@
     <button
       type="button"
       :class="{ 'is-on': mode === 'traditional' }"
-      :title="mode === 'traditional' ? '当前:传统模式' : '切换到传统模式(现有布局)'"
+      :title="mode === 'traditional' ? '当前:自主模式' : '切换到自主模式(现有布局)'"
       @click="setMode('traditional')"
     >
       <Icon icon="i-mdi:view-dashboard-outline" :size="13" />
-      传统
+      自主
     </button>
     <button
       type="button"

+ 4 - 0
ai-electron/frontend/uno.config.ts

@@ -57,6 +57,10 @@ export default defineConfig({
     'i-mdi:chevron-down',
     'i-mdi:chevron-up',
     'i-mdi:database-search-outline',
+    // 文件管理(智能侧)菜单图标
+    'i-mdi:database-outline',
+    // 研判方案页「基于清洗结果智能分析」入口图标
+    'i-mdi:filter-variant',
     'i-mdi:format-list-bulleted',
     'i-mdi:table-search',
     'i-mdi:language-python',