34 KiB
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,clearopacity: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 有两条完全不同的导出路径:
- 前端生成(
src/utils/excel.js的ExportVxeTableExcel/ExportExcel): 纯浏览器 ExcelJS。数据源是 VXE 表格内存 ——getCheckboxRecords()有勾选就用勾选, 否则getFullData()。支持多级表头(手算 merge)、列宽、displayFormat、页脚合计。 无任何权限校验(数据已在浏览器里)。 - 后端生成(
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)。详见当次回复。
导出设计:用户已明确的四条约束
- 「批量导出」= 全量导出,单模块,不需要多模块同时导。
- 普通导出必须与列表一致(列/条件/排序沿用列表);高级导出可选列并保存导出列。
- 先不做行数上限。
- 合计行 + 多级表头都必须。
第二个参考实现:D:/workspace/code/container-ocr
- 前端
ocr-vue/src/views/container/high-download-dialog.vue(652 行)「高级下载」: 弹窗自带独立查询条件(上传日期区间/箱号/操作员,条件为空即全部)、顶部节流 300ms 实时COUNT(*)显示「共 N 条」、模式三选一(全部导出 / 按区间导出 / 分页拆分导出)、 重置 + 下载、JSZip + file-saver 打包 zip。 - 后端
ocr-springbootPOST /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()供取数与导出共用(这才是「与列表一致」的落地方式),工具栏加「导出」按钮。
写测试时的两个发现(实现是对的,是我的预期错了)
- 两级表头里叶子已在最后一行,不该再纵向合并 —— 否则会与分组的横向合并区域重叠。
- 叶子标题写在自己所在层级并向下合并,不是写在第一行。
验证
- 后端:
ExportServiceTests14 passed、ExportServiceIntegrationTests3 passed (真实库导出 → xlsx 可被 POI 解析、表头/数据行正确、勾选行只出 1 行、模块不存在时响应体为空即未写流)。 - 全量后端 46 项,仅
OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry失败(既有基线)。 - 前端:改动文件 oxlint 0 error;
fms-module-list-page3 个失败均为引用已不存在的.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 行为坑(已修)
- 0 行导出会生成「没有任何工作表」的空 xlsx —— Fesod 只在
write被调用时才创建工作表, 所以最后一批即使为空也必须调一次。 - 合计行是拼出来的数据行,「合计」标签要显式构造(落在第一个无聚合值的列)。
验证:导出相关 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-height220px → 340px(约 10 行)。
列表合计:该页合计 + 总合计(table footer 两行)
- schemaRender.js:
buildListConfig透传node.summary(sum/count)。 - FmsTable.vue:新增
footerData/footerConfigprops 传给StkTable; 加:deep(.stk-footer) td样式(上边框 + 背景 + 加粗)。 - FmsModuleTable.vue:
- 新增
grandTotalsprop(后端/export/totals返回的全量聚合)。 computed footerData:按listConfig.summary从dataSource实时算「该页合计」; 第一列显示标签("该页合计"/"总合计"),其余列按字段类型格式化(money 保留 2 位)。
- 新增
- FmsModuleListPage.vue:
fetchData成功后异步调loadTotalsApi拿总合计。- 普通导出(toolbar 按钮)改为仅导出当前页(无勾选时补传
rangeStart/rangeEnd), 使「导出合计 = 当页合计」自洽;勾选行时仍导勾选行。
- exportService.js:新增
loadTotalsApi(调POST /export/totals);顺手修了countExportApi里未定义post的 bug(改为http.post)。 - 后端:
ExportService已在此前改好(区间合计走ROW_NUMBER子查询、新增totals()方法、ExportController新增/export/totalsendpoint)。
测试:前端 lint 0 error,export 对话框 9 项全过,fms-table-virtual 11 项全过; 后端 ExportService*Tests 24 项全过;全项目基线 13 失败未增加。