Files
workspace/code/fms/.workbuddy-ai/memory/2026-09-16.md
T
2026-09-16 23:15:06 +08:00

34 KiB
Raw Blame History

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 这个 <span> 没有 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 的「叠放 + 透明淡入」:

.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 插槽渲染 <span :title>(照抄同文件 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 <cols> FROM (SELECT ROW_NUMBER() OVER (ORDER BY <order>) AS row_num, t.* FROM view t WHERE 1=1 AND <cond>) 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<List<String>> 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<List<String>>)、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 行)。
  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 失败未增加。