# 2026-09-16 工作日志 ## UI 控件横向内边距拆分(输入型 vs 点击型) **背景**:`--fms-control-padding-x` 原本被 6 处共用,输入框偏宽。 **改动**: - `src/theme/tokens.css` 新增 `--fms-field-padding-x: 4px`(输入型控件专用), `--fms-control-padding-x: 10px` 保留给按钮/标签页等「点击型」元素。 - 切到 `--fms-field-padding-x` 的 4 处:`ui/input/index.scss`(`.input-wrapper` 单行 + `.input-textarea` 多行)、`ui/select/index.scss`(触发器)、`ui/date/index.scss`(`.fms-date-trigger` / `.fms-range-trigger` 共用规则,DatePicker + RangePicker 都覆盖)。 - 保持 10px:`ui/button/index.scss:13`、`ui/tabs/index.scss:147`。 **结论**:以后调输入框内边距只动 `--fms-field-padding-x`,不会波及按钮/标签页。 ## DatePicker 触发器布局重构(清除按钮绝对定位) **问题**:`.fms-date-suffix` 这个 `` 没有 `v-if`,空态也占位;外层 `gap: 6px` 照常生效, 所以 input 右侧总有距离。更糟的是 hover 时清除按钮从 `display:none` 变 `inline-flex`, 会挤压 input 造成文字左右跳动。 **改法**(`ui/date/index.scss`): - `.fms-date-trigger` / `.fms-range-trigger`:加 `position: relative`,**删掉 `gap: 6px`**。 - `.fms-date-suffix`:改 `position: absolute` + `top/right/bottom: 0`,脱离文档流; `pointer-events: none`,子元素 `> *` 恢复 `auto`(避免绝对定位层挡住触发器点击)。 - `.fms-date-input` / `.fms-range-input`:`padding: 0`(原生元素不留内边距)。 **第二版修正(重要)**:第一版给 input 加了 `padding-right: 18px` 预留清除按钮位, 导致**无值时右侧恒有 18px 空白**。改为对齐 antd 的「叠放 + 透明淡入」: ```scss .fms-date-clear { display: inline-flex; /* 常驻 DOM,不再 display:none */ opacity: 0; /* 默认透明 */ pointer-events: none; transition: opacity var(--fms-duration-base) var(--fms-ease-standard); } .fms-date-trigger:is(:hover, :focus-within) .fms-date-clear, .fms-range-trigger:is(:hover, :focus-within) .fms-date-clear { opacity: 1; pointer-events: auto; } ``` ### 组件库通行做法(查证结论,别再自己发明) | | Ant Design | Element Plus | |---|---|---| | 按钮定位 | `absolute` | `absolute` | | 隐藏方式 | `visibility:hidden`(Input)/ `opacity:0`(Picker) | 条件渲染 | | 是否预留空间 | **不预留**,靠叠放/淡入淡出 | 常驻预留(`clearable` 时 padding+14px,被吐槽) | | 切换动画 | 有(opacity 过渡) | 无 | - antd Input 用 `visibility: hidden` 而非 `display:none`,因为前者**保留布局空间**,文字不跳。 - antd DatePicker 的 `-clear` 与 `-suffix` **叠在同一位置**,hover 时 clear 淡入、suffix 淡出。 - antd 原生 `input` 一律 `padding: 0`,内边距全由外层 wrapper 提供。 - **结论**:正确解法是「绝对定位 + opacity 过渡」,不是「预留 padding」。后者必然造成常驻空白。 **实测验证**(Playwright 量取真实渲染几何): - 未 hover:input `padding: 0/0`,触发器 296px 内 input 286px,Lgap 5 / Rgap 5,clear `opacity:0`。 - hover 后:**input 宽度漂移 0.00px、leftGap 漂移 0.00px**,clear `opacity:1`。 - 即:空间不浪费 + 文字不跳动,两个目标同时达成。 ### 复用经验:本地用 Playwright 验证前端视觉 本项目 `fms-vue` 没装 playwright;用托管 workspace 的: - 安装:`C:/Users/Administrator/.workbuddy-ai/binaries/node/versions/22.22.2-2/npm.cmd install playwright`(在 `C:/Users/Administrator/.workbuddy-ai/binaries/node/workspace`) - 运行:`"C:/Users/Administrator/.workbuddy-ai/binaries/node/versions/22.22.2-2/node.exe" script.mjs` - **坑**:ESM 不支持 `NODE_PATH`,也不支持裸目录绝对路径,必须写 `import { chromium } from 'file:///C:/.../node_modules/playwright/index.mjs'`。 - `/demo` 组件演示页需要登录态。绕过登录:后端 `POST /api/auth/login` (`{"orgid":"G3HD","userid":"g3soft","password":"g3soft"}`)拿 token, 用 `addInitScript` 写入 `sessionStorage['fms-login']`(形如 `{loginInfo:{token,user}}`) + `localStorage['fms-user']`(`{userInfo:{orgId,account}}`),再进 `/demo`。 - 左侧分类要点击「DatePicker 日期选择」才会渲染对应 demo,直接 goto 拿到 0 个触发器。 - 验证完记得删掉临时脚本和截图。 ## 侧栏改为黑色(深色导航面 + 对比度重配) **需求**:`fms-layout` 侧栏背景改黑,字体与图标颜色要跟着调(否则黑底上看不见)。 **关键决策:新增侧栏专用 token,不动全局中性色。** 浅色主题下侧栏要黑,但 `--fms-surface` / `--fms-text` 是顶栏、内容卡、**全部业务页面** 共用的浅底配色;直接改全局会让整个主区一起变深。故在 `src/theme/tokens.css` 的 `:root` 与 `.dark` 各加一组 `--fms-sidebar-*`,只被 `AppSidebar.vue` / `NavMenuItem.vue` 引用: | token | 浅色 | 深色 | |---|---|---| | `--fms-sidebar-bg` | `#111318` | `#0b0d11` | | `--fms-sidebar-text` | `#d7dbe2` | `#ced3db` | | `--fms-sidebar-text-secondary` | `#8b93a2` | `#838b99` | | `--fms-sidebar-hover-bg` | `rgba(255,255,255,.08)` | `.1` | | `--fms-sidebar-divider` | `rgba(255,255,255,.12)` | `.14` | | `--fms-sidebar-accent-fill` | `color-mix(primary 38%, #000)` | `46%` | | `--fms-sidebar-accent-edge` | `color-mix(primary 40%, #fff)` | `46%` | | `--fms-sidebar-logo-bg` | `color-mix(primary 55%, #fff)` | `45%` | **踩坑点(下次改深色底必看)**:默认主色「墨蓝」`#0f172b` **本身就是近黑**。 原激活态写法 `color: var(--fms-primary)` + `background: color-mix(primary 10%, transparent)` 在黑侧栏上等于完全不可见。 **已改文件**: - `src/theme/tokens.css` — 新增上述两组变量。 - `src/layouts/components/AppSidebar.vue` — 底色/文字/hover/focus/次级文字/滚动条换成 `--fms-sidebar-*`;去掉 `box-shadow: var(--fms-shadow-right)`(阴影落黑底不可见, 反在交界处糊灰边),改 `border-right: 1px solid var(--fms-sidebar-divider)`; Logo 底改 `--fms-sidebar-logo-bg` + 图标 `#111318`。 **团队下拉菜单(`.fms-team-menu-*`)保持浅色 token 不变** —— 它在 `body` 下(Teleport)。 - `src/layouts/components/NavMenuItem.vue` — 全部 `--fms-text` / `--fms-secondary` / `--fms-border` 换 `--fms-sidebar-*`;hover 文字提到 `#fff`;激活态见下节。 - `src/layouts/DefaultLayout.vue` — 侧栏侧分隔条 `::before` 提亮 (`--fms-sidebar-text-secondary`),否则浅色下拖拽线在黑底上看不见。 对比度(底 `#111318`):菜单文字 12.7:1、次级文字/箭头 5.9:1、Logo 图标 7.2:1。 ## 菜单选中项:主题色实底 + 白字 + 主色亮描边 **需求**:选中菜单项用主题色做背景、文字白色。 **走过的弯路(重要,别再试)**:第一版按常规模板写成 「`background: color-mix(primary 42%, #fff)` + `color: #fff`」(即把主色**提亮**当浅色底)。 实测白字对比度只有 **1.4~2.7:1**,全部不合格且远低于 AA 的 4.5:1 —— 因为任何主色与白混到能看清色相的亮度,都已经是浅色(pastel),白字压不住。 **结论:黑侧栏上的选中块必须是「深底 + 白字」,不能用「浅底 + 深字」。** 方案是把主题色拆成一对 token: - `--fms-sidebar-accent-fill` = `color-mix(primary 38%, #000)` —— **压暗**保留色相, 作为实底,白字对比度 5.1~19.2:1 全部达标(最深/最浅预设都过)。 - `--fms-sidebar-accent-edge` = `color-mix(primary 40%, #fff)` —— **提亮**做主色描边。 没有它,近黑主色(墨蓝)的实底与侧栏底只有 **1.04:1**,选中态整块消失; 有描边后边界可辨度 3.96:1。 即:**文字可读性靠填充、选中可辨识性靠描边,二者分离**,这样 9 个预设色全过。 提亮版仍用于 focus 描边(`NavMenuItem` / `AppSidebar` 的 `:focus-visible`)。 **连带细节**:加了 `border: 1px` 会挤占尺寸导致选中时文案左右跳动, 故 `.fms-nav-link` 的 padding 由 `0 8px` 收为 `0 7px` 补偿(折叠态规则在后、特异性更高, `padding: 0` 不受影响)。激活项 hover 只轻微提亮填充,不退回普通 hover 的半透明白底。 **验证**:`pnpm lint` 无新增问题(唯一 error 是既有 `createCellRenderer.old.js` 的 `no-unused-vars`,与本改动无关);`pnpm build --outDir dist-verify --emptyOutDir` 通过。 ## 表单设计器「数据视图」栅格宽度改为普通数字输入 **需求**:模块管理 → 表单设计 → 数据,`栅格宽度` 列不要下拉、不要「整行 24/24」文案,直接显示数字。 **改动**(`components/design-editor/FormDesignEditor.vue`): - `formDataColumns()` 里 `b_colspan` 的 `editor` 由 `{ type:'select', options: () => COLSPAN_OPTIONS }` 改为 `{ type:'input', inputType:'number', onChange: (v, row) => { row.b_colspan = clamp(1..24) } }`, 列宽 120 → 100。仅做边界收敛,不做文案换算。 - 删掉 `form/useFormGrid.js` 的 `COLSPAN_OPTIONS` 导出(唯一使用点已移除)及对应 import。 **注意**:`设计`视图属性面板(`FormInspector.vue`)的栅格宽度早就是普通 Input, **数据视图**这一列是最后残留的下拉。`列表配置`(TableDesignEditor,mode=list)的 数据视图只有「列宽 b_width」,本来就是数字输入,无同类问题。 **联动行为未改**:设计视图改栅格宽度仍会压缩同行其它字段(`onColspanChange`), 这是保证逻辑行与视觉行一致的必要逻辑;数据视图改值不触发联动。 **验证**:改动文件 oxlint 0 error;`tests/unit/{form-edit-panel-mount,module-table-edit-panel-dnd,module-list-config-panel-dnd,schema-render}.spec.js` 25 passed。 ## 设计器视图切换改名:「数据」→「批量编辑」 **背景**:用户问「数据这部分这样显示合适,还是放 modal」。结论是**保持内联切换**, modal 只适合短事务,而这里是 10 列批量编辑网格;`ui/modal/modal.vue` 默认宽 520px 且自带全屏按钮,本身就说明装不下。真痛点其实是命名 —— 「数据」会被理解成录入业务数据。 **改名**:`VIEW_OPTIONS` 第二项 `数据` → `批量编辑`(`value` 仍是 `'data'`,不动状态机)。 只改第二项,「设计」保留,减少肌肉记忆成本。没选「属性表」是因为会和设计视图右侧 「属性」面板撞概念。 **落地**: - `FormDesignEditor.vue` / `TableDesignEditor.vue` 的 `VIEW_OPTIONS` 各加 `title`, 并用 `Segmented` 的 `#label` 插槽渲染 ``(照抄同文件 `EDIT_MODE_OPTIONS` 写法)。 `Segmented` 插槽传的是 `option.raw`(原始对象),`segmented-item-text` 这个类**没有任何 CSS**, 所以自定义 span 与默认渲染等价,只是多了 title。 - 同步注释里的「数据视图」→「批量编辑视图」:两个编辑器 + `EditDataView.vue` + `schemaModel.js`。 UI 上的「返回设计视图」按钮文案保留。 **结论(后续别再提 modal)**:内联切换的另一个硬理由是 `selectedNodeKey ↔ activeKey` 已互通, 切视图不丢选中字段;modal 会打断「同一上下文两副镜头」。若哪天真要画布同时可见, 正确解法是照 `components/fms-module-list/FmsQuerySettingsDrawer.vue` 用 Drawer, 但要把「改完即写草稿」改成「草稿+保存」,代价大于收益。 **验证**:design-editor 目录 oxlint 0 error;5 个相关测试文件 30 passed。 `module-list-components.spec.js` 的 2 个失败是既有基线(高级查询行间连接符,测试只 import fms-module-list 组件,与本次改动无关)。`oxfmt --check` 报 EditDataView/schemaModel/useFormGrid 有格式问题,实测是 **CRLF vs LF 的行尾差异**(`diff --strip-trailing-cr` 完全相同),非本次引入。 ## 列表配置的视图切换器移到左边(与表单设计对齐) **现象**:两个编辑器都用 `mm-edit__head` 三段式(left/center/right),但只有 `TableDesignEditor` 真的有三段 —— `left` 空占位、`center` 放视图切换、`right` 放按钮, `left`/`right` 都是 `flex: 1 1 auto`,于是切换器被挤到**中间**。 `FormDesignEditor` 只有 `center` + `right` 两段,切换器实际贴在**最左**。 所以同一个控件在两个页签位置不一致。 **改法**:把 `TableDesignEditor` 的 `mm-edit__view-switch` 移进 `mm-edit__head-left` (放在 `编辑方式` 之前,保证两个页签的**最左控件是同一个**),删掉空的 `head-center` div 及其 CSS。`FormDesignEditor` 不动。 **验证方式(可复用)**:临时写 `tests/unit/.tmp-head-check.spec.js`,用 `mount` 挂 `ModuleListConfigPanel` / `ModuleEditPanel`,断言 `.mm-edit__head-left` 内含 `.mm-edit__view-switch` 且 `.mm-edit__head-center` 不存在,并 `console.log(head.html())` 肉眼核对结构,跑完删除。比开浏览器登录(`g3soft` 密码非空串,空串登录返回 401)快得多。 注意:用 `#label` 插槽渲染的 span **没有** `segmented-item-text` 类,选择器要写 `.segmented-item-label > span`(写 `.mm-edit__view-switch span` 会把外层 label span 也算进去, 每个标签命中两次)。 **遗留**:`FormDesignEditor.vue` 里那个 `mm-edit__head-center` 类名已名不副实(它是 第一个 flex 子元素,视觉上就是左),改名属可选清理。 ### 补:FormDesignEditor 的 `mm-edit__head-center` 改名(上条遗留已清) 模板类名 `mm-edit__head-center` → `mm-edit__head-left`,删掉原 `.mm-edit__head-center` CSS(`flex: none` + 居中),改由既有的 `.mm-edit__head-left`(`flex: 1 1 auto` + `justify-content: flex-start`)接管;同时改掉上面那段「左右两翼挤出中间位、保证居中」 的过期注释。**两个编辑器的 header 骨架现在完全一致**: `head-left`(视图切换) + `head-right`(操作按钮),用临时挂载测试断言过 `LIST === FORM`。 ## 导出 / 批量导出:参考实现调研(讨论阶段,尚未定案) **参考实现 `D:/workspace/code/g3hd/frontend` 有两条完全不同的导出路径:** 1. **前端生成**(`src/utils/excel.js` 的 `ExportVxeTableExcel` / `ExportExcel`): 纯浏览器 ExcelJS。数据源是 VXE 表格内存 —— `getCheckboxRecords()` 有勾选就用勾选, 否则 `getFullData()`。支持多级表头(手算 merge)、列宽、displayFormat、页脚合计。 **无任何权限校验**(数据已在浏览器里)。 2. **后端生成**(`src/components/g3-excel-export/index.vue` + 后端 `ExcelController`): 「导出所有页」两步向导。步骤1 列配置表存旧表 **`s_columnexcel`**(per view + userid: col_FieldName/col_Caption/col_Width/col_Visible/ShowSummary);步骤2 按 `limit=10000` 把 total 切段,逐段 `POST /excel/exportAll`(EasyExcel),前端 JSZip 打包 zip。 后端 SQL 用 `select identity(int,1,1),* into #tmptj from view ... where indexid between a and b` 拿行号做区间切片。 **旧实现的坑(别照搬)**: - `handleDownloadZip` 存的是 `mx_mapfilename`,释放时却 `revokeObjectURL(file.url)` → blob URL 从未释放。 - 列清单由前端传后端照单全收 → 字段权限形同虚设。 - `#tmptj` 临时表并发导出会让 tempdb 膨胀。 **新系统现状(已核实)**: - 前端**无**任何导出代码;列表页 `FmsModuleListPage.vue` 工具栏已有「更多操作」Dropdown (刷新数据 / 恢复栏位设置 / 保存栏位设置),导出入口可挂这里。 - 后端 **无 POI / EasyExcel 依赖**(pom 里没有)。 - **后端完全没有权限过滤**:`s_user_field_power` / `s_user_data_power` / `s_power` 在 fms-api 里**零命中**。目前只有 JWT 鉴权 + 机构数据源路由,任何登录用户都能直接 POST `/api/data/loaddata` 取全表。→ 导出会是第一个把「后端权限出口」变成刚需的功能。 - 设计文档**已定义**:`s_user_field_power.b_export`(字段级导出权限)、`b_view=0` 的字段 在导出中统一移除(14.3 节);开发规范第 10 条要求字段可见/可查询/可导出权限必须在 后端 SQL 落实。`export` 已是标准动作权限点(`ModulePowerPanel.ACTION_NAMES`), 编码格式 `action.{moduleId}.export`。 - 配置载体现成:`s_module_schema.view`(系统默认,columns 已含 field/visible/width/format, 支持 `type:'group'` 分组表头)+ `s_user_module_pref.view`(个人增量覆盖)。 **不需要新建 s_columnexcel 之类的表。** **推荐方向(待用户拍板)**:后端生成 xlsx(一次请求 + 流式游标 + SXSSF), 导出列/合计复用 view schema,前置补 `SqlPermissionBuilder`(动作权限 + 字段白名单 + 数据范围 AND)。详见当次回复。 ### 导出设计:用户已明确的四条约束 1. 「批量导出」= **全量导出**,单模块,不需要多模块同时导。 2. **普通导出必须与列表一致**(列/条件/排序沿用列表);**高级导出**可选列并**保存导出列**。 3. **先不做行数上限**。 4. 合计行 + 多级表头**都必须**。 ### 第二个参考实现:`D:/workspace/code/container-ocr` - 前端 `ocr-vue/src/views/container/high-download-dialog.vue`(652 行)「高级下载」: 弹窗**自带独立查询条件**(上传日期区间/箱号/操作员,条件为空即全部)、顶部节流 300ms 实时 `COUNT(*)` 显示「共 N 条」、模式三选一(全部导出 / 按区间导出 / 分页拆分导出)、 重置 + 下载、JSZip + file-saver 打包 zip。 - 后端 `ocr-springboot` `POST /data/range`(`DataListController` → `DbUtils.range`): `SELECT FROM (SELECT ROW_NUMBER() OVER (ORDER BY ) AS row_num, t.* FROM view t WHERE 1=1 AND ) sub WHERE sub.row_num BETWEEN ? AND ?`,**1 基闭区间**。 **比 g3hd 的 `#tmptj` 临时表干净,可直接借鉴**;无 ORDER BY 时用 `ORDER BY (SELECT NULL)` 兜底。 注意它把 `validateFinalSql` 里的非法关键字检查**注释掉了**,别跟着抄。 ### 我给出的方案(待确认) - 两个入口:普通导出(不弹窗,列/条件沿用列表,范围=勾选行或当前页);批量导出(弹窗,参考 ocr-vue 形态 + 选列 + 保存列 + 三模式)。 - **后端是唯一出口,且后端自己按 moduleId 读 `s_module_schema.view` + `s_user_module_pref` 推导导出列**,不信任前端传的列清单(否则字段权限形同虚设)。这同时天然保证「与列表一致」。 - 保存导出列:`s_user_module_pref` 加 `b_schema_type = 'export'`,零新表。 - 合计行/分组表头来自 view schema(列节点加 `summary`,group 节点映射 EasyExcel 的 `List>` head)。 - 不上限的替代保护:全局导出并发闸门 + 每用户 1 个 + 语句超时 + 流式写 + 引导用分卷模式。 - 接口 `POST /api/export/list`(moduleId / searchCondition / orderBy / rowKeys+keyField / columns? / rangeStart+rangeEnd? / volumeSize? / withSummary / format)。 ## 导出 P0 落地(后端权限出口 + 导出接口 + 前端导出按钮) 用户确认了三个设计问题后开工:导出范围规则(有勾选导勾选、否则当前条件全部)、 保存导出列存 `s_user_module_pref`(`b_schema_type='export'`,P1 用)、高级导出条件区复用现有查询面板。 **新增文件** - `fms-api/.../service/SqlPermissionService.java` —— 三层权限(动作/字段/数据范围), 是「后端 SQL 权限出口」的第一个实现,后续列表查询复用。 - `fms-api/.../service/ExportSchemaService.java` —— 按 moduleId 读 s_module + s_field + s_module_schema.view,按**与列表相同的顺序**还原导出列(含 group 节点 → 多级表头、summary → 合计)。 - `fms-api/.../service/ExportService.java` —— 拼 SQL(条件 + 数据范围 + 勾选行全 AND)、 SQL 层聚合合计、JDBC 游标 + POI SXSSF 流式写;全局并发 2 / 每用户 1。 - `fms-api/.../service/ExportHeaderWriter.java` —— 多级表头:**分组只横向合并、叶子只纵向合并**, 靠「分组层级必浅于叶子」保证合并区域永不重叠。 - `fms-api/.../controller/ExportController.java` —— `POST /export/list`。 - 测试:`ExportServiceTests`(14 项,SQL 拼装 + 合并规则)、`ExportServiceIntegrationTests`(3 项,连真实库)。 **修改** - `pom.xml` 加 `poi-ooxml:5.2.5`;`DbUtils` 暴露 `buildSafeOrderBy`(原私有 buildPageOrderBy 的薄包装)。 - 前端 `services/exportService.js`(blob + `filename*` 解析 + 错误 JSON 识别)、 `services/http.js` 响应拦截器加 blob 直通、`FmsModuleListPage` 抽出 `buildSearchCondition()` 供取数与导出共用(这才是「与列表一致」的落地方式),工具栏加「导出」按钮。 **写测试时的两个发现(实现是对的,是我的预期错了)** 1. 两级表头里叶子已在最后一行,**不该**再纵向合并 —— 否则会与分组的横向合并区域重叠。 2. 叶子标题写在自己所在层级并向下合并,不是写在第一行。 **验证** - 后端:`ExportServiceTests` 14 passed、`ExportServiceIntegrationTests` 3 passed (真实库导出 → xlsx 可被 POI 解析、表头/数据行正确、勾选行只出 1 行、模块不存在时响应体为空即未写流)。 - 全量后端 46 项,仅 `OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry` 失败(既有基线)。 - 前端:改动文件 oxlint 0 error;`fms-module-list-page` 3 个失败均为引用已不存在的 `.filter-drawer` 的过期断言(既有基线)。 **未做(留给 P1)**:批量导出弹窗(条件区/条数/三模式/选列/保存列)、前端按 `canPower` 隐藏导出按钮、 合计与分组表头在模块管理里的配置入口(现在没有任何模块配 `summary` / `group`,所以线上看不到合计行)。 ## 导出功能交接文档 新建仓库根 **`FMS导出功能设计与进度.md`**(与其它设计文档同级,226 行 / 9 节), 内容:现状一句话 → 已完成文件清单 → 接口契约 → 设计决策与理由 → 怎么验证(含本机 mvn 的可行命令) → 数据库现状(解释为什么看不到合计行)→ 未完成(P1/P2 + 已知口径取舍)→ 批量导出弹窗设计 → 附元数据事实。 **用户明确**:合计行与分组表头**以后再说**,所以 P1 第一项(合计/分组的配置入口)暂缓, 下一步应优先批量导出弹窗(后端 `columns` / `rangeStart` / `rangeEnd` 参数已实现,弹窗可直接对接)。 ### 澄清:普通导出现在就是「全量」,不是当前页 用户问「批量导出还没做吗」,核对代码时发现一个**与最初讨论不一致的事实**: `ExportService.buildSelectSql` 只在传了 `rangeStart`/`rangeEnd` 时才加 `OFFSET/FETCH`, 不传就是不限行数 → 前端「导出」按钮(不传区间)实际导的是**当前查询条件下的全部行**。 已把 `ExportServiceIntegrationTests` 的断言从 `getLastRowNum() >= 1` 收紧为 `== rowCountOfModule()`(`b_con_type` 8 行 → 导出 8 行数据),跑通确认。 **影响**:「批量导出」剩下的差异只有 选列 / 区间 / 分卷 / 保存导出列 / 条数预览, 数据范围与普通导出相同。两种走法待产品定: (a) 保留现状(一键全量,批量导出靠选列分卷区分);(b) 普通导出改当前页(前端补传当前页区间,后端已支持)。 已写进 `FMS导出功能设计与进度.md` 第 0 节与 6.1 第 0 项,**未定之前不要动**。 同时给文档补了 **第 9 节「实施过程中踩到的坑」**:环境(start.cmd 路径错 / mvn 起不来 / 8088 是旧实例 / 登不进浏览器)、选型(EasyExcel→POI 的原因)、实现(排序校验找错口、多级表头合并规则第一版想错)、 数据权限现实(权限表全空、无模块配 summary/group)、顺带发现的既有问题。 ## Excel 库换成 Apache Fesod (Incubating) **需求**:把第一版的 POI SXSSF 换成 Apache Fesod(EasyExcel → FastExcel → Apache Fesod 的续作)。 **坐标**(`org.apache.fesod:fesod-sheet:2.0.2-incubating`,支持 jdk8–jdk25): 自带 POI 5.5.1 / commons-csv / ehcache,**不要再单独引 POI**。包名 `org.apache.fesod.sheet.*`, 入口类 `FesodSheet`(同时保留 `EasyExcel` / `FastExcel` 兼容类名),API 与 EasyExcel 一致。 **查 API 不要靠记忆**:`jar tf` + `javap` 直接看 D:/data/maven 里下下来的 jar 最快。 **改动** - 删 `ExportHeaderWriter.java`(手写合并区域那段),新增 `ExportHeadBuilder`(表头树 → `List>`)、`ExportStyleHandler`(`CellWriteHandler`:表头/数据/合计三样式 + 逐列列宽)、 `ExportWorkbookWriter`(表头 + 分批明细 + 合计行 → xlsx,行来源抽象成 `RowSource`, 所以能脱离数据库单测)。 - `ExportService`:写出层重写;`applyValue` → `normalizeValue`(时间落文本、其余保留原类型); 游标改成「先 `executeQuery()` 再设响应头」,这样 SQL 报错仍能返回 JSON。 - 多级表头合并交给 `automaticMergeHead(true)`;`ExportServiceTests` 里那 4 个合并区域断言 换成表头结构断言;新增 `ExportWorkbookWriterTests`(6 项)。 **被自己的测试抓到的两个 Fesod 行为坑(已修)** 1. **0 行导出会生成「没有任何工作表」的空 xlsx** —— Fesod 只在 `write` 被调用时才创建工作表, 所以最后一批即使为空也必须调一次。 2. 合计行是拼出来的数据行,「合计」标签要显式构造(落在第一个无聚合值的列)。 **验证**:导出相关 23 项全过(14 + 6 + 3,含连真实库的端到端);后端全量 52 项, 唯一失败仍是既有基线 `OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry`。 文档 `FMS导出功能设计与进度.md` 已同步(§1.1 文件清单 / §3.5 §3.7 决策 / §4.2 计数 / §9.2 §9.3 踩坑)。 ## 批量导出弹窗(P1 第 2 项)完成 **需求**:导出按钮改成只显示图标;然后做批量导出功能。 **后端新增** - `POST /api/export/count` → `{total}`。**单独做而不是复用列表分页的 total**,因为它必须走 与导出完全相同的权限链路(动作权限 + 字段裁剪 + 数据范围),列表分页的 total 不受数据范围约束。 - `volumeSize` 分卷:一次请求后端切卷打包 zip(每卷一个 xlsx,命名 `{模块名}_{起}-{止}.xlsx`), 与区间互斥、逐卷不输出合计。`ExportRequest` 加了 `volumeSize`(现在 10 个字段)。 与旧系统 `g3hd`「前端切段 N 次请求 + 前端 JSZip」的做法不同 —— 前端不用管进度与重试。 - `ExportService` 重构出 `prepare()`(权限 + 列推导 + WHERE/ORDER 共用)与 `countRows()`;`buildSelectSql` 加 `paged` 参数(区间与分卷共用 OFFSET/FETCH)。 **前端新增/改动** - `FmsExportDialog.vue`:选列(默认全选或已保存的列,可拖拽排序)+ 三种方式(全部/区间/分卷)+ 保存配置。 **条件只读**(沿用列表条件),不另写一套条件表单。 - `useModulePref.js`:`loadModulePref`/`saveModulePref` 加 `schemaType` 参数(默认 `query`), 供「我的导出列」复用;个人导出列存 `s_user_module_pref` 的 `b_schema_type='export'`, 「恢复列表列」= 删除该行覆盖。 - `FmsModuleListPage`:导出按钮改纯图标(`class="list-toolbar__icon"`,间距回到 4px); 「更多操作」加「批量导出」;抽出 `exportColumns`(列表可见列 + 字段名称)传给弹窗。 - 测试 `tests/unit/fms-export-dialog.spec.js`(8 项,一次通过)。 **踩坑(都写进文档 §9.3 了)** - 测试里用 `b_id = N'绝不存在的值'` 构造「查不到」,SQL Server 报 `Error converting data type nvarchar to bigint` —— **`b_con_type.b_id` 是 bigint**(业务表用雪花 ID), 不是字典表那种 varchar。教训:构造测试条件前先确认列的真实类型。 - 二进制响应不要调 `response.setCharacterEncoding`,否则 content-type 变成 `application/zip;charset=UTF-8`。 **验证**:后端 54 项(唯一失败仍是既有基线 `OrgDataSourceFactoryTests`); 导出相关 25 项全过(14 + 6 + 5,含真实库的导出/计数/分卷);前端弹窗 8 项全过。 既有前端基线失败:`fms-module-list-page.spec.js` 3 项(`.filter-drawer` 已不存在)、 `module-pref.spec.js` 2 项(默认布局断言与实现不一致,测试未跟上)。 ### 批量导出弹窗改版(用户反馈「看起来很乱」) **改动**(`FmsExportDialog.vue` 重写) - **删掉全部说明性文字**:模式说明(单个文件 / 只导第 N 到 M 条 / 每 N 条一个文件打包 zip)、 条件说明(沿用列表当前条件)、底部那段「分组表头与合计行按列表配置生成…」全删。 顶部原来那条 summary 也去掉,条数挪到 footer 左侧。 - **导出列改竖排列表 + 可拖拽排序**(`vuedraggable`,项目已有依赖,设计编辑器同款; `handle=".export-dialog__handle"` + GripVertical 图标)。显示顺序即导出顺序。 - **动作从 4 个减到 2 个**:「全选」「保存为我的导出列」。 关键洞察:候选集就是列表可见列,所以**「全选」等价于原来的「恢复列表列」**(顺手清个人覆盖); 「反选」删掉。 - **导出方式用 `Segmented`** 替代三个带说明的 radio;参数行只在需要时出现,且去掉「第/条」等冗余字。 - 个人保存的列:已保存的按保存顺序排前面并勾上,其余列补在后面(不勾选),这样用户还能勾回来。 **踩到的坑**:`ui/modal` 的 `footer` prop 为 false 时**整个 footer 容器(含 `#footer` 插槽)都不渲染**, 不是「隐藏默认按钮、保留插槽」。用 `#footer` 自定义按钮时必须让 `footer` 保持默认 true。 (测试里表现为 `footerButtons()[1]` undefined。) **验证**:`tests/unit/fms-export-dialog.spec.js` 8 项全过(新增「保存顺序优先 + 未保存列补在后面」 的断言;「全选」断言 emit 空数组)。**拖拽手势本身没法在 jsdom 里模拟** —— 只验证了 「渲染顺序 = 数组顺序 → 导出列顺序」这条链路,SortableJS 的实际拖动依赖真浏览器, 但它与设计编辑器用的是同一个库同一套写法。 ### 批量导出弹窗:导出方式参数行对齐 用户反馈「分卷的也出现在下面」。原因是参数行原来作为 inline span 跟在 `Segmented` 后面, 靠 flex-wrap 决定换不换行 —— 区间参数短、常留在行内,分卷参数长(「每 5000 条一个文件,共 2 个」) 才换行,两种模式位置不一致。 改法:`导出方式` 改成「左标题 + 右控件列」的 grid,控件列里 `Segmented` 与参数行纵向排列, 于是**区间与分卷的参数都固定单独占一行、与 Segmented 同列对齐**。 纯布局改动,`tests/unit/fms-export-dialog.spec.js` 8 项仍全过(选择器 `.export-dialog__params input` 未变)。 ### 批量导出弹窗:导出方式布局再修 + 按钮文案 用户要求:① 导出方式 label 与 Segmented 同一行;② 填写的参数单独一行;③ 主按钮文案「下载」→「导出」。 上一版把 Segmented 与参数行放进了一个「控件列」div(flex column),再用 `align-items: center` 对齐 grid —— 结果标题是相对**整个控件列**(两行高)垂直居中的,看起来就掉到两行中间了。 改成**三个 grid 项平铺**:标题(row1 col1)、Segmented(row1 col2)、参数行(row2 col2), 各居自己所在行,标题自然与 Segmented 同行,参数行单独一行且与 Segmented 左对齐(无需硬编码缩进)。 `tests/unit/fms-export-dialog.spec.js` 补了一条结构断言锁住这个布局(9 项全过)。 ### 批量导出弹窗:Segmented 不要 100% 宽 `导出方式` 的两列 grid 里,Segmented 作为 grid 项默认 `justify-self: stretch`, 被拉伸到整列宽(看起来占满一行)。加 `.export-dialog__modes { justify-self: start }` 按内容宽度靠左; 参数行同理(`display: inline-flex` 在 grid 里也会被拉伸),一并加 `justify-self: start`。 **通用提醒**:grid 里的 `display:flex/inline-flex` 子元素默认都会被拉伸,想按内容宽度必须显式 `justify-self: start`。 ### 批量导出弹窗:按钮改名 + 列区加高 - 「保存为我的导出列」→「**保存配置**」(4 个字,与旁边的「全选」等宽,动作区更紧凑)。 测试里的按钮文案查找同步改了。 - 导出列列表 `max-height` 220px → **340px**(约 10 行)。 ### 列表合计:该页合计 + 总合计(table footer 两行) 1. **schemaRender.js**:`buildListConfig` 透传 `node.summary`(`sum`/`count`)。 2. **FmsTable.vue**:新增 `footerData` / `footerConfig` props 传给 `StkTable`; 加 `:deep(.stk-footer) td` 样式(上边框 + 背景 + 加粗)。 3. **FmsModuleTable.vue**: - 新增 `grandTotals` prop(后端 `/export/totals` 返回的全量聚合)。 - `computed footerData`:按 `listConfig.summary` 从 `dataSource` 实时算「该页合计」; 第一列显示标签("该页合计"/"总合计"),其余列按字段类型格式化(money 保留 2 位)。 4. **FmsModuleListPage.vue**: - `fetchData` 成功后异步调 `loadTotalsApi` 拿总合计。 - 普通导出(toolbar 按钮)改为**仅导出当前页**(无勾选时补传 `rangeStart/rangeEnd`), 使「导出合计 = 当页合计」自洽;勾选行时仍导勾选行。 5. **exportService.js**:新增 `loadTotalsApi`(调 `POST /export/totals`);顺手修了 `countExportApi` 里未定义 `post` 的 bug(改为 `http.post`)。 6. **后端**:`ExportService` 已在此前改好(区间合计走 `ROW_NUMBER` 子查询、新增 `totals()` 方法、 `ExportController` 新增 `/export/totals` endpoint)。 测试:前端 lint 0 error,export 对话框 9 项全过,fms-table-virtual 11 项全过; 后端 ExportService*Tests 24 项全过;全项目基线 13 失败未增加。