diff --git a/code/fms/.workbuddy-ai/memory/2026-09-17.md b/code/fms/.workbuddy-ai/memory/2026-09-17.md new file mode 100644 index 00000000..3a4a4d08 --- /dev/null +++ b/code/fms/.workbuddy-ai/memory/2026-09-17.md @@ -0,0 +1,17 @@ +# 2026-09-17 + +## 修复:合作伙伴分类删除报「order_by 不能为空」 + +- 现象:`base/othercompany` 删除分类 → 「分类数据检查失败:order_by 不能为空」。 +- 根因:`onCategoryDelete` 的守卫查询调 `pageDataApi`(`/data/page`)**漏传 `orderBy`**; + 后端 `DataService.page` 用 `getRequiredString(params,"order_by")`,`DbUtils.buildPageOrderBy` + 对空值抛「order_by 不能为空」。`/data/loaddata` 的 order_by 是可选的,`/data/page` 是必填 + (分页需要确定性排序),所以同类守卫在 `views/system/user` 用 loaddata 没问题、这里用 page 就炸。 +- 改动:`src/views/base/othercompany/index.vue` 守卫查询加 `orderBy: 'b_id ASC'`; + `tests/views/othercompany.spec.js` 新增回归用例断言 pageDataApi 的入参(含 orderBy)。 + 全套 10 个用例通过;oxlint 干净(oxfmt 全仓库既有 format issues,未动)。 +- 已核对仓库内另 3 处 `pageDataApi` 调用(FileListPanel / useLookupOptions / FmsModuleListPage)都传了 orderBy。 + +## 维护 + +- `MEMORY.md` 超限被截断,已按主题重写精简(合并重复项、删掉过期与可推导内容)。 diff --git a/code/fms/.workbuddy-ai/memory/MEMORY.md b/code/fms/.workbuddy-ai/memory/MEMORY.md index a18a4f6a..fe35a944 100644 --- a/code/fms/.workbuddy-ai/memory/MEMORY.md +++ b/code/fms/.workbuddy-ai/memory/MEMORY.md @@ -1,195 +1,92 @@ # FMS 项目长期约定 -## 目录与文件 +## 目录 / 文档 / 库 -- 后端 `fms-api`,前端 `fms-vue`(Vue 3.5 + Vite 8 + Pinia 4 + pnpm,**纯 JS 无 TS**)。 -- 设计文档集中在仓库根:`FMS新系统核心表结构设计.md`(新库 FMS)、`FMS业务表设计.md`、 - `Fms旧系统表结构.md`(旧库 G3HY2025,**只有表、不含视图**)、`开发规范.md`、`FMS删除规则引擎设计.md`。 -- 建库/注册脚本在 `sql/`:`fms_core.sql`、`fms_business_*.sql`(建表+视图)、`fms_module_*.sql`(模块元数据)。 +- 后端 `fms-api`(Spring Boot + JDK21,JDBC+Druid 无 ORM);前端 `fms-vue`(Vue 3.5 + Vite 8 + Pinia,纯 JS)。 + `fms-vue-old1/old2` 仅供历史参考,**不要改**。 +- 根目录设计文档:`FMS新系统核心表结构设计.md`(权威表结构)、`FMS业务表设计.md`、`开发规范.md`(AI 规范)、 + `Fms旧系统表结构.md`(旧库 G3HY2025,只有表无视图)、`FMS删除规则引擎设计.md`。 +- 建库/模块注册脚本在 `sql/`(`fms_core.sql`、`fms_business_*.sql`、`fms_module_*.sql`)。 +- SQL Server `118.89.70.199:1433`:旧库 `G3HY2025`,新库 **FMS**(新系统一律用 FMS,**生产库,写前先确认**)。 +- 机构→库映射在 `fms-api/config/dbconfigs/{ORG_ID}.properties`(gitignore):`G3HD.properties` → FMS, + `G3HY2025.properties` → G3HY2025。**没有 FMS.properties**。 +- 后端 8088(context-path `/api`),前端 dev 5082(`/api` → 127.0.0.1:8088)。直连接口需先 `/auth/login`。 -## 数据库 +## 元数据核心表(真实列名,已连库核对,别再猜) -- 服务器 `118.89.70.199:1433`;**旧库 `G3HY2025`**、**新库 `FMS`**(新系统一律用 FMS)。 -- 机构与库的映射写在 `fms-api/config/dbconfigs/{ORG_ID}.properties`(该目录被 gitignore)。 - 已存在:`G3HD.properties` → databaseName=**FMS**;`G3HY2025.properties` → databaseName=G3HY2025。 - **没有 FMS.properties**,G3HD 就是指向新库的那份。 -- 后端 `fms-api` 监听 **8088**(context-path `/api`);前端 dev server **5082**(proxy `/api` → 127.0.0.1:8088)。 -- 直接 curl 后端接口会返回 `401 未登录或登录已失效`,需先 `/auth/login`。 +- `s_module`:PK **`b_id`**(无 `b_module_code`;无 `b_route`,那是 `s_menu` 的);本身是树: + `b_parent_id`/`b_depth`/`b_path`;另有 `b_module_type`(module/data/virtual)、`b_view_table`、`b_save_table`、 + `b_key_field`、`b_order_sql`、`b_query_sql`、`b_config_json`、`b_canuse`、`b_xh`、`b_bz`、`b_name`、`b_i18n`。 +- `s_field`:PK **`(b_module_id, b_field)`**,无 `b_id`;`b_type` 实测 `input`/`number`/`checkbox`/`datetime`/`date`。 +- `s_module_schema`:PK `(b_module_id, b_schema_type∈view|edit|query)`,`b_schema_json`。 +- `s_menu`:`b_id`/`b_parent_id`/`b_depth`/`b_path`/`b_menu_type`(directory|page)/`b_route`/`b_icon`;`s_menu_module` 绑菜单与模块。 +- **权限靠 `b_id`**:permissionStore 的 `moduleCodes` 即 `s_module.b_id`,页面 `MODULE_CODE` 必须等于它; + 还要在 `s_menu_module` 里绑菜单,否则非超管看不到授权入口。 +- 列表模块编码统一 `v_`+表名(`v_b_othercompany`),`b_view_table`→视图,`b_save_table`→真实表。 +- schema JSON 形状:`view{columns:[{field,width}|{type:group,title,children}]}`、 + `edit{children:[{field,span,required,readonly,defaultValue}|group]}`、`query{groups:[{conditions}],quick}`。 +- 路由守卫只看 `meta.moduleId`/`meta.adminOnly`;`views/module/**` 是 adminOnly(仅 g3soft)。 -## 元数据驱动的核心约定 +## 树结构规范(模块/菜单/部门/分类树通用) -- 模块树 `s_module`(`b_module_type` ∈ module/data/virtual),字段 `s_field`, - 界面配置 `s_module_schema`(PK = `b_module_id` + `b_schema_type` ∈ view/edit/query,JSON 存在 `b_schema_json`)。 -- **这三张表的真实列名(已连库核对,写脚本时别再猜)**: - - `s_module`:PK 是 **`b_id`**(**没有** `b_module_code` 列);且它**本身就是树** —— - `b_parent_id` / `b_depth` / `b_path`;另有 `b_module_type`(`module` / `data`)、 - `b_view_table` / `b_save_table` / `b_key_field` / `b_order_sql` / `b_query_sql` / `b_config_json` - / `b_canuse` / `b_xh` / `b_bz` / `b_name` / `b_i18n`。**没有 `b_route`**(那是 `s_menu` 的列)。 - - `s_field`:PK 是 **`(b_module_id, b_field)`**,**没有 `b_id`**;列为 - `b_field` / `b_name` / `b_i18n` / `b_type` / `b_canuse` / `b_xh`。`b_type` 实测取值: - `input` / `number` / `checkbox` / `datetime` / `date`。 - - `s_module_schema`:PK `(b_module_id, b_schema_type)`,另有 `b_schema_json` / `b_canuse`。 - - `s_menu`:`b_id` / `b_parent_id` / `b_depth` / `b_path` / `b_name` / `b_i18n` / - `b_menu_type`(`directory` 或 `page`)/ `b_route` / `b_icon` / `b_xh` / `b_canuse`。 - - `s_menu_module`:`b_menu_id` / `b_module_id` / `b_xh` / `b_canuse`。 -- **模块编码写法**:列表模块统一 `v_` + 表名(如 `v_b_contact`),`b_view_table` 指向查询视图, - `b_save_table` 指向真实表。数据模块挂在分组 module 节点下(如 `base`、`system`)。 -- **前端权限靠 `b_id`**:`usePermissionStore` 的 `moduleCodes` 就是 `s_module.b_id`; - `canPower(code, power)` 查 `${b_id}.${power}`。所以页面里写的 `MODULE_CODE` 必须等于模块 `b_id`。 - 另外要记得在 `s_menu_module` 里把菜单绑到模块,否则侧栏入口不会出现在角色的「菜单权限」树中,非超管无法授权。 -- **s_module_schema JSON 真实形状**(与 `schemaRender.js` 一一对应,别写错): - - `view`:`{ schemaVersion, columns: [ {field,width} | {type:'group',title,children} ] }` - - `edit`:`{ schemaVersion, children: [ {field,span,required,readonly,defaultValue} | {type:'group',...} ] }` - - `query`:`{ schemaVersion, groups: [ { conditions: [ {field,operator} ] } ], quick: [field] }` -- **s_menu 无种子数据**,业务菜单由「菜单管理」页面维护。`s_menu_module` 把菜单关联到 data/virtual 模块。 -- 路由守卫只看 `meta.moduleId` / `meta.adminOnly`(`to.matched.some`),**没有 moduleId 的路由不需要模块记录即可访问**。 +`b_parent_id` 是权威关系,`b_depth`(根=0)、`b_path`(`/cn/east/`)是派生字段,移动节点要同事务更新全部后代; +不一致时以 `b_parent_id` 为准。工具在 `src/utils/tree.js`(`buildSortedTree`/`computeTreeMove`/`collectDescendantIds`/ +`applyTreePosition`/`filterTreeByText`/`findTreeNode`),展示层用 `components/fms-tree/FmsTree.vue`(自管虚拟根)。 -## 树结构规范(模块树 / 菜单树 / 部门树通用) +## 后端通用接口(前端业务数据只走这几个) -见 `FMS新系统核心表结构设计.md` 第 1.7 节:**邻接表为主 + `b_depth`/`b_path` 冗余**。 +- `POST /auth/login`:字段是**小写** `orgid`/`userid`/`password`;`orgid=G3HD`。 +- `/data/loaddata`:`view_name`/`search_condition`/**可选** `order_by`/`search_columns`(真 JSON 数组)。 +- `/data/page`:**`order_by` 必填**(`DbUtils.buildPageOrderBy` 会报「order_by 不能为空」), + 分页参数名是 **`page_no`/`page_size`**(不是 pageindex/pagesize)。 +- `/data/saveobjt`:body 是数组,每项 `{table, key_field, deletes, updates, inserts}`,**`key_field` 必填** + (复合主键逗号分隔),顺序 delete→update→insert。 +- 模块元数据不由接口下发,前端读本地 schema JSON,改元数据无需重启后端。 +- `DbUtils` 是 SQL 唯一出口:标识符加 `[]`,`validateReadOnlySql()` 只允许查询。 -- `b_parent_id` 是**权威关系**;`b_depth`、`b_path` 是业务层维护的**派生字段**。 -- 根节点 `b_parent_id` 为 `NULL`,`b_depth` 从 0 起算;`b_path` 形如 `/cn/east/`(前后都带 `/`)。 -- 新增/移动节点时必须在同一事务中同步当前节点及**全部后代**的 `b_depth`/`b_path`。 -- 二者不一致时**以 `b_parent_id` 为准**,并提供按父子关系重建冗余字段的能力。 -- 全项目通用的树工具在 **`fms-vue/src/utils/tree.js`**(`buildSortedTree` / `computeTreeMove` / - `collectDescendantIds` / `applyTreePosition` / `filterTreeByText` 等)。新增一棵树优先复用它。 -- 展示层适配器 **`fms-vue/src/components/fms-tree/FmsTree.vue`**:自管 `loading` 遮罩与 - 「全部分类」虚拟根(空选中键 ⟺ 虚拟根,调用方不需要知道 `rootKey`)。 +## 环境坑(本机特有) -## 主题与样式约定 +- **编译/测试 fms-api 唯一可行命令**(start.cmd 里的 JDK/Maven 路径都是坏的): + ``` + cd /d/workspace/code/fms/fms-api && /d/devtool/jdk/jdk21/bin/java \ + -Dclassworlds.conf="D:/devtool/maven/bin/m2.conf" -Dmaven.home="D:/devtool/maven" \ + -Dmaven.multiModuleProjectDirectory="D:/workspace/code/fms/fms-api" \ + -cp "D:/devtool/maven/boot/plexus-classworlds-2.11.0.jar" \ + org.codehaus.plexus.classworlds.launcher.Launcher -o -Dtest=XxxTests test + ``` + 本地仓库是 `D:\data\maven`(不是 ~/.m2);联网下载去掉 `-o`;`target/` 被 git 跟踪,编译后有噪音。 +- 无 sqlcmd/pyodbc/pymssql:用 `fms-api/tools/migration/RunSqlFile.java` 或一次性 `SqlRunner.java` + + JDBC jar(`~/.m2/.../mssql-jdbc-13.4.0.jre11.jar`)+ `/d/devtool/jdk/jdk17/bin/java`。 + 脚本要按行首 `GO` 分批,路径传绝对路径。 +- `vite build` 被 safe-delete 守卫拦(要删 600+ 文件):用 `--outDir dist-verify --emptyOutDir`; + 清理用 `find dist-verify -type f -delete` 再 `find dist-verify -type d -empty -delete`。 +- `oxfmt --check` 全仓库都报 format issues(版本漂移),**不是自己引入的**,别顺手格式化整个仓库。 +- 本工作区可能被多会话同时编辑,验证失败项先用 `find -mmin -N` 确认是否自己引入。 -- 全站配色唯一真相是 **`fms-vue/src/theme/tokens.css`**:浅色定义在 `:root`, - 深色覆盖在 `.dark`,**两组必须同步增删同名变量**;`--fms-primary` 由 JS 写入, - 衍生色一律用 `color-mix()` 派生。改主题只动这个文件,不在组件里硬编码颜色。 -- **侧栏当前是浅色的**(`--fms-surface` 白底 + `box-shadow: var(--fms-shadow-right)`), - 与顶栏同为「框架面」。2026-09-16 曾改造为黑色并已**按用户要求全部回退**, - 现存代码里**没有** `--fms-sidebar-*` 变量。若再看到「侧栏是黑的」的描述即为过期信息。 -- 主色预设(`src/theme/presets.js`)**默认是「墨蓝」`#0f172b`,本身就是近黑**。 - 所以任何深色底上做主题色元素时,别直接套浅底模板(见下条实测结论)。 -- **深色底 + 主题色高亮态的实测结论(回退前验证过,重做深色侧栏时直接复用)**: - 「浅底 + 白字」走不通 —— 主色向白混到能看清色相时已成 pastel,白字对比度实测仅 - **1.4~2.7:1**(9 个预设色全不合格)。必须用「深底 + 白字 + 主色亮描边」三件套: - 填充 = `color-mix(primary 38%, #000)`(白字 5.1~19.2:1 达标), - 描边 = `color-mix(primary 40%, #fff)`(近黑主色填充与黑底仅 1.04:1,全靠描边勾边界)。 - 即**文字可读性靠填充、可辨识性靠描边,二者分离**才能覆盖全部预设色。 -- **要改侧栏配色时的正确做法**:新增一组 `--fms-sidebar-*` 专用 token, - 只给 `layouts/components/AppSidebar.vue` 与 `NavMenuItem.vue` 用。 - **不要改 `--fms-surface` / `--fms-text` 等全局中性色** —— 那些是顶栏、内容卡、 - 全部业务页面共用的,改了会让整个主区一起变。 -- **Teleport 到 `body` 的浮层不算侧栏内元素**(如团队下拉 `.fms-team-menu-*`): - 它们渲染在浅色浮层容器里,改侧栏配色时必须保持全局浅色 token,不能跟着一起改。 +## 测试基线(既有失败,别去修) -## 测试 +- 后端 `OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry`(断言 Druid retry=0,实测 1)。 +- 前端 `tests/unit/fms-module-list-page.spec.js` 里引用 `.filter-drawer`/`.filter-row__*` 的用例 + (类名已不存在,是高级查询面板改版后的过期断言)—— 另有约 13 个长期失败基线。 +- `tests/helpers/app.js` 的 `mountPage` 用 shallowMount 且默认打桩子组件:给 `Splitter`/`SplitterPanel` + 打桩时键名要写 **Vue 推断名 `'splitter'`/`'panel'`**(文件名小写),写 `'SplitterPanel'` 打不上桩; + 自定义组件传 `false` 可强制真实挂载。`global` 会整体覆盖内部 stubs,别在里面再写 stubs。 -- 测试 harness 在 `fms-vue/tests/helpers/app.js`:`mountPage` 内部用 **shallowMount**,默认打桩所有子组件。 -- **陷阱**:给布局容器(`Splitter`/`SplitterPanel`)打桩时,键名要写 **Vue 推断出的组件名**。 - 这两个 SFC 文件名是 `splitter.vue` / `panel.vue`,推断名是 `'splitter'` / `'panel'`, - **不是** `'SplitterPanel'`;写错键就打不上桩,容器成空壳、插槽不渲染。 - 自定义组件(如 `DeptTreePanel`)传 `false` 可强制真实挂载。 -- `mountPage` 的 `global` 选项会整体覆盖内部 `stubs`,不要在 `global` 里再写 `stubs`。 -- 既有 13 个失败用例(query-UI / router-auth / login / scratch repro)是长期基线,非新改动引入。 +## 主题约定 -## 环境坑 +- 配色唯一真相 `fms-vue/src/theme/tokens.css`:`:root` 浅色 + `.dark` 深色必须同步增删同名变量, + `--fms-primary` 由 JS 写入,衍生色用 `color-mix()`。改主题只动这个文件。 +- **侧栏当前是浅色的**(不存在 `--fms-sidebar-*` 变量;若再看到「侧栏是黑的」即为过期信息)。 + 要改侧栏配色就新增 `--fms-sidebar-*` 只给 `AppSidebar.vue`/`NavMenuItem.vue` 用, + **别动 `--fms-surface`/`--fms-text` 等全局中性色**(顶栏与所有业务页共用)。 +- 深底+主题色高亮必须用「深底 + 白字 + 主色亮描边」:填充 `color-mix(primary 38%, #000)`、 + 描边 `color-mix(primary 40%, #fff)`;「浅底+白字」对比度只有 1.4~2.7:1,不可用。 +- Teleport 到 body 的浮层(如 `.fms-team-menu-*`)不在侧栏内,保持全局浅色 token。 -- `vite build` 会被本机 safe-delete 守卫拦下(默认输出目录要删 672 个文件 > 阈值 50), - 验证构建请用 `--outDir dist-verify --emptyOutDir`,用完清理。 -- 清理 `dist-verify`(600+ 文件)时 `rm -rf` 同样被 safe-delete 拦(genie-trash 报 - `Some operations were aborted`)。可行做法:`find dist-verify -type f -delete`, - 再 `find dist-verify -type d -empty -delete`。 -- 本工作区**可能被多个会话同时编辑**,验证失败项前先用 `find -mmin -N` 按 mtime 确认是否自己引入。 +## 导出(P0 已落地,摘要) -## 直连 FMS 库执行 SQL(本机无 sqlcmd / pymssql / pyodbc 时) - -本机**没有** `sqlcmd`,Python 也**没有** `pymssql` / `pyodbc`。但有两样现成资源可拼出可用客户端: - -- JDBC 驱动:`C:/Users/admin/.m2/repository/com/microsoft/sqlserver/mssql-jdbc/13.4.0.jre11/mssql-jdbc-13.4.0.jre11.jar` -- JDK:`/d/devtool/jdk/jdk17/bin/java`(另有 jdk21 / jdk8 目录) - -做法:写一个一次性 `SqlRunner.java`,读脚本文件 → 按**独立成行的 `GO`** 分批 → 逐批 -`Statement.execute` 并把结果集打成对齐表格。编译运行: - -``` -/d/devtool/jdk/jdk17/bin/javac -d . SqlRunner.java -/d/devtool/jdk/jdk17/bin/java -cp ".;" SqlRunner "D:/abs/path/to.sql" -``` - -注意: -- **脚本没有 `GO` 就会被当成一个批次整体发送**,中途一句报错会导致后面全不执行; - 自检/探查脚本记得用 `GO` 分开。 -- `SqlRunner` 传相对路径会以 `cd` 后的目录解析,**一律传绝对路径**避免踩坑。 -- 连接串取自 `fms-api/config/dbconfigs/G3HD.properties`(gitignored),指向**远端生产库** - `118.89.70.199:1433` / `FMS`。**改动生产库前先探查现状、并清理自己造的测试数据。** -- 该库是**生产环境**,执行任何写操作前先确认清楚。 - -## 直连后端 API(比查库更接近真实行为) - -后端 `fms-api` 跑在 **8088**(context-path `/api`)。验证数据/权限链路时优先走它: - -- 登录 `POST /api/auth/login`:字段是**小写** `orgid` / `userid` / `password`。 - `orgid` 传 `G3HD`(对应 `dbconfigs/G3HD.properties` → `FMS` 库);缺了报「机构码不能为空」。 -- `POST /api/data/loaddata`:`view_name` / `search_condition` / `order_by` / `search_columns`(JSON 数组)。 -- `POST /api/data/page`:追加 `page_no` / `page_size`。 - **参数名是 `page_no`/`page_size`,不是 `pageindex`/`pagesize`**;`search_columns` 必须是**真 JSON 数组**, - 传字符串会报「search_columns 参数类型不正确」。 -- `POST /api/data/saveobjt`:body 是**数组**,每项形如 - `{table, key_field, deletes, updates, inserts}` —— **`key_field` 必填**,缺了报「key_field 不能为空」; - 复合主键用逗号分隔(如 `s_field` 传 `b_module_id,b_field`)。执行顺序 delete → update → insert。 -- 模块元数据(`s_module` / `s_field` / `s_module_schema`)**不由后端接口下发**, - 前端从本地 schema JSON 解析,所以改元数据后无需重启后端。 - -## 编译 / 测试 fms-api(本机 mvn 入口全是坏的,用这条) - -`fms-api/start.cmd` 里的 `JAVA_HOME=D:\devtool\jdk\jdk-21` 与 -`MAVEN_HOME=D:\devtool\maven\apache-maven-3.8.9` **两个路径都不存在** -(实际是 `D:\devtool\jdk\jdk21`、`D:\devtool\maven`),照抄必失败。 -`/d/devtool/maven/bin/mvn`(shell 脚本)在 Git Bash 下报 -`找不到或无法加载主类 org.codehaus.plexus.classworlds.launcher.Launcher`; -从 Bash 调 `cmd.exe` 被安全策略拦;另一个 shell 工具的输出不回流(得写文件再读)。 - -**唯一可行做法:直接用 JDK 拉起 Maven 的 classworlds launcher。** - -```bash -cd /d/workspace/code/fms/fms-api -/d/devtool/jdk/jdk21/bin/java \ - -Dclassworlds.conf="D:/devtool/maven/bin/m2.conf" \ - -Dmaven.home="D:/devtool/maven" \ - -Dmaven.multiModuleProjectDirectory="D:/workspace/code/fms/fms-api" \ - -cp "D:/devtool/maven/boot/plexus-classworlds-2.11.0.jar" \ - org.codehaus.plexus.classworlds.launcher.Launcher -o -Dtest=XxxTests test -``` - -- 本地仓库是 **`D:\data\maven`**(settings.xml 里改过 localRepository),不是 `~/.m2`。 -- 新依赖要联网首次下载,去掉 `-o` 即可(`repo1.maven.org` 可达)。 -- 中文乱码时输出里加 `grep -a`。 -- `target/` 被 git 跟踪,编译后会有一堆 `.class` / surefire 报告显示为 modified,属正常噪音。 - -**既有基线失败(不是自己引入的,别去修)**: -- `OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry` - (断言 Druid `getConnectionErrorRetryAttempts()==0`,实测 1)。 -- 前端 `tests/unit/fms-module-list-page.spec.js` 里所有引用 `.filter-drawer` / - `.filter-row__*` 的用例 —— 这些类名在 `src/` 里**已不存在**,是高级查询面板改版后没更新的过期断言。 - -## 导出功能(P0 已落地) - -- 入口:`POST /api/export/list`(`ExportController`),**返回文件流**,不是 ApiResponse。 -- 分层:`SqlPermissionService`(动作权限 + 字段白名单 + 数据范围)→ `ExportSchemaService` - (按 moduleId 读 s_module + s_field + s_module_schema.view,**与列表同源推导导出列**)→ - `ExportService`(拼 SQL、SQL 层聚合合计、JDBC 游标 + Fesod 流式写)→ - `ExportWorkbookWriter`(表头/明细/合计 → xlsx)→ `ExportHeadBuilder`(表头树 → `List>`) - + `ExportStyleHandler`(三种样式 + 逐列列宽)。 -- 关键约束:**导出列不由前端传**,否则字段权限(`s_user_field_power.b_export`)形同虚设; - **所有校验必须在写 `getOutputStream()` 之前完成**,且游标要「先 `executeQuery()` 再设响应头」, - 否则 SQL 报错时用户拿到的是半截文件而不是 JSON 错误。 -- 依赖:**`org.apache.fesod:fesod-sheet:2.0.2-incubating`**(Apache Fesod Incubating = EasyExcel 的 - Apache 续作,包名 `org.apache.fesod.sheet.*`,入口类 `FesodSheet`,API 与 EasyExcel 一致, - 自带 POI 5.5.1,**不要再单独引 POI**)。多级表头合并用 `automaticMergeHead(true)`, - 各列标题层数可不同(Fesod 底部对齐补空,列名落在最后一行)。 - **两个 Fesod 行为坑**:① 0 行导出必须仍调用一次 `write`(哪怕传空集合),否则生成的 xlsx - **没有任何工作表**;② 合计行是拼出来的数据行,「合计」标签要自己构造(落在第一个无聚合值的列)。 -- 合计行只在 view schema 的列节点带 `summary: sum|count` 时才出现;**目前没有任何模块配了它**, - 所以线上看不到合计行(多级表头同理,没有模块配 `type:group`)。 -- 前端:`services/exportService.js` + `FmsModuleListPage` 工具栏「导出」按钮; - `services/http.js` 的响应拦截器加了 blob 直通分支(下载类响应要能读 headers)。 -- 权限现状:`s_power` 只有 2 行(`s_i18n_type`),`s_user_field_power` / `s_user_data_power` - 都是空表。非超管走白名单语义会被拒(「没有导出权限」),**g3soft 提权可直接用**。 +`POST /api/export/list` 返回文件流:`SqlPermissionService` → `ExportSchemaService`(列与列表同源推导) +→ `ExportService`(Fesod 流式写)→ xlsx。**导出列不由前端传**,否则字段权限形同虚设; +校验必须在 `getOutputStream()` 前完成。依赖 `org.apache.fesod:fesod-sheet:2.0.2-incubating`(自带 POI,别再引)。 +Fesod 坑:0 行也要调一次 `write`,否则无工作表;合计行要自己构造标签。 +权限现状:`s_power` 只有 2 行,非超管会被拒,g3soft 可直接用。 diff --git a/code/fms/FMS导出功能设计与进度.md b/code/fms/FMS导出功能设计与进度.md deleted file mode 100644 index 945f6155..00000000 --- a/code/fms/FMS导出功能设计与进度.md +++ /dev/null @@ -1,353 +0,0 @@ -# FMS 导出功能 · 设计与进度 - -> 状态:**P0 + 批量导出已完成并验证通过;剩余见第 6 节。** -> 最后更新:2026-09-16 - ---- - -## 0. 一句话现状 - -**导出**:列表工具栏的图标按钮,一键走人 —— 列与条件完全沿用列表,后端按模块元数据推导导出列、 -套字段权限与数据范围、流式生成 xlsx 返回下载。 - -**批量导出**:「更多操作 → 批量导出」打开弹窗,可选列(并能保存成个人偏好)、 -按条数区间导出、或每 N 条分卷打包成 zip。条件沿用列表当前条件,弹窗里不重做一套条件表单。 - -**合计行与多级表头**:后端已支持(读 view schema 的 `summary` 与 `type:'group'`), -但没有任何模块配置过,所以界面上还看不到(用户已明确「以后再说」)。 - -> ⚠️ **需要澄清一点(与最初讨论不一致)**:「导出」按钮导的**不是当前页,而是当前查询条件下的全部行**。 -> 后端只在传了 `rangeStart` / `rangeEnd` 时才加 `OFFSET/FETCH`,不传就是不限行数。 -> 集成测试已断言「导出行数 == 模块视图总行数」(`b_con_type` 8 行 → 8 行数据)。 -> 于是「批量导出」的差异在**选列 / 区间 / 分卷 / 保存导出列 / 条数预览**,数据范围上两者一样。 -> 若希望普通导出只导当前页,前端补传当前页区间即可(后端参数已就绪),是一行改动 —— 待定,见第 6.1 节。 - ---- - -## 1. 已完成(P0) - -### 1.1 后端 - -| 文件 | 职责 | -| --- | --- | -| `fms-api/.../service/SqlPermissionService.java` | **后端 SQL 权限出口**,三层:动作权限 / 字段白名单 / 数据范围。这是全项目第一个实现,后续列表查询接入时复用同一套判定 | -| `fms-api/.../service/ExportSchemaService.java` | 按 `moduleId` 读 `s_module` + `s_field` + `s_module_schema.view`,还原导出列与表头树 | -| `fms-api/.../service/ExportService.java` | 拼 SQL、SQL 层聚合合计、JDBC 游标 + Fesod 流式写出、并发闸门 | -| `fms-api/.../service/ExportWorkbookWriter.java` | 表头 + 明细(分批)+ 合计行 → xlsx;行来源抽象成 `RowSource`,所以能脱离数据库单测 | -| `fms-api/.../service/ExportHeadBuilder.java` | view schema 表头树 → Fesod 的 `List>` 表头结构 | -| `fms-api/.../service/ExportStyleHandler.java` | 表头 / 数据行 / 合计行三种样式 + 逐列列宽(`CellWriteHandler`) | -| `fms-api/.../controller/ExportController.java` | `POST /api/export/list`(文件流)与 `POST /api/export/count`(行数) | -| `src/test/.../ExportServiceTests.java` | 14 项:SQL 拼装、权限条件组合、合计 SQL、表头结构 | -| `src/test/.../ExportWorkbookWriterTests.java` | 6 项:表头 / 明细 / 合计 → xlsx 的写出(含分组表头与多批行数) | -| `src/test/.../ExportServiceIntegrationTests.java` | 5 项:连真实库端到端导出、计数、分卷 zip | - -改动: -- `pom.xml` 新增 `org.apache.fesod:fesod-sheet:2.0.2-incubating`(Apache Fesod Incubating,EasyExcel 的 Apache 续作); -- `DbUtils` 新增 `public String buildSafeOrderBy(String)`(原私有 `buildPageOrderBy` 的薄包装,导出复用它做排序校验,不另写一套)。 - -### 1.2 前端 - -| 文件 | 说明 | -| --- | --- | -| `fms-vue/src/services/exportService.js` | 新增。`exportListApi`(blob 下载 + `filename*` 解析 + 错误 JSON 识别)、`countExportApi` | -| `fms-vue/src/services/http.js` | 响应拦截器加 blob 直通分支(下载类响应需要读 headers) | -| `fms-vue/src/components/fms-module-list/FmsExportDialog.vue` | 新增。批量导出弹窗:选列 / 三种导出方式 / 保存导出列 | -| `fms-vue/src/components/fms-module-list/FmsModuleListPage.vue` | 工具栏「导出」图标按钮 + `handleExport()`;抽出 `buildSearchCondition()` 供取数与导出共用;「更多操作 → 批量导出」接线;个人导出列的读写 | -| `fms-vue/src/components/fms-module-list/useModulePref.js` | `loadModulePref` / `saveModulePref` 加 `schemaType` 参数(默认仍是 `query`),供「我的导出列」复用 | -| `fms-vue/tests/unit/fms-export-dialog.spec.js` | 新增。8 项:默认全选、取消勾选后的列、区间 / 分卷参数、非法输入不发起请求、保存 / 恢复导出列 | - ---- - -## 2. 接口契约 - -``` -POST /api/export/list → 文件流(xlsx;分卷时是 zip) -POST /api/export/count → ApiResponse<{ total }> -``` - -两个接口吃同一套参数,`count` 忽略导出方式相关参数。 - -| 参数 | 必填 | 说明 | -| --- | --- | --- | -| `moduleId` | ✅ | 模块编码。后端据此读元数据与权限,**不接受前端传列清单** | -| `searchCondition` | | 查询条件(不含 WHERE),与列表当前条件一致;后端再 AND 数据范围 | -| `orderBy` | | 排序子句;缺省用模块默认排序 `s_module.b_order_sql` | -| `rowKeys` | | 勾选行主键值数组;非空时只导出这些行(用 `?` 占位绑定,不拼字面量) | -| `keyField` | | 勾选行主键列;缺省用 `s_module.b_key_field` | -| `columns` | | 勾选导出的字段;省略 = 沿用列表全部列(批量导出用) | -| `rangeStart` / `rangeEnd` | | 按条数区间(1 基,闭区间),两者同时给出才生效,走 `OFFSET/FETCH`。**不传即导出全部行** | -| `volumeSize` | | 分卷行数:> 0 时返回 zip(每卷一个 xlsx,命名 `{模块名}_{起}-{止}.xlsx`)。与区间互斥,逐卷不输出合计行 | -| `withSummary` | | 是否输出合计行,默认 `true` | - -响应: -- 成功:文件流,`Content-Type` 为 `application/vnd...spreadsheetml.sheet`(分卷时 `application/zip`), - 文件名在 `Content-Disposition: attachment; filename*=UTF-8''{urlencoded}`; -- 失败:**HTTP 200 + JSON 业务错误**(`{code, message, data}`)。前端必须按 `content-type` 区分, - 否则用户会下到一个内容其实是 JSON 的 `.xlsx`。 - -`POST /api/export/count` 单独存在(而不是复用列表分页的 total)的原因是: -它必须走**与导出完全相同**的权限链路(动作权限 + 字段裁剪 + 数据范围), -列表分页的 total 不受数据范围约束,配了数据范围时两者会对不上。 - ---- - -## 3. 设计决策与理由 - -1. **后端是唯一出口,且导出列由后端自己读元数据推导。** - 前端只传 `moduleId`。理由是字段权限(`s_user_field_power.b_export`)只能在后端执行, - 若信任前端传来的列清单,改一下 payload 就能导出工资列。副产品是「普通导出与列表一致」 - 成了同源推导的必然结果,而不是两边各自实现再祈祷一致。 - -2. **`ExportSchemaService` 与列表用同一套还原顺序**(`s_module` → `s_field`(`b_canuse=1`、 - 标识符合法)→ `s_module_schema.view` 按文档顺序取叶子、递归展开分组 → 过滤 `visible=false` - 与未知字段)。前端对应实现是 `schemaRender.js` 的 `buildListConfig`。 - -3. **所有校验必须在写 `response.getOutputStream()` 之前完成。** - 一旦开始写流就不能再改 header 和状态码,失败只会给用户一个损坏文件且看不到错误。 - `ExportService.export()` 的顺序是:参数校验 → 动作权限 → 列推导 + 字段裁剪 → - 数据范围 → SQL 校验 → 合计 → **`executeQuery()`** → 才设响应头并开始写流。 - 注意游标是「先查后设头」:这样 SQL 本身报错(列名写错、语法问题)仍然能返回 JSON; - 代价是一旦开始写流,中途的数据库异常无法再以 JSON 回传 —— 流式导出的固有权衡。 - 集成测试专门验证了这条(模块不存在时响应体为空)。 - -4. **流式**:JDBC `TYPE_FORWARD_ONLY` + `setFetchSize(500)` 配 Fesod 分批 `write`(底层是 SXSSF, - 内存占用与总行数无关;每批 1000 行)。合计用**独立聚合 SQL**(`SUM()` / `COUNT()`), - 不把明细拉到内存 reduce。 - -5. **用 Apache Fesod (Incubating)**(`org.apache.fesod:fesod-sheet:2.0.2-incubating`,EasyExcel 的 Apache 续作, - 自带 POI 5.5.1 / Commons CSV / Ehcache,**不要再单独引 POI**)。 - 最初一版是自己用 POI SXSSF 手写,后按需求换成 Fesod:多级表头合并、样式、列宽都有现成能力, - 少维护一层。**注意 Fesod 的 API 与 EasyExcel 一致**(入口类 `FesodSheet`,同时保留了 - `EasyExcel` / `FastExcel` 兼容类名),所以旧资料可以直接参考。 - -6. **「先不做行数上限」的替代保护**:全局同时最多 2 个导出任务、同一用户同时 1 个、 - 语句走 Druid 超时、流式写。若将来要加硬上限,位置在 `ExportService.export()` 开头的校验段。 - -7. **多级表头交给 Fesod**:`ExportHeadBuilder` 只把表头树转成 `List>` - (外层 = 叶子列,内层 = 该列自上而下的标题),合并由 `automaticMergeHead(true)` 负责。 - 实测**各列内层长度可以不同**(顶层叶子只有 1 层),Fesod 会底部对齐补空 —— - 列名一定落在表头最后一行,分组标题按相邻同值横向合并,正是要的形态。 - (第一版自己手写合并区域时踩过「分组横向 + 叶子纵向区域重叠」的坑,换库后这段代码直接删掉了。) - ---- - -## 4. 怎么验证 - -### 4.1 本机编译 / 测试(**注意:`start.cmd` 是坏的**) - -`fms-api/start.cmd` 里的 `JAVA_HOME=D:\devtool\jdk\jdk-21` 与 -`MAVEN_HOME=D:\devtool\maven\apache-maven-3.8.9` **两个路径都不存在** -(实际是 `D:\devtool\jdk\jdk21` 与 `D:\devtool\maven`)。 -`bin/mvn` 这个 shell 脚本在 Git Bash 下会报「找不到或无法加载主类 -`org.codehaus.plexus.classworlds.launcher.Launcher`」。 - -可行的做法是直接用 JDK 拉起 Maven 的 classworlds launcher: - -```bash -cd /d/workspace/code/fms/fms-api -/d/devtool/jdk/jdk21/bin/java \ - -Dclassworlds.conf="D:/devtool/maven/bin/m2.conf" \ - -Dmaven.home="D:/devtool/maven" \ - -Dmaven.multiModuleProjectDirectory="D:/workspace/code/fms/fms-api" \ - -cp "D:/devtool/maven/boot/plexus-classworlds-2.11.0.jar" \ - org.codehaus.plexus.classworlds.launcher.Launcher -o -Dtest=ExportServiceTests test -``` - -- 本地仓库是 `D:\data\maven`(`settings.xml` 改过 `localRepository`),不是 `~/.m2`; -- 新依赖首次要联网,去掉 `-o`; -- 输出中文乱码时加 `grep -a`。 - -### 4.2 本次验证结果 - -- `ExportServiceTests`:14 passed。 -- `ExportWorkbookWriterTests`:6 passed。覆盖单行表头、**分组表头与顶层叶子混排**、三层表头逐级合并、 - 列宽换算、0 行导出仍出表头、2500 行跨批次全部写出。 -- `ExportServiceIntegrationTests`:5 passed。导出 `b_con_type`(箱型管理,8 行)→ - 生成的字节流能被 POI 解析、sheet 名正确、表头与数据行齐全且行数等于视图总行数; - 勾选 1 行 → 结果只有 1 行数据;**计数**与视图总行数一致、条件参与计数; - **分卷**(每卷 3 行)→ zip 内 3 个文件、行数 3/3/2;模块不存在 → 抛业务异常且响应体为空。 -- 前端 `tests/unit/fms-export-dialog.spec.js`:8 passed(默认全选、取消勾选后的列、 - 区间 / 分卷参数、非法区间与空选不发请求、保存 / 恢复导出列)。 -- 后端全量 54 项,唯一失败 `OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry` - 是**既有基线**(断言 Druid `getConnectionErrorRetryAttempts()==0`,实测 1,与本次改动无关)。 -- 前端改动文件 oxlint 0 error。既有基线失败:`fms-module-list-page.spec.js` 3 项 - (断言的 `.filter-drawer` / `.filter-row__*` 在 `src/` 里已不存在)、 - `module-pref.spec.js` 2 项(断言的默认布局「非 quick 字段进 advanced」与实现的 - 「非 hidden 即 quick」不一致,测试未跟上实现)。 - ---- - -## 5. 数据库现状(决定了「为什么现在看不到合计行」) - -| 表 | 行数 | 影响 | -| --- | --- | --- | -| `s_power` | 2(都是 `s_i18n_type`) | 除超管外,任何模块都没有 `action.*.export` 权限点 → 非超管导出会被拒(「没有导出权限」) | -| `s_user_power` | 0 | 同上,白名单语义下未授权即拒绝 | -| `s_user_field_power` | 0 | 无记录 = 可查看/可查询/可导出(14.3 默认行为),当前无字段被裁剪 | -| `s_user_data_power` | 0 | 无生效规则 = 无数据范围限制 | -| `s_user_module_pref` | 0 | 个人层覆盖(P1 的「保存导出列」会写这里,`b_schema_type='export'`) | -| `s_module_schema` | view 71 / edit 68 / query 19 | **没有任何 view schema 配了 `summary` 或 `type:'group'`** → 合计行与分组表头不会出现 | - ---- - -## 6. 未完成 - -### 6.1 P1(建议按此顺序) - -0. ~~**普通导出语义**~~ **已改为当前页**:无勾选时导出当前页(前端补传 `rangeStart/rangeEnd`), - 合计行即当页合计;有勾选时仍导出勾选行。批量导出(高级导出)按所选区间/分卷聚合合计。 - -1. ~~**合计行与分组表头的配置入口**~~ **已完成**:模块管理 → 列表配置 → 批量编辑已支持 - 「合计」列(无 / 求和 / 计数),写回 `s_module_schema.view` 的列节点 `summary`; - 列表页底部已显示两行合计(该页合计 + 总合计),导出文件也按范围输出对应合计行。 - 分组表头本来就能在画布里配,不需要新做,只要配了就会生效。 - -2. ~~**批量导出弹窗**~~ **已完成**(见第 7 节)。后端 `columns` / `rangeStart` / `rangeEnd` / - `volumeSize` 与「保存导出列」都已落地并验证。 - -3. **前端按 `canPower(moduleId, 'export')` 隐藏导出入口** - - 现在没做,无权限用户会看到入口、点了才报错。后端已拦截,属于体验问题。 - - 做之前要确认 `canPower` 对「数据模块编码」的判定是否符合预期 - (`canPower` 内部先查 `canAccess(scopeCode)`,而页面路由的 `meta.moduleId` 与 - `dataModule.b_id` 未必是同一个编码,存在把有权限的用户也隐藏掉的风险)。 - -4. **字段权限的前端缺口**:列表目前只用 `b_query` 过滤查询条件, - **没有用 `b_view` 过滤列**(`FmsModuleListPage` 里 `activeFields = fields` 是全量)。 - 设计文档 14.3 要求 `b_view=0` 的字段在列表/表单/查询/导出中统一移除。 - 导出这一侧已经做了,列表那侧没做,于是会出现「列表能看到某列、导出里没有」的不一致。 - 当前 `s_user_field_power` 是空表所以无感,但应该补。 - -5. **批量导出的条件编辑**:目前弹窗里条件只读(显示「沿用列表当前条件」)。 - 若要在弹窗内改条件,正确做法是复用现有高级查询面板,而不是另写一套字段表单 —— 见第 7 节。 - -### 6.2 P2 - -- CSV 导出(`format` 参数目前只认 xlsx,未实现 CSV); -- 分卷 zip(`volumeSize` 未实现,接口里也没有这个参数); -- 模板导出(旧系统有 `s_exceltpl` / `s_exceltpl_sub` 那套自定义 Excel 模板,业务真需要再单独立项)。 - -### 6.3 已知口径 / 取舍 - -- **列宽换算**:`view` 的 `width` 是像素,Excel 列宽单位是字符数,换算为 `px / 8`, - 夹在 `[8, 60]` 之间,无配置时兜底 16。 -- **单元格格式**:目前不读 `view` 节点的 `format`(如 `money`),数字按 Excel 原生数值写入, - 没有设置数字格式。要按业务格式显示需要后续补。 -- **表头文案**:取 `s_field.b_name`,**未做多语言**(前端列表是走 i18n store 的)。 - 导出跟当前界面语言一致需要后续补。 -- **勾选行跨页**:`rowKeys` 是主键值数组,跨页勾选可用;但一次勾太多会撑大 `IN (...)` 的占位数量, - 极端情况下应改成分批或临时表。 - ---- - -## 7. 批量导出弹窗(已实现) - -入口:列表工具栏「更多操作 → 批量导出」。组件 `components/fms-module-list/FmsExportDialog.vue`。 - -参考 `D:\workspace\code\container-ocr\ocr-vue` 的 `high-download-dialog.vue`,但做了几处调整: - -- **界面刻意极简**:只留「导出列」「导出方式」两块 + 底部「共 N 条 / 重置 / 下载」。 - 不放任何说明性文字(模式说明、条件说明、分组表头与合计行的说明全部删掉)—— - 说明写在本文件与代码注释里,不占用界面。 -- **导出列是竖排列表,可拖拽排序**(用项目已有的 `vuedraggable`,与设计编辑器同一套库)。 - 显示顺序即导出顺序。有个人保存的列时:已保存的按保存顺序排前面并勾上,其余列补在后面且不勾选。 -- **条件区不另写一套字段表单**:弹窗里条件只读,要改条件先在列表上改。 - 新系统字段是元数据驱动的,`ocr-vue` 那种硬编码几个字段的做法不可能覆盖; - 将来若要支持弹窗内改条件,应复用现有高级查询面板,而不是第三套 UI。 -- 顶部「共 N 条数据」调 `POST /api/export/count`,**与导出走同一条权限链路**(不是列表分页的 total); -- **导出方式**用 `Segmented` 三选一:全部 / 按区间 / 分卷(每 N 条一个文件,后端打包 zip)。 - 布局是「标题 + Segmented 同一行,参数行单独一行、落在 Segmented 下方并左对齐」: - `.export-dialog__mode` 是两列 grid(左标题 / 右控件),三个 grid 项各居自己所在行, - 所以标题不会被参数行拉低,区间与分卷的参数出现位置也完全一致。 - 底部主按钮文案是**导出**(不是「下载」)。 - -列区只有两个动作: -- **全选** —— 候选集就是列表可见列,所以「全选」等价于「回到列表列」,顺手清掉个人覆盖; -- **保存配置** —— 把当前勾选与顺序存成个人偏好。 - -导出列列表限高 340px 内滚动(超过就出现滚动条,弹窗本身不会被撑高)。 - -个人导出列存 `s_user_module_pref`(`b_schema_type='export'`,JSON `{ schemaVersion, columns: [字段编码] }`, -数组顺序即导出顺序),沿用《FMS新系统核心表结构设计》第 7 节的「个人层只存增量、 -恢复默认即删除覆盖」规则。 - -分组表头与合计行**不放进弹窗**,它们属于 `view` schema(列表与导出共用)。 -分卷导出逐卷不输出合计行(逐卷合计没有业务意义)。 - -**与旧系统 `g3hd` 的关键差别**:那边是「前端按 10000 行切段 → N 次请求 → 前端 JSZip 打包」, -这里是「一次请求,后端切卷打包」。前端不用维护 N 次往返的进度与失败重试, -也不用同时持有 N 个 blob。代价是单卷内容在内存里渲染(受 `volumeSize` 约束,所以是有界内存, -不是流式),卷大小要设得合理。 - ---- - -## 8. 附:本次顺带确认的元数据事实 - -- `s_module.b_id` 是模块编码,`b_view_table` 是查询视图名,`b_key_field` 是主键列, - `b_order_sql` 是默认排序(裸 SQL,如 `b_xh ASC, b_id ASC`)。 -- `s_module_schema.view` 的 JSON 形状:`{"schemaVersion":1,"columns":[{"field":"b_name","width":150}, - {"type":"group","title":"基本信息","children":[...]}]}`;列节点可选 `visible` / `summary`。 -- 权限编码格式:`menu.{菜单}` / `module.{模块}` / `action.{模块}.{动作}`, - 由 `s_power.b_object_type` + `b_object_id` + `b_action` 派生(`ModulePowerPanel.powerCodeOf`)。 -- 旧系统参考实现里**不要照搬**的三处: - `select identity(int,1,1),* into #tmptj` 取行号(并发导出会撑爆 tempdb,改用 `ROW_NUMBER()` 或 `OFFSET/FETCH`); - 前端 ExcelJS 生成(新系统列表是分页的,只能导当前 20 条); - `g3hd` 的 `handleDownloadZip` 存的是 `mx_mapfilename` 却 `revokeObjectURL(file.url)`,blob URL 从未释放。 - ---- - -## 9. 实施过程中踩到的坑(供后续参考) - -### 9.1 环境(最耗时的部分) - -- `fms-api/start.cmd` 里的 `JAVA_HOME` / `MAVEN_HOME` **两个路径都不存在**,照抄必失败; - `bin/mvn` 在 Git Bash 下报 classworlds 找不到;从 Bash 调 `cmd.exe` 被安全策略拦; - 另一个 shell 工具的输出不回流(只能写文件再读)。最终靠 JDK 直拉 classworlds launcher 跑通(见 4.1)。 -- 本地 Maven 仓库是 `D:\data\maven` 而不是 `~/.m2`,查「依赖有没有缓存」时别找错地方。 -- 后端 8088 上跑的是**改动前**的实例,没有导出接口。想走 HTTP 端到端验证就得重启它(会打断 - 正在开发的人),所以改用 `@SpringBootTest` 直连真实库做集成测试 —— 这条路反而更好,可重复。 -- 想开浏览器截图验证前端,但登不进去:`g3soft` 空密码返回 401,密码非空串。 - 没有为了验证去读用户密码,改成挂载组件断言 DOM 结构。 - -### 9.2 技术选型 - -- 第一版为了控制表头合并、逐列列宽、合计行样式三处,绕开 EasyExcel 4.0.x 的 `CellWriteHandler` - 签名变动,直接用 POI SXSSF 手写;后按要求改为 **Apache Fesod (Incubating) 2.0.2**(EasyExcel 的 Apache 续作)。 - 迁移代价很小:表头合并改用 `automaticMergeHead(true)`,手写的 `ExportHeaderWriter` 直接删除; - 样式与列宽收进一个 `CellWriteHandler`。 -- 选型时**不要靠记忆猜 API**:Fesod 的类名/包名与 EasyExcel 不同(`org.apache.fesod.sheet.*`), - 用 `jar tf` + `javap` 直接看下载下来的 jar 最省事。 - -### 9.3 实现 - -- 首次编译 7 个类型错误:`Map.get()` 返回 `Object`,而工具方法只接受 `String`。 -- **排序校验的位置一开始找错了**:`buildSelectSql` 信任传入的 `orderSql`(真正的校验在 - `resolveOrderSql` 里)。测试改成打真正的校验口,顺带把 `resolveOrderSql` 从 private 放开为包级可见。 -- 手写合并的那一版,多级表头规则我第一版测试预期就是错的:以为叶子要纵向合并到表头最后一行, - 实际上两级表头里叶子已经在最后一行、再合并就会和分组的横向合并区域**重叠** - (POI 会抛异常或静默丢内容)。换成 Fesod 后这类问题不再需要自己保证, - 但**表头结构测试仍然保留**(`ExportHeadBuilder` 的路径展开 + 写出后的表头位置断言)。 -- **换库时被自己的测试抓到两个真问题**(这两条都是 Fesod 行为,不是笔误): - 1. **0 行导出会生成「没有任何工作表」的空 xlsx** —— Fesod 只在 `write` 被调用时才创建工作表, - 所以最后一批即使为空也必须调用一次。已修,并加了「0 行仍出表头」的用例。 - 2. 合计行的「合计」标签原来由 POI 版本写在首列;换成 Fesod 后合计行是拼出来的数据行, - 需要显式构造:标签落在**第一个没有聚合值的列**(正常就是首列),所有列都有聚合值时不加标签。 -- **写测试时又被自己抓到一个错**:集成测试里用 `b_id = N'绝不存在的值'` 构造「查不到」的条件, - 结果 SQL Server 报 `Error converting data type nvarchar to bigint` —— - `b_con_type.b_id` 是 **bigint**(新系统业务表用雪花 ID),拿字符串去比会转换失败。 - 教训:**测试里构造条件前先确认列的真实类型**,别照抄字典表(`s_module.b_id` 是 varchar)的习惯。 -- 二进制响应**不要调 `response.setCharacterEncoding`**:会让 content-type 变成 - `application/zip;charset=UTF-8`,前端按 `content-type` 判断文件类型时会受影响。 - -### 9.4 数据与权限现实 - -- `s_power` 只有 2 行(都是 `s_i18n_type`),`s_user_field_power` / `s_user_data_power` 都是空表。 - 也就是说**这套权限体系实际上还没启用**,导出成了第一个真正执行它的功能 —— 因此除了 `g3soft`, - 其他账号现在都会被拒。这不是 bug,是白名单语义下的必然结果。 -- 没有任何模块的 view schema 配过 `summary` 或 `type:'group'`,所以合计行与多级表头写完也看不到。 - -### 9.5 顺带发现的既有问题(不是本次引入) - -- 列表只用 `b_query` 过滤查询条件,**没有用 `b_view` 过滤列**,与导出的裁剪口径不一致(见 6.1 第 4 项)。 -- `fms-api/target/` 被 git 跟踪,编译后一堆 `.class` 与 surefire 报告会显示为 modified。 -- 既有测试基线失败:后端 `OrgDataSourceFactoryTests` 1 项;前端 `fms-module-list-page.spec.js` - 3 项(断言的 `.filter-drawer` / `.filter-row__*` 在 `src/` 里已不存在)。 diff --git a/code/fms/FMS新系统核心表结构设计.md b/code/fms/FMS新系统核心表结构设计.md index c62b32db..80e65966 100644 --- a/code/fms/FMS新系统核心表结构设计.md +++ b/code/fms/FMS新系统核心表结构设计.md @@ -370,8 +370,9 @@ query }, { "type": "group", + "id": "group1", "title": "费用信息", - "i18n": "group.cw_fee.amount", + "i18n": "group.cw_fee.view.group1", "children": [ { "field": "mx_amount", "width": 120, "format": "money" }, { "field": "mx_currency", "width": 90 } @@ -389,8 +390,9 @@ query "children": [ { "type": "group", + "id": "group1", "title": "基础信息", - "i18n": "group.sea_main.basic", + "i18n": "group.sea_main.edit.group1", "children": [ { "type": "row", @@ -442,6 +444,20 @@ query } ``` +### 6.4 分组节点 id 与资源键 + +`view`、`edit` 的 JSON 里,分组节点(`type: "group"`)用 `id` 表达稳定身份;层级由 `children` 嵌套表达,顺序由数组顺序表达,分组不建独立表(见本节开头的 `s_field_group`)。 + +```text +"id": "group1" 按 schema 类型独立编号(view 与 edit 各自从 group1 开始) +``` + +- **唯一范围**:分组 id 只在单个 `b_schema_json` 内唯一。列表的 `group1` 与表单的 `group1` 是两处不同分组,互不引用,因此也不做模块内跨 schema 全局唯一。 +- **稳定优先**:编号一旦分配不再重排,删除分组留下的空洞允许存在(重排会改身份,进而改资源键)。 +- **取号规则**:新建分组取该配置内已用编号里最小的未用正整数;同时保留 `s_i18n` 中该模块同 schema 已存在的分组键编号,避免新分组捡到已删除分组的编号、连带继承其残留译文。 +- **老数据兼容**:`id` 缺失或重复的节点由归一化补齐(先序、取最小可用编号),非 `group{n}` 的自定义 id 视为已占用并原样保留。归一化必须是确定性纯函数——加载后要与基线比较,若补号依赖外部上下文,同一份数据两次归一化结果会不同而被误判为未保存改动。 +- **资源键**:分组标题的资源键为 `group.{模块编码}.{schema 类型}.{分组编号}`(如 `group.cw_fee.view.group1`),由 id 派生而非人工填写;节点上的 `i18n` 手填优先,未填时按此规则派生,使分组标题无需手工登记即进入多语言受管清单。schema 类型必须入键,否则列表与表单的同号分组会互相覆盖译文。 + ## 7. 用户个性化(个人层) 个人配置是两层继承的最上层,只保存相对系统默认(`s_module_schema`)的偏离量,不复制完整模块配置。与 `s_module_schema` 统一使用 `b_schema_type` + `b_schema_json`,按 schema 类型分别存一条。 @@ -623,11 +639,13 @@ create index ix_s_i18n_locale | `module.` | 模块名称或模块业务文案 | `module.sea.name` | | `menu.` | 菜单名称 | `menu.main.title` | | `field.` | 字段名称 | `field.sea.b_no` | -| `group.` | 列表/表单布局分组标题 | `group.sea.basic` | +| `group.` | 列表/表单布局分组标题 | `group.sea.view.group1` | | `action.` | 动作动词(按钮 + 权限共用) | `action.insert` | 资源键一旦投入使用原则上不可修改;模块、菜单或字段删除时,由业务层根据已知资源键清理对应翻译行。 +`module.*`、`menu.*`、`field.*`、`action.*` 由业务编码派生,不需要人工填写;`group.*` 的编号段取自界面配置里分组节点的 `id`,未手填节点 `i18n` 时同样由 id 派生,规则见 6.4 节。模块管理页登记的分组译文在表单渲染时按同一规则解析(节点缺失 id 的老数据由归一化补齐后同样能取到译文)。 + `action.*` 保存通用的动作动词资源键(例如 `action.insert`、`action.update`、`action.audit`),按钮文案和权限点名称共用同一翻译,同一动词只维护一份;动作动词是通用词,不按模块或对象分段。 ## 12. 日志 diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/config/JwtAuthFilter.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/config/JwtAuthFilter.java index 8c79df3f..8d91a2af 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/config/JwtAuthFilter.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/config/JwtAuthFilter.java @@ -56,15 +56,7 @@ public class JwtAuthFilter extends OncePerRequestFilter { return; } - if (!activeSessionRegistry.hasActiveSession(claims.orgId(), claims.userId())) { - writeUnauthorized( - response, - ApiResponse.AUTH_ERROR_CODE, - "未登录或登录已失效" - ); - return; - } - + // JWT 已通过签名和到期校验;重启后缺失的内存会话由首个有效请求恢复。 if (!activeSessionRegistry.isActive( claims.orgId(), claims.userId(), diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/ExportController.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/ExportController.java index eb6c5979..b49f9039 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/ExportController.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/ExportController.java @@ -1,6 +1,7 @@ package cn.g3soft.fmsapi.controller; import cn.g3soft.fmsapi.service.ExportService; +import cn.g3soft.fmsapi.exception.BusinessException; import cn.g3soft.fmsapi.utils.ApiResponse; import cn.g3soft.fmsapi.utils.ParamUtils; import jakarta.annotation.Resource; @@ -11,6 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.io.IOException; +import java.math.BigDecimal; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -87,7 +89,12 @@ public class ExportController { private Integer readInteger(Map params, String field) { Number number = ParamUtils.get(params, field, Number.class); - return number == null ? null : number.intValue(); + if (number == null) return null; + try { + return new BigDecimal(number.toString()).intValueExact(); + } catch (ArithmeticException | NumberFormatException exception) { + throw new BusinessException(field + " 必须是有效整数"); + } } /** 缺省输出合计行 */ diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ActiveSessionRegistry.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ActiveSessionRegistry.java index 91e532ff..8d667395 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ActiveSessionRegistry.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ActiveSessionRegistry.java @@ -14,15 +14,17 @@ public class ActiveSessionRegistry { activeSessions.put(new AccountKey(orgId, userId), sessionId); } + /** + * 必须在 JWT 签名和有效期校验通过后调用。 + * 重启后由首个有效请求恢复账号会话,不持久化;重启前的互踢历史也随之清空。 + */ public boolean isActive(String orgId, String userId, String sessionId) { if (sessionId == null || sessionId.isBlank()) { return false; } - return sessionId.equals(activeSessions.get(new AccountKey(orgId, userId))); - } - - public boolean hasActiveSession(String orgId, String userId) { - return activeSessions.containsKey(new AccountKey(orgId, userId)); + // 原子登记:并发首次请求只能有一个会话胜出,也不会覆盖并发的新登录。 + String activeSession = activeSessions.putIfAbsent(new AccountKey(orgId, userId), sessionId); + return activeSession == null || sessionId.equals(activeSession); } private record AccountKey(String orgId, String userId) { diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportSchemaService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportSchemaService.java index 2276c8eb..b07fbfdc 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportSchemaService.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportSchemaService.java @@ -156,6 +156,9 @@ public class ExportSchemaService { ) { Set selected = selectedColumns == null ? null : new LinkedHashSet<>(selectedColumns); List nodes = filterNodes(schema.nodes(), deniedFields, selected); + if (selected != null) { + nodes = orderNodes(nodes, selected); + } if (nodes.isEmpty()) { throw new BusinessException("没有可导出的列"); } @@ -198,6 +201,45 @@ public class ExportSchemaService { return kept; } + /** 按用户选择的叶子顺序重建表头;跨组拖拽时分开不相邻的同组列,保留每列的分组路径。 */ + private List orderNodes(List nodes, Set selected) { + Map> paths = new LinkedHashMap<>(); + collectPaths(nodes, List.of(), paths); + List ordered = new ArrayList<>(); + List previous = List.of(); + List> levels = new ArrayList<>(); + levels.add(ordered); + for (String field : selected) { + List path = paths.get(field); + if (path == null) continue; + int shared = 0; + while (shared < path.size() - 1 && shared < previous.size() - 1 + && path.get(shared) == previous.get(shared)) { + shared++; + } + while (levels.size() > shared + 1) levels.remove(levels.size() - 1); + for (int depth = shared; depth < path.size() - 1; depth++) { + List children = new ArrayList<>(); + levels.get(depth).add(new ExportNode(path.get(depth).title(), null, children)); + levels.add(children); + } + levels.get(path.size() - 1).add(path.get(path.size() - 1)); + previous = path; + } + return ordered; + } + + private void collectPaths( + List nodes, List parents, Map> paths + ) { + for (ExportNode node : nodes) { + List path = new ArrayList<>(parents); + path.add(node); + if (node.isGroup()) collectPaths(node.children(), path, paths); + else if (node.column() != null) paths.putIfAbsent(node.column().field(), path); + } + } + /* ================= 元数据读取 ================= */ private Map loadFieldCaptions(Connection connection, String moduleId) throws SQLException { @@ -263,7 +305,7 @@ public class ExportSchemaService { continue; } String field = text(node.path("field").asString(null)); - if (field.isBlank() || !SAFE_IDENTIFIER.matcher(field).matches()) { + if (field.isBlank() || !SAFE_IDENTIFIER.matcher(field).matches() || !captions.containsKey(field)) { continue; } // 与列表一致:visible=false 的节点不出现在列表,也不出现在导出 diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportService.java index 5bca728c..c0ac013b 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportService.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportService.java @@ -214,8 +214,8 @@ public class ExportService { ? loadTotals( prepared, whereParameters, - isRanged(request) ? request.rangeStart() : null, - isRanged(request) ? request.rangeEnd() : null + isRanged(request) ? request.rangeStart().longValue() : null, + isRanged(request) ? request.rangeEnd().longValue() : null ) : List.of(); @@ -267,9 +267,12 @@ public class ExportService { private boolean isVolumed(ExportRequest request) { Integer volumeSize = request.volumeSize(); - if (volumeSize == null || volumeSize <= 0) { + if (volumeSize == null) { return false; } + if (volumeSize <= 0) { + throw new BusinessException("每卷行数必须大于 0"); + } if (isRanged(request)) { throw new BusinessException("分卷导出与按区间导出不能同时使用"); } @@ -321,9 +324,12 @@ public class ExportService { ? schema.orderSql() : requestedOrderBy.trim(); if (orderBy == null || orderBy.isBlank()) { - return null; + orderBy = schema.keyField() == null ? null : schema.keyField() + " ASC"; + } else if (schema.keyField() != null) { + // 相同排序值以主键打破平局,明细、区间合计与各卷使用同一行顺序。 + orderBy += ", " + schema.keyField() + " ASC"; } - return dbUtils.buildSafeOrderBy(orderBy); + return orderBy == null ? null : dbUtils.buildSafeOrderBy(orderBy); } String buildSelectSql( @@ -370,10 +376,10 @@ public class ExportService { private boolean isRanged(ExportRequest request) { Integer start = request.rangeStart(); Integer end = request.rangeEnd(); - if (start == null || end == null) { + if (start == null && end == null) { return false; } - if (start < 1 || end < start) { + if (start == null || end == null || start < 1 || end < start) { throw new BusinessException("导出区间不正确"); } return true; @@ -387,8 +393,8 @@ public class ExportService { private List loadTotals( PreparedExport prepared, List whereParameters, - Integer rangeStart, - Integer rangeEnd + Long rangeStart, + Long rangeEnd ) { List columns = prepared.columns(); List indexes = summableIndexes(columns); @@ -412,16 +418,15 @@ public class ExportService { statement.setObject(index++, parameter); } if (ranged) { - statement.setLong(index++, (long) rangeStart - 1); - statement.setLong(index, (long) (rangeEnd - rangeStart + 1)); + // ROW_NUMBER 从 1 开始,BETWEEN 接收闭区间端点,不是 OFFSET/FETCH 的偏移和条数。 + statement.setLong(index++, rangeStart); + statement.setLong(index, rangeEnd); } try (ResultSet resultSet = statement.executeQuery()) { if (resultSet.next()) { for (int position = 0; position < indexes.size(); position++) { BigDecimal value = resultSet.getBigDecimal(position + 1); - if (value != null) { - totals.set(indexes.get(position), value); - } + totals.set(indexes.get(position), value == null ? BigDecimal.ZERO : value); } } } @@ -530,20 +535,24 @@ public class ExportService { dbUtils.validateReadOnlySql(selectSql); long volumeCount = total == 0 ? 1 : (total + volumeSize - 1) / volumeSize; + // 第一卷查询、合计和渲染成功后才开始响应,首卷失败仍能返回业务错误。 + byte[] firstVolume = renderVolume(prepared, request, selectSql, sheetName, 0, + (int) Math.min(volumeSize, total)); applyResponseHeader(response, prepared.schema().moduleName(), "zip", "application/zip"); - ZipOutputStream zip = new ZipOutputStream(response.getOutputStream(), StandardCharsets.UTF_8); - try { + try (ZipOutputStream zip = new ZipOutputStream(response.getOutputStream(), StandardCharsets.UTF_8)) { for (long index = 0; index < volumeCount; index++) { long offset = index * volumeSize; int count = (int) Math.min(volumeSize, Math.max(0, total - offset)); + byte[] content = index == 0 ? firstVolume + : renderVolume(prepared, request, selectSql, sheetName, offset, count); zip.putNextEntry(new ZipEntry(volumeEntryName(prepared.schema().moduleName(), offset, count))); - zip.write(renderVolume(prepared, request, selectSql, sheetName, offset, count)); + zip.write(content); + firstVolume = null; zip.closeEntry(); } zip.finish(); - } finally { - response.flushBuffer(); } + response.flushBuffer(); } /** 单卷内容渲染到内存:卷大小已限定行数,因此内存有界 */ @@ -558,7 +567,7 @@ public class ExportService { List whereParameters = bindWhereParameters(new ArrayList<>(), request); // 该卷的合计:与明细同一个行范围,口径与其它导出方式一致 List totals = request.withSummary() && count > 0 - ? loadTotals(prepared, whereParameters, (int) (offset + 1), (int) (offset + count)) + ? loadTotals(prepared, whereParameters, offset + 1, offset + count) : List.of(); ByteArrayOutputStream buffer = new ByteArrayOutputStream(); if (count <= 0) { diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportWorkbookWriter.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportWorkbookWriter.java index 38b836e4..9fecb815 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportWorkbookWriter.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportWorkbookWriter.java @@ -5,6 +5,7 @@ import cn.g3soft.fmsapi.service.ExportSchemaService.ExportNode; import org.apache.fesod.sheet.ExcelWriter; import org.apache.fesod.sheet.FesodSheet; import org.apache.fesod.sheet.write.metadata.WriteSheet; +import org.apache.poi.ss.util.WorkbookUtil; import java.io.IOException; import java.io.OutputStream; @@ -60,7 +61,7 @@ final class ExportWorkbookWriter { .registerWriteHandler(styleHandler) .build(); try { - WriteSheet sheet = FesodSheet.writerSheet(sheetName).build(); + WriteSheet sheet = FesodSheet.writerSheet(WorkbookUtil.createSafeSheetName(sheetName)).build(); int rowCount = 0; List> batch = new ArrayList<>(BATCH_SIZE); diff --git a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/config/JwtAuthFilterTests.java b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/config/JwtAuthFilterTests.java new file mode 100644 index 00000000..68dbb7ec --- /dev/null +++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/config/JwtAuthFilterTests.java @@ -0,0 +1,124 @@ +package cn.g3soft.fmsapi.config; + +import cn.g3soft.fmsapi.database.OrgContext; +import cn.g3soft.fmsapi.service.ActiveSessionRegistry; +import cn.g3soft.fmsapi.utils.JwtUtils; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; +import tools.jackson.databind.ObjectMapper; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +class JwtAuthFilterTests { + + private static final String SECRET = "restart-test-signing-key-at-least-32-bytes"; + private final ObjectMapper objectMapper = new ObjectMapper(); + private ActiveSessionRegistry sessions; + + @BeforeEach + void setUp() { + sessions = new ActiveSessionRegistry(); + } + + private JwtUtils newJwtUtils() { + AuthProperties properties = new AuthProperties(); + properties.setJwtSecret(SECRET); + JwtUtils jwt = new JwtUtils(); + ReflectionTestUtils.setField(jwt, "properties", properties); + jwt.init(); + return jwt; + } + + private JwtAuthFilter newFilter() { + JwtAuthFilter filter = new JwtAuthFilter(); + ReflectionTestUtils.setField(filter, "jwtUtils", newJwtUtils()); + ReflectionTestUtils.setField(filter, "activeSessionRegistry", sessions); + ReflectionTestUtils.setField(filter, "objectMapper", objectMapper); + return filter; + } + + private MockHttpServletRequest request(String token) { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/data/load"); + request.setServletPath("/data/load"); + request.addHeader("Authorization", "Bearer " + token); + return request; + } + + @Test + void 重建签名校验器和过滤器后原Token仍然通过校验() throws Exception { + String token = newJwtUtils().generateToken("user", "G3HD", "session-a"); + sessions.activate("G3HD", "user", "session-a"); + sessions = new ActiveSessionRegistry(); + FilterChain chain = mock(FilterChain.class); + var request = request(token); + var response = new MockHttpServletResponse(); + doAnswer(invocation -> { + assertThat(OrgContext.requireOrgId()).isEqualTo("G3HD"); + assertThat(OrgContext.requireUserId()).isEqualTo("user"); + return null; + }).when(chain).doFilter(request, response); + + newFilter().doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + assertThat(response.getContentAsString()).isEmpty(); + assertThat(OrgContext.getOrgId()).isNull(); + assertThat(sessions.isActive("G3HD", "user", "session-a")).isTrue(); + assertThat(sessions.isActive("G3HD", "user", "session-other")).isFalse(); + } + + @Test + void 后登录替换会话后旧Token返回互踢错误() throws Exception { + String token = newJwtUtils().generateToken("user", "G3HD", "session-a"); + sessions.activate("G3HD", "user", "session-b"); + var request = request(token); + var response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + newFilter().doFilter(request, response, chain); + + assertThat(objectMapper.readTree(response.getContentAsString()).path("code").asInt()).isEqualTo(40101); + verify(chain, never()).doFilter(request, response); + } + + @Test + void 无效签名不能恢复内存会话() throws Exception { + String invalid = Jwts.builder().subject("user").id("session-a").claim("orgid", "G3HD") + .expiration(Date.from(Instant.now().plusSeconds(60))) + .signWith(Keys.hmacShaKeyFor((SECRET + "-wrong").getBytes(StandardCharsets.UTF_8))).compact(); + var response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + newFilter().doFilter(request(invalid), response, chain); + assertThat(objectMapper.readTree(response.getContentAsString()).path("code").asInt()).isEqualTo(401); + verifyNoInteractions(chain); + assertThat(sessions.isActive("G3HD", "user", "valid-session")).isTrue(); + } + + @Test + void 过期Token不因重启恢复会话而延长有效期() throws Exception { + String expired = Jwts.builder().subject("user").id("session-a").claim("orgid", "G3HD") + .expiration(Date.from(Instant.now().minusSeconds(60))) + .signWith(Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8))).compact(); + var response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + newFilter().doFilter(request(expired), response, chain); + assertThat(objectMapper.readTree(response.getContentAsString()).path("code").asInt()).isEqualTo(401); + verifyNoInteractions(chain); + assertThat(sessions.isActive("G3HD", "user", "valid-session")).isTrue(); + } +} diff --git a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.java b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.java index 5342d937..b4cb9297 100644 --- a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.java +++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.java @@ -2,6 +2,10 @@ package cn.g3soft.fmsapi.service; import org.junit.jupiter.api.Test; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + import static org.assertj.core.api.Assertions.assertThat; class ActiveSessionRegistryTests { @@ -10,12 +14,9 @@ class ActiveSessionRegistryTests { @Test void newerLoginReplacesPreviousSessionForSameAccount() { - assertThat(registry.hasActiveSession("G3HD", "USER_A")).isFalse(); - registry.activate("G3HD", "USER_A", "session-a"); registry.activate("G3HD", "USER_A", "session-b"); - assertThat(registry.hasActiveSession("G3HD", "USER_A")).isTrue(); assertThat(registry.isActive("G3HD", "USER_A", "session-a")).isFalse(); assertThat(registry.isActive("G3HD", "USER_A", "session-b")).isTrue(); } @@ -28,4 +29,46 @@ class ActiveSessionRegistryTests { assertThat(registry.isActive("G3HD", "USER_A", "g3hd-session")).isTrue(); assertThat(registry.isActive("OTHER", "USER_A", "other-session")).isTrue(); } + + @Test + void 重启后首次有效请求恢复会话而新登录仍能顶掉它() { + registry.activate("G3HD", "USER_A", "session-a"); + ActiveSessionRegistry restarted = new ActiveSessionRegistry(); + + assertThat(restarted.isActive("G3HD", "USER_A", "session-a")).isTrue(); + assertThat(restarted.isActive("G3HD", "USER_A", "session-a")).isTrue(); + restarted.activate("G3HD", "USER_A", "session-b"); + assertThat(restarted.isActive("G3HD", "USER_A", "session-a")).isFalse(); + assertThat(restarted.isActive("G3HD", "USER_A", "session-b")).isTrue(); + } + + @Test + void 空会话不能占用重启后的账号() { + assertThat(registry.isActive("G3HD", "USER_A", null)).isFalse(); + assertThat(registry.isActive("G3HD", "USER_A", " ")).isFalse(); + assertThat(registry.isActive("G3HD", "USER_A", "valid-session")).isTrue(); + } + + @Test + void 并发首次恢复只有一个不同会话能成功() throws Exception { + CountDownLatch start = new CountDownLatch(1); + try (var executor = Executors.newFixedThreadPool(2)) { + var first = executor.submit(() -> { + start.await(); + return registry.isActive("G3HD", "USER_A", "session-a"); + }); + var second = executor.submit(() -> { + start.await(); + return registry.isActive("G3HD", "USER_A", "session-b"); + }); + start.countDown(); + boolean firstAccepted = first.get(5, TimeUnit.SECONDS); + boolean secondAccepted = second.get(5, TimeUnit.SECONDS); + assertThat(firstAccepted).isNotEqualTo(secondAccepted); + String winner = firstAccepted ? "session-a" : "session-b"; + String loser = firstAccepted ? "session-b" : "session-a"; + assertThat(registry.isActive("G3HD", "USER_A", winner)).isTrue(); + assertThat(registry.isActive("G3HD", "USER_A", loser)).isFalse(); + } + } } diff --git a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.java b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.java index 1075a2fa..74cfc1b6 100644 --- a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.java +++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.java @@ -9,6 +9,8 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; +import cn.g3soft.fmsapi.utils.DbUtils; import java.io.ByteArrayInputStream; import java.math.BigDecimal; @@ -28,6 +30,9 @@ import javax.sql.DataSource; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** * 导出端到端(连真实库,只读)。验证「元数据 → SQL → POI 流 → 可解析的 xlsx」整条链路, @@ -203,7 +208,7 @@ class ExportServiceIntegrationTests { ); try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setLong(1, 0); + statement.setLong(1, 1); statement.setLong(2, 3); try (ResultSet resultSet = statement.executeQuery()) { resultSet.next(); @@ -223,6 +228,79 @@ class ExportServiceIntegrationTests { } } + @Test + void 当页区间勾选及各分卷合计与文件内明细一致() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + // 只替换内存里的导出配置,真实 SQL Server 查询与工作簿写出照常执行,不改数据库配置。 + ExportSchemaService.ExportSchema schema = new ExportSchemaService.ExportSchema( + MODULE_WITH_DATA, "合计回归", MODULE_WITH_DATA, "b_id", "b_xh ASC", + List.of( + new ExportSchemaService.ExportNode(null, + new ExportSchemaService.ExportColumn("b_name", "名称", 16, ""), null), + new ExportSchemaService.ExportNode(null, + new ExportSchemaService.ExportColumn("b_xh", "序号求和", 16, "sum"), null), + new ExportSchemaService.ExportNode(null, + new ExportSchemaService.ExportColumn("b_id", "记录计数", 16, "count"), null))); + ExportSchemaService schemas = mock(ExportSchemaService.class); + when(schemas.load(MODULE_WITH_DATA)).thenReturn(schema); + when(schemas.filter(any(), any(), any())).thenCallRealMethod(); + ExportService service = new ExportService(); + ReflectionTestUtils.setField(service, "dataSource", dataSource); + ReflectionTestUtils.setField(service, "dbUtils", new DbUtils()); + ReflectionTestUtils.setField(service, "sqlPermissionService", mock(SqlPermissionService.class)); + ReflectionTestUtils.setField(service, "exportSchemaService", schemas); + + List keys = new ArrayList<>(); + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet result = statement.executeQuery( + "SELECT TOP 5 [b_id] FROM [b_con_type] ORDER BY [b_id]")) { + while (result.next()) keys.add(result.getObject(1)); + } + assertThat(keys).hasSize(5); + for (Integer[] range : new Integer[][]{{null, null}, {1, 1}, {3, 4}, {5, 5}, {8, 9}}) { + MockHttpServletResponse response = new MockHttpServletResponse(); + service.export(new ExportService.ExportRequest(MODULE_WITH_DATA, "b_id IS NOT NULL", + null, keys, null, null, range[0], range[1], null, true), response); + int expectedRows = range[0] == null ? 5 : Math.max(0, Math.min(5, range[1]) - range[0] + 1); + assertSummaryMatchesDetails(response.getContentAsByteArray(), expectedRows); + } + + MockHttpServletResponse response = new MockHttpServletResponse(); + service.export(new ExportService.ExportRequest(MODULE_WITH_DATA, null, null, keys, + null, null, null, null, 2, true), response); + try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(response.getContentAsByteArray()))) { + for (int expectedRows : List.of(2, 2, 1)) { + assertThat(zip.getNextEntry()).isNotNull(); + assertSummaryMatchesDetails(zip.readAllBytes(), expectedRows); + } + assertThat(zip.getNextEntry()).isNull(); + } + } finally { + OrgContext.clear(); + } + } + + private void assertSummaryMatchesDetails(byte[] bytes, int expectedRows) throws Exception { + try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) { + Sheet sheet = workbook.getSheetAt(0); + assertThat(sheet.getLastRowNum()).isEqualTo(expectedRows + 1); + double sum = 0; + for (int row = 1; row <= expectedRows; row++) { + var cell = sheet.getRow(row).getCell(1); + if (cell != null && cell.getCellType() == org.apache.poi.ss.usermodel.CellType.NUMERIC) { + sum += cell.getNumericCellValue(); + } + } + var summary = sheet.getRow(expectedRows + 1); + assertThat(summary.getCell(0).getStringCellValue()).isEqualTo("合计"); + assertThat(summary.getCell(1).getNumericCellValue()).isEqualTo(sum); + assertThat(summary.getCell(2).getNumericCellValue()).isEqualTo(expectedRows); + } + } + @Test void 总合计接口在没有配合计列时返回空() { OrgContext.setOrgId("G3HD"); diff --git a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceTests.java b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceTests.java index c9d7bd00..e6592285 100644 --- a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceTests.java +++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceTests.java @@ -113,9 +113,33 @@ class ExportServiceTests { @Test void 排序缺省时回退模块默认排序且加引号() { ExportSchema schema = schema("b_xh DESC", List.of(leaf("b_name"))); - assertThat(service.resolveOrderSql(schema, null)).isEqualTo("[b_xh] DESC"); - assertThat(service.resolveOrderSql(schema, "b_name ASC")).isEqualTo("[b_name] ASC"); - assertThat(service.resolveOrderSql(schema("", List.of(leaf("b_name"))), null)).isNull(); + assertThat(service.resolveOrderSql(schema, null)).isEqualTo("[b_xh] DESC, [b_id] ASC"); + assertThat(service.resolveOrderSql(schema, "b_name ASC")).isEqualTo("[b_name] ASC, [b_id] ASC"); + assertThat(service.resolveOrderSql(schema("", List.of(leaf("b_name"))), null)).isEqualTo("[b_id] ASC"); + } + + @Test + void 不完整或逆序区间不能退化成全部导出() { + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + for (ExportService.ExportRequest request : List.of( + request(null, null, null, null, 3, null), + request(null, null, null, null, null, 4), + request(null, null, null, null, 4, 3))) { + assertThatThrownBy(() -> service.buildSelectSql(schema, schema.columns(), null, null, request)) + .isInstanceOf(BusinessException.class).hasMessageContaining("区间"); + } + } + + @Test + void 导出列采用选择顺序且跨组拖拽后保留各列分组和权限() { + ExportSchema schema = schema(null, List.of( + new ExportNode("A", null, List.of(leaf("b_a"), leaf("b_b"))), + leaf("b_c"), leaf("b_denied"))); + ExportSchema result = new ExportSchemaService().filter(schema, java.util.Set.of("b_denied"), + List.of("b_b", "b_c", "b_a", "b_denied", "b_unknown", "b_b")); + assertThat(result.columns()).extracting(ExportColumn::field).containsExactly("b_b", "b_c", "b_a"); + assertThat(ExportHeadBuilder.headOf(result.nodes())).containsExactly( + List.of("A", "b_b"), List.of("b_c"), List.of("A", "b_a")); } /* ================= WHERE 拼装:数据范围只收窄不放开 ================= */ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/JwtAuthFilter.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/JwtAuthFilter.class index dfc53f4b..04b4d42c 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/JwtAuthFilter.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/config/JwtAuthFilter.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/ExportController.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/ExportController.class index 2f422279..3b377d45 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/ExportController.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/ExportController.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ActiveSessionRegistry$AccountKey.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ActiveSessionRegistry$AccountKey.class index 1e7ed09e..3ce03cc9 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ActiveSessionRegistry$AccountKey.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ActiveSessionRegistry$AccountKey.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ActiveSessionRegistry.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ActiveSessionRegistry.class index ffaa78c5..e2690636 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ActiveSessionRegistry.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ActiveSessionRegistry.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService.class index 629014ed..e794b365 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$1.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$1.class index 63d2f86f..a1f72a65 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$1.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$1.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$ExportRequest.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$ExportRequest.class index 7f568c97..d679dcc8 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$ExportRequest.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$ExportRequest.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$PreparedExport.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$PreparedExport.class index 30b1e5ca..40ab135c 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$PreparedExport.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$PreparedExport.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$ResultSetRowSource.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$ResultSetRowSource.class index e1a81752..d7af75ed 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$ResultSetRowSource.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$ResultSetRowSource.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService.class index fbc3f3d6..2cdb18e8 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService.class differ diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportWorkbookWriter.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportWorkbookWriter.class index f012bec0..87b993d5 100644 Binary files a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportWorkbookWriter.class and b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportWorkbookWriter.class differ diff --git a/code/fms/fms-api/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst b/code/fms/fms-api/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst index 544190ff..f4627835 100644 --- a/code/fms/fms-api/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst +++ b/code/fms/fms-api/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst @@ -1,3 +1,4 @@ +cn\g3soft\fmsapi\config\JwtAuthFilterTests.class cn\g3soft\fmsapi\service\AuthServiceTests.class cn\g3soft\fmsapi\utils\JwtUtilsTests.class cn\g3soft\fmsapi\database\OrgDataSourceManagerTests$1.class diff --git a/code/fms/fms-api/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst b/code/fms/fms-api/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst index b55b9e67..509776cd 100644 --- a/code/fms/fms-api/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst +++ b/code/fms/fms-api/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst @@ -1,3 +1,4 @@ +D:\workspace\code\fms\fms-api\src\test\java\cn\g3soft\fmsapi\config\JwtAuthFilterTests.java D:\workspace\code\fms\fms-api\src\test\java\cn\g3soft\fmsapi\database\OrgDatabaseConfigLoaderTests.java D:\workspace\code\fms\fms-api\src\test\java\cn\g3soft\fmsapi\database\OrgDatabaseConfigWatcherTests.java D:\workspace\code\fms\fms-api\src\test\java\cn\g3soft\fmsapi\database\OrgDataSourceFactoryTests.java diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.config.JwtAuthFilterTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.config.JwtAuthFilterTests.xml new file mode 100644 index 00000000..586fd182 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.config.JwtAuthFilterTests.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ActiveSessionRegistryTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ActiveSessionRegistryTests.xml index e78d1de8..5a3b0006 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ActiveSessionRegistryTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ActiveSessionRegistryTests.xml @@ -1,70 +1,67 @@ - + - + - + - + - - - + + + - + - - + + - - - + - - + + - - - - + + + - - - + - - + + - - - + + + + + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.AuthServiceTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.AuthServiceTests.xml index c292dc27..b45cae41 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.AuthServiceTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.AuthServiceTests.xml @@ -1,69 +1,63 @@ - + - + - + - + - - - + + + - + - - + + - - - + - - + + - - - - + + + - - - + - - + + - - + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.xml index 72589286..06108b84 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.xml @@ -1,31 +1,31 @@ - + - + - + - + - - + + - + - + - - + + @@ -34,44 +34,43 @@ - + - - + + - - - - + + + - + - + - - + + - - + - - - - - - + + + + + + + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceTests.xml index d9d6d427..3c9823cd 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceTests.xml @@ -1,31 +1,31 @@ - + - + - + - + - - + + - + - + - - + + @@ -34,53 +34,54 @@ - + - - + + - - - - + + + - + - + - - + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.xml index 349c35f3..7ec0b47a 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.xml @@ -1,30 +1,31 @@ - + - + - + - + - - + + + - + - - + + @@ -33,42 +34,41 @@ - + - - + + - - - - + + + - + - + - - + + - - - - - - + + + + + + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.utils.JwtUtilsTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.utils.JwtUtilsTests.xml index 8f5e7b7f..6ab790fe 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.utils.JwtUtilsTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.utils.JwtUtilsTests.xml @@ -1,69 +1,63 @@ - + - + - + - + - - - + + + - + - - + + - - - + - - + + - - - - + + + - - - + - - + + - - + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.config.JwtAuthFilterTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.config.JwtAuthFilterTests.txt new file mode 100644 index 00000000..69d4db04 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.config.JwtAuthFilterTests.txt @@ -0,0 +1,4 @@ +------------------------------------------------------------------------------- +Test set: cn.g3soft.fmsapi.config.JwtAuthFilterTests +------------------------------------------------------------------------------- +Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 10.25 s -- in cn.g3soft.fmsapi.config.JwtAuthFilterTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ActiveSessionRegistryTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ActiveSessionRegistryTests.txt index 39d18265..eccc98b4 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ActiveSessionRegistryTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ActiveSessionRegistryTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.service.ActiveSessionRegistryTests ------------------------------------------------------------------------------- -Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.002 s -- in cn.g3soft.fmsapi.service.ActiveSessionRegistryTests +Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.183 s -- in cn.g3soft.fmsapi.service.ActiveSessionRegistryTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.AuthServiceTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.AuthServiceTests.txt index b22c6746..381e1a7f 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.AuthServiceTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.AuthServiceTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.service.AuthServiceTests ------------------------------------------------------------------------------- -Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.218 s -- in cn.g3soft.fmsapi.service.AuthServiceTests +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.993 s -- in cn.g3soft.fmsapi.service.AuthServiceTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.txt index c4a192c3..565a0704 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.service.ExportServiceIntegrationTests ------------------------------------------------------------------------------- -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.740 s -- in cn.g3soft.fmsapi.service.ExportServiceIntegrationTests +Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 29.90 s -- in cn.g3soft.fmsapi.service.ExportServiceIntegrationTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceTests.txt index 422b2b35..b351b432 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.service.ExportServiceTests ------------------------------------------------------------------------------- -Tests run: 17, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.171 s -- in cn.g3soft.fmsapi.service.ExportServiceTests +Tests run: 19, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.294 s -- in cn.g3soft.fmsapi.service.ExportServiceTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.txt index 22f9eac4..08b7085f 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.service.ExportWorkbookWriterTests ------------------------------------------------------------------------------- -Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.263 s -- in cn.g3soft.fmsapi.service.ExportWorkbookWriterTests +Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.362 s -- in cn.g3soft.fmsapi.service.ExportWorkbookWriterTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.utils.JwtUtilsTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.utils.JwtUtilsTests.txt index 630bc087..a0f5b067 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.utils.JwtUtilsTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.utils.JwtUtilsTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.utils.JwtUtilsTests ------------------------------------------------------------------------------- -Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.099 s -- in cn.g3soft.fmsapi.utils.JwtUtilsTests +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.089 s -- in cn.g3soft.fmsapi.utils.JwtUtilsTests diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/config/JwtAuthFilterTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/config/JwtAuthFilterTests.class new file mode 100644 index 00000000..3818e35d Binary files /dev/null and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/config/JwtAuthFilterTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class index 2f3cd581..6310143e 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ActiveSessionRegistryTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class index f6314188..b2ce9c11 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class differ diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class index 8f8dd232..47e20269 100644 Binary files a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class and b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class differ diff --git a/code/fms/fms-vue/src/components/I18nNameModal.vue b/code/fms/fms-vue/src/components/I18nNameModal.vue index 361009bc..f2dcadcf 100644 --- a/code/fms/fms-vue/src/components/I18nNameModal.vue +++ b/code/fms/fms-vue/src/components/I18nNameModal.vue @@ -93,7 +93,7 @@ function onCancel() { .i18n-name-row__label { flex-shrink: 0; width: 80px; - font-size: 13px; + font-size: var(--fms-font-size); color: var(--fms-text-secondary); } diff --git a/code/fms/fms-vue/src/components/captcha/SliderCaptcha.vue b/code/fms/fms-vue/src/components/captcha/SliderCaptcha.vue index 36930ee6..e494173d 100644 --- a/code/fms/fms-vue/src/components/captcha/SliderCaptcha.vue +++ b/code/fms/fms-vue/src/components/captcha/SliderCaptcha.vue @@ -277,7 +277,7 @@ defineExpose({ resume }) justify-content: center; width: 100%; height: 100%; - font-size: 13px; + font-size: var(--fms-font-size); color: var(--fms-text); pointer-events: none; user-select: none; diff --git a/code/fms/fms-vue/src/components/fms-file/FileCard.vue b/code/fms/fms-vue/src/components/fms-file/FileCard.vue index 492b7713..40b2484a 100644 --- a/code/fms/fms-vue/src/components/fms-file/FileCard.vue +++ b/code/fms/fms-vue/src/components/fms-file/FileCard.vue @@ -150,7 +150,7 @@ function onMoreClick(info) { width: 46px; height: 46px; border-radius: 6px; - font-size: 13px; + font-size: var(--fms-font-size); font-weight: 600; letter-spacing: 0.5px; color: var(--c); @@ -245,7 +245,7 @@ function onMoreClick(info) { .fcard__title { flex: 1; min-width: 0; - font-size: 13px; + font-size: var(--fms-font-size); font-weight: 500; color: var(--fms-text); line-height: 1.5; diff --git a/code/fms/fms-vue/src/components/fms-file/FileListPanel.vue b/code/fms/fms-vue/src/components/fms-file/FileListPanel.vue index 96dea228..007bcc6d 100644 --- a/code/fms/fms-vue/src/components/fms-file/FileListPanel.vue +++ b/code/fms/fms-vue/src/components/fms-file/FileListPanel.vue @@ -740,7 +740,7 @@ defineExpose({ files, triggerSelect }) .fl-table { width: 100%; border-collapse: collapse; - font-size: 13px; + font-size: var(--fms-font-size); th, td { @@ -865,7 +865,7 @@ defineExpose({ files, triggerSelect }) justify-content: center; gap: 8px; min-height: 160px; - font-size: 13px; + font-size: var(--fms-font-size); color: var(--fms-text-secondary); &--fill { @@ -948,7 +948,7 @@ defineExpose({ files, triggerSelect }) /* ── 删除确认弹窗 ── */ .fl-confirm-content { - font-size: 13px; + font-size: var(--fms-font-size); line-height: 20px; color: var(--fms-text); word-break: break-all; diff --git a/code/fms/fms-vue/src/components/fms-file/FilePreviewPanel.vue b/code/fms/fms-vue/src/components/fms-file/FilePreviewPanel.vue index 6e05d710..07367b26 100644 --- a/code/fms/fms-vue/src/components/fms-file/FilePreviewPanel.vue +++ b/code/fms/fms-vue/src/components/fms-file/FilePreviewPanel.vue @@ -148,7 +148,7 @@ const extUpper = computed(() => (props.file?.mx_fileext || '?').toUpperCase()) &__name { flex: 1; - font-size: 13px; + font-size: var(--fms-font-size); font-weight: 500; color: var(--fms-text); overflow: hidden; @@ -210,7 +210,7 @@ const extUpper = computed(() => (props.file?.mx_fileext || '?').toUpperCase()) } &__name { - font-size: 13px; + font-size: var(--fms-font-size); font-weight: 600; color: var(--fms-text); word-break: break-all; @@ -239,7 +239,7 @@ const extUpper = computed(() => (props.file?.mx_fileext || '?').toUpperCase()) color: var(--fms-border); &__text { - font-size: 13px; + font-size: var(--fms-font-size); color: var(--fms-text-secondary); } } diff --git a/code/fms/fms-vue/src/components/fms-file/FmsFile.vue b/code/fms/fms-vue/src/components/fms-file/FmsFile.vue index d47204e0..5fd87517 100644 --- a/code/fms/fms-vue/src/components/fms-file/FmsFile.vue +++ b/code/fms/fms-vue/src/components/fms-file/FmsFile.vue @@ -132,7 +132,6 @@ defineExpose({ moduleId, refreshCategories: loadCategories })
- 正在解析模块...
@@ -246,7 +245,7 @@ defineExpose({ moduleId, refreshCategories: loadCategories }) justify-content: center; height: 100%; gap: 8px; - font-size: 13px; + font-size: var(--fms-font-size); color: var(--fms-text-secondary); &--error { diff --git a/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js b/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js index c17731d1..5a32a90a 100644 --- a/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js +++ b/code/fms/fms-vue/src/components/fms-module-common/schemaRender.js @@ -10,7 +10,7 @@ * - 列宽/顺序回写(栏位设置保存)→ 更新 view/edit JSON 节点 */ import { loadDataApi } from '@/services/api' -import { parseSchema } from '@/utils/schemaJson' +import { normalizeGroupIds, parseSchema } from '@/utils/schemaJson' /** 需要还原的界面配置类型 */ const RENDER_SCHEMA_TYPES = ['view', 'edit', 'query'] @@ -74,25 +74,21 @@ export function buildListConfig(viewSchema) { })) } -/** 编辑分组 id(JSON 无主键,进入运行时生成临时编码) */ -let groupSequence = 0 -function nextGroupId() { - groupSequence += 1 - return `g${Date.now().toString(36)}${groupSequence.toString(36)}` -} - /** * edit JSON → { groups, rows }(编辑弹窗消费)。 * groups 为分组节点(b_id/b_title/b_xh),rows 为字段行(含栅格/只读/必填/默认值)。 */ export function buildEditRender(editSchema) { + // 分组 id 与配置态同源:老数据缺 id 时按同一规则补齐(确定性纯函数), + // 运行时分组身份与设计态一致,多次渲染结果也稳定 + const base = normalizeGroupIds('edit', editSchema || parseSchema('edit')) const groups = [] const rows = [] const walk = (list, parentGroupId) => { for (const node of list || []) { if (!node || typeof node !== 'object') continue if (node.type === 'group') { - const gid = nextGroupId() + const gid = String(node.id) groups.push({ b_id: gid, b_parent_id: parentGroupId, @@ -125,7 +121,7 @@ export function buildEditRender(editSchema) { }) } } - walk(editSchema?.children || [], null) + walk(base?.children || [], null) rows.forEach((row, index) => { row.b_xh = (index + 1) * 10 }) diff --git a/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue b/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue index 7191458f..6db78461 100644 --- a/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue +++ b/code/fms/fms-vue/src/components/fms-module-edit/FmsModuleEditModal.vue @@ -21,13 +21,15 @@ * ctx = { isNew, record } */ import { computed, ref, watch } from 'vue' -import { Button, Form, FormGroup, FormItem, Message, Modal } from '@/components/ui' +import { Button, Form, FormGroup, FormItem, Message, Modal, Spin } from '@/components/ui' import FmsField from '@/components/fms-field/FmsField.vue' import { loadDataApi, loadDataBySqlApi, nextIdApi, saveObjectApi } from '@/services/api' import { diffRow } from '@/utils/dataChanges' +import { buildGroupI18nKey } from '@/utils/schemaJson' import { identifierRe, isEmptyValue } from '@/components/fms-module-common/queryUtils' import { parseOptions } from '@/components/fms-module-common/fieldUtils' import { loadModuleSchemas, buildEditRender } from '@/components/fms-module-common/schemaRender' +import { useI18nStore } from '@/stores/i18n' const props = defineProps({ /** 受控打开 */ @@ -50,6 +52,8 @@ const props = defineProps({ const emit = defineEmits(['update:open', 'saved']) +const i18nStore = useI18nStore() + /* ================= 元数据状态 ================= */ const configLoading = ref(false) const saving = ref(false) @@ -107,7 +111,17 @@ const sections = computed(() => { const gid = String(group.b_id) const rows = byGroup.get(gid) || [] if (!rows.length) continue - built.push({ key: `g-${gid}`, title: (group.b_title || '').trim(), group, rows }) + // 分组标题:节点手填 i18n 优先,未填按分组 id 派生键(与模块管理页同一规则)取译文; + // 键无译文时回退 b_title,与旧行为一致 + built.push({ + key: `g-${gid}`, + title: i18nStore.t( + group.b_i18n || buildGroupI18nKey(moduleMeta.value?.b_id, 'edit', gid), + (group.b_title || '').trim(), + ), + group, + rows, + }) } const ungrouped = byGroup.get('') || [] if (ungrouped.length) built.push({ key: 'ungrouped', title: '', group: null, rows: ungrouped }) @@ -459,7 +473,9 @@ function controlPropsOf(row) { @update:open="handleClose" @ok="handleSave" > -
加载中...
+
+ +
{{ configError }} diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsAdvancedQueryPanel.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsAdvancedQueryPanel.vue index e515e353..6bb99e7c 100644 --- a/code/fms/fms-vue/src/components/fms-module-list/FmsAdvancedQueryPanel.vue +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsAdvancedQueryPanel.vue @@ -536,7 +536,7 @@ const previewText = computed(() => describeAdvancedQuery(props.modelValue, itemB border-radius: 4px; background: transparent; color: var(--fms-primary, #0f172b); - font-size: 13px; + font-size: var(--fms-font-size); cursor: pointer; } .filter-add:hover { @@ -573,7 +573,7 @@ const previewText = computed(() => describeAdvancedQuery(props.modelValue, itemB min-width: 0; overflow: hidden; color: var(--fms-text, #1f2329); - font-size: 13px; + font-size: var(--fms-font-size); white-space: nowrap; text-overflow: ellipsis; } diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsExportDialog.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsExportDialog.vue index aca8bcd0..97877f12 100644 --- a/code/fms/fms-vue/src/components/fms-module-list/FmsExportDialog.vue +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsExportDialog.vue @@ -15,7 +15,7 @@ import { computed, ref, watch } from 'vue' import { Download, GripVertical, RefreshCcw } from '@lucide/vue' import draggable from 'vuedraggable' -import { Button, Input, Message, Modal, Segmented } from '@/components/ui' +import { Button, Input, Message, Modal, Segmented, Spin } from '@/components/ui' import { countExportApi, exportListApi } from '@/services/exportService' const props = defineProps({ @@ -52,9 +52,15 @@ const volumeSize = ref(DEFAULT_VOLUME_SIZE) const total = ref(null) const counting = ref(false) const exporting = ref(false) +let countRequestVersion = 0 const selectedCount = computed(() => fields.value.filter((item) => item.checked).length) -const allChecked = computed(() => fields.value.length > 0 && selectedCount.value === fields.value.length) +const usingListColumns = computed( + () => + fields.value.length > 0 && + fields.value.length === props.columns.length && + fields.value.every((item, index) => item.checked && item.field === props.columns[index].field), +) const volumeCount = computed(() => { if (!total.value || !volumeSize.value || volumeSize.value < 1) return 0 return Math.ceil(total.value / volumeSize.value) @@ -68,7 +74,9 @@ function resetForm() { const savedSet = new Set(saved) fields.value = [ ...saved.map((field) => ({ ...byField.get(field), checked: true })), - ...props.columns.filter((item) => !savedSet.has(item.field)).map((item) => ({ ...item, checked: false })), + ...props.columns + .filter((item) => !savedSet.has(item.field)) + .map((item) => ({ ...item, checked: false })), ] } else { fields.value = props.columns.map((item) => ({ ...item, checked: true })) @@ -86,9 +94,8 @@ function toggleField(field, checked) { /** 全选 = 回到列表列(候选集就是列表可见列),因此顺手清掉个人覆盖 */ function selectAll() { - fields.value = fields.value.map((item) => ({ ...item, checked: true })) + fields.value = props.columns.map((item) => ({ ...item, checked: true })) emit('save-columns', []) - Message.success('已恢复为列表列') } function saveColumns() { @@ -97,7 +104,6 @@ function saveColumns() { return } emit('save-columns', selectedFields()) - Message.success('已保存为我的导出列') } function selectedFields() { @@ -106,26 +112,34 @@ function selectedFields() { async function loadTotal() { if (!props.moduleId) return + const requestVersion = ++countRequestVersion counting.value = true try { - const res = await countExportApi({ moduleId: props.moduleId, searchCondition: props.searchCondition }) + const res = await countExportApi({ + moduleId: props.moduleId, + searchCondition: props.searchCondition, + }) + if (requestVersion !== countRequestVersion) return total.value = Number(res?.data?.total) || 0 } catch (error) { + if (requestVersion !== countRequestVersion) return total.value = null Message.error(error?.message || '统计行数失败') } finally { - counting.value = false + if (requestVersion === countRequestVersion) counting.value = false } } watch( - () => props.open, - (open) => { + () => [props.open, props.moduleId, props.searchCondition], + ([open]) => { + countRequestVersion++ if (!open) return total.value = null resetForm() void loadTotal() }, + { immediate: true }, ) function buildParams() { @@ -199,7 +213,9 @@ async function handleDownload() {
导出列 - + @@ -233,18 +249,40 @@ async function handleDownload() {
导出方式 - +
- + 至 - + 条
每 - - 条一个文件 + + 条一个文件
@@ -252,7 +290,7 @@ async function handleDownload() { diff --git a/code/fms/fms-vue/src/components/fms-table/FmsTable.vue b/code/fms/fms-vue/src/components/fms-table/FmsTable.vue index 774369cf..b0bcf1ac 100644 --- a/code/fms/fms-vue/src/components/fms-table/FmsTable.vue +++ b/code/fms/fms-vue/src/components/fms-table/FmsTable.vue @@ -646,6 +646,8 @@ defineExpose({ :columns="displayColumns" @update:columns="onColumnsUpdate" :data-source="dataSource" + :footer-data="footerData" + :footer-config="footerConfig" :drag-row-config="{ mode: rowDraggable ? 'insert' : 'none' }" :row-key="rowKey" :no-data-full="noDataFull" @@ -708,7 +710,7 @@ defineExpose({ .fms-table__inner.stk-table { th, td { - font-size: 13px; + font-size: var(--fms-font-size); } } @@ -814,7 +816,7 @@ defineExpose({ width: 100%; height: 100%; min-width: 0; - font-size: 13px; + font-size: var(--fms-font-size); } // 内部 Input/Select 默认高度由 --fms-control-height 控制,表格行内需要填满单元格 @@ -920,7 +922,7 @@ defineExpose({ } /* 合计行:与数据区之间加一条分隔线,背景保持与表头一致 */ -:deep(.stk-footer) { +.fms-table .stk-footer { td { border-top: 1px solid var(--fms-border); background: var(--fms-surface, #fff); diff --git a/code/fms/fms-vue/src/components/fms-tree/FmsTree.vue b/code/fms/fms-vue/src/components/fms-tree/FmsTree.vue index f6401df7..be4d8ec1 100644 --- a/code/fms/fms-vue/src/components/fms-tree/FmsTree.vue +++ b/code/fms/fms-vue/src/components/fms-tree/FmsTree.vue @@ -10,8 +10,9 @@ * 4. 加载态(loading)由本组件统一渲染遮罩,调用方不必各自写 Spin 与定位样式。 * * 除下表声明的 props 外,其余 props 与事件(draggable / filter / disabled / contextMenu / - * selectedKeys / checkedKeys / lazy / v-model:selected-keys …)经 $attrs 原样透传给 ui/Tree, - * 事件名与 ui/Tree 保持一致;其中 select 事件额外做了一层归一化,见下。 + * checkedKeys / lazy / v-model:checked-keys …)经 $attrs 原样透传给 ui/Tree, + * 事件名与 ui/Tree 保持一致;其中 selectedKeys 与 select 因为要承载下面的虚拟根归一化, + * 由本组件单独声明,不再走 $attrs。 * * 虚拟根的选中约定(showRoot 时): * - 传入:selectedKeys 为 [] / 空值时,本组件自动把虚拟根当作选中项渲染(高亮「全部分类」); @@ -37,15 +38,12 @@ * * */ -import { computed, ref, useAttrs } from 'vue' +import { computed, ref } from 'vue' import { Spin, Tree } from '@/components/ui' import { translate } from '@/stores/i18n' defineOptions({ inheritAttrs: false }) -/** 未声明的 ui/Tree props(含 selectedKeys)经 $attrs 透传,归一化时需读取原值 */ -const attrs = useAttrs() - /** 虚拟根节点 key:纯内部实现细节,调用方用「空选中键」表达选中根,不需要也不应引用它 */ const ALL_CATEGORY_KEY = '__fms_tree_root__' @@ -64,6 +62,15 @@ const props = defineProps({ rootTitle: { type: String, default: '' }, /** 加载态:树区域盖一层居中 Spin 遮罩(懒加载子节点的局部 loading 仍由 ui/Tree 负责) */ loading: { type: Boolean, default: false }, + /** + * 选中键。undefined 表示非受控:原样交给 ui/Tree 用内部状态维护(点哪个高亮哪个)。 + * 传了数组即受控,选中项完全由调用方决定;showRoot 时的虚拟根归一化见 effectiveSelectedKeys。 + * + * 必须声明为 prop 而不是从 $attrs 取:调用方模板写的是 `:selected-keys`(kebab), + * Vue 放进 $attrs 的键名就是 'selected-keys',用 attrs.selectedKeys 永远取不到值 + * (现有测试用 props 传 camelCase,会绕过这条归一化,测不出问题)。 + */ + selectedKeys: { type: Array, default: undefined }, /** * 展开键。undefined 表示非受控:无虚拟根时原样交给 ui/Tree(default-expand-all 等初始展开配置照常生效), * 有虚拟根时由本组件用内部状态维护,并照常抛出 update:expandedKeys。 @@ -123,13 +130,10 @@ const hasRootKey = (keys) => keys.some((key) => String(key) === String(props.roo * 传入 [] / 空值 → 补成 [rootKey],让虚拟根呈现选中态,调用方用「空」表达「选中全部」; * 传入非空数组 → 原样透传,业务节点的选中完全由调用方决定。 * 只补虚拟根这一个 key,不会顺手选中任何业务节点。 - * - * 注意 selectedKeys 与其他 ui/Tree props 一样走 $attrs(本组件未声明它), - * 因此这里必须从 attrs 取值;若从 props 取会永远拿到 undefined, - * 从而把调用方传的真实选中项覆盖成虚拟根。 + * 未传 selectedKeys(undefined)时保持 undefined,让 ui/Tree 走非受控内部状态。 */ const effectiveSelectedKeys = computed(() => { - const keys = attrs.selectedKeys + const keys = props.selectedKeys if (!props.showRoot) return keys if (keys && keys.length) return keys return [props.rootKey] diff --git a/code/fms/fms-vue/src/components/ui/date/calendar-panel.vue b/code/fms/fms-vue/src/components/ui/date/calendar-panel.vue index a66a8774..9ed4ec13 100644 --- a/code/fms/fms-vue/src/components/ui/date/calendar-panel.vue +++ b/code/fms/fms-vue/src/components/ui/date/calendar-panel.vue @@ -2,6 +2,7 @@ import { computed, nextTick, ref, watch } from 'vue' import dayjs from 'dayjs' import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from '@lucide/vue' +import Button from '../button/button.vue' import TimeSelect from './time-select.vue' import { DEFAULT_DATE_FORMAT, @@ -40,7 +41,7 @@ const props = defineProps({ disabledDate: { type: Function, default: () => false }, }) -const emit = defineEmits(['pick', 'hover', 'update:start', 'update:end']) +const emit = defineEmits(['pick', 'hover', 'update:start', 'update:end', 'confirm']) /* ---------- 日历状态 ---------- */ function anchorDate() { @@ -70,6 +71,11 @@ const calendarLabel = computed(() => `${viewYear.value} 年 ${viewMonth.value + const activeKey = computed(() => dayjs(activeDate.value).format('YYYY-MM-DD')) const isPickingStart = computed(() => !props.start || (props.start && props.end)) +/** 底部「确定」(仅带时间的面板渲染):区间要两端齐、单选要有草稿值才允许提交 */ +const confirmDisabled = computed(() => + props.type === 'range' ? !props.start || !props.end : !props.modelValue, +) + // 单次计算 42 个日期格状态,避免模板渲染中重复调用 disabledDate 和日期比较。 const rows = computed(() => { const today = new Date() @@ -556,5 +562,10 @@ defineExpose({ focusActive })
+ + +
diff --git a/code/fms/fms-vue/src/components/ui/date/date-picker.vue b/code/fms/fms-vue/src/components/ui/date/date-picker.vue index a5f46262..7ae9c4bf 100644 --- a/code/fms/fms-vue/src/components/ui/date/date-picker.vue +++ b/code/fms/fms-vue/src/components/ui/date/date-picker.vue @@ -38,6 +38,8 @@ const draftValue = ref(null) const panelRef = ref(null) const pickerConfig = computed(() => analyzeDateFormat(props.format)) +/** 带时间的面板不在点选时写入,改由面板右下角「确定」提交 */ +const needConfirm = computed(() => pickerConfig.value.hasTime) const resolvedValueFormat = computed(() => hasFormat(props.valueFormat) ? props.valueFormat : pickerConfig.value.primaryFormat, ) @@ -67,8 +69,16 @@ function commit(date) { function handlePick(date) { draftValue.value = normalizeDraft(date) + if (needConfirm.value) return commit(draftValue.value) - if (!pickerConfig.value.hasTime) open.value = false + open.value = false +} + +/** 「确定」:提交草稿并关面板;点外部 / Esc 关闭不经过这里,草稿随之丢弃 */ +function handleConfirm() { + if (!draftValue.value) return + commit(draftValue.value) + open.value = false } function handleClear(event) { @@ -168,7 +178,7 @@ watch(pickerConfig, () => { aria-label="清除日期或时间" @click="handleClear" > -