From 182ed86966627c7fbd2d7211c76fcc508a41b1ff Mon Sep 17 00:00:00 2001 From: oneao Date: Wed, 16 Sep 2026 23:15:06 +0800 Subject: [PATCH] 20260916231505 --- code/fms/.tmp-probe/P5.java | 20 + code/fms/.tmp-probe/Probe4.java | 51 ++ code/fms/.workbuddy-ai/memory/2026-09-16.md | 349 ++++++++ code/fms/.workbuddy-ai/memory/MEMORY.md | 56 ++ code/fms/FMS导出功能设计与进度.md | 353 ++++++++ code/fms/fms-api/pom.xml | 8 + .../fmsapi/controller/ExportController.java | 104 +++ .../fmsapi/service/ExportHeadBuilder.java | 64 ++ .../fmsapi/service/ExportSchemaService.java | 343 ++++++++ .../g3soft/fmsapi/service/ExportService.java | 761 ++++++++++++++++++ .../fmsapi/service/ExportStyleHandler.java | 131 +++ .../fmsapi/service/ExportWorkbookWriter.java | 110 +++ .../fmsapi/service/SqlPermissionService.java | 298 +++++++ .../java/cn/g3soft/fmsapi/utils/DbUtils.java | 9 + .../ExportServiceIntegrationTests.java | 270 +++++++ .../fmsapi/service/ExportServiceTests.java | 290 +++++++ .../service/ExportWorkbookWriterTests.java | 211 +++++ .../fmsapi/controller/ExportController.class | Bin 0 -> 5477 bytes .../fmsapi/service/ExportHeadBuilder.class | Bin 0 -> 3074 bytes .../ExportSchemaService$ExportColumn.class | Bin 0 -> 2249 bytes .../ExportSchemaService$ExportNode.class | Bin 0 -> 2782 bytes .../ExportSchemaService$ExportSchema.class | Bin 0 -> 4186 bytes .../fmsapi/service/ExportSchemaService.class | Bin 0 -> 13694 bytes .../fmsapi/service/ExportService$1.class | Bin 0 -> 1130 bytes .../service/ExportService$ExportRequest.class | Bin 0 -> 3139 bytes .../ExportService$PreparedExport.class | Bin 0 -> 2782 bytes .../ExportService$ResultSetRowSource.class | Bin 0 -> 3140 bytes .../g3soft/fmsapi/service/ExportService.class | Bin 0 -> 28738 bytes .../fmsapi/service/ExportStyleHandler.class | Bin 0 -> 6018 bytes .../ExportWorkbookWriter$RowSource.class | Bin 0 -> 390 bytes .../fmsapi/service/ExportWorkbookWriter.class | Bin 0 -> 5174 bytes .../fmsapi/service/SqlPermissionService.class | Bin 0 -> 12589 bytes .../fmsapi/utils/DbUtils$ColumnMetadata.class | Bin 2268 -> 2268 bytes .../fmsapi/utils/DbUtils$PageResult.class | Bin 2267 -> 2267 bytes .../fmsapi/utils/DbUtils$TableMetadata.class | Bin 2434 -> 2434 bytes .../cn/g3soft/fmsapi/utils/DbUtils.class | Bin 29301 -> 29403 bytes .../compile/default-compile/createdFiles.lst | 5 + .../compile/default-compile/inputFiles.lst | 7 + .../default-testCompile/inputFiles.lst | 3 + .../2026-09-16T21-13-32_187.dumpstream | 5 + .../2026-09-16T21-14-17_744.dumpstream | 5 + .../2026-09-16T21-14-56_126.dumpstream | 5 + .../2026-09-16T21-17-03_642.dumpstream | 5 + .../2026-09-16T21-24-02_171.dumpstream | 5 + .../2026-09-16T21-39-48_013.dumpstream | 5 + .../2026-09-16T21-40-15_270.dumpstream | 5 + .../2026-09-16T21-40-32_507.dumpstream | 5 + .../2026-09-16T21-40-45_467.dumpstream | 5 + .../2026-09-16T21-47-36_296.dumpstream | 5 + .../2026-09-16T21-48-16_896.dumpstream | 5 + .../2026-09-16T21-49-10_993.dumpstream | 5 + .../2026-09-16T21-50-00_871.dumpstream | 5 + .../2026-09-16T21-50-26_075.dumpstream | 5 + .../2026-09-16T21-50-44_849.dumpstream | 5 + .../2026-09-16T22-55-35_629.dumpstream | 5 + .../2026-09-16T22-57-12_944.dumpstream | 5 + .../2026-09-16T22-57-47_025.dumpstream | 5 + .../2026-09-16T23-13-40_995.dumpstream | 5 + ...n.g3soft.fmsapi.FmsApiApplicationTests.xml | 40 +- ...api.database.OrgDataSourceFactoryTests.xml | 30 +- ...api.database.OrgDataSourceManagerTests.xml | 64 +- ....database.OrgDatabaseConfigLoaderTests.xml | 16 +- ...database.OrgDatabaseConfigWatcherTests.xml | 40 +- ....exception.GlobalExceptionHandlerTests.xml | 52 +- ...api.service.ActiveSessionRegistryTests.xml | 14 +- ...g3soft.fmsapi.service.AuthServiceTests.xml | 18 +- ...ft.fmsapi.service.DataSaveServiceTests.xml | 24 +- ....service.ExportServiceIntegrationTests.xml | 106 +++ ...soft.fmsapi.service.ExportServiceTests.xml | 86 ++ ...sapi.service.ExportWorkbookWriterTests.xml | 74 ++ ...T-cn.g3soft.fmsapi.utils.JwtUtilsTests.xml | 18 +- ...tils.snowflake.DefaultIdGeneratorTests.xml | 28 +- ...n.g3soft.fmsapi.FmsApiApplicationTests.txt | 2 +- ...api.database.OrgDataSourceFactoryTests.txt | 9 +- ...api.database.OrgDataSourceManagerTests.txt | 2 +- ....database.OrgDatabaseConfigLoaderTests.txt | 2 +- ...database.OrgDatabaseConfigWatcherTests.txt | 2 +- ....exception.GlobalExceptionHandlerTests.txt | 2 +- ...g3soft.fmsapi.service.AuthServiceTests.txt | 2 +- ...ft.fmsapi.service.DataSaveServiceTests.txt | 2 +- ....service.ExportServiceIntegrationTests.txt | 4 + ...soft.fmsapi.service.ExportServiceTests.txt | 4 + ...sapi.service.ExportWorkbookWriterTests.txt | 4 + .../cn.g3soft.fmsapi.utils.JwtUtilsTests.txt | 2 +- ...tils.snowflake.DefaultIdGeneratorTests.txt | 2 +- .../ExportServiceIntegrationTests.class | Bin 0 -> 13872 bytes .../fmsapi/service/ExportServiceTests.class | Bin 0 -> 13167 bytes .../service/ExportWorkbookWriterTests$1.class | Bin 0 -> 1166 bytes .../service/ExportWorkbookWriterTests.class | Bin 0 -> 9929 bytes .../fms-module-common/schemaRender.js | 1 + .../fms-module-list/FmsExportDialog.vue | 394 +++++++++ .../fms-module-list/FmsModuleListPage.vue | 184 ++++- .../fms-module-list/FmsModuleTable.vue | 68 ++ .../fms-module-list/useModulePref.js | 8 +- .../src/components/fms-table/FmsTable.vue | 11 + .../src/components/fms-tree/FmsTreePanel.vue | 133 +++ .../src/components/ui/button/button.vue | 6 +- .../src/components/ui/button/index.scss | 17 + .../src/components/ui/splitter/index.scss | 45 +- .../src/components/ui/splitter/splitter.vue | 4 +- .../fms/fms-vue/src/layouts/DefaultLayout.vue | 22 +- .../src/layouts/components/AppTopbar.vue | 31 +- .../fms/fms-vue/src/services/exportService.js | 133 +++ code/fms/fms-vue/src/services/http.js | 6 + code/fms/fms-vue/src/styles/common.scss | 174 +--- code/fms/fms-vue/src/theme/tokens.css | 3 +- .../src/views/base/othercompany/index.vue | 78 +- .../src/views/base/otherdata/index.vue | 54 +- .../module/menu-management/MenuTreePanel.vue | 89 +- .../views/module/menu-management/index.vue | 15 +- .../module-management/ModuleTreePanel.vue | 77 +- .../design-editor/FormDesignEditor.vue | 58 +- .../design-editor/TableDesignEditor.vue | 59 +- .../design-editor/common/EditDataView.vue | 6 +- .../design-editor/common/schemaModel.js | 7 +- .../design-editor/form/useFormGrid.js | 10 - .../components/fieldDefaults.js | 5 +- .../views/module/module-management/index.vue | 18 +- .../src/views/system/log/audit/index.vue | 2 +- .../src/views/system/log/login/index.vue | 2 +- .../src/views/system/log/technical/index.vue | 2 +- .../fms-vue/src/views/system/user/index.vue | 155 ++-- .../tests/unit/fms-export-dialog.spec.js | 230 ++++++ .../unit/module-table-edit-panel-dnd.spec.js | 22 + code/fms/开发规范.md | 18 + ...Web Report Designer 初期架构设计方案 V1.md | 332 ++++++++ 126 files changed, 5900 insertions(+), 766 deletions(-) create mode 100644 code/fms/.tmp-probe/P5.java create mode 100644 code/fms/.tmp-probe/Probe4.java create mode 100644 code/fms/FMS导出功能设计与进度.md create mode 100644 code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/ExportController.java create mode 100644 code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportHeadBuilder.java create mode 100644 code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportSchemaService.java create mode 100644 code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportService.java create mode 100644 code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportStyleHandler.java create mode 100644 code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportWorkbookWriter.java create mode 100644 code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/SqlPermissionService.java create mode 100644 code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.java create mode 100644 code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceTests.java create mode 100644 code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.java create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/controller/ExportController.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportHeadBuilder.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService$ExportColumn.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService$ExportNode.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService$ExportSchema.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$1.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$ExportRequest.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$PreparedExport.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService$ResultSetRowSource.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportService.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportStyleHandler.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportWorkbookWriter$RowSource.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportWorkbookWriter.class create mode 100644 code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/SqlPermissionService.class create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T21-13-32_187.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T21-14-17_744.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T21-14-56_126.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T21-17-03_642.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T21-24-02_171.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T21-39-48_013.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-15_270.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-32_507.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-45_467.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T21-47-36_296.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T21-48-16_896.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T21-49-10_993.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-00_871.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-26_075.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-44_849.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T22-55-35_629.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T22-57-12_944.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T22-57-47_025.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/2026-09-16T23-13-40_995.dumpstream create mode 100644 code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.xml create mode 100644 code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceTests.xml create mode 100644 code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.xml create mode 100644 code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.txt create mode 100644 code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceTests.txt create mode 100644 code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.txt create mode 100644 code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.class create mode 100644 code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportServiceTests.class create mode 100644 code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests$1.class create mode 100644 code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.class create mode 100644 code/fms/fms-vue/src/components/fms-module-list/FmsExportDialog.vue create mode 100644 code/fms/fms-vue/src/components/fms-tree/FmsTreePanel.vue create mode 100644 code/fms/fms-vue/src/services/exportService.js create mode 100644 code/fms/fms-vue/tests/unit/fms-export-dialog.spec.js create mode 100644 code/one-designer/Web Report Designer 初期架构设计方案 V1.md diff --git a/code/fms/.tmp-probe/P5.java b/code/fms/.tmp-probe/P5.java new file mode 100644 index 00000000..77653560 --- /dev/null +++ b/code/fms/.tmp-probe/P5.java @@ -0,0 +1,20 @@ +import java.sql.*; +public class P5 { + public static void main(String[] a) throws Exception { + String url="jdbc:sqlserver://118.89.70.199:1433;databaseName=FMS;encrypt=true;trustServerCertificate=true"; + try (Connection c=DriverManager.getConnection(url,"sa","zhang520..")) { + System.out.println("--- 配了 summary 的 view schema ---"); + try (Statement s=c.createStatement(); ResultSet r=s.executeQuery( + "SELECT b_module_id, b_schema_json FROM s_module_schema WHERE b_schema_type='view' AND b_schema_json LIKE '%summary%'")) { + int n=0; + while (r.next() && n++<8) System.out.println(r.getString(1)+" => "+r.getString(2)); + if (n==0) System.out.println("(没有任何模块配了 summary)"); + } + System.out.println("--- 含 type:group 的 view schema 数 ---"); + try (Statement s=c.createStatement(); ResultSet r=s.executeQuery( + "SELECT COUNT_BIG(1) FROM s_module_schema WHERE b_schema_type='view' AND b_schema_json LIKE '%\"group\"%'")) { + r.next(); System.out.println(r.getLong(1)); + } + } + } +} diff --git a/code/fms/.tmp-probe/Probe4.java b/code/fms/.tmp-probe/Probe4.java new file mode 100644 index 00000000..d8a09cfa --- /dev/null +++ b/code/fms/.tmp-probe/Probe4.java @@ -0,0 +1,51 @@ +import java.sql.*; + +/** 一次性探针:复现 COUNT_BIG 报错。用完即删。 */ +public class Probe4 { + public static void main(String[] args) throws Exception { + String url = "jdbc:sqlserver://118.89.70.199:1433;databaseName=FMS;encrypt=true;trustServerCertificate=true"; + try (Connection conn = DriverManager.getConnection(url, "sa", "zhang520..")) { + + try (Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery("SELECT COUNT_BIG(1) FROM [b_con_type]")) { + rs.next(); + System.out.println("Statement 无别名: " + rs.getLong(1)); + } catch (SQLException e) { + System.out.println("Statement 无别名 失败: " + e.getMessage()); + } + + try (Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery("SELECT COUNT_BIG(1) AS total FROM [b_con_type]")) { + rs.next(); + System.out.println("Statement 有别名: " + rs.getLong(1) + " / getLong(\"total\")=" + rs.getLong("total")); + } catch (SQLException e) { + System.out.println("Statement 有别名 失败: " + e.getMessage()); + } + + try (PreparedStatement ps = conn.prepareStatement("SELECT COUNT_BIG(1) FROM [b_con_type]"); + ResultSet rs = ps.executeQuery()) { + rs.next(); + System.out.println("PreparedStatement 无别名 getLong(1): " + rs.getLong(1)); + } catch (SQLException e) { + System.out.println("PreparedStatement 无别名 失败: " + e.getMessage()); + } + + try (PreparedStatement ps = conn.prepareStatement("SELECT COUNT_BIG(1) AS total FROM [b_con_type]"); + ResultSet rs = ps.executeQuery()) { + rs.next(); + System.out.println("PreparedStatement 有别名 getLong(1): " + rs.getLong(1)); + } catch (SQLException e) { + System.out.println("PreparedStatement 有别名 失败: " + e.getMessage()); + } + + System.out.println("列类型: " + columnType(conn, "SELECT COUNT_BIG(1) FROM [b_con_type]")); + } + } + + private static String columnType(Connection conn, String sql) throws SQLException { + try (PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { + ResultSetMetaData md = rs.getMetaData(); + return md.getColumnLabel(1) + " / " + md.getColumnTypeName(1) + " / type=" + md.getColumnType(1); + } + } +} diff --git a/code/fms/.workbuddy-ai/memory/2026-09-16.md b/code/fms/.workbuddy-ai/memory/2026-09-16.md index bf148d02..dfc04551 100644 --- a/code/fms/.workbuddy-ai/memory/2026-09-16.md +++ b/code/fms/.workbuddy-ai/memory/2026-09-16.md @@ -141,3 +141,352 @@ **验证**:`pnpm lint` 无新增问题(唯一 error 是既有 `createCellRenderer.old.js` 的 `no-unused-vars`,与本改动无关);`pnpm build --outDir dist-verify --emptyOutDir` 通过。 + +## 表单设计器「数据视图」栅格宽度改为普通数字输入 + +**需求**:模块管理 → 表单设计 → 数据,`栅格宽度` 列不要下拉、不要「整行 24/24」文案,直接显示数字。 + +**改动**(`components/design-editor/FormDesignEditor.vue`): +- `formDataColumns()` 里 `b_colspan` 的 `editor` 由 + `{ type:'select', options: () => COLSPAN_OPTIONS }` 改为 + `{ type:'input', inputType:'number', onChange: (v, row) => { row.b_colspan = clamp(1..24) } }`, + 列宽 120 → 100。仅做边界收敛,不做文案换算。 +- 删掉 `form/useFormGrid.js` 的 `COLSPAN_OPTIONS` 导出(唯一使用点已移除)及对应 import。 + +**注意**:`设计`视图属性面板(`FormInspector.vue`)的栅格宽度早就是普通 Input, +**数据视图**这一列是最后残留的下拉。`列表配置`(TableDesignEditor,mode=list)的 +数据视图只有「列宽 b_width」,本来就是数字输入,无同类问题。 + +**联动行为未改**:设计视图改栅格宽度仍会压缩同行其它字段(`onColspanChange`), +这是保证逻辑行与视觉行一致的必要逻辑;数据视图改值不触发联动。 + +**验证**:改动文件 oxlint 0 error;`tests/unit/{form-edit-panel-mount,module-table-edit-panel-dnd,module-list-config-panel-dnd,schema-render}.spec.js` 25 passed。 + +## 设计器视图切换改名:「数据」→「批量编辑」 + +**背景**:用户问「数据这部分这样显示合适,还是放 modal」。结论是**保持内联切换**, +modal 只适合短事务,而这里是 10 列批量编辑网格;`ui/modal/modal.vue` 默认宽 520px +且自带全屏按钮,本身就说明装不下。真痛点其实是命名 —— 「数据」会被理解成录入业务数据。 + +**改名**:`VIEW_OPTIONS` 第二项 `数据` → `批量编辑`(`value` 仍是 `'data'`,不动状态机)。 +只改第二项,「设计」保留,减少肌肉记忆成本。没选「属性表」是因为会和设计视图右侧 +「属性」面板撞概念。 + +**落地**: +- `FormDesignEditor.vue` / `TableDesignEditor.vue` 的 `VIEW_OPTIONS` 各加 `title`, + 并用 `Segmented` 的 `#label` 插槽渲染 ``(照抄同文件 `EDIT_MODE_OPTIONS` 写法)。 + `Segmented` 插槽传的是 `option.raw`(原始对象),`segmented-item-text` 这个类**没有任何 CSS**, + 所以自定义 span 与默认渲染等价,只是多了 title。 +- 同步注释里的「数据视图」→「批量编辑视图」:两个编辑器 + `EditDataView.vue` + `schemaModel.js`。 + UI 上的「返回设计视图」按钮文案保留。 + +**结论(后续别再提 modal)**:内联切换的另一个硬理由是 `selectedNodeKey ↔ activeKey` 已互通, +切视图不丢选中字段;modal 会打断「同一上下文两副镜头」。若哪天真要画布同时可见, +正确解法是照 `components/fms-module-list/FmsQuerySettingsDrawer.vue` 用 Drawer, +但要把「改完即写草稿」改成「草稿+保存」,代价大于收益。 + +**验证**:design-editor 目录 oxlint 0 error;5 个相关测试文件 30 passed。 +`module-list-components.spec.js` 的 2 个失败是既有基线(高级查询行间连接符,测试只 import +fms-module-list 组件,与本次改动无关)。`oxfmt --check` 报 EditDataView/schemaModel/useFormGrid +有格式问题,实测是 **CRLF vs LF 的行尾差异**(`diff --strip-trailing-cr` 完全相同),非本次引入。 + +## 列表配置的视图切换器移到左边(与表单设计对齐) + +**现象**:两个编辑器都用 `mm-edit__head` 三段式(left/center/right),但只有 +`TableDesignEditor` 真的有三段 —— `left` 空占位、`center` 放视图切换、`right` 放按钮, +`left`/`right` 都是 `flex: 1 1 auto`,于是切换器被挤到**中间**。 +`FormDesignEditor` 只有 `center` + `right` 两段,切换器实际贴在**最左**。 +所以同一个控件在两个页签位置不一致。 + +**改法**:把 `TableDesignEditor` 的 `mm-edit__view-switch` 移进 `mm-edit__head-left` +(放在 `编辑方式` 之前,保证两个页签的**最左控件是同一个**),删掉空的 `head-center` div +及其 CSS。`FormDesignEditor` 不动。 + +**验证方式(可复用)**:临时写 `tests/unit/.tmp-head-check.spec.js`,用 `mount` 挂 +`ModuleListConfigPanel` / `ModuleEditPanel`,断言 `.mm-edit__head-left` 内含 +`.mm-edit__view-switch` 且 `.mm-edit__head-center` 不存在,并 `console.log(head.html())` +肉眼核对结构,跑完删除。比开浏览器登录(`g3soft` 密码非空串,空串登录返回 401)快得多。 +注意:用 `#label` 插槽渲染的 span **没有** `segmented-item-text` 类,选择器要写 +`.segmented-item-label > span`(写 `.mm-edit__view-switch span` 会把外层 label span 也算进去, +每个标签命中两次)。 + +**遗留**:`FormDesignEditor.vue` 里那个 `mm-edit__head-center` 类名已名不副实(它是 +第一个 flex 子元素,视觉上就是左),改名属可选清理。 + +### 补:FormDesignEditor 的 `mm-edit__head-center` 改名(上条遗留已清) + +模板类名 `mm-edit__head-center` → `mm-edit__head-left`,删掉原 `.mm-edit__head-center` +CSS(`flex: none` + 居中),改由既有的 `.mm-edit__head-left`(`flex: 1 1 auto` + +`justify-content: flex-start`)接管;同时改掉上面那段「左右两翼挤出中间位、保证居中」 +的过期注释。**两个编辑器的 header 骨架现在完全一致**: +`head-left`(视图切换) + `head-right`(操作按钮),用临时挂载测试断言过 `LIST === FORM`。 + +## 导出 / 批量导出:参考实现调研(讨论阶段,尚未定案) + +**参考实现 `D:/workspace/code/g3hd/frontend` 有两条完全不同的导出路径:** + +1. **前端生成**(`src/utils/excel.js` 的 `ExportVxeTableExcel` / `ExportExcel`): + 纯浏览器 ExcelJS。数据源是 VXE 表格内存 —— `getCheckboxRecords()` 有勾选就用勾选, + 否则 `getFullData()`。支持多级表头(手算 merge)、列宽、displayFormat、页脚合计。 + **无任何权限校验**(数据已在浏览器里)。 +2. **后端生成**(`src/components/g3-excel-export/index.vue` + 后端 `ExcelController`): + 「导出所有页」两步向导。步骤1 列配置表存旧表 **`s_columnexcel`**(per view + userid: + col_FieldName/col_Caption/col_Width/col_Visible/ShowSummary);步骤2 按 `limit=10000` + 把 total 切段,逐段 `POST /excel/exportAll`(EasyExcel),前端 JSZip 打包 zip。 + 后端 SQL 用 `select identity(int,1,1),* into #tmptj from view ... where indexid between a and b` + 拿行号做区间切片。 + +**旧实现的坑(别照搬)**: +- `handleDownloadZip` 存的是 `mx_mapfilename`,释放时却 `revokeObjectURL(file.url)` → blob URL 从未释放。 +- 列清单由前端传后端照单全收 → 字段权限形同虚设。 +- `#tmptj` 临时表并发导出会让 tempdb 膨胀。 + +**新系统现状(已核实)**: +- 前端**无**任何导出代码;列表页 `FmsModuleListPage.vue` 工具栏已有「更多操作」Dropdown + (刷新数据 / 恢复栏位设置 / 保存栏位设置),导出入口可挂这里。 +- 后端 **无 POI / EasyExcel 依赖**(pom 里没有)。 +- **后端完全没有权限过滤**:`s_user_field_power` / `s_user_data_power` / `s_power` 在 + fms-api 里**零命中**。目前只有 JWT 鉴权 + 机构数据源路由,任何登录用户都能直接 + POST `/api/data/loaddata` 取全表。→ 导出会是第一个把「后端权限出口」变成刚需的功能。 +- 设计文档**已定义**:`s_user_field_power.b_export`(字段级导出权限)、`b_view=0` 的字段 + 在导出中统一移除(14.3 节);开发规范第 10 条要求字段可见/可查询/可导出权限必须在 + 后端 SQL 落实。`export` 已是标准动作权限点(`ModulePowerPanel.ACTION_NAMES`), + 编码格式 `action.{moduleId}.export`。 +- 配置载体现成:`s_module_schema.view`(系统默认,columns 已含 field/visible/width/format, + 支持 `type:'group'` 分组表头)+ `s_user_module_pref.view`(个人增量覆盖)。 + **不需要新建 s_columnexcel 之类的表。** + +**推荐方向(待用户拍板)**:后端生成 xlsx(一次请求 + 流式游标 + SXSSF), +导出列/合计复用 view schema,前置补 `SqlPermissionBuilder`(动作权限 + 字段白名单 + +数据范围 AND)。详见当次回复。 + +### 导出设计:用户已明确的四条约束 + +1. 「批量导出」= **全量导出**,单模块,不需要多模块同时导。 +2. **普通导出必须与列表一致**(列/条件/排序沿用列表);**高级导出**可选列并**保存导出列**。 +3. **先不做行数上限**。 +4. 合计行 + 多级表头**都必须**。 + +### 第二个参考实现:`D:/workspace/code/container-ocr` + +- 前端 `ocr-vue/src/views/container/high-download-dialog.vue`(652 行)「高级下载」: + 弹窗**自带独立查询条件**(上传日期区间/箱号/操作员,条件为空即全部)、顶部节流 300ms + 实时 `COUNT(*)` 显示「共 N 条」、模式三选一(全部导出 / 按区间导出 / 分页拆分导出)、 + 重置 + 下载、JSZip + file-saver 打包 zip。 +- 后端 `ocr-springboot` `POST /data/range`(`DataListController` → `DbUtils.range`): + `SELECT FROM (SELECT ROW_NUMBER() OVER (ORDER BY ) AS row_num, t.* FROM view t + WHERE 1=1 AND ) sub WHERE sub.row_num BETWEEN ? AND ?`,**1 基闭区间**。 + **比 g3hd 的 `#tmptj` 临时表干净,可直接借鉴**;无 ORDER BY 时用 `ORDER BY (SELECT NULL)` 兜底。 + 注意它把 `validateFinalSql` 里的非法关键字检查**注释掉了**,别跟着抄。 + +### 我给出的方案(待确认) + +- 两个入口:普通导出(不弹窗,列/条件沿用列表,范围=勾选行或当前页);批量导出(弹窗,参考 ocr-vue 形态 + 选列 + 保存列 + 三模式)。 +- **后端是唯一出口,且后端自己按 moduleId 读 `s_module_schema.view` + `s_user_module_pref` 推导导出列**,不信任前端传的列清单(否则字段权限形同虚设)。这同时天然保证「与列表一致」。 +- 保存导出列:`s_user_module_pref` 加 `b_schema_type = 'export'`,零新表。 +- 合计行/分组表头来自 view schema(列节点加 `summary`,group 节点映射 EasyExcel 的 `List>` head)。 +- 不上限的替代保护:全局导出并发闸门 + 每用户 1 个 + 语句超时 + 流式写 + 引导用分卷模式。 +- 接口 `POST /api/export/list`(moduleId / searchCondition / orderBy / rowKeys+keyField / columns? / rangeStart+rangeEnd? / volumeSize? / withSummary / format)。 + +## 导出 P0 落地(后端权限出口 + 导出接口 + 前端导出按钮) + +用户确认了三个设计问题后开工:导出范围规则(有勾选导勾选、否则当前条件全部)、 +保存导出列存 `s_user_module_pref`(`b_schema_type='export'`,P1 用)、高级导出条件区复用现有查询面板。 + +**新增文件** +- `fms-api/.../service/SqlPermissionService.java` —— 三层权限(动作/字段/数据范围), + 是「后端 SQL 权限出口」的第一个实现,后续列表查询复用。 +- `fms-api/.../service/ExportSchemaService.java` —— 按 moduleId 读 s_module + s_field + + s_module_schema.view,按**与列表相同的顺序**还原导出列(含 group 节点 → 多级表头、summary → 合计)。 +- `fms-api/.../service/ExportService.java` —— 拼 SQL(条件 + 数据范围 + 勾选行全 AND)、 + SQL 层聚合合计、JDBC 游标 + POI SXSSF 流式写;全局并发 2 / 每用户 1。 +- `fms-api/.../service/ExportHeaderWriter.java` —— 多级表头:**分组只横向合并、叶子只纵向合并**, + 靠「分组层级必浅于叶子」保证合并区域永不重叠。 +- `fms-api/.../controller/ExportController.java` —— `POST /export/list`。 +- 测试:`ExportServiceTests`(14 项,SQL 拼装 + 合并规则)、`ExportServiceIntegrationTests`(3 项,连真实库)。 + +**修改** +- `pom.xml` 加 `poi-ooxml:5.2.5`;`DbUtils` 暴露 `buildSafeOrderBy`(原私有 buildPageOrderBy 的薄包装)。 +- 前端 `services/exportService.js`(blob + `filename*` 解析 + 错误 JSON 识别)、 + `services/http.js` 响应拦截器加 blob 直通、`FmsModuleListPage` 抽出 `buildSearchCondition()` + 供取数与导出共用(这才是「与列表一致」的落地方式),工具栏加「导出」按钮。 + +**写测试时的两个发现(实现是对的,是我的预期错了)** +1. 两级表头里叶子已在最后一行,**不该**再纵向合并 —— 否则会与分组的横向合并区域重叠。 +2. 叶子标题写在自己所在层级并向下合并,不是写在第一行。 + +**验证** +- 后端:`ExportServiceTests` 14 passed、`ExportServiceIntegrationTests` 3 passed + (真实库导出 → xlsx 可被 POI 解析、表头/数据行正确、勾选行只出 1 行、模块不存在时响应体为空即未写流)。 +- 全量后端 46 项,仅 `OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry` 失败(既有基线)。 +- 前端:改动文件 oxlint 0 error;`fms-module-list-page` 3 个失败均为引用已不存在的 + `.filter-drawer` 的过期断言(既有基线)。 + +**未做(留给 P1)**:批量导出弹窗(条件区/条数/三模式/选列/保存列)、前端按 `canPower` 隐藏导出按钮、 +合计与分组表头在模块管理里的配置入口(现在没有任何模块配 `summary` / `group`,所以线上看不到合计行)。 + +## 导出功能交接文档 + +新建仓库根 **`FMS导出功能设计与进度.md`**(与其它设计文档同级,226 行 / 9 节), +内容:现状一句话 → 已完成文件清单 → 接口契约 → 设计决策与理由 → 怎么验证(含本机 mvn 的可行命令) +→ 数据库现状(解释为什么看不到合计行)→ 未完成(P1/P2 + 已知口径取舍)→ 批量导出弹窗设计 → 附元数据事实。 + +**用户明确**:合计行与分组表头**以后再说**,所以 P1 第一项(合计/分组的配置入口)暂缓, +下一步应优先批量导出弹窗(后端 `columns` / `rangeStart` / `rangeEnd` 参数已实现,弹窗可直接对接)。 + +### 澄清:普通导出现在就是「全量」,不是当前页 + +用户问「批量导出还没做吗」,核对代码时发现一个**与最初讨论不一致的事实**: +`ExportService.buildSelectSql` 只在传了 `rangeStart`/`rangeEnd` 时才加 `OFFSET/FETCH`, +不传就是不限行数 → 前端「导出」按钮(不传区间)实际导的是**当前查询条件下的全部行**。 +已把 `ExportServiceIntegrationTests` 的断言从 `getLastRowNum() >= 1` 收紧为 +`== rowCountOfModule()`(`b_con_type` 8 行 → 导出 8 行数据),跑通确认。 + +**影响**:「批量导出」剩下的差异只有 选列 / 区间 / 分卷 / 保存导出列 / 条数预览, +数据范围与普通导出相同。两种走法待产品定: +(a) 保留现状(一键全量,批量导出靠选列分卷区分);(b) 普通导出改当前页(前端补传当前页区间,后端已支持)。 +已写进 `FMS导出功能设计与进度.md` 第 0 节与 6.1 第 0 项,**未定之前不要动**。 + +同时给文档补了 **第 9 节「实施过程中踩到的坑」**:环境(start.cmd 路径错 / mvn 起不来 / 8088 是旧实例 / +登不进浏览器)、选型(EasyExcel→POI 的原因)、实现(排序校验找错口、多级表头合并规则第一版想错)、 +数据权限现实(权限表全空、无模块配 summary/group)、顺带发现的既有问题。 + +## Excel 库换成 Apache Fesod (Incubating) + +**需求**:把第一版的 POI SXSSF 换成 Apache Fesod(EasyExcel → FastExcel → Apache Fesod 的续作)。 + +**坐标**(`org.apache.fesod:fesod-sheet:2.0.2-incubating`,支持 jdk8–jdk25): +自带 POI 5.5.1 / commons-csv / ehcache,**不要再单独引 POI**。包名 `org.apache.fesod.sheet.*`, +入口类 `FesodSheet`(同时保留 `EasyExcel` / `FastExcel` 兼容类名),API 与 EasyExcel 一致。 +**查 API 不要靠记忆**:`jar tf` + `javap` 直接看 D:/data/maven 里下下来的 jar 最快。 + +**改动** +- 删 `ExportHeaderWriter.java`(手写合并区域那段),新增 `ExportHeadBuilder`(表头树 → + `List>`)、`ExportStyleHandler`(`CellWriteHandler`:表头/数据/合计三样式 + 逐列列宽)、 + `ExportWorkbookWriter`(表头 + 分批明细 + 合计行 → xlsx,行来源抽象成 `RowSource`, + 所以能脱离数据库单测)。 +- `ExportService`:写出层重写;`applyValue` → `normalizeValue`(时间落文本、其余保留原类型); + 游标改成「先 `executeQuery()` 再设响应头」,这样 SQL 报错仍能返回 JSON。 +- 多级表头合并交给 `automaticMergeHead(true)`;`ExportServiceTests` 里那 4 个合并区域断言 + 换成表头结构断言;新增 `ExportWorkbookWriterTests`(6 项)。 + +**被自己的测试抓到的两个 Fesod 行为坑(已修)** +1. **0 行导出会生成「没有任何工作表」的空 xlsx** —— Fesod 只在 `write` 被调用时才创建工作表, + 所以最后一批即使为空也必须调一次。 +2. 合计行是拼出来的数据行,「合计」标签要显式构造(落在第一个无聚合值的列)。 + +**验证**:导出相关 23 项全过(14 + 6 + 3,含连真实库的端到端);后端全量 52 项, +唯一失败仍是既有基线 `OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry`。 +文档 `FMS导出功能设计与进度.md` 已同步(§1.1 文件清单 / §3.5 §3.7 决策 / §4.2 计数 / §9.2 §9.3 踩坑)。 + +## 批量导出弹窗(P1 第 2 项)完成 + +**需求**:导出按钮改成只显示图标;然后做批量导出功能。 + +**后端新增** +- `POST /api/export/count` → `{total}`。**单独做而不是复用列表分页的 total**,因为它必须走 + 与导出完全相同的权限链路(动作权限 + 字段裁剪 + 数据范围),列表分页的 total 不受数据范围约束。 +- `volumeSize` 分卷:一次请求后端切卷打包 zip(每卷一个 xlsx,命名 `{模块名}_{起}-{止}.xlsx`), + 与区间互斥、逐卷不输出合计。`ExportRequest` 加了 `volumeSize`(现在 10 个字段)。 + 与旧系统 `g3hd`「前端切段 N 次请求 + 前端 JSZip」的做法不同 —— 前端不用管进度与重试。 +- `ExportService` 重构出 `prepare()`(权限 + 列推导 + WHERE/ORDER 共用)与 + `countRows()`;`buildSelectSql` 加 `paged` 参数(区间与分卷共用 OFFSET/FETCH)。 + +**前端新增/改动** +- `FmsExportDialog.vue`:选列(默认全选或已保存的列,可拖拽排序)+ 三种方式(全部/区间/分卷)+ 保存配置。 + **条件只读**(沿用列表条件),不另写一套条件表单。 +- `useModulePref.js`:`loadModulePref`/`saveModulePref` 加 `schemaType` 参数(默认 `query`), + 供「我的导出列」复用;个人导出列存 `s_user_module_pref` 的 `b_schema_type='export'`, + 「恢复列表列」= 删除该行覆盖。 +- `FmsModuleListPage`:导出按钮改纯图标(`class="list-toolbar__icon"`,间距回到 4px); + 「更多操作」加「批量导出」;抽出 `exportColumns`(列表可见列 + 字段名称)传给弹窗。 +- 测试 `tests/unit/fms-export-dialog.spec.js`(8 项,一次通过)。 + +**踩坑(都写进文档 §9.3 了)** +- 测试里用 `b_id = N'绝不存在的值'` 构造「查不到」,SQL Server 报 + `Error converting data type nvarchar to bigint` —— **`b_con_type.b_id` 是 bigint**(业务表用雪花 ID), + 不是字典表那种 varchar。教训:构造测试条件前先确认列的真实类型。 +- 二进制响应不要调 `response.setCharacterEncoding`,否则 content-type 变成 + `application/zip;charset=UTF-8`。 + +**验证**:后端 54 项(唯一失败仍是既有基线 `OrgDataSourceFactoryTests`); +导出相关 25 项全过(14 + 6 + 5,含真实库的导出/计数/分卷);前端弹窗 8 项全过。 +既有前端基线失败:`fms-module-list-page.spec.js` 3 项(`.filter-drawer` 已不存在)、 +`module-pref.spec.js` 2 项(默认布局断言与实现不一致,测试未跟上)。 + +### 批量导出弹窗改版(用户反馈「看起来很乱」) + +**改动**(`FmsExportDialog.vue` 重写) +- **删掉全部说明性文字**:模式说明(单个文件 / 只导第 N 到 M 条 / 每 N 条一个文件打包 zip)、 + 条件说明(沿用列表当前条件)、底部那段「分组表头与合计行按列表配置生成…」全删。 + 顶部原来那条 summary 也去掉,条数挪到 footer 左侧。 +- **导出列改竖排列表 + 可拖拽排序**(`vuedraggable`,项目已有依赖,设计编辑器同款; + `handle=".export-dialog__handle"` + GripVertical 图标)。显示顺序即导出顺序。 +- **动作从 4 个减到 2 个**:「全选」「保存为我的导出列」。 + 关键洞察:候选集就是列表可见列,所以**「全选」等价于原来的「恢复列表列」**(顺手清个人覆盖); + 「反选」删掉。 +- **导出方式用 `Segmented`** 替代三个带说明的 radio;参数行只在需要时出现,且去掉「第/条」等冗余字。 +- 个人保存的列:已保存的按保存顺序排前面并勾上,其余列补在后面(不勾选),这样用户还能勾回来。 + +**踩到的坑**:`ui/modal` 的 `footer` prop 为 false 时**整个 footer 容器(含 `#footer` 插槽)都不渲染**, +不是「隐藏默认按钮、保留插槽」。用 `#footer` 自定义按钮时必须让 `footer` 保持默认 true。 +(测试里表现为 `footerButtons()[1]` undefined。) + +**验证**:`tests/unit/fms-export-dialog.spec.js` 8 项全过(新增「保存顺序优先 + 未保存列补在后面」 +的断言;「全选」断言 emit 空数组)。**拖拽手势本身没法在 jsdom 里模拟** —— 只验证了 +「渲染顺序 = 数组顺序 → 导出列顺序」这条链路,SortableJS 的实际拖动依赖真浏览器, +但它与设计编辑器用的是同一个库同一套写法。 + +### 批量导出弹窗:导出方式参数行对齐 + +用户反馈「分卷的也出现在下面」。原因是参数行原来作为 inline span 跟在 `Segmented` 后面, +靠 flex-wrap 决定换不换行 —— 区间参数短、常留在行内,分卷参数长(「每 5000 条一个文件,共 2 个」) +才换行,两种模式位置不一致。 + +改法:`导出方式` 改成「左标题 + 右控件列」的 grid,控件列里 `Segmented` 与参数行纵向排列, +于是**区间与分卷的参数都固定单独占一行、与 Segmented 同列对齐**。 +纯布局改动,`tests/unit/fms-export-dialog.spec.js` 8 项仍全过(选择器 `.export-dialog__params input` 未变)。 + +### 批量导出弹窗:导出方式布局再修 + 按钮文案 + +用户要求:① 导出方式 label 与 Segmented 同一行;② 填写的参数单独一行;③ 主按钮文案「下载」→「导出」。 + +上一版把 Segmented 与参数行放进了一个「控件列」div(flex column),再用 `align-items: center` +对齐 grid —— 结果标题是相对**整个控件列**(两行高)垂直居中的,看起来就掉到两行中间了。 +改成**三个 grid 项平铺**:标题(row1 col1)、Segmented(row1 col2)、参数行(row2 col2), +各居自己所在行,标题自然与 Segmented 同行,参数行单独一行且与 Segmented 左对齐(无需硬编码缩进)。 +`tests/unit/fms-export-dialog.spec.js` 补了一条结构断言锁住这个布局(9 项全过)。 + +### 批量导出弹窗:Segmented 不要 100% 宽 + +`导出方式` 的两列 grid 里,Segmented 作为 grid 项默认 `justify-self: stretch`, +被拉伸到整列宽(看起来占满一行)。加 `.export-dialog__modes { justify-self: start }` 按内容宽度靠左; +参数行同理(`display: inline-flex` 在 grid 里也会被拉伸),一并加 `justify-self: start`。 +**通用提醒**:grid 里的 `display:flex/inline-flex` 子元素默认都会被拉伸,想按内容宽度必须显式 `justify-self: start`。 + +### 批量导出弹窗:按钮改名 + 列区加高 + +- 「保存为我的导出列」→「**保存配置**」(4 个字,与旁边的「全选」等宽,动作区更紧凑)。 + 测试里的按钮文案查找同步改了。 +- 导出列列表 `max-height` 220px → **340px**(约 10 行)。 + +### 列表合计:该页合计 + 总合计(table footer 两行) + +1. **schemaRender.js**:`buildListConfig` 透传 `node.summary`(`sum`/`count`)。 +2. **FmsTable.vue**:新增 `footerData` / `footerConfig` props 传给 `StkTable`; + 加 `:deep(.stk-footer) td` 样式(上边框 + 背景 + 加粗)。 +3. **FmsModuleTable.vue**: + - 新增 `grandTotals` prop(后端 `/export/totals` 返回的全量聚合)。 + - `computed footerData`:按 `listConfig.summary` 从 `dataSource` 实时算「该页合计」; + 第一列显示标签("该页合计"/"总合计"),其余列按字段类型格式化(money 保留 2 位)。 +4. **FmsModuleListPage.vue**: + - `fetchData` 成功后异步调 `loadTotalsApi` 拿总合计。 + - 普通导出(toolbar 按钮)改为**仅导出当前页**(无勾选时补传 `rangeStart/rangeEnd`), + 使「导出合计 = 当页合计」自洽;勾选行时仍导勾选行。 +5. **exportService.js**:新增 `loadTotalsApi`(调 `POST /export/totals`);顺手修了 `countExportApi` + 里未定义 `post` 的 bug(改为 `http.post`)。 +6. **后端**:`ExportService` 已在此前改好(区间合计走 `ROW_NUMBER` 子查询、新增 `totals()` 方法、 + `ExportController` 新增 `/export/totals` endpoint)。 + +测试:前端 lint 0 error,export 对话框 9 项全过,fms-table-virtual 11 项全过; +后端 ExportService*Tests 24 项全过;全项目基线 13 失败未增加。 diff --git a/code/fms/.workbuddy-ai/memory/MEMORY.md b/code/fms/.workbuddy-ai/memory/MEMORY.md index f23b9060..a18a4f6a 100644 --- a/code/fms/.workbuddy-ai/memory/MEMORY.md +++ b/code/fms/.workbuddy-ai/memory/MEMORY.md @@ -137,3 +137,59 @@ 复合主键用逗号分隔(如 `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 提权可直接用**。 diff --git a/code/fms/FMS导出功能设计与进度.md b/code/fms/FMS导出功能设计与进度.md new file mode 100644 index 00000000..945f6155 --- /dev/null +++ b/code/fms/FMS导出功能设计与进度.md @@ -0,0 +1,353 @@ +# 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-api/pom.xml b/code/fms/fms-api/pom.xml index be08cccf..75cd6661 100644 --- a/code/fms/fms-api/pom.xml +++ b/code/fms/fms-api/pom.xml @@ -77,6 +77,14 @@ 1.6.1 + + + org.apache.fesod + fesod-sheet + 2.0.2-incubating + + org.springframework.boot spring-boot-devtools 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 new file mode 100644 index 00000000..eb6c5979 --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/controller/ExportController.java @@ -0,0 +1,104 @@ +package cn.g3soft.fmsapi.controller; + +import cn.g3soft.fmsapi.service.ExportService; +import cn.g3soft.fmsapi.utils.ApiResponse; +import cn.g3soft.fmsapi.utils.ParamUtils; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 列表导出。返回文件流而不是 ApiResponse;校验失败时在写流之前抛出, + * 由 GlobalExceptionHandler 以 HTTP 200 + JSON 业务错误返回(前端按 content-type 区分)。 + */ +@RestController +@RequestMapping("/export") +public class ExportController { + + @Resource + private ExportService exportService; + + @PostMapping("/list") + public void exportList( + @RequestBody(required = false) Map params, + HttpServletResponse response + ) throws IOException { + exportService.export(readRequest(params), response); + } + + /** + * 符合条件的行数(批量导出弹窗的「共 N 条」)。 + * 走与导出完全相同的权限链路,因此数字与真正导出的行数一致。 + */ + @PostMapping("/count") + public ApiResponse> count( + @RequestBody(required = false) Map params + ) { + Map result = new LinkedHashMap<>(); + result.put("total", exportService.count(readRequest(params))); + return ApiResponse.success(result); + } + + /** + * 各合计列的聚合值(列表底部的「总合计」)。 + * 与导出共用同一条权限链路与同一套聚合 SQL,因此与导出文件里的合计行必然一致。 + */ + @PostMapping("/totals") + public ApiResponse> totals( + @RequestBody(required = false) Map params + ) { + return ApiResponse.success(exportService.totals(readRequest(params))); + } + + private ExportService.ExportRequest readRequest(Map params) { + return new ExportService.ExportRequest( + ParamUtils.getRequiredString(params, "moduleId"), + ParamUtils.getString(params, "searchCondition"), + ParamUtils.getString(params, "orderBy"), + readRowKeys(params), + ParamUtils.getString(params, "keyField"), + ParamUtils.getList(params, "columns"), + readInteger(params, "rangeStart"), + readInteger(params, "rangeEnd"), + readInteger(params, "volumeSize"), + readBoolean(params, "withSummary") + ); + } + + /** + * 勾选行主键。JSON 数字可能被解析成 Integer/Long/Double,这里统一按原值透传给 + * PreparedStatement,不做字符串化(避免数字主键变成字符串后索引失效)。 + */ + private List readRowKeys(Map params) { + List values = ParamUtils.getList(params, "rowKeys"); + if (values == null) { + return null; + } + return List.copyOf(values); + } + + private Integer readInteger(Map params, String field) { + Number number = ParamUtils.get(params, field, Number.class); + return number == null ? null : number.intValue(); + } + + /** 缺省输出合计行 */ + private boolean readBoolean(Map params, String field) { + Object value = params == null ? null : params.get(field); + if (value == null) { + return true; + } + if (value instanceof Boolean flag) { + return flag; + } + return Boolean.parseBoolean(String.valueOf(value)); + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportHeadBuilder.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportHeadBuilder.java new file mode 100644 index 00000000..ce611b90 --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportHeadBuilder.java @@ -0,0 +1,64 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportNode; + +import java.util.ArrayList; +import java.util.List; + +/** + * 把 view schema 的表头树(分组节点 + 叶子列)转成 Fesod 需要的 + * {@code List>} 表头结构:外层是列,内层是该列自上而下的各层标题。 + * + * 例:`费用 > 明细 > b_a` 这一列 → `["费用", "明细", "b_a"]`;顶层叶子 `b_d` → `["b_d"]`。 + * 各列内层长度可以不同(顶层叶子只有 1 层),Fesod 会按「底部对齐」补空行 —— + * 也就是说列名一定落在表头最后一行,这正是多级表头的常规形态。 + * + * 合并由 Fesod 的 `automaticMergeHead(true)` 负责,本类不生成任何合并区域 + * (原先手写合并的 ExportHeaderWriter 已删除)。 + */ +final class ExportHeadBuilder { + + private ExportHeadBuilder() { + } + + /** 表头结构:外层 = 叶子列(顺序与数据列一致),内层 = 该列自上而下的标题 */ + static List> headOf(List nodes) { + List> head = new ArrayList<>(); + collect(nodes, new ArrayList<>(), head); + return head; + } + + private static void collect(List nodes, List path, List> out) { + for (ExportNode node : nodes) { + if (node.isGroup()) { + List next = new ArrayList<>(path); + next.add(node.title() == null ? "" : node.title()); + collect(node.children(), next, out); + continue; + } + List column = new ArrayList<>(path); + column.add(node.column().caption()); + out.add(column); + } + } + + /** 表头层数(最长列的层数):单行表头为 1,用于冻结窗格 */ + static int depth(List nodes) { + int depth = 1; + for (ExportNode node : nodes) { + if (node.isGroup()) { + depth = Math.max(depth, 1 + depth(node.children())); + } + } + return depth; + } + + /** 叶子列数,应与数据列数一致 */ + static int leafCount(List nodes) { + int count = 0; + for (ExportNode node : nodes) { + count += node.isGroup() ? leafCount(node.children()) : 1; + } + return count; + } +} 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 new file mode 100644 index 00000000..2276c8eb --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportSchemaService.java @@ -0,0 +1,343 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.exception.BusinessException; +import cn.g3soft.fmsapi.utils.DbUtils; +import jakarta.annotation.Resource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.stereotype.Service; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 导出列来源:按模块编码读元数据,产出与列表**同一份**列定义。 + * + * 列表(FmsModuleListPage)的列是这样来的:s_module 拿查询视图 → s_field 拿启用字段与名称 + * → s_module_schema.view JSON 按文档顺序取叶子节点(分组节点递归展开)→ 过滤 visible=false + * 与未知字段。本服务照同一顺序还原,保证「普通导出与列表一致」不是两边各自实现, + * 而是同源推导;差别只在导出额外套一层字段权限(b_view / b_export)。 + * + * 列上的 summary(sum / count)与分组节点一起从 view JSON 读取,供合计行与多级表头使用。 + */ +@Service +public class ExportSchemaService { + + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + /** 无配置列宽时的兜底字符宽度(Excel 列宽单位是字符数) */ + private static final int DEFAULT_COLUMN_WIDTH = 16; + private static final double PIXELS_PER_CHARACTER = 8.0; + + @Resource + private DbUtils dbUtils; + + @Resource + private DataSource dataSource; + + @Resource + private ObjectMapper objectMapper; + + /** 导出列定义 */ + public record ExportColumn(String field, String caption, int width, String summary) { + public boolean summable() { + return "sum".equals(summary) || "count".equals(summary); + } + } + + /** 导出表头节点:children 非空即分组,column 非空即叶子列 */ + public record ExportNode(String title, ExportColumn column, List children) { + public boolean isGroup() { + return children != null && !children.isEmpty(); + } + } + + /** 一个模块的导出定义 */ + public record ExportSchema( + String moduleId, + String moduleName, + String viewTable, + String keyField, + String orderSql, + List nodes + ) { + /** 按表头顺序摊平的叶子列 */ + public List columns() { + List columns = new ArrayList<>(); + collectColumns(nodes, columns); + return columns; + } + + private static void collectColumns(List nodes, List out) { + for (ExportNode node : nodes) { + if (node.isGroup()) { + collectColumns(node.children(), out); + } else if (node.column() != null) { + out.add(node.column()); + } + } + } + } + + /** + * 读取模块的导出定义(未做字段权限过滤,由 {@link #filter} 负责裁剪)。 + */ + public ExportSchema load(String moduleId) { + String id = requireText(moduleId, "moduleId"); + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + Map module = firstRow( + connection, + "SELECT [b_id], [b_name], [b_module_type], [b_view_table], [b_key_field], [b_order_sql] " + + "FROM [s_module] WHERE [b_id] = ? AND [b_canuse] = 1", + id + ); + if (module == null) { + throw new BusinessException("模块不存在或已停用: " + id); + } + String moduleType = text(module.get("b_module_type")); + if (!"data".equals(moduleType) && !"virtual".equals(moduleType)) { + throw new BusinessException("模块不是数据模块,无法导出: " + id); + } + String viewTable = text(module.get("b_view_table")); + if (viewTable.isBlank()) { + throw new BusinessException("模块未配置查询视图,无法导出: " + id); + } + dbUtils.validateIdentifier(viewTable); + + Map captions = loadFieldCaptions(connection, id); + String schemaJson = firstString( + connection, + "SELECT [b_schema_json] FROM [s_module_schema] " + + "WHERE [b_module_id] = ? AND [b_schema_type] = 'view' AND [b_canuse] = 1", + id + ); + List nodes = buildNodes(parseColumns(schemaJson), captions); + + String keyField = text(module.get("b_key_field")); + if (!keyField.isBlank()) { + dbUtils.validateIdentifier(keyField); + } + return new ExportSchema( + id, + text(module.get("b_name")), + viewTable, + keyField.isBlank() ? null : keyField, + text(module.get("b_order_sql")).isBlank() ? null : text(module.get("b_order_sql")), + nodes + ); + } catch (SQLException exception) { + throw new BusinessException("导出配置读取失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + /** + * 按字段权限与调用方勾选的列裁剪表头树:叶子被移除后空分组一并丢弃。 + * + * @param deniedFields 不允许导出的字段(b_view = 0 或 b_export = 0) + * @param selectedColumns 调用方勾选的字段编码;null 表示沿用列表全部列 + */ + public ExportSchema filter( + ExportSchema schema, + Set deniedFields, + List selectedColumns + ) { + Set selected = selectedColumns == null ? null : new LinkedHashSet<>(selectedColumns); + List nodes = filterNodes(schema.nodes(), deniedFields, selected); + if (nodes.isEmpty()) { + throw new BusinessException("没有可导出的列"); + } + return new ExportSchema( + schema.moduleId(), + schema.moduleName(), + schema.viewTable(), + schema.keyField(), + schema.orderSql(), + nodes + ); + } + + private List filterNodes( + List nodes, + Set deniedFields, + Set selected + ) { + List kept = new ArrayList<>(); + for (ExportNode node : nodes) { + if (node.isGroup()) { + List children = filterNodes(node.children(), deniedFields, selected); + if (!children.isEmpty()) { + kept.add(new ExportNode(node.title(), null, children)); + } + continue; + } + ExportColumn column = node.column(); + if (column == null) { + continue; + } + if (deniedFields.contains(column.field())) { + continue; + } + if (selected != null && !selected.contains(column.field())) { + continue; + } + kept.add(node); + } + return kept; + } + + /* ================= 元数据读取 ================= */ + + private Map loadFieldCaptions(Connection connection, String moduleId) throws SQLException { + Map captions = new LinkedHashMap<>(); + try (PreparedStatement statement = connection.prepareStatement( + "SELECT [b_field], [b_name] FROM [s_field] " + + "WHERE [b_module_id] = ? AND [b_canuse] = 1 ORDER BY [b_xh], [b_field]")) { + statement.setString(1, moduleId); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + String field = text(resultSet.getString("b_field")); + // 与列表一致:字段编码必须能安全参与 SQL 才可用 + if (!SAFE_IDENTIFIER.matcher(field).matches()) { + continue; + } + captions.put(field, text(resultSet.getString("b_name"))); + } + } + } + return captions; + } + + /** 解析 view JSON 的 columns 数组;缺失或非法时返回空列表(导出会因无列而报错) */ + private List parseColumns(String schemaJson) { + if (schemaJson == null || schemaJson.isBlank()) { + return List.of(); + } + JsonNode root; + try { + root = objectMapper.readTree(schemaJson); + } catch (RuntimeException exception) { + throw new BusinessException("模块列表配置解析失败"); + } + JsonNode columns = root == null ? null : root.path("columns"); + if (columns == null || !columns.isArray()) { + return List.of(); + } + List nodes = new ArrayList<>(); + for (JsonNode node : columns) { + nodes.add(node); + } + return nodes; + } + + private List buildNodes(List jsonNodes, Map captions) { + List nodes = new ArrayList<>(); + for (JsonNode node : jsonNodes) { + if (node == null || !node.isObject()) { + continue; + } + if ("group".equals(node.path("type").asString(""))) { + List children = new ArrayList<>(); + JsonNode childNodes = node.path("children"); + if (childNodes != null && childNodes.isArray()) { + for (JsonNode child : childNodes) { + children.add(child); + } + } + List childResult = buildNodes(children, captions); + if (!childResult.isEmpty()) { + nodes.add(new ExportNode(node.path("title").asString(""), null, childResult)); + } + continue; + } + String field = text(node.path("field").asString(null)); + if (field.isBlank() || !SAFE_IDENTIFIER.matcher(field).matches()) { + continue; + } + // 与列表一致:visible=false 的节点不出现在列表,也不出现在导出 + if (node.has("visible") && !node.path("visible").asBoolean(true)) { + continue; + } + String caption = captions.get(field); + if (caption == null || caption.isBlank()) { + caption = field; + } + nodes.add(new ExportNode( + null, + new ExportColumn( + field, + caption, + toCharacterWidth(node.path("width")), + text(node.path("summary").asString(null)) + ), + null + )); + } + return nodes; + } + + /** 列表配置里的宽度是像素;Excel 列宽单位是字符数,统一在这里换算 */ + private int toCharacterWidth(JsonNode widthNode) { + if (widthNode == null || widthNode.isMissingNode() || widthNode.isNull()) { + return DEFAULT_COLUMN_WIDTH; + } + double pixels = widthNode.asDouble(0); + if (pixels <= 0) { + return DEFAULT_COLUMN_WIDTH; + } + return (int) Math.min(60, Math.max(8, Math.round(pixels / PIXELS_PER_CHARACTER))); + } + + /* ================= 小工具 ================= */ + + private Map firstRow( + Connection connection, + String sql, + Object... parameters + ) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.length; index++) { + statement.setObject(index + 1, parameters[index]); + } + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return null; + } + int columnCount = resultSet.getMetaData().getColumnCount(); + Map row = new LinkedHashMap<>(columnCount); + for (int index = 1; index <= columnCount; index++) { + row.put(resultSet.getMetaData().getColumnLabel(index), resultSet.getObject(index)); + } + return row; + } + } + } + + private String firstString(Connection connection, String sql, Object... parameters) throws SQLException { + Map row = firstRow(connection, sql, parameters); + return row == null ? null : text(row.values().iterator().next()); + } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new BusinessException(fieldName + " 不能为空"); + } + return value.trim(); + } + + private static String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } +} 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 new file mode 100644 index 00000000..5bca728c --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportService.java @@ -0,0 +1,761 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.database.OrgContext; +import cn.g3soft.fmsapi.exception.BusinessException; +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportColumn; +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportSchema; +import cn.g3soft.fmsapi.utils.DbUtils; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.stereotype.Service; + +import javax.sql.DataSource; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * 列表导出(xlsx)。 + * + * 三条硬约束: + * 1. **后端是唯一出口**:导出列由后端按 moduleId 读元数据推导(ExportSchemaService), + * 不信任调用方传入的列清单,字段权限(b_view / b_export)在推导后统一裁剪; + * 2. **权限与校验必须先于写流**:一旦开始写 {@code response.getOutputStream()} 就不能再改 + * header 与状态码,失败只会给用户一个损坏的文件。因此本服务把参数校验、动作权限、 + * 数据范围、列推导、聚合全部放在 {@link #writeWorkbook} 之前完成; + * 3. **流式**:JDBC 游标(forward-only + fetchSize)配 POI SXSSF,内存与总行数无关。 + * + * 并发保护(暂不设行数上限的替代手段):全局同时最多 2 个导出,同一用户同时 1 个。 + */ +@Service +public class ExportService { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); + + /** 明细查询的游标批量:SQL Server 驱动按此批量取行,避免一次拉全量 */ + private static final int FETCH_SIZE = 500; + /** 按行范围聚合时的行号列名:取名刻意生僻,避免与业务列冲突 */ + private static final String ROW_NUMBER_COLUMN = "__fms_row_number"; + + private static final Semaphore GLOBAL_SLOTS = new Semaphore(2); + private static final Set RUNNING_USERS = ConcurrentHashMap.newKeySet(); + + @Resource + private DbUtils dbUtils; + + @Resource + private DataSource dataSource; + + @Resource + private SqlPermissionService sqlPermissionService; + + @Resource + private ExportSchemaService exportSchemaService; + + /** + * 导出请求。 + * + * @param moduleId 模块编码(必填),后端据此读元数据与权限 + * @param searchCondition 查询条件(不含 WHERE),与列表当前条件一致;后端会再 AND 数据范围 + * @param orderBy 排序子句;缺省用模块默认排序 s_module.b_order_sql + * @param rowKeys 勾选行主键值;非空时只导出这些行 + * @param keyField 勾选行主键列;缺省用模块默认主键 s_module.b_key_field + * @param columns 勾选导出的字段;null 表示沿用列表全部列 + * @param rangeStart 按条数区间的起始(1 基,闭区间);与 rangeEnd 同时给出才生效 + * @param rangeEnd 按条数区间的结束(1 基,闭区间) + * @param volumeSize 分卷行数:> 0 时返回 zip(每卷一个 xlsx),与 range 互斥 + * @param withSummary 是否输出合计行 + */ + public record ExportRequest( + String moduleId, + String searchCondition, + String orderBy, + List rowKeys, + String keyField, + List columns, + Integer rangeStart, + Integer rangeEnd, + Integer volumeSize, + boolean withSummary + ) { + } + + /** 一次导出请求预处理后的产物:列、WHERE、ORDER 都已定稿 */ + private record PreparedExport( + String userId, + ExportSchema schema, + List columns, + String whereSql, + String orderSql + ) { + } + + /** + * 符合条件的行数(批量导出弹窗的「共 N 条」)。 + * 与导出走**同一条**权限链路(动作权限 + 字段裁剪 + 数据范围),所以显示的数字 + * 与真正导出的行数一致 —— 不能用列表分页的总数代替,那个口径不受数据范围约束。 + */ + public long count(ExportRequest request) { + String userId = OrgContext.requireUserId(); + return countRows(prepare(request, userId), request); + } + + private long countRows(PreparedExport prepared, ExportRequest request) { + String sql = "SELECT COUNT_BIG(1) FROM [" + prepared.schema().viewTable() + "]" + + (prepared.whereSql() == null ? "" : " WHERE " + prepared.whereSql()); + dbUtils.validateReadOnlySql(sql); + + Connection connection = DataSourceUtils.getConnection(dataSource); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + bindWhereParameters(statement, request); + try (ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + return resultSet.getLong(1); + } + } catch (SQLException exception) { + throw new BusinessException("行数统计失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + /** + * 各合计列的聚合值(列表底部的「总合计」)。 + * + * 与导出**共用** {@link #prepare} 与 {@link #loadTotals},所以这个数字与导出文件里的合计行 + * 必然一致 —— 前端自己算或用别的接口算都可能对不上(数据范围、字段权限、NULL 口径都会差)。 + * + * @return 字段编码 → 聚合值;没有配 summary 的列不出现在结果里 + */ + public Map totals(ExportRequest request) { + String userId = OrgContext.requireUserId(); + PreparedExport prepared = prepare(request, userId); + List whereParameters = bindWhereParameters(new ArrayList<>(), request); + List totals = loadTotals(prepared, whereParameters, null, null); + + // loadTotals 用「空列表」表示没有可合计的列(导出侧据此决定不写合计行) + List columns = prepared.columns(); + if (totals.size() != columns.size()) { + return Map.of(); + } + + Map result = new LinkedHashMap<>(); + for (int index = 0; index < columns.size(); index++) { + BigDecimal value = totals.get(index); + if (value != null) { + result.put(columns.get(index).field(), value); + } + } + return result; + } + + public void export(ExportRequest request, HttpServletResponse response) throws IOException { + String userId = OrgContext.requireUserId(); + requireText(request.moduleId(), "moduleId"); + + if (!RUNNING_USERS.add(userId)) { + throw new BusinessException("已有导出任务正在进行,请等待完成后再试"); + } + boolean globalSlotHeld = false; + try { + globalSlotHeld = GLOBAL_SLOTS.tryAcquire(); + if (!globalSlotHeld) { + throw new BusinessException("导出任务繁忙,请稍后重试"); + } + + // ---- 以下全部在写流之前完成:任何失败都还能返回 JSON 错误 ---- + PreparedExport prepared = prepare(request, userId); + if (isVolumed(request)) { + writeVolumes(response, prepared, request); + return; + } + String selectSql = buildSelectSql( + prepared.schema(), + prepared.columns(), + prepared.whereSql(), + prepared.orderSql(), + request + ); + // 与查询同一条只读校验:必须以 select 开头、无分号、无注释、无写关键字 + dbUtils.validateReadOnlySql(selectSql); + + List whereParameters = bindWhereParameters(new ArrayList<>(), request); + List parameters = new ArrayList<>(whereParameters); + if (isRanged(request)) { + parameters.add((long) request.rangeStart() - 1); + parameters.add((long) (request.rangeEnd() - request.rangeStart() + 1)); + } + + // 合计口径 = 本次导出的数据范围(当前页 / 勾选行 / 区间),与列表底部的「该页合计」一致 + List totals = request.withSummary() + ? loadTotals( + prepared, + whereParameters, + isRanged(request) ? request.rangeStart() : null, + isRanged(request) ? request.rangeEnd() : null + ) + : List.of(); + + writeWorkbook(response, prepared.schema(), prepared.columns(), selectSql, parameters, totals); + } finally { + if (globalSlotHeld) { + GLOBAL_SLOTS.release(); + } + RUNNING_USERS.remove(userId); + } + } + + /** 动作权限 → 列推导 + 字段裁剪 → WHERE / ORDER,导出与计数共用这一条链路 */ + private PreparedExport prepare(ExportRequest request, String userId) { + String moduleId = requireText(request.moduleId(), "moduleId"); + sqlPermissionService.requireAction(moduleId, userId, "export"); + ExportSchema schema = exportSchemaService.filter( + exportSchemaService.load(moduleId), + sqlPermissionService.deniedExportFields(moduleId, userId), + request.columns() + ); + List columns = schema.columns(); + return new PreparedExport( + userId, + schema, + columns, + buildWhereClause(moduleId, userId, schema, request), + resolveOrderSql(schema, request.orderBy()) + ); + } + + /** WHERE 子句的绑定值:勾选行主键(数据范围是拼进去的 SQL 片段,不带参数) */ + private int bindWhereParameters(PreparedStatement statement, ExportRequest request) throws SQLException { + int index = 1; + if (request.rowKeys() != null) { + for (Object key : request.rowKeys()) { + statement.setObject(index++, key); + } + } + return index; + } + + private List bindWhereParameters(List parameters, ExportRequest request) { + if (request.rowKeys() != null) { + parameters.addAll(request.rowKeys()); + } + return parameters; + } + + private boolean isVolumed(ExportRequest request) { + Integer volumeSize = request.volumeSize(); + if (volumeSize == null || volumeSize <= 0) { + return false; + } + if (isRanged(request)) { + throw new BusinessException("分卷导出与按区间导出不能同时使用"); + } + return true; + } + + /* ================= SQL 拼装 ================= */ + + /** + * WHERE 子句:调用方条件 + 数据范围 + 勾选行,三者一律 AND。 + * 数据范围只能收窄调用方条件,不能被其覆盖(开发规范 通用原则 10)。 + */ + String buildWhereClause( + String moduleId, + String userId, + ExportSchema schema, + ExportRequest request + ) { + List conditions = new ArrayList<>(); + String searchCondition = request.searchCondition(); + if (searchCondition != null && !searchCondition.isBlank()) { + conditions.add("(" + searchCondition.trim() + ")"); + } + String dataScope = sqlPermissionService.buildDataScopeCondition(moduleId, userId, "export"); + if (dataScope != null) { + conditions.add(dataScope); + } + if (request.rowKeys() != null && !request.rowKeys().isEmpty()) { + String keyField = resolveKeyField(schema, request.keyField()); + String placeholders = String.join(", ", Collections.nCopies(request.rowKeys().size(), "?")); + conditions.add("[" + keyField + "] IN (" + placeholders + ")"); + } + return conditions.isEmpty() ? null : String.join(" AND ", conditions); + } + + private String resolveKeyField(ExportSchema schema, String requestedKeyField) { + String keyField = requestedKeyField == null || requestedKeyField.isBlank() + ? schema.keyField() + : requestedKeyField.trim(); + if (keyField == null || keyField.isBlank()) { + throw new BusinessException("模块未配置主键字段,无法按勾选行导出"); + } + dbUtils.validateIdentifier(keyField); + return keyField; + } + + String resolveOrderSql(ExportSchema schema, String requestedOrderBy) { + String orderBy = requestedOrderBy == null || requestedOrderBy.isBlank() + ? schema.orderSql() + : requestedOrderBy.trim(); + if (orderBy == null || orderBy.isBlank()) { + return null; + } + return dbUtils.buildSafeOrderBy(orderBy); + } + + String buildSelectSql( + ExportSchema schema, + List columns, + String whereSql, + String orderSql, + ExportRequest request + ) { + return buildSelectSql(schema, columns, whereSql, orderSql, isRanged(request)); + } + + /** @param paged true 时追加 OFFSET/FETCH(按区间导出与分卷导出都需要) */ + private String buildSelectSql( + ExportSchema schema, + List columns, + String whereSql, + String orderSql, + boolean paged + ) { + String columnSql = columns.stream() + .map(column -> "[" + column.field() + "]") + .reduce((left, right) -> left + ", " + right) + .orElseThrow(() -> new BusinessException("没有可导出的列")); + + StringBuilder sql = new StringBuilder("SELECT ") + .append(columnSql) + .append(" FROM [") + .append(schema.viewTable()) + .append("]"); + if (whereSql != null) { + sql.append(" WHERE ").append(whereSql); + } + if (paged) { + // OFFSET/FETCH 必须有 ORDER BY;无排序配置时用常量排序占位(与分页同一取舍) + sql.append(" ORDER BY ").append(orderSql == null ? "(SELECT NULL)" : orderSql); + sql.append(" OFFSET ? ROWS FETCH NEXT ? ROWS ONLY"); + } else if (orderSql != null) { + sql.append(" ORDER BY ").append(orderSql); + } + return sql.toString(); + } + + private boolean isRanged(ExportRequest request) { + Integer start = request.rangeStart(); + Integer end = request.rangeEnd(); + if (start == null || end == null) { + return false; + } + if (start < 1 || end < start) { + throw new BusinessException("导出区间不正确"); + } + return true; + } + + /** + * 合计行:在 SQL 层聚合(开发规范 通用原则 4),不把明细拉到内存再算。 + * 口径统一为「**导出数据的合计**」:不传范围就是全量合计,传了范围(当前页 / 区间 / 单卷) + * 就是该范围的合计 —— 这样表格底部的「该页合计」与导出文件的合计行必然一致。 + */ + private List loadTotals( + PreparedExport prepared, + List whereParameters, + Integer rangeStart, + Integer rangeEnd + ) { + List columns = prepared.columns(); + List indexes = summableIndexes(columns); + if (indexes.isEmpty()) { + return List.of(); + } + boolean ranged = rangeStart != null && rangeEnd != null; + String sql = buildTotalsSql( + prepared.schema().viewTable(), + columns, + prepared.whereSql(), + ranged ? orderByOrDefault(prepared.orderSql()) : null + ); + dbUtils.validateReadOnlySql(sql); + + List totals = new ArrayList<>(Collections.nCopies(columns.size(), null)); + Connection connection = DataSourceUtils.getConnection(dataSource); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + int index = 1; + for (Object parameter : whereParameters) { + statement.setObject(index++, parameter); + } + if (ranged) { + statement.setLong(index++, (long) rangeStart - 1); + statement.setLong(index, (long) (rangeEnd - rangeStart + 1)); + } + 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); + } + } + } + } + } catch (SQLException exception) { + throw new BusinessException("合计计算失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + return totals; + } + + private String orderByOrDefault(String orderSql) { + return orderSql == null ? "(SELECT NULL)" : orderSql; + } + + /** 配了 summary 的列下标(sum → SUM,count → COUNT) */ + static List summableIndexes(List columns) { + List indexes = new ArrayList<>(); + for (int index = 0; index < columns.size(); index++) { + if (columns.get(index).summable()) { + indexes.add(index); + } + } + return indexes; + } + + /** 合计查询:与明细同一条 WHERE,逐列聚合。orderSql 非空表示要按行范围聚合 */ + static String buildTotalsSql( + String viewTable, + List columns, + String whereSql, + String orderSql + ) { + List expressions = new ArrayList<>(); + for (ExportColumn column : columns) { + if (!column.summable()) { + continue; + } + expressions.add(("count".equals(column.summary()) ? "COUNT" : "SUM") + + "([" + column.field() + "])"); + } + String aggregateSql = String.join(", ", expressions); + if (orderSql == null) { + StringBuilder sql = new StringBuilder("SELECT ") + .append(aggregateSql) + .append(" FROM [") + .append(viewTable) + .append("]"); + if (whereSql != null) { + sql.append(" WHERE ").append(whereSql); + } + return sql.toString(); + } + + // 按行范围聚合(当前页 / 区间 / 单卷):先给符合条件的行编号,再对范围内的行聚合。 + // 不能直接对明细求和后按比例折算,也不能把行拉到内存里算(开发规范 通用原则 4)。 + StringBuilder inner = new StringBuilder("SELECT "); + List innerColumns = new ArrayList<>(); + for (ExportColumn column : columns) { + if (column.summable()) { + innerColumns.add("[" + column.field() + "]"); + } + } + inner.append(String.join(", ", innerColumns)) + .append(innerColumns.isEmpty() ? "" : ", ") + .append("ROW_NUMBER() OVER (ORDER BY ") + .append(orderSql == null ? "(SELECT NULL)" : orderSql) + .append(") AS [") + .append(ROW_NUMBER_COLUMN) + .append("] FROM [") + .append(viewTable) + .append("]"); + if (whereSql != null) { + inner.append(" WHERE ").append(whereSql); + } + return "SELECT " + aggregateSql + " FROM (" + inner + ") AS [totals_source]" + + " WHERE [totals_source].[" + ROW_NUMBER_COLUMN + "] BETWEEN ? AND ?"; + } + + /* ================= 分卷导出(批量导出的一种方式) ================= */ + + /** + * 分卷导出:按 volumeSize 切成若干 xlsx,打包成一个 zip 直接下发。 + * + * 与旧系统 `g3hd` 的做法不同:那边是前端逐段请求、前端 JSZip 打包,N 次往返 + N 个临时 + * blob;这里一次请求由后端切卷,前端只负责保存文件。每一卷用 OFFSET/FETCH 单独查, + * 单卷内存受 volumeSize 约束(所以是「有界内存」而不是「流式」,卷大小要合理)。 + * + * 每卷输出**该卷的合计**(口径与其它导出方式统一:合计 = 该文件里数据的合计)。 + */ + private void writeVolumes( + HttpServletResponse response, + PreparedExport prepared, + ExportRequest request + ) throws IOException { + int volumeSize = request.volumeSize(); + long total = countRows(prepared, request); + String sheetName = sheetNameOf(prepared.schema()); + String selectSql = buildSelectSql( + prepared.schema(), + prepared.columns(), + prepared.whereSql(), + prepared.orderSql(), + true + ); + dbUtils.validateReadOnlySql(selectSql); + + long volumeCount = total == 0 ? 1 : (total + volumeSize - 1) / volumeSize; + applyResponseHeader(response, prepared.schema().moduleName(), "zip", "application/zip"); + ZipOutputStream zip = new ZipOutputStream(response.getOutputStream(), StandardCharsets.UTF_8); + try { + for (long index = 0; index < volumeCount; index++) { + long offset = index * volumeSize; + int count = (int) Math.min(volumeSize, Math.max(0, total - offset)); + zip.putNextEntry(new ZipEntry(volumeEntryName(prepared.schema().moduleName(), offset, count))); + zip.write(renderVolume(prepared, request, selectSql, sheetName, offset, count)); + zip.closeEntry(); + } + zip.finish(); + } finally { + response.flushBuffer(); + } + } + + /** 单卷内容渲染到内存:卷大小已限定行数,因此内存有界 */ + private byte[] renderVolume( + PreparedExport prepared, + ExportRequest request, + String selectSql, + String sheetName, + long offset, + int count + ) throws IOException { + List whereParameters = bindWhereParameters(new ArrayList<>(), request); + // 该卷的合计:与明细同一个行范围,口径与其它导出方式一致 + List totals = request.withSummary() && count > 0 + ? loadTotals(prepared, whereParameters, (int) (offset + 1), (int) (offset + count)) + : List.of(); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + if (count <= 0) { + // 0 行也要给出一份带表头的文件,否则用户拿到空 zip 会以为导出失败 + ExportWorkbookWriter.write( + buffer, + sheetName, + prepared.schema().nodes(), + prepared.columns(), + emptyRowSource(), + List.of() + ); + return buffer.toByteArray(); + } + + Connection connection = DataSourceUtils.getConnection(dataSource); + try (PreparedStatement statement = connection.prepareStatement( + selectSql, + ResultSet.TYPE_FORWARD_ONLY, + ResultSet.CONCUR_READ_ONLY + )) { + int index = bindWhereParameters(statement, request); + statement.setLong(index++, offset); + statement.setInt(index, count); + statement.setFetchSize(FETCH_SIZE); + try (ResultSet resultSet = statement.executeQuery()) { + ExportWorkbookWriter.write( + buffer, + sheetName, + prepared.schema().nodes(), + prepared.columns(), + new ResultSetRowSource(resultSet, prepared.columns().size()), + totals + ); + } + return buffer.toByteArray(); + } catch (SQLException exception) { + throw new BusinessException("分卷导出失败: " + exception.getMessage()); + } catch (IOException exception) { + throw new BusinessException("分卷导出失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + private ExportWorkbookWriter.RowSource emptyRowSource() { + return new ExportWorkbookWriter.RowSource() { + @Override + public boolean hasNext() { + return false; + } + + @Override + public List next() { + throw new IllegalStateException("没有下一行"); + } + }; + } + + private String volumeEntryName(String moduleName, long offset, int count) { + String base = moduleName == null || moduleName.isBlank() ? "导出数据" : moduleName; + return base + "_" + (offset + 1) + "-" + (offset + count) + ".xlsx"; + } + + private String sheetNameOf(ExportSchema schema) { + return schema.moduleName() == null || schema.moduleName().isBlank() + ? "数据" + : schema.moduleName(); + } + + /* ================= 写出 ================= */ + + /** + * 打开游标 → 设响应头 → 流式写出。 + * + * 顺序很关键:**查询失败要发生在设响应头之前**,否则用户拿到的是半截文件而不是 JSON 错误。 + * 因此这里是「先 executeQuery,再 applyResponseHeader,最后 write」;代价是一旦开始写流, + * 中途的数据库异常无法再以 JSON 形式回传(流式导出的固有权衡)。 + */ + private void writeWorkbook( + HttpServletResponse response, + ExportSchema schema, + List columns, + String selectSql, + List parameters, + List totals + ) throws IOException { + String sheetName = sheetNameOf(schema); + Connection connection = DataSourceUtils.getConnection(dataSource); + try (PreparedStatement statement = connection.prepareStatement( + selectSql, + ResultSet.TYPE_FORWARD_ONLY, + ResultSet.CONCUR_READ_ONLY + )) { + for (int index = 0; index < parameters.size(); index++) { + statement.setObject(index + 1, parameters.get(index)); + } + statement.setFetchSize(FETCH_SIZE); + try (ResultSet resultSet = statement.executeQuery()) { + applyResponseHeader(response, schema.moduleName()); + ExportWorkbookWriter.write( + response.getOutputStream(), + sheetName, + schema.nodes(), + columns, + new ResultSetRowSource(resultSet, columns.size()), + totals + ); + response.flushBuffer(); + } + } catch (SQLException exception) { + throw new BusinessException("导出查询失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + /** JDBC 游标行来源:边读边交给 Fesod,不把结果集攒进内存 */ + private record ResultSetRowSource(ResultSet resultSet, int columnCount) implements + ExportWorkbookWriter.RowSource { + + @Override + public boolean hasNext() { + try { + return resultSet.next(); + } catch (SQLException exception) { + throw new BusinessException("导出读取数据失败: " + exception.getMessage()); + } + } + + @Override + public List next() { + List row = new ArrayList<>(columnCount); + try { + for (int index = 1; index <= columnCount; index++) { + row.add(normalizeValue(resultSet.getObject(index))); + } + } catch (SQLException exception) { + throw new BusinessException("导出读取数据失败: " + exception.getMessage()); + } + return row; + } + } + + /** + * 单元格值归一化:时间列落成界面同款文本(与 DbUtils 读取 ResultSet 的口径一致), + * 其余保持原类型交给 Fesod 写出(数字仍是数字,Excel 里可继续参与计算)。 + */ + static Object normalizeValue(Object value) { + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime().format(DATE_TIME_FORMATTER); + } + if (value instanceof java.sql.Date date) { + return date.toLocalDate().format(DATE_FORMATTER); + } + if (value instanceof java.sql.Time time) { + return time.toLocalTime().format(TIME_FORMATTER); + } + if (value instanceof LocalDateTime dateTime) { + return dateTime.format(DATE_TIME_FORMATTER); + } + return value; + } + + /* ================= 响应 ================= */ + + private void applyResponseHeader(HttpServletResponse response, String moduleName) throws IOException { + applyResponseHeader( + response, + moduleName, + "xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ); + } + + private void applyResponseHeader( + HttpServletResponse response, + String moduleName, + String extension, + String contentType + ) throws IOException { + String baseName = moduleName == null || moduleName.isBlank() ? "导出数据" : moduleName; + String fileName = baseName + "_" + + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + + "." + extension; + String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replace("+", "%20"); + response.setStatus(HttpServletResponse.SC_OK); + // 二进制响应不设 charset:加了会让 content-type 变成 "application/zip;charset=UTF-8" + response.setContentType(contentType); + response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded); + response.setHeader("Access-Control-Expose-Headers", "Content-Disposition"); + } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new BusinessException(fieldName + " 不能为空"); + } + return value.trim(); + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportStyleHandler.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportStyleHandler.java new file mode 100644 index 00000000..b2b2117e --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportStyleHandler.java @@ -0,0 +1,131 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportColumn; +import org.apache.fesod.sheet.write.handler.CellWriteHandler; +import org.apache.fesod.sheet.write.handler.context.CellWriteHandlerContext; +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.FillPatternType; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.IndexedColors; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.apache.poi.ss.usermodel.Workbook; + +import java.util.List; + +/** + * xlsx 单元格样式与列宽:表头(灰底加粗居中)、数据行、合计行(灰底加粗)三种, + * 外加按列表配置换算出来的逐列宽度。 + * + * 样式在 {@code afterCellDispose} 里统一落地(Fesod 最后一个单元格钩子),因此一定覆盖 + * 库的默认样式。合计行只能**事后标记**:数据是流式写出的,写之前不知道总行数, + * 所以由 {@link ExportWorkbookWriter} 在写出合计行之前调用 {@link #markTotalRow(int)}。 + */ +final class ExportStyleHandler implements CellWriteHandler { + + /** Excel 列宽单位是「字符数」× 256 */ + private static final int WIDTH_UNIT = 256; + + private final int[] columnWidths; + private final boolean[] widthApplied; + + private CellStyle headerStyle; + private CellStyle dataStyle; + private CellStyle totalStyle; + private int totalRowIndex = -1; + + ExportStyleHandler(List columns) { + this.columnWidths = new int[columns.size()]; + this.widthApplied = new boolean[columns.size()]; + for (int index = 0; index < columns.size(); index++) { + columnWidths[index] = columns.get(index).width(); + } + } + + /** 合计行的行号(表头层数 + 明细行数);写合计行之前调用 */ + void markTotalRow(int rowIndex) { + this.totalRowIndex = rowIndex; + } + + @Override + public void afterCellDispose(CellWriteHandlerContext context) { + Cell cell = context.getCell(); + if (cell == null) { + return; + } + applyColumnWidth(context, cell); + cell.setCellStyle(styleOf(context)); + } + + private void applyColumnWidth(CellWriteHandlerContext context, Cell cell) { + Integer columnIndex = cell.getColumnIndex(); + if (columnIndex == null || columnIndex < 0 || columnIndex >= columnWidths.length) { + return; + } + if (widthApplied[columnIndex]) { + return; + } + Sheet sheet = context.getWriteSheetHolder().getSheet(); + sheet.setColumnWidth(columnIndex, columnWidths[columnIndex] * WIDTH_UNIT); + widthApplied[columnIndex] = true; + } + + private CellStyle styleOf(CellWriteHandlerContext context) { + Workbook workbook = context.getWriteSheetHolder().getSheet().getWorkbook(); + if (Boolean.TRUE.equals(context.getHead())) { + if (headerStyle == null) { + headerStyle = createHeaderStyle(workbook); + } + return headerStyle; + } + Integer rowIndex = context.getRowIndex(); + if (totalRowIndex >= 0 && rowIndex != null && rowIndex == totalRowIndex) { + if (totalStyle == null) { + totalStyle = createTotalStyle(workbook); + } + return totalStyle; + } + if (dataStyle == null) { + dataStyle = createDataStyle(workbook); + } + return dataStyle; + } + + private CellStyle createHeaderStyle(Workbook workbook) { + CellStyle style = baseStyle(workbook); + style.setAlignment(HorizontalAlignment.CENTER); + style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + Font font = workbook.createFont(); + font.setBold(true); + style.setFont(font); + return style; + } + + private CellStyle createTotalStyle(Workbook workbook) { + CellStyle style = baseStyle(workbook); + style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + Font font = workbook.createFont(); + font.setBold(true); + style.setFont(font); + return style; + } + + private CellStyle createDataStyle(Workbook workbook) { + return baseStyle(workbook); + } + + private CellStyle baseStyle(Workbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setVerticalAlignment(VerticalAlignment.CENTER); + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + return style; + } +} 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 new file mode 100644 index 00000000..38b836e4 --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/ExportWorkbookWriter.java @@ -0,0 +1,110 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportColumn; +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 java.io.IOException; +import java.io.OutputStream; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 把表头、明细行、合计行写成一个 xlsx(Apache Fesod / 原 EasyExcel)。 + * + * 行来源抽象成 {@link RowSource},因此本类不关心数据是 JDBC 游标还是内存列表: + * 生产走游标 + 分批 write(Fesod 底层是 SXSSF,内存与总行数无关),测试直接喂内存行, + * 于是「多级表头 + 数据行 + 合计行」这条最容易出错的路径可以脱离数据库验证。 + */ +final class ExportWorkbookWriter { + + /** 每批交给 Fesod 的行数:批太大失去流式意义,太小写入次数过多 */ + private static final int BATCH_SIZE = 1000; + + private ExportWorkbookWriter() { + } + + /** 行来源:生产实现是 JDBC ResultSet,测试实现是内存列表 */ + interface RowSource { + boolean hasNext(); + + List next(); + } + + /** + * @param nodes 表头树(分组 + 叶子),用于生成多级表头 + * @param columns 叶子列(顺序与 {@link RowSource} 返回的行内元素一致),用于列宽 + * @param rows 明细行,元素个数应与 columns 一致 + * @param totals 合计行,元素个数应与 columns 一致;空列表表示不输出合计行 + * @return 写出的明细行数 + */ + static int write( + OutputStream outputStream, + String sheetName, + List nodes, + List columns, + RowSource rows, + List totals + ) throws IOException { + List> head = ExportHeadBuilder.headOf(nodes); + ExportStyleHandler styleHandler = new ExportStyleHandler(columns); + + ExcelWriter writer = FesodSheet.write(outputStream) + .head(head) + // 相邻同值表头自动合并(分组横向、叶子纵向由库按表头结构推导) + .automaticMergeHead(true) + .registerWriteHandler(styleHandler) + .build(); + try { + WriteSheet sheet = FesodSheet.writerSheet(sheetName).build(); + + int rowCount = 0; + List> batch = new ArrayList<>(BATCH_SIZE); + while (rows.hasNext()) { + batch.add(rows.next()); + rowCount++; + if (batch.size() >= BATCH_SIZE) { + writer.write(batch, sheet); + batch = new ArrayList<>(BATCH_SIZE); + } + } + // 最后一批一定要调一次(可能为空):Fesod 只在 write 被调用时才真正创建工作表, + // 0 行导出若不调用会得到一个「没有任何工作表」的空 xlsx + writer.write(batch, sheet); + + if (totals != null && !totals.isEmpty()) { + // 合计行只能事后标记:流式写出时事先不知道总行数 + styleHandler.markTotalRow(ExportHeadBuilder.depth(nodes) + rowCount); + writer.write(Collections.singletonList(buildTotalRow(columns.size(), totals)), sheet); + } + return rowCount; + } finally { + writer.finish(); + } + } + + /** + * 合计行:各列写聚合值,「合计」标签落在**第一个没有聚合值的列**(正常就是首列, + * 首列一般是编码/名称这类不可求和的列)。若所有列都有聚合值,则不加标签。 + */ + private static List buildTotalRow(int columnCount, List totals) { + List row = new ArrayList<>(columnCount); + boolean labelled = false; + for (int index = 0; index < columnCount; index++) { + BigDecimal total = index < totals.size() ? totals.get(index) : null; + if (total != null) { + row.add(total); + } else if (!labelled) { + row.add("合计"); + labelled = true; + } else { + row.add(null); + } + } + return row; + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/SqlPermissionService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/SqlPermissionService.java new file mode 100644 index 00000000..6899e95c --- /dev/null +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/SqlPermissionService.java @@ -0,0 +1,298 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.exception.BusinessException; +import cn.g3soft.fmsapi.utils.DbUtils; +import jakarta.annotation.Resource; +import org.springframework.jdbc.datasource.DataSourceUtils; +import org.springframework.stereotype.Service; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 后端 SQL 权限出口(开发规范 通用原则 10)。 + * + * 前端隐藏菜单/按钮/字段只是体验,真正生效的过滤必须在后端拼进 SQL。本服务是这一层 + * 的唯一实现,供导出使用;后续列表/查询接入时复用同一套判定,不再各写一份。 + * + * 三层,与《FMS新系统核心表结构设计》第 14 节一一对应: + * 1. 动作权限(14.1 / 14.2):s_power(action.{模块}.{动作})+ s_user_power,白名单语义; + * 2. 字段权限(14.3):s_user_field_power 的 b_view / b_export,无记录按模块默认(可看可导出); + * 3. 数据范围(14.4):s_user_data_power,按操作精确匹配优先、多条规则 OR、整体再与其它条件 AND。 + */ +@Service +public class SqlPermissionService { + + /** 管理员账号:与前端 permission store 的提权口径保持一致 */ + public static final String ADMIN_ACCOUNT = "g3soft"; + + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + private static final Pattern WRITE_KEYWORD_PATTERN = Pattern.compile( + "(?i)\\b(insert|update|delete|merge|drop|truncate|alter|create|exec|execute|grant|revoke|backup|restore|dbcc|kill|use|into)\\b" + ); + private static final Pattern SQL_COMMENT_PATTERN = Pattern.compile("--|/\\*|\\*/"); + + @Resource + private DbUtils dbUtils; + + @Resource + private DataSource dataSource; + + /** 是否管理员账号(提权:跳过动作权限校验) */ + public boolean isAdministrator(String userId) { + return ADMIN_ACCOUNT.equalsIgnoreCase(String.valueOf(userId).trim()); + } + + /** + * 动作权限校验。白名单语义:s_power 中不存在该权限点、或用户未被授权、 + * 或任一侧 b_canuse = 0 时一律拒绝。 + */ + public void requireAction(String moduleId, String userId, String action) { + if (isAdministrator(userId)) { + return; + } + String sql = "SELECT TOP 1 p.[b_id] FROM [s_power] p " + + "JOIN [s_user_power] up ON up.[b_power_id] = p.[b_id] AND up.[b_canuse] = 1 " + + "WHERE p.[b_object_type] = 'module' AND p.[b_object_id] = ? AND p.[b_action] = ? " + + "AND p.[b_canuse] = 1 AND up.[b_user_id] = ?"; + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + if (query(connection, sql, moduleId, action, userId).isEmpty()) { + throw new BusinessException("没有导出权限"); + } + } catch (SQLException exception) { + throw new BusinessException("权限校验失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + /** + * 不允许导出的字段集合:b_view = 0(在列表/表单/查询/导出中统一移除)或 b_export = 0。 + * 无记录视为可导出,与 14.3 的模块默认行为一致。 + */ + public Set deniedExportFields(String moduleId, String userId) { + Set denied = new LinkedHashSet<>(); + String sql = "SELECT [b_field_id], [b_view], [b_export] FROM [s_user_field_power] " + + "WHERE [b_user_id] = ? AND [b_module_id] = ? AND [b_canuse] = 1"; + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + for (Map row : query(connection, sql, userId, moduleId)) { + String field = String.valueOf(row.get("b_field_id")); + if (number(row.get("b_view")) == 0 || number(row.get("b_export")) == 0) { + denied.add(field); + } + } + } catch (SQLException exception) { + throw new BusinessException("字段权限读取失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + return denied; + } + + /** + * 数据范围条件(不含 WHERE 关键字,形如 `([b_inputuser_id] = N'u001')`)。 + * 无生效规则返回 null,表示该模块无数据范围限制。 + */ + public String buildDataScopeCondition(String moduleId, String userId, String operation) { + String sql = "SELECT [b_operation], [b_scope_type], [b_scope_field], [b_condition_sql] " + + "FROM [s_user_data_power] " + + "WHERE [b_user_id] = ? AND [b_module_id] = ? AND [b_canuse] = 1 " + + "AND ([b_operation] = ? OR [b_operation] = '*') " + + "ORDER BY [b_xh]"; + Connection connection = DataSourceUtils.getConnection(dataSource); + try { + List> rows = query(connection, sql, userId, moduleId, operation); + List> exact = new ArrayList<>(); + List> wildcard = new ArrayList<>(); + for (Map row : rows) { + // 精确操作优先:命中精确规则时不再叠加 '*' 规则(14.4 判定规则 1) + if (operation.equalsIgnoreCase(text(row.get("b_operation")))) { + exact.add(row); + } else { + wildcard.add(row); + } + } + List> effective = exact.isEmpty() ? wildcard : exact; + + String deptId = null; + List fragments = new ArrayList<>(); + for (Map row : effective) { + String scopeType = text(row.get("b_scope_type")).toLowerCase(Locale.ROOT); + String scopeField = text(row.get("b_scope_field")); + switch (scopeType) { + case "all", "" -> { + // 全部数据:不追加条件 + } + case "self" -> { + String field = requireScopeField(scopeField); + fragments.add(quote(field) + " = " + dbUtils.toSqlStringLiteral(userId)); + } + case "dept", "dept_tree" -> { + String field = requireScopeField(scopeField); + if (deptId == null) { + deptId = loadDeptId(connection, userId); + } + // 未归属部门时该规则不生效(14.4) + if (deptId == null || deptId.isBlank()) { + break; + } + if ("dept".equals(scopeType)) { + fragments.add(quote(field) + " = " + dbUtils.toSqlStringLiteral(deptId)); + } else { + String deptPath = loadDeptPath(connection, deptId); + if (deptPath == null || deptPath.isBlank()) { + break; + } + fragments.add(quote(field) + " IN (SELECT [b_id] FROM [b_dept] WHERE [b_path] LIKE " + + dbUtils.toSqlStringLiteral(deptPath + "%") + ")"); + } + } + case "custom" -> { + String fragment = substitutePlaceholders(text(row.get("b_condition_sql")), userId, deptId); + if (!fragment.isBlank()) { + validateConditionFragment(fragment); + fragments.add("(" + fragment + ")"); + } + } + default -> throw new BusinessException("不支持的数据范围类型: " + scopeType); + } + } + if (fragments.isEmpty()) { + return null; + } + return "(" + String.join(" OR ", fragments) + ")"; + } catch (SQLException exception) { + throw new BusinessException("数据范围读取失败: " + exception.getMessage()); + } finally { + DataSourceUtils.releaseConnection(connection, dataSource); + } + } + + private String requireScopeField(String scopeField) { + if (scopeField == null || scopeField.isBlank()) { + throw new BusinessException("数据范围规则未配置判断字段"); + } + return scopeField; + } + + private String loadDeptId(Connection connection, String userId) throws SQLException { + List> rows = query( + connection, + "SELECT [b_dept_id] FROM [b_user] WHERE [b_id] = ?", + userId + ); + return rows.isEmpty() ? null : text(rows.get(0).get("b_dept_id")); + } + + private String loadDeptPath(Connection connection, String deptId) throws SQLException { + List> rows = query( + connection, + "SELECT [b_path] FROM [b_dept] WHERE [b_id] = ?", + deptId + ); + return rows.isEmpty() ? null : text(rows.get(0).get("b_path")); + } + + /** b_condition_sql 只由管理员配置,运行时仅替换受控占位符(14.5) */ + private String substitutePlaceholders(String conditionSql, String userId, String deptId) { + if (conditionSql == null) { + return ""; + } + return conditionSql.trim() + .replace("{userId}", userId) + .replace("{deptId}", deptId == null ? "" : deptId); + } + + /** + * 条件片段校验:与 DbUtils.validateReadOnlySql 同一套口径,但允许不以 select 开头 + * (片段是 WHERE 里的布尔表达式)。 + */ + private void validateConditionFragment(String fragment) { + if (fragment.contains(";")) { + throw new BusinessException("数据范围条件不允许包含分号"); + } + String structure = removeStringLiterals(fragment); + if (SQL_COMMENT_PATTERN.matcher(structure).find()) { + throw new BusinessException("数据范围条件不允许包含注释"); + } + if (WRITE_KEYWORD_PATTERN.matcher(structure).find()) { + throw new BusinessException("数据范围条件包含非只读关键字"); + } + } + + /** 剔除字符串字面量,避免字面量里的关键字/分号被误判(与 DbUtils 同一做法) */ + private String removeStringLiterals(String sql) { + StringBuilder result = new StringBuilder(sql.length()); + boolean inString = false; + for (int index = 0; index < sql.length(); index++) { + char character = sql.charAt(index); + if (character != '\'') { + result.append(inString ? ' ' : character); + continue; + } + if (inString && index + 1 < sql.length() && sql.charAt(index + 1) == '\'') { + result.append(" "); + index++; + continue; + } + inString = !inString; + result.append(' '); + } + if (inString) { + throw new BusinessException("数据范围条件字符串未闭合"); + } + return result.toString(); + } + + private String quote(String field) { + if (!SAFE_IDENTIFIER.matcher(field).matches()) { + throw new BusinessException("非法数据库标识符: " + field); + } + return "[" + field + "]"; + } + + private static int number(Object value) { + return value instanceof Number num ? num.intValue() : 1; + } + + private static String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private List> query( + Connection connection, + String sql, + Object... parameters + ) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < parameters.length; index++) { + statement.setObject(index + 1, parameters[index]); + } + try (ResultSet resultSet = statement.executeQuery()) { + int columnCount = resultSet.getMetaData().getColumnCount(); + List> rows = new ArrayList<>(); + while (resultSet.next()) { + Map row = new LinkedHashMap<>(columnCount); + for (int index = 1; index <= columnCount; index++) { + row.put(resultSet.getMetaData().getColumnLabel(index), resultSet.getObject(index)); + } + rows.add(row); + } + return rows; + } + } + } +} diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java index 337d5fdf..55216d02 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/utils/DbUtils.java @@ -562,6 +562,15 @@ public class DbUtils { .collect(Collectors.joining(", ")); } + /** + * 对外暴露的排序子句构建:校验并加引号,格式与分页/查询完全一致 + * (`field [ASC|DESC]` 或 `CASE ... END`,逗号分隔多列)。 + * 导出等需要自己拼 SQL 的场景复用它,避免另写一套校验。 + */ + public String buildSafeOrderBy(String orderBy) { + return buildPageOrderBy(orderBy); + } + public String quoteQualifiedIdentifier(String identifier) { if (!hasText(identifier)) { throw new BusinessException("数据库标识符不能为空"); 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 new file mode 100644 index 00000000..1075a2fa --- /dev/null +++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceIntegrationTests.java @@ -0,0 +1,270 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.database.OrgContext; +import cn.g3soft.fmsapi.exception.BusinessException; +import jakarta.annotation.Resource; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +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 java.io.ByteArrayInputStream; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.sql.PreparedStatement; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import javax.sql.DataSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 导出端到端(连真实库,只读)。验证「元数据 → SQL → POI 流 → 可解析的 xlsx」整条链路, + * 以及校验失败发生在写流之前(还能返回 JSON 而不是半个损坏文件)。 + * + * 用管理员账号跑,动作权限提权、字段权限与数据范围表为空即无过滤,因此本用例只验证链路本身; + * 权限裁剪的规则由 ExportServiceTests 与 SqlPermissionService 的判定逻辑覆盖。 + */ +@SpringBootTest +class ExportServiceIntegrationTests { + + /** 探针确认过有数据的模块:箱型管理,视图 b_con_type,主键 b_id */ + private static final String MODULE_WITH_DATA = "b_con_type"; + + @Resource + private ExportService exportService; + + @Resource + private DataSource dataSource; + + @Test + void 导出真实模块得到可解析的xlsx() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + MockHttpServletResponse response = new MockHttpServletResponse(); + exportService.export( + new ExportService.ExportRequest( + MODULE_WITH_DATA, null, null, null, null, null, null, null, null, true + ), + response + ); + + assertThat(response.getContentType()) + .contains("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + assertThat(response.getHeader("Content-Disposition")) + .contains("attachment") + .contains("filename*=UTF-8''"); + + byte[] bytes = response.getContentAsByteArray(); + assertThat(bytes).isNotEmpty(); + + try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) { + Sheet sheet = workbook.getSheetAt(0); + assertThat(workbook.getNumberOfSheets()).isEqualTo(1); + assertThat(sheet.getSheetName()).isNotBlank(); + + // 该模块未配分组表头,第一行即列名;至少一列 + assertThat(sheet.getRow(0).getCell(0).getStringCellValue()).isNotBlank(); + // 不传区间时后端不加 OFFSET/FETCH,导出的是当前条件下的**全部行**(不是当前页) + assertThat(sheet.getLastRowNum()).isEqualTo(rowCountOfModule()); + assertThat(sheet.getRow(1).getCell(0)).isNotNull(); + } + } finally { + OrgContext.clear(); + } + } + + @Test + void 勾选行导出只包含选中的行() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + String firstKey = firstKeyOfModule(); + MockHttpServletResponse response = new MockHttpServletResponse(); + exportService.export( + new ExportService.ExportRequest( + MODULE_WITH_DATA, null, null, java.util.List.of(firstKey), "b_id", + null, null, null, null, true + ), + response + ); + + try (Workbook workbook = new XSSFWorkbook( + new ByteArrayInputStream(response.getContentAsByteArray()))) { + Sheet sheet = workbook.getSheetAt(0); + // 单行表头 + 1 行数据 + assertThat(sheet.getLastRowNum()).isEqualTo(1); + } + } finally { + OrgContext.clear(); + } + } + + @Test + void 模块不存在时在写流之前报错() { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + MockHttpServletResponse response = new MockHttpServletResponse(); + assertThatThrownBy(() -> exportService.export( + new ExportService.ExportRequest( + "不存在的模块", null, null, null, null, null, null, null, null, true + ), + response + )).isInstanceOf(BusinessException.class) + .hasMessageContaining("模块"); + // 关键:报错时响应体仍是空的,前端能拿到 JSON 错误而不是损坏的 xlsx + assertThat(response.getContentAsByteArray()).isEmpty(); + } finally { + OrgContext.clear(); + } + } + + @Test + void 计数与导出走同一权限链路() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + long total = exportService.count(request(null)); + assertThat(total).isEqualTo(rowCountOfModule()); + + // 条件同样参与计数(b_id 非空即全部) + assertThat(exportService.count(request("b_id IS NOT NULL"))).isEqualTo(total); + // 匹配不到任何行的条件返回 0,而不是报错。 + // 注意 b_con_type.b_id 是 bigint(新系统业务表用雪花 ID),不能拿字符串字面量去比 + assertThat(exportService.count(request("b_id = -1"))).isZero(); + } finally { + OrgContext.clear(); + } + } + + @Test + void 分卷导出返回zip且每卷行数正确() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + int total = rowCountOfModule(); + int volumeSize = 3; + MockHttpServletResponse response = new MockHttpServletResponse(); + exportService.export(volumeRequest(volumeSize), response); + + assertThat(response.getContentType()).isEqualTo("application/zip"); + assertThat(response.getHeader("Content-Disposition")).contains(".zip"); + + Map rowsByEntry = new LinkedHashMap<>(); + try (ZipInputStream zip = new ZipInputStream( + new ByteArrayInputStream(response.getContentAsByteArray()), StandardCharsets.UTF_8)) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + byte[] content = zip.readAllBytes(); + try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) { + // 表头 1 行,其余为数据行 + rowsByEntry.put(entry.getName(), workbook.getSheetAt(0).getLastRowNum()); + } + } + } + + int expectedVolumes = (total + volumeSize - 1) / volumeSize; + assertThat(rowsByEntry).hasSize(expectedVolumes); + // 每卷行数:前 n-1 卷是 volumeSize,最后一卷是余数 + List expectedRowsPerVolume = new ArrayList<>(); + for (long remaining = total; remaining > 0; remaining -= volumeSize) { + expectedRowsPerVolume.add((int) Math.min(volumeSize, remaining)); + } + assertThat(rowsByEntry.values()).containsExactlyElementsOf(expectedRowsPerVolume); + } finally { + OrgContext.clear(); + } + } + + @Test + void 按行范围聚合的合计SQL在真实库上可执行且数值正确() throws Exception { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + // 「当前页 / 区间 / 单卷」的合计都靠这段 SQL:字符串对不代表能在 SQL Server 上跑通 + String sql = ExportService.buildTotalsSql( + MODULE_WITH_DATA, + List.of(new ExportSchemaService.ExportColumn("b_id", "ID", 16, "sum")), + null, + "[b_id] ASC" + ); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, 0); + statement.setLong(2, 3); + try (ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + BigDecimal ranged = resultSet.getBigDecimal(1); + // 期望值:同一视图按同一排序取前 3 行直接求和 + try (Statement plain = connection.createStatement(); + ResultSet expected = plain.executeQuery( + "SELECT SUM([b_id]) FROM (SELECT TOP 3 [b_id] FROM [b_con_type] " + + "ORDER BY [b_id] ASC) AS [expected_source]")) { + expected.next(); + assertThat(ranged).isEqualByComparingTo(expected.getBigDecimal(1)); + } + } + } + } finally { + OrgContext.clear(); + } + } + + @Test + void 总合计接口在没有配合计列时返回空() { + OrgContext.setOrgId("G3HD"); + OrgContext.setUserId(SqlPermissionService.ADMIN_ACCOUNT); + try { + // 目前没有任何模块配 summary,所以结果为空 —— 验证整条链路不会因为「没有可合计列」出错 + assertThat(exportService.totals(request(null))).isEmpty(); + } finally { + OrgContext.clear(); + } + } + + private ExportService.ExportRequest request(String searchCondition) { + return new ExportService.ExportRequest( + MODULE_WITH_DATA, searchCondition, null, null, null, null, null, null, null, true + ); + } + + private ExportService.ExportRequest volumeRequest(int volumeSize) { + return new ExportService.ExportRequest( + MODULE_WITH_DATA, null, null, null, null, null, null, null, volumeSize, true + ); + } + + private String firstKeyOfModule() throws Exception { + // 走应用自己的机构数据源(OrgContext 已在用例内设好),不在测试里写死连接串 + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery( + "SELECT TOP 1 [b_id] FROM [b_con_type] ORDER BY [b_id]")) { + resultSet.next(); + return resultSet.getString(1); + } + } + + /** 模块视图的总行数,用于断言「不传区间 = 导出全部行」 */ + private int rowCountOfModule() throws Exception { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT COUNT_BIG(1) FROM [b_con_type]")) { + resultSet.next(); + return resultSet.getInt(1); + } + } +} 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 new file mode 100644 index 00000000..c9d7bd00 --- /dev/null +++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportServiceTests.java @@ -0,0 +1,290 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.exception.BusinessException; +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportColumn; +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportNode; +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportSchema; +import cn.g3soft.fmsapi.utils.DbUtils; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 导出 SQL 拼装与表头结构(纯逻辑,不连库)。 + * 表头 → xlsx 的写出验证见 ExportWorkbookWriterTests,连库端到端见 ExportServiceIntegrationTests。 + */ +class ExportServiceTests { + + private final ExportService service = new ExportService(); + + ExportServiceTests() { + ReflectionTestUtils.setField(service, "dbUtils", new DbUtils()); + } + + private static ExportColumn column(String field) { + return new ExportColumn(field, field, 16, ""); + } + + private static ExportNode leaf(String field) { + return new ExportNode(null, column(field), null); + } + + private static ExportSchema schema(String orderSql, List nodes) { + return new ExportSchema("m1", "测试模块", "v_test", "b_id", orderSql, nodes); + } + + private static ExportService.ExportRequest request( + String searchCondition, + String orderBy, + List rowKeys, + String keyField, + Integer rangeStart, + Integer rangeEnd + ) { + return new ExportService.ExportRequest( + "m1", searchCondition, orderBy, rowKeys, keyField, null, rangeStart, rangeEnd, null, true + ); + } + + /* ================= SELECT 拼装 ================= */ + + @Test + void 默认无条件时只拼表名与列() { + ExportSchema schema = schema(null, List.of(leaf("b_name"), leaf("b_bz"))); + String sql = service.buildSelectSql( + schema, + schema.columns(), + null, + null, + request(null, null, null, null, null, null) + ); + + assertThat(sql).isEqualTo("SELECT [b_name], [b_bz] FROM [v_test]"); + } + + @Test + void 条件与排序都参与拼装且标识符被加引号() { + ExportSchema schema = schema("b_xh ASC", List.of(leaf("b_name"))); + // 排序子句由 resolveOrderSql 经 DbUtils.buildSafeOrderBy 校验加引号后传入 + String sql = service.buildSelectSql( + schema, + schema.columns(), + "(b_canuse = 1)", + "[b_xh] ASC", + request(null, null, null, null, null, null) + ); + + assertThat(sql).isEqualTo("SELECT [b_name] FROM [v_test] WHERE (b_canuse = 1) ORDER BY [b_xh] ASC"); + } + + @Test + void 区间导出走OFFSET_FETCH且无排序时用常量占位() { + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + String sql = service.buildSelectSql( + schema, + schema.columns(), + null, + null, + request(null, null, null, null, 101, 200) + ); + + assertThat(sql).isEqualTo( + "SELECT [b_name] FROM [v_test] ORDER BY (SELECT NULL) OFFSET ? ROWS FETCH NEXT ? ROWS ONLY" + ); + } + + @Test + void 排序子句非法时被拒绝() { + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + // 排序必须经 resolveOrderSql 校验(导出主流程就是这条路径),不能把原始串直接塞进 SQL + assertThatThrownBy(() -> service.resolveOrderSql(schema, "b_name; drop table x")) + .isInstanceOf(BusinessException.class); + assertThatThrownBy(() -> service.resolveOrderSql(schema, "b_name b_xh")) + .isInstanceOf(BusinessException.class); + } + + @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(); + } + + /* ================= WHERE 拼装:数据范围只收窄不放开 ================= */ + + @Test + void 调用方条件与数据范围按AND组合() { + SqlPermissionService permission = mock(SqlPermissionService.class); + when(permission.buildDataScopeCondition("m1", "u001", "export")) + .thenReturn("([b_inputuser_id] = N'u001')"); + ReflectionTestUtils.setField(service, "sqlPermissionService", permission); + + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + String where = service.buildWhereClause( + "m1", + "u001", + schema, + request("b_status = N'正常'", null, null, null, null, null) + ); + + assertThat(where).isEqualTo("(b_status = N'正常') AND ([b_inputuser_id] = N'u001')"); + } + + @Test + void 勾选行用参数占位而不是字面量拼接() { + SqlPermissionService permission = mock(SqlPermissionService.class); + when(permission.buildDataScopeCondition("m1", "u001", "export")).thenReturn(null); + ReflectionTestUtils.setField(service, "sqlPermissionService", permission); + + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + String where = service.buildWhereClause( + "m1", + "u001", + schema, + request(null, null, List.of(1L, 2L, 3L), null, null, null) + ); + + assertThat(where).isEqualTo("[b_id] IN (?, ?, ?)"); + } + + @Test + void 无任何条件时不生成WHERE子句() { + SqlPermissionService permission = mock(SqlPermissionService.class); + when(permission.buildDataScopeCondition("m1", "u001", "export")).thenReturn(null); + ReflectionTestUtils.setField(service, "sqlPermissionService", permission); + + ExportSchema schema = schema(null, List.of(leaf("b_name"))); + assertThat(service.buildWhereClause( + "m1", + "u001", + schema, + request(null, null, null, null, null, null) + )).isNull(); + } + + /* ================= 合计 ================= */ + + @Test + void 只有配了summary的列参与合计() { + List columns = List.of( + new ExportColumn("b_name", "名称", 16, ""), + new ExportColumn("b_amount", "金额", 16, "sum"), + new ExportColumn("b_count", "笔数", 16, "count") + ); + + assertThat(ExportService.summableIndexes(columns)).containsExactly(1, 2); + assertThat(ExportService.buildTotalsSql("v_test", columns, "(b_canuse = 1)", null)) + .isEqualTo("SELECT SUM([b_amount]), COUNT([b_count]) FROM [v_test] WHERE (b_canuse = 1)"); + } + + @Test + void 按行范围聚合时用行号子查询限定范围() { + List columns = List.of( + new ExportColumn("b_name", "名称", 16, ""), + new ExportColumn("b_amount", "金额", 16, "sum") + ); + + assertThat(ExportService.buildTotalsSql("v_test", columns, "(b_canuse = 1)", "[b_xh] DESC")) + .isEqualTo("SELECT SUM([b_amount]) FROM (" + + "SELECT [b_amount], ROW_NUMBER() OVER (ORDER BY [b_xh] DESC) AS [__fms_row_number] " + + "FROM [v_test] WHERE (b_canuse = 1)" + + ") AS [totals_source] WHERE [totals_source].[__fms_row_number] BETWEEN ? AND ?"); + } + + @Test + void 无排序配置时按行范围聚合用常量排序占位() { + List columns = List.of(new ExportColumn("b_amount", "金额", 16, "sum")); + + assertThat(ExportService.buildTotalsSql("v_test", columns, null, "(SELECT NULL)")) + .isEqualTo("SELECT SUM([b_amount]) FROM (" + + "SELECT [b_amount], ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS [__fms_row_number] " + + "FROM [v_test]) AS [totals_source] " + + "WHERE [totals_source].[__fms_row_number] BETWEEN ? AND ?"); + } + + @Test + void 合计SQL能通过只读校验() { + // 行号子查询里出现了 ROW_NUMBER / AS,必须仍被 validateReadOnlySql 接受 + ExportService service = new ExportService(); + ReflectionTestUtils.setField(service, "dbUtils", new DbUtils()); + List columns = List.of(new ExportColumn("b_amount", "金额", 16, "sum")); + String sql = ExportService.buildTotalsSql("v_test", columns, "(b_canuse = 1)", "[b_xh] ASC"); + + assertThatCode(() -> new DbUtils().validateReadOnlySql(sql)).doesNotThrowAnyException(); + } + + @Test + void 没有可合计列时不生成合计SQL() { + List columns = List.of(column("b_name")); + assertThat(ExportService.summableIndexes(columns)).isEmpty(); + } + + /* ================= 表头结构(转成 Fesod 的 List>) ================= */ + + @Test + void 单行表头每列只有一个标题() { + List nodes = List.of(leaf("b_name"), leaf("b_bz")); + + assertThat(ExportHeadBuilder.headOf(nodes)).containsExactly( + List.of("b_name"), + List.of("b_bz") + ); + assertThat(ExportHeadBuilder.depth(nodes)).isEqualTo(1); + assertThat(ExportHeadBuilder.leafCount(nodes)).isEqualTo(2); + } + + @Test + void 两级表头按分组路径展开且列名在最后() { + ExportNode groupA = new ExportNode("基本信息", null, List.of(leaf("b_name"), leaf("b_bz"))); + ExportNode groupB = new ExportNode("其他", null, List.of(leaf("b_xh"))); + List nodes = List.of(groupA, groupB); + + assertThat(ExportHeadBuilder.headOf(nodes)).containsExactly( + List.of("基本信息", "b_name"), + List.of("基本信息", "b_bz"), + List.of("其他", "b_xh") + ); + assertThat(ExportHeadBuilder.depth(nodes)).isEqualTo(2); + assertThat(ExportHeadBuilder.leafCount(nodes)).isEqualTo(3); + } + + @Test + void 顶层叶子与分组混排时叶子路径更短() { + // 各列内层长度不同是允许的:Fesod 会底部对齐补空行,列名仍落在最后一行 + ExportNode groupA = new ExportNode("基本信息", null, List.of(leaf("b_name"), leaf("b_bz"))); + List nodes = List.of(groupA, leaf("b_xh")); + + assertThat(ExportHeadBuilder.headOf(nodes)).containsExactly( + List.of("基本信息", "b_name"), + List.of("基本信息", "b_bz"), + List.of("b_xh") + ); + assertThat(ExportHeadBuilder.depth(nodes)).isEqualTo(2); + } + + @Test + void 三层表头路径完整且分组标题可为空() { + ExportNode inner = new ExportNode("明细", null, List.of(leaf("b_a"), leaf("b_b"))); + ExportNode outer = new ExportNode("费用", null, List.of(inner, leaf("b_c"))); + ExportNode untitled = new ExportNode(null, null, List.of(leaf("b_e"))); + List nodes = List.of(outer, leaf("b_d"), untitled); + + assertThat(ExportHeadBuilder.headOf(nodes)).containsExactly( + List.of("费用", "明细", "b_a"), + List.of("费用", "明细", "b_b"), + List.of("费用", "b_c"), + List.of("b_d"), + List.of("", "b_e") + ); + assertThat(ExportHeadBuilder.depth(nodes)).isEqualTo(3); + assertThat(ExportHeadBuilder.leafCount(nodes)).isEqualTo(5); + } +} diff --git a/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.java b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.java new file mode 100644 index 00000000..ac6d0860 --- /dev/null +++ b/code/fms/fms-api/src/test/java/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.java @@ -0,0 +1,211 @@ +package cn.g3soft.fmsapi.service; + +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportColumn; +import cn.g3soft.fmsapi.service.ExportSchemaService.ExportNode; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 表头 + 明细 + 合计 → xlsx 的写出验证(Apache Fesod,不连库)。 + * + * 这里覆盖的是最容易出错、又没法用真实模块验证的部分:**分组表头与顶层叶子混排**时, + * 各列的标题层数不同,必须仍然「列名落在表头最后一行、分组标题横向合并」。 + * 库里 71 份 view schema 都没有配过分组,所以这条路径只能靠本用例守住。 + */ +class ExportWorkbookWriterTests { + + private static ExportColumn column(String field, int width) { + return new ExportColumn(field, field, width, ""); + } + + private static ExportNode leaf(String field) { + return new ExportNode(null, column(field, 12), null); + } + + private static ExportWorkbookWriter.RowSource rowsOf(List> rows) { + var iterator = rows.iterator(); + return new ExportWorkbookWriter.RowSource() { + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public List next() { + return iterator.next(); + } + }; + } + + private static Workbook write( + List nodes, + List columns, + List> rows, + List totals + ) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ExportWorkbookWriter.write(out, "测试表", nodes, columns, rowsOf(rows), totals); + return new XSSFWorkbook(new ByteArrayInputStream(out.toByteArray())); + } + + private static String text(Sheet sheet, int rowIndex, int columnIndex) { + Row row = sheet.getRow(rowIndex); + if (row == null || row.getCell(columnIndex) == null) { + return null; + } + return row.getCell(columnIndex).getStringCellValue(); + } + + @Test + void 单行表头加明细与合计行() throws Exception { + List columns = List.of(column("b_name", 12), column("b_amount", 20)); + List nodes = List.of(leaf("b_name"), leaf("b_amount")); + List totals = new ArrayList<>(); + totals.add(null); + totals.add(new BigDecimal("300.50")); + + try (Workbook workbook = write( + nodes, + columns, + List.of(List.of("甲", 100.5), List.of("乙", 200)), + totals + )) { + assertThat(workbook.getSheetName(0)).isEqualTo("测试表"); + Sheet sheet = workbook.getSheetAt(0); + + // 表头 1 行 + 明细 2 行 + 合计 1 行 + assertThat(sheet.getLastRowNum()).isEqualTo(3); + assertThat(text(sheet, 0, 0)).isEqualTo("b_name"); + assertThat(text(sheet, 0, 1)).isEqualTo("b_amount"); + assertThat(text(sheet, 1, 0)).isEqualTo("甲"); + assertThat(sheet.getRow(1).getCell(1).getNumericCellValue()).isEqualTo(100.5); + // 合计行:首列是「合计」标签,数值列是聚合结果 + assertThat(text(sheet, 3, 0)).isEqualTo("合计"); + assertThat(sheet.getRow(3).getCell(1).getNumericCellValue()).isEqualTo(300.50); + } + } + + @Test + void 分组表头横向合并且列名落在最后一行() throws Exception { + List columns = List.of( + column("b_name", 12), + column("b_bz", 12), + column("b_xh", 8) + ); + ExportNode group = new ExportNode("基本信息", null, List.of(leaf("b_name"), leaf("b_bz"))); + List nodes = List.of(group, leaf("b_xh")); + + try (Workbook workbook = write( + nodes, + columns, + List.of(List.of("甲", "备注一", 1)), + List.of() + )) { + Sheet sheet = workbook.getSheetAt(0); + + // 两行表头 + 一行明细 + assertThat(sheet.getLastRowNum()).isEqualTo(2); + assertThat(text(sheet, 0, 0)).isEqualTo("基本信息"); + // 列名一律落在表头最后一行:分组下的两列与顶层叶子都一样 + assertThat(text(sheet, 1, 0)).isEqualTo("b_name"); + assertThat(text(sheet, 1, 1)).isEqualTo("b_bz"); + assertThat(text(sheet, 1, 2)).isEqualTo("b_xh"); + // 分组标题横向合并跨两列 + assertThat(sheet.getMergedRegions()).contains(new CellRangeAddress(0, 0, 0, 1)); + } + } + + @Test + void 三层表头分组逐级合并() throws Exception { + List columns = List.of( + column("b_a", 12), + column("b_b", 12), + column("b_c", 12), + column("b_d", 12) + ); + ExportNode inner = new ExportNode("明细", null, List.of(leaf("b_a"), leaf("b_b"))); + ExportNode outer = new ExportNode("费用", null, List.of(inner, leaf("b_c"))); + List nodes = List.of(outer, leaf("b_d")); + + try (Workbook workbook = write( + nodes, + columns, + List.of(List.of(1, 2, 3, 4)), + List.of() + )) { + Sheet sheet = workbook.getSheetAt(0); + + assertThat(sheet.getLastRowNum()).isEqualTo(3); + assertThat(text(sheet, 0, 0)).isEqualTo("费用"); + assertThat(text(sheet, 1, 0)).isEqualTo("明细"); + assertThat(text(sheet, 2, 0)).isEqualTo("b_a"); + assertThat(text(sheet, 2, 1)).isEqualTo("b_b"); + assertThat(text(sheet, 2, 2)).isEqualTo("b_c"); + assertThat(text(sheet, 2, 3)).isEqualTo("b_d"); + // 费用跨 3 列、明细跨 2 列 + assertThat(sheet.getMergedRegions()).contains(new CellRangeAddress(0, 0, 0, 2)); + assertThat(sheet.getMergedRegions()).contains(new CellRangeAddress(1, 1, 0, 1)); + } + } + + @Test + void 列宽按列表配置换算后写入() throws Exception { + List columns = List.of(column("b_name", 12), column("b_amount", 20)); + + try (Workbook workbook = write( + List.of(leaf("b_name"), leaf("b_amount")), + columns, + List.of(List.of("甲", 1)), + List.of() + )) { + Sheet sheet = workbook.getSheetAt(0); + // POI 的列宽单位是 1/256 字符 + assertThat(sheet.getColumnWidth(0)).isEqualTo(12 * 256); + assertThat(sheet.getColumnWidth(1)).isEqualTo(20 * 256); + } + } + + @Test + void 无数据时仍写出表头() throws Exception { + try (Workbook workbook = write( + List.of(leaf("b_name"), leaf("b_bz")), + List.of(column("b_name", 12), column("b_bz", 12)), + List.of(), + List.of() + )) { + Sheet sheet = workbook.getSheetAt(0); + assertThat(sheet.getLastRowNum()).isZero(); + assertThat(text(sheet, 0, 0)).isEqualTo("b_name"); + assertThat(text(sheet, 0, 1)).isEqualTo("b_bz"); + } + } + + @Test + void 超过一批的行数也能全部写出() throws Exception { + List columns = List.of(column("b_name", 12)); + List> rows = new ArrayList<>(); + for (int index = 0; index < 2500; index++) { + rows.add(List.of("行" + index)); + } + + try (Workbook workbook = write(List.of(leaf("b_name")), columns, rows, List.of())) { + Sheet sheet = workbook.getSheetAt(0); + // 1 行表头 + 2500 行明细(跨越多个写入批次) + assertThat(sheet.getLastRowNum()).isEqualTo(2500); + assertThat(text(sheet, 1, 0)).isEqualTo("行0"); + assertThat(text(sheet, 2500, 0)).isEqualTo("行2499"); + } + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..2f4222793cef77e33a0efff43f262a04ff5f4ab2 GIT binary patch literal 5477 zcmb_g30E7}8NI_cLJUjfc#G@SYl;wfinqqbbz%r$9I#!8NsZGcgYa_!IEDYjQHOdJ4H_EJq@d%nv0&&~!`7798?tKN5m zvGC<^W?!6U+cdPJLqUrpjLf*WQV=fD4R?-SH$FM^|qi1_YRx;YD;r;jkd(w8nqPLE15M=U99J{ec#a<2j zuwOx2Stp~mHLE~fFtP=4ZbreT&NIm%Cccd1a9;~Ph)xwr4P8i42W1KbPG#i_1ouD~ z%82^%}ba6pBw;X~+Eu%{|^QXH;+DsL8jNxW_$offXUw%IjARJDdL znL`>5;|OKoyPT_0k5%T^)buJDgtSUBR`Fp4hc~iR49672a(1SW6(gBA`fx(UM>L$o zg9>(6tr+k+Z#YKof^?IDduFBRt`tm1WF|bvl;J(HdYeU}5X`9vOxP8}DFyAWFr4%p z^FYS*Oxu!(1~fb@;WX1!FgJ!F2E}$V!s%a<+x@hL5u7P*cYE;>u|#aUSL2BKZPawV zz8FSHe_kvNnIfBs;VhR;+u1_Sa;4?-8a|3~_R+GOui((?CD*0q2PZW0sDik|G$|%L z?p_IaQp02TSO|E;^2Drg6dbL2bzk=3dt@<17QTT&i!2_exdq8WOqfrJ7(PKei>5a> zQOM;C$9LG18VpQ__WNx+D-27)z8dUL#c>oFSpk@nuZJ)AaKboNdt|+yvWwo;j3VvG zOK;OPm7I)0<{T%GMGH(^Rxz(3i(D}`m5^|ArtSQaOii6pXAHNHqr&N4W=G{+mZJFBP&0{p1K zH54R9?X;1dG#pbthn)@HoXJHGRHyg1Ir7j-JUAc(x#q+gda1ekQ^<6Rkt2%sKxe*S zrR-#`2#*O0x=Os?@kl}ORB~O)7x^BkG;tz$Tqi4pk~%5*4qi=*ydMG;Kj(44SFCC4 zBj-vZ6v>$I=Io4Lc7!J!a%!G1XD!1kIE=vbDnHd|y>{ZsN!GMc+nLqfysU6D^s893 zoq2sxOzYF8mC+-jId8ij-JIt{GHmpZI&Afn zd0xjFg}A1p3Z+AR6tYZN&uz@LwU`N`R8(b+L6f3s;=RE?psITDAeDR1aMp7CA_PCG z)L}KDRbb_!U3bFF|qfYrMpf~=N1SMU4 zn^BhFhVS6$#Z=;f6&z0`9$G}ftp5hYTI83G^cev_fRNyW@bkPZG0NU#O(j4H;t6(mx5OolL@Wh+7KNOdQy73A;2 zI@|LmROKbadzRs?;A$CfuwyS0#gvb;D|oepb4Q4i%V8O}XsCseUSX8$BYRiQl87Zv z4RvqH-^Q#Vm6s8THQd9?{P#tE>hLN*ynKduU*l6F`+HL%$W-@je7X)2bYsJfo;Psq zd9?T3#kJ!N+Z&eg`5QeeczOlT-r!4t*Y9I~0-hkUCvh*A-hwnUPzFiN_^b}oz+TR| zgTMLwdQ1FzOZ<96ejQAw*YR2fKRK(&uMG_`QX|PGekayu_&Q(zyU+EmzrPdY_y(W< XK_>+{>!0`+pW}Q!%3t38jqU#jXJOlj literal 0 HcmV?d00001 diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportHeadBuilder.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportHeadBuilder.class new file mode 100644 index 0000000000000000000000000000000000000000..75cc612f051103948c7065319cd9cc3b4b7b73ee GIT binary patch literal 3074 zcmb_e-E$LF6#w01(@oQDX$z!=AeJv_`ays~k)~9k@)2nZ5-cAoben9Og=9Cg*)V1F z%_pCH<}auZI-?Krp)-ze&N$<{{|by6fA?dyP=`Eu2)*y*a}-M8~r`rKm0_56#LSvXs> zO9jgl(DQDoq&_&@H@W%&={vL4JT=pXE_CbIZeRy?3UsV>AP}{E%QJnKABy*-n*3!O z>zOw6;0+xK1G|tE=w9`(til+JW_8+HBq`+Lmb7@&z#hCMpgC%J$FlDx1~WSL3Y^@6 zi(I~FmCanh&w+rSX}4g-(2M;B4#=2vyL!QMYZb}$+cIH?1QN|;O#~F$u9Hd4NXwLg zehdgieA_Qs0&C;T`JU}8WTF@nVCK4zHgE(-iOwt(1a>xDH)BcNl5U3$9LEW=nlCbY zp5>6QW#pCNdxcT6pM)2t(B>FntHE51(DG_6GH}LI!+rHm#|K~vL{(@X2mB{ zvS!~icofc(rh--Ri!AVFQWcg|Hiq|cPRDrz7jSX8!W1=ArnCu{%|&|X%Vwo+8$K{_ zN&0LpS?2tNTXR@7+5|Hyux-+Itm#^L&hoCAb22a8lWyKD&6u7o=Z!_pFWOas!L9ge zIz|~XUF4~{JTI_sBZ#KDSEv}T!-)8i&W&4|bj0JbHnazov0A;LW!M-BAQMcpDuye}ti*DhH>6vAVhnZknbM}H``ZbTtezBf~{}X2O#nBYh zO3A-4NKMkrdVOVzSUZ)V%-k9a>A>b}tz}1`J?ESGdsAjbRg{kWnhQRlQQ+&>NzuOv zY;&EhSWL|bh^)Z$>kKzVhmm}6I%7Gv=H;#PwmgyX4K3=hEJy((>o}G-Q8KGltE!{K z1NvWXA%WQH6d&R$!peceQR>Ejf`0QcS||G(!oU;62EOA?Sb6N=2sXedl5C{AmDhM+ zi5#veuO0Y^lE94gx3YJAW$y;}ZgLh8Gy!h$3E^V~%ijb+IO0o=EnNS+|0z0|(V?e^ z3p|8FVYk39=pEG(+9T|Hh!ZlgzAv!jUd2H|J%l^xgNqav*-Jme zfFfm_@egr*52=VyBf&N&r6fMZCww%Cu0ffUZY#>9bca$%k$0T>fsff0A5sb|C82@k zEC~%PZ_v`DoJHhM=sJ4p?87ZOx|aWYbo5e&;H%CcHrop+<+ljH6 z^ikjixZ#4%5SYmY55Pk)?2%+gnS@YCX5>9*y}RG;r`>h_{OjNk0AJwi0y4&4j?@<8P;KJd zA@2nY=c}nK*0x>}Q^p76G!nP^fU%A;+ZaKlh&%c1Xx$F3@9=7gEJrh1>?S49vm%OKQ2 zgSPlzT8f%@G^wQrQieh3^Fdr-VBi76g=o$i$rv@F(P<>3Ql$B!XOf;0J>_B^%LO#? zm5wJST3BJYbU2+p&4Jq&c3fV9!7#Bkf@OG>ZUxudle@x>4VSss>4{*zCFSmL&^X=< zDNW+I@p2#@ioo&RK`8yS%TkXAL0XaY*f+<6q|uK|-1sG#P0tg46jD$tnCUg8<0-@C zLkdnQNk^Mu?sQXnS#kZiPKzr;W2wJ(4++ zmHO{s^@V-t^~HTm)-Ud(RG-!MQGP|=W^j!@^W+J93_JRayzn6*tG`Nws=lv8xGI;5 z+OEh(CxxxyBRWrMWC}7;DS}olDC`Xi%c6uf2uy{Uv)-!zit=xm{Sig$QgZ$Qy7m*a z?9XJ8O(k_RO3I8qlcYW&n>?~J+4~Grv}tc92-is}1igNMcY$B%c)Y$il%9l|q7C}# z3GSBxmC3)m(+>t&DZ=lNROA@-g#*<7$$vVw8PjXvGu)yC6oENL7e(5+aY;7aR7p9a zNk6TiToREH|06!q&Xcd_Y>nASJC{!5;t+ZIMC9`8Am?zKs?4B4G%9%)pOdUmXT?1~ r>Uh=fakt;6lSSNoNv&YeKPXFhh(~yg)kvQq-Ce9<7VCJ14b1)xh}z`h literal 0 HcmV?d00001 diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService$ExportNode.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService$ExportNode.class new file mode 100644 index 0000000000000000000000000000000000000000..3bda96ffabb3c27051757233a1c22ce4b51ce1b0 GIT binary patch literal 2782 zcmcImOLN;)6#lLsk>kpZ?YLyY|(KYvCNPxS2o9y!MKL3j#JPXuG+4#eI@W73FA>WuzHRW zaDUgaxv}~~&+|poZu73yRJ>WmYj`crkQR>U5G~dYJvN2!xZ9@maazX&^5oq1I{mK8 zVAe)(wGt|a28%i-QDVs0ZKu=nIho8W*L~r1jG7Y&^Ax6VR>L_R)0km69Y%KWnL%@c z)oxEb388pcBmiI6@dnP5RpF`N8S3RxB9)++mE%}QCvWO_3vZK?wiUEjLi83-;ACCe zoYgUhcL`tqec$Q?42{t(#-hHr^_bhjtUP2GZ#vtqCHg)c^4;ff$e;qi?-LgZ~0c23+|JjpZ?eUPjWUAI%<+TzSr+DMPk9E`sSIw`txbQLDX zu%5-|xT~S2qmBl{g~O@r_g&$1xuKAcEK`4P4V*DNjUS%l?s_}iP=afg+v@OOw&r;| z{hm3baSt6uiW^k?w|HRtPEUA#TxF$W1wotcH?GJtt5mIoh|VkYtrOF7Z-COV!`Os`Go$stbF_RWI(L@RayT%+r00bN~|z zbgwFTT*M`MBhApC{9Xz@$|y>5y^^j$&w8jUIc4I!Pn=wmR6xQYCZfPDlh_BCp}P#L zK~$QYuKtF?pO}>751e865nAeJq>{hT$9QZ@VQk9RbQIeal1U?R_6AioZRQUn+tQP? ztyTB&8t^MUCXQvPENPLY0`1(7hNxcvWFY^oK8KG-g&hO)$f772*G6x`M za220W02015{b>k$fkxgyoQ4r~gm-g`z`M<*SPKXzAW0i9AKUM%o#yU=_D<2lt3OLwXrp$9?)) P`+{hb=ms|NHKzXo+IzU= literal 0 HcmV?d00001 diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService$ExportSchema.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/service/ExportSchemaService$ExportSchema.class new file mode 100644 index 0000000000000000000000000000000000000000..b56a010c40853e780749e9bdba071e2c353e86f6 GIT binary patch literal 4186 zcmcgvTT>fl7=9K)HiYF|uxag?A_Ur4ORY7C0zH5Yp^{2%>uE{0uw}EG?rv!Dd_Mhy zjx*lY3oq=9SX*Ztubj~fe^95RKHu&pEG>30WxUvY-@g03r{_ImQbYxc5=3HAh=hH$_ zi-K06CGMZhszptZ+ZA*Oa^sS&T|KML86I_uf-XUgEo#drbJjOQX6LRL zg1Ak=c04Yj!L$mRL!W)^Wmh*+8QpQyGImPn^i1-IqqeOsi!X8PKz*Dd_9)nkeG*#o zmSJdlcib|{MU%4j_1!P*{`dabChSMAj3*TIp`S8V6$u^mKzB7;buC*$cVB;qe=?w_ zn=pVu83z;`M2hgON~j!>ATOv+R=Y-djN1jlaY(^aI4mJzdgLt?*e8N8WU+7>hoK$cM6ExDb{1lWh-B*ilKj+gy z;55;UAVmK%PRv{ZUfwG!q{j)3pANSUbZfeU$tXB@~g{<;fjPKm4PF@I!~mlGeo*FH>4}G zLRut@)5HeJ1_rF=jGE7(Kl zDuzk887_0(T(TCmlz(tSH4BF3^kgh+v0O?&v=s)7XW3DXYfj$QOB@`+EaQgiIAKDN z0vAJK*U`S_{;H;hGHIHc?THkVlM)Uj=QRIS1YO3*wM~b9!B$24uQEQ9uzwZZb?mL6 zCF2VTJ0E_Onc_o>L!9O6cz;GYQF5T=u0xER-pU!%TRByFD+ft$246c@Np zL@5vyC7cD>!5JAmfWWDXWO)_CbVa!ICRd@d<7ezRe+#=Z$sdpy_zAlQz9-c;zr`~p zLolb%hOFmCx6c*05ks6Z1#lIke9CL2_)xyi6P&3jNof6(+`_4`ajtH4$4)T;fyZ{pib^_%cyj&EBKxd&Osz&-TH z7`lu0yYxi>-&p5?ta9tSoJTun&H@>Q%ZIp%SjovdH~|QB>mEoBKb&%aYCC61Z5{SW z;JXd6Pvbb12<;-=BFwSmz#T^MU-*5;W7XV@;S%O)fN-xt;tTlFRCOuEH{ntPb4Grc zLk)pMiD_BEBh1{qG3JhSncFtT+_f%q=f;?~uFKrLG3M>nVhj76*a)+qNDC}`ojBLw zG8VnnfXVeBOWtp5m8K?InBUO+cDA;eS^V0~h+ShP$54U|2QJE3!d1M_Z+lruF&Ov& W3I16#@FCZaxPHR*Q+$puk@yGNI|N$* literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..629014ed915a2b616b4d72a1e8653b99fb31b8a4 GIT binary patch literal 13694 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 OKZVLZ<1hF>7q literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..63d2f86f339a450829c160dce44f15dfa9d4db2c GIT binary patch literal 1130 zcmb7DT~8B16g{&o-L_V!Rs2Ljt+fHSTKZIaL{If~V@T>2uu;NK@gjg!w)o(vE$HDOI}Pzq_(mrQZf+#@tD)72ow#NPzy+ZCoeqN9%D0lPEC^i*`r9hzv`a zo_+|!zb1`}m|60D5iEK<3`J;Tnqm6C$fykwEyBAYHveL$wPi{W<^SvWy^f{?JDAxD13_=Gx%Q)EYJ4bhxzXX*Gf&d_ei zZjd!;R_v|WBP4Ew#2Sz=oW(iHI^WB^Fpy*9(nX&z_9S;{7ZYFpU}{B}e#(jCYA-8~ cYh>dnpol5*a>Q=f%r`{pW)ev_4cK`qY literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..7f568c977285ef8919b48c9802889ebd8f73c4ee GIT binary patch literal 3139 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 Mt)cRuTKro17w?rUfdBvi literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..fbc3f3d656dbb01cd0789edfe58bc3376166faf6 GIT binary patch literal 28738 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 literal 0 HcmV?d00001 diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$ColumnMetadata.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils$ColumnMetadata.class index 74c543a509616967b3db87679e7ce4c2b0c1eeb8..4d0c2298f422b3ec0bbe1ec7178927aa796920e7 100644 GIT binary patch delta 87 zcmca3ct>!~=c$`%MD7qW{^wqaL?vMQl08z}!XT*iO{ LqDFXfHb)@!~=c$`%MD7qW{^wqaL?vMQl08z}!XT*iO{ LqDFXfHb)@wa+f#i8MJ1{9aS(#l6$ns+k1dwa+f#i8MJ1{9aS(#l6$ns+k1djzA!JkVACxOb&H0%LYsePZs1X1OQK3 B6jlHL delta 57 zcmZn?ZW7+0!OnPivL<^cklf5}2P8kS+XG1>jzA!JkVACxOb&H0%LYsePZs1X1OP|@ B6c+#h diff --git a/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils.class b/code/fms/fms-api/target/classes/cn/g3soft/fmsapi/utils/DbUtils.class index 0ffa5aca195cc15023cceb7335d057d71805cfe9..669b7f6d9fed35746a8875df8c0e9c101dce3240 100644 GIT binary patch delta 1323 zcmX|9drXye7=C{5%jEzkIGYB8)jZ4sLUA@U^$(&pIuBm1k?dp=I>+n)D%@0Z{A=d`*zqXKXK zGja_;I!|2)+_NIkQr_&SugEW}svPQCQRSmv({ro(REzFRw5lEYWMY)ssc#E!*K>)H zYL~Xyo=NHqgo*c<*-eucnhf#fw>^fvW_Hn}iT5jnZph6{FR)vcHQ!z!SeR|k6V1f$ zXqPQAxW^NI(9BLxxYqr)h=^W=1#0%(;LKR>gWkcJ(Oomc!!}RGVf~{mKIlCKpPXnF zZQ`Tak`x_vT!EB$KN4wGA-+hFDNcCG4wyL?2^K!3@T6A33&S^xcie)w5I_Fh6L7}N z^++)BIlVXODK)51CB-HTDFiF)^l3Nk%y;8Onga!H>na6f2rWBtgEf5QsGJ< zkMLE09yOzc$FwPVBKUeB-{7zOdECrj^EX3xlh-Pr9~7R|4as3THN~QC>)ez9wRGrl z%KhjpehCR6nvoZ(YMq~~GXdU86jzQ~@NqJaD`N*RUsHBakp+aq|S*eP& zyQUzhNczo5b>f%xR89G!%`npz#84TQQ8{c>Y0Q!$lUe0}n{=)tUKQ&O$7)rg#~lOD zIp9OJ2%!dqkyHA)u$J6dPmL(1t*E0Wv{Ey6(l*^w8)+})&G3(`u-Db7m0^H@xSa<_|ew;|Z+l93lH z8<-J`8 zcIsbUFY4{?I9=$rDz|7$^$B5(dcxhBe*rU9J4pZl delta 1255 zcmYjPT~L%|6n@@wfL)din8_rdav6*z2*(C+WBHST4QPzaNFhK&!DU%~b|w46fFzJq z{Cm@+q99PitU!toL(P)Pv@UGA>4rwgY8-Ex>aOYR*EZA5bDsA(?|I&H&iDPesQy?` zmdk(7oC8q6-%eP1GbTqeYC_brT4fzk&+DUEHr1}rWF@H=^}O&-t(*z!C7tM8-uSWw zX6~_aE6qjITvRCc1%y|u+(L6^-lMSeiM2%qHc53XBVbtZj8-1ZAz^62doZQxjA5UNKZK9)T{c6^GRnv_hn^{l6aMy3ZcrDYs(jhX$zm>855tiqJ}@! z@i~_w&sq39Uoi1!R=&uePu|KYRUwxZ?$up6F?u{VEV6C+n$}kDcAtr_DBSa(th@7i zRO)1S{&l;~ce`U(!AuU^PEI6IHday&9;93pl2e~|Ur2BvoE}0N6~jR#D56q4N)KZl ztwB9Kf(9znLk*d#LjTZE9#H|%E&0C0l{(Y2dD(hcX#;5)r{}ZV=`a=HW(RKrxqamF{XSQ&al8)=cHqp}ypp z9HZW9+%Rzy{RBi4H|vAG`?b$!(}lh?JuC5mM2p|1d^*+d?;Mx!Jb_ho8d)@ne2Jm# zmp6#F(RGPoP}~Oa)56WN>pI-TJa3oN_qdxsk?b=DK4;(y2JSGh!@w?{1@lhs=H4LN zB`i?4JAj?MHz@B5$_I_4Ht?{4uNgQHy!QsbX`BZQd^@NPi~8?F?*!Q~KEdw>wXtB$ zDWm3tpgJxu76dw(Fyu)CKjtrFwiJZQq-MT^Sj-Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-14-17_744.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-14-17_744.dumpstream new file mode 100644 index 00000000..ffb3613e --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-14-17_744.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:14:17.880 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-14-56_126.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-14-56_126.dumpstream new file mode 100644 index 00000000..2c493149 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-14-56_126.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:14:56.290 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-17-03_642.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-17-03_642.dumpstream new file mode 100644 index 00000000..75ac8f87 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-17-03_642.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:17:03.779 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-24-02_171.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-24-02_171.dumpstream new file mode 100644 index 00000000..64d92f71 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-24-02_171.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:24:02.295 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-39-48_013.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-39-48_013.dumpstream new file mode 100644 index 00000000..093f51cd --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-39-48_013.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:39:48.149 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-15_270.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-15_270.dumpstream new file mode 100644 index 00000000..16b05aaf --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-15_270.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:40:15.395 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-32_507.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-32_507.dumpstream new file mode 100644 index 00000000..122b080b --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-32_507.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:40:32.634 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-45_467.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-45_467.dumpstream new file mode 100644 index 00000000..8d2c633c --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-40-45_467.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:40:45.605 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-47-36_296.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-47-36_296.dumpstream new file mode 100644 index 00000000..153fa385 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-47-36_296.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:47:36.415 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-48-16_896.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-48-16_896.dumpstream new file mode 100644 index 00000000..e6d3bca4 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-48-16_896.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:48:17.017 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-49-10_993.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-49-10_993.dumpstream new file mode 100644 index 00000000..80f301c4 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-49-10_993.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:49:11.117 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-00_871.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-00_871.dumpstream new file mode 100644 index 00000000..2d409ca2 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-00_871.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:50:01.012 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-26_075.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-26_075.dumpstream new file mode 100644 index 00000000..096fd9ab --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-26_075.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:50:26.206 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-44_849.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-44_849.dumpstream new file mode 100644 index 00000000..b951d672 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T21-50-44_849.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T21:50:44.966 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T22-55-35_629.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T22-55-35_629.dumpstream new file mode 100644 index 00000000..936e7756 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T22-55-35_629.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T22:55:35.792 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T22-57-12_944.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T22-57-12_944.dumpstream new file mode 100644 index 00000000..e061f525 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T22-57-12_944.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T22:57:13.078 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T22-57-47_025.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T22-57-47_025.dumpstream new file mode 100644 index 00000000..fe8d49b7 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T22-57-47_025.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T22:57:47.155 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/2026-09-16T23-13-40_995.dumpstream b/code/fms/fms-api/target/surefire-reports/2026-09-16T23-13-40_995.dumpstream new file mode 100644 index 00000000..33ff5b2a --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/2026-09-16T23-13-40_995.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-09-16T23:13:41.132 +Boot Manifest-JAR contains absolute paths in classpath 'D:\data\maven\org\apache\maven\surefire\surefire-booter\3.5.6\surefire-booter-3.5.6.jar' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.FmsApiApplicationTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.FmsApiApplicationTests.xml index d19aa808..a7b442c8 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.FmsApiApplicationTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.FmsApiApplicationTests.xml @@ -1,10 +1,10 @@ - + - + @@ -16,9 +16,9 @@ - + - + @@ -33,7 +33,7 @@ - + @@ -45,18 +45,18 @@ - + - + - + @@ -65,12 +65,12 @@ - - + - + - + @@ -15,10 +15,9 @@ - + - - + @@ -31,7 +30,7 @@ - + @@ -43,7 +42,7 @@ - + @@ -52,7 +51,7 @@ - + @@ -60,10 +59,17 @@ - - + + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDataSourceManagerTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDataSourceManagerTests.xml index 634cbddb..18296303 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDataSourceManagerTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDataSourceManagerTests.xml @@ -1,10 +1,10 @@ - + - + @@ -15,9 +15,9 @@ - + - + @@ -30,7 +30,7 @@ - + @@ -42,7 +42,7 @@ - + @@ -51,7 +51,7 @@ - + @@ -59,20 +59,20 @@ - - + - - - + - - + - - + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests.xml index f4bf1250..52dab7cc 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests.xml @@ -1,10 +1,10 @@ - + - + @@ -15,9 +15,9 @@ - + - + @@ -30,7 +30,7 @@ - + @@ -42,7 +42,7 @@ - + @@ -51,7 +51,7 @@ - + @@ -59,5 +59,5 @@ - + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests.xml index 3132a095..2c57d76f 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests.xml @@ -1,10 +1,10 @@ - + - + @@ -15,9 +15,9 @@ - + - + @@ -30,7 +30,7 @@ - + @@ -42,7 +42,7 @@ - + @@ -51,7 +51,7 @@ - + @@ -59,21 +59,21 @@ - - + - - + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.xml index 46abc5a8..d5ae8809 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.xml @@ -1,66 +1,66 @@ - + - - + + - + - + - - + + - - + - + - - + + - + - - + + - - - - + + + + + - + - - + + - - + - - + - + @@ -16,9 +16,9 @@ - + - + @@ -33,7 +33,7 @@ - + @@ -45,18 +45,18 @@ - + - + - + 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 f1aabc89..c292dc27 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,10 +1,10 @@ - + - + @@ -16,9 +16,9 @@ - + - + @@ -33,7 +33,7 @@ - + @@ -45,18 +45,18 @@ - + - + - + @@ -65,5 +65,5 @@ - + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.DataSaveServiceTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.DataSaveServiceTests.xml index 2c1a0be0..5432d81b 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.DataSaveServiceTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.DataSaveServiceTests.xml @@ -1,10 +1,10 @@ - + - + @@ -16,9 +16,9 @@ - + - + @@ -33,7 +33,7 @@ - + @@ -45,18 +45,18 @@ - + - + - + @@ -65,11 +65,11 @@ - + - + - - + + \ 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 new file mode 100644 index 00000000..72589286 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 new file mode 100644 index 00000000..d9d6d427 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportServiceTests.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 new file mode 100644 index 00000000..349c35f3 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 36b5ae82..8f5e7b7f 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,10 +1,10 @@ - + - + @@ -16,9 +16,9 @@ - + - + @@ -33,7 +33,7 @@ - + @@ -45,18 +45,18 @@ - + - + - + @@ -65,5 +65,5 @@ - + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests.xml b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests.xml index 94f4f03f..08680a0b 100644 --- a/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests.xml +++ b/code/fms/fms-api/target/surefire-reports/TEST-cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests.xml @@ -1,10 +1,10 @@ - + - + @@ -16,9 +16,9 @@ - + - + @@ -33,7 +33,7 @@ - + @@ -45,18 +45,18 @@ - + - + - + @@ -65,10 +65,10 @@ - - - - - - + + + + + + \ No newline at end of file diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.FmsApiApplicationTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.FmsApiApplicationTests.txt index 108aceb5..fb4294e5 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.FmsApiApplicationTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.FmsApiApplicationTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.FmsApiApplicationTests ------------------------------------------------------------------------------- -Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.599 s -- in cn.g3soft.fmsapi.FmsApiApplicationTests +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.896 s -- in cn.g3soft.fmsapi.FmsApiApplicationTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests.txt index 89359787..73fe3d94 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests.txt @@ -1,4 +1,11 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests ------------------------------------------------------------------------------- -Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.253 s -- in cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests +Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.008 s <<< FAILURE! -- in cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests +cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry -- Time elapsed: 0.007 s <<< FAILURE! +org.opentest4j.AssertionFailedError: + +expected: 0 + but was: 1 + at cn.g3soft.fmsapi.database.OrgDataSourceFactoryTests.databaseConnectionFailureDoesNotRetry(OrgDataSourceFactoryTests.java:25) + diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDataSourceManagerTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDataSourceManagerTests.txt index 67f7736b..fdeedf0e 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDataSourceManagerTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDataSourceManagerTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.database.OrgDataSourceManagerTests ------------------------------------------------------------------------------- -Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.887 s -- in cn.g3soft.fmsapi.database.OrgDataSourceManagerTests +Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.892 s -- in cn.g3soft.fmsapi.database.OrgDataSourceManagerTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests.txt index f5ce0f90..ebab0ce3 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests ------------------------------------------------------------------------------- -Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.129 s -- in cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.158 s -- in cn.g3soft.fmsapi.database.OrgDatabaseConfigLoaderTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests.txt index 169eb26e..882d8c85 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests ------------------------------------------------------------------------------- -Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.124 s -- in cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests +Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.166 s -- in cn.g3soft.fmsapi.database.OrgDatabaseConfigWatcherTests diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.txt index 0a5c1301..f4f70e80 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests ------------------------------------------------------------------------------- -Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.531 s -- in cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests +Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.012 s -- in cn.g3soft.fmsapi.exception.GlobalExceptionHandlerTests 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 053f3031..b22c6746 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.194 s -- in 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 diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.DataSaveServiceTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.DataSaveServiceTests.txt index de615113..29719782 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.DataSaveServiceTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.DataSaveServiceTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.service.DataSaveServiceTests ------------------------------------------------------------------------------- -Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.417 s -- in cn.g3soft.fmsapi.service.DataSaveServiceTests +Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.445 s -- in cn.g3soft.fmsapi.service.DataSaveServiceTests 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 new file mode 100644 index 00000000..c4a192c3 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceIntegrationTests.txt @@ -0,0 +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 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 new file mode 100644 index 00000000..422b2b35 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportServiceTests.txt @@ -0,0 +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 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 new file mode 100644 index 00000000..22f9eac4 --- /dev/null +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.service.ExportWorkbookWriterTests.txt @@ -0,0 +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 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 4d1f412c..630bc087 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.091 s -- in 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 diff --git a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests.txt b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests.txt index 4f3763ae..ed873c72 100644 --- a/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests.txt +++ b/code/fms/fms-api/target/surefire-reports/cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests.txt @@ -1,4 +1,4 @@ ------------------------------------------------------------------------------- Test set: cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests ------------------------------------------------------------------------------- -Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.594 s -- in cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests +Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.625 s -- in cn.g3soft.fmsapi.utils.snowflake.DefaultIdGeneratorTests 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 new file mode 100644 index 0000000000000000000000000000000000000000..f6314188812c14de14272dbb7c7c185c6da24c9b GIT binary patch literal 13872 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 BO@jac literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..8f8dd2321854edd94dd7d5116d3544a01820aeee GIT binary patch literal 13167 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 literal 0 HcmV?d00001 diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests$1.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests$1.class new file mode 100644 index 0000000000000000000000000000000000000000..843a79c396c9b21ca7406a6b5fdea26467042ced GIT binary patch literal 1166 zcmb7DTTc^F5dNmL-Cnj(YZdW=w~B2MR*=X8gv1bx#9EA)lK6JIEakv<581OV{3stZ z(HMO&KKrALv%A$cf)Cx~+-7IK^UZf=e*XIQ9l$dzXOX~!fuxBP(u5V;vpP?-ItZ+T zt`;ZK(vIJkwqtFbohU!pQ~q&VspCCg29E#M(Sfck5zM}DE1^XM$|sZ?N1`vRULaj- zdthD5A%m=eoQXV4LNRi2h1aom+DDEZ5Yp??lfedIqFQU^F^MSyMH81$B9zV}5{yHk zo6Z@Ba>TnZqHN*{t`d@7WG+{0F_n>f)(l)DOb+oHQU?aE6IRDczVb$$?y8<|JA~=# zMH$yxOk`8-v%^$FdQP*~ZL?^i?XsoRP_}Se!k6KBU`z&wQWKsw#$qyfhBX%Tl@lDQ z{nx@5U5ABdtn9Arcp~We;hlfvh+Zbln`7qA8Q0Hiw!x0MINRqkq(A%LdRPpV<&3vH z+f`b6otTq>8ew_-yiCmF5!Z&VPW8?K;pM2*Q3|#3)Ed4U=agx33BOR9OUT^zJjdU3 zh1QNX@R+bL7F(A3Z$;n^w^as&8Bnnpwib8W1dlv!@OqPXE9{ZrHCLbi461*D@sUpn z+~PUSk-$UD@@z)?9BwlvaF<7lkYdn#yiG*Exk1<|2Bp}&{t5II1;YEtYY1Au0F=0A z?lV@1u*&h_<%JmGGT{fNKl}q?jerye%wjqfB=I2fo@4&tQx$bAM3yQRc`flpVcy|4 J0}D&(%x{^0A8r5u literal 0 HcmV?d00001 diff --git a/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.class b/code/fms/fms-api/target/test-classes/cn/g3soft/fmsapi/service/ExportWorkbookWriterTests.class new file mode 100644 index 0000000000000000000000000000000000000000..b03b69d70821be0372d724ec33e8dbde01106b07 GIT binary patch literal 9929 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 literal 0 HcmV?d00001 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 167708a8..c17731d1 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 @@ -70,6 +70,7 @@ export function buildListConfig(viewSchema) { b_visible: node.visible === false ? 0 : 1, b_xh: (index + 1) * 10, options: node.options, + summary: node.summary, })) } 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 new file mode 100644 index 00000000..aca8bcd0 --- /dev/null +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsExportDialog.vue @@ -0,0 +1,394 @@ + + + + + diff --git a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue index 8936e134..3d595322 100644 --- a/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue +++ b/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue @@ -1,9 +1,10 @@ + + + + diff --git a/code/fms/fms-vue/src/components/ui/button/button.vue b/code/fms/fms-vue/src/components/ui/button/button.vue index 54ad1b6c..6b31f70d 100644 --- a/code/fms/fms-vue/src/components/ui/button/button.vue +++ b/code/fms/fms-vue/src/components/ui/button/button.vue @@ -5,17 +5,17 @@ import { Loader } from '@lucide/vue' /* ---------- props / emits / slots ---------- */ /** * 按钮视觉变体 - * @typedef {'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'link'} ButtonType + * @typedef {'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'link' | 'icon'} ButtonType */ const props = defineProps({ - /** 视觉变体 */ + /** 视觉变体;icon 为工具条 / 面板头部动作位的正方形描边图标按钮 */ type: { /** @type {ButtonType} */ type: String, default: 'primary', validator: (value) => - ['primary', 'secondary', 'outline', 'ghost', 'danger', 'link'].includes(value), + ['primary', 'secondary', 'outline', 'ghost', 'danger', 'link', 'icon'].includes(value), }, /** 禁用态 */ disabled: { diff --git a/code/fms/fms-vue/src/components/ui/button/index.scss b/code/fms/fms-vue/src/components/ui/button/index.scss index db32f3a5..4daca56c 100644 --- a/code/fms/fms-vue/src/components/ui/button/index.scss +++ b/code/fms/fms-vue/src/components/ui/button/index.scss @@ -134,6 +134,23 @@ text-decoration: underline; } +/* 图标按钮 —— 工具条 / 面板头部动作位的正方形描边按钮,不随文字缩放。 + * 必须写在 .button 之后:两处对 padding / border 的声明权重相同(0,1,0),靠源码顺序覆盖。 */ +.button-icon { + width: var(--fms-control-height); + height: var(--fms-control-height); + flex-shrink: 0; + padding: 0; + border-color: var(--fms-border); + background: var(--fms-card); + color: var(--fms-text-secondary); +} + +.button-icon:hover:not(:disabled) { + background: var(--fms-secondary); + color: var(--fms-text); +} + /* 禁用态 —— 参考 shadcn-vue:保留原配色,整体降透明度,悬停显示禁用光标 */ .button:disabled { opacity: 0.5; diff --git a/code/fms/fms-vue/src/components/ui/splitter/index.scss b/code/fms/fms-vue/src/components/ui/splitter/index.scss index 8787c09c..ac1f3a38 100644 --- a/code/fms/fms-vue/src/components/ui/splitter/index.scss +++ b/code/fms/fms-vue/src/components/ui/splitter/index.scss @@ -62,33 +62,6 @@ height: var(--fms-splitter-bar-size); } -/* 中央手柄:hover/聚焦/拖拽时高亮,提示可拖 */ -.fms-splitter-bar-grip { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background: var(--fms-text-secondary); - border-radius: 2px; - opacity: 0; -} - -.fms-splitter-horizontal .fms-splitter-bar-grip { - width: var(--fms-splitter-bar-size); - height: var(--fms-splitter-handle-size); -} - -.fms-splitter-vertical .fms-splitter-bar-grip { - width: var(--fms-splitter-handle-size); - height: var(--fms-splitter-bar-size); -} - -.fms-splitter-bar:hover .fms-splitter-bar-grip, -.fms-splitter-bar-active .fms-splitter-bar-grip, -.fms-splitter-bar:focus-visible .fms-splitter-bar-grip { - opacity: 1; -} - .fms-splitter-bar:hover::before, .fms-splitter-bar-active::before, .fms-splitter-bar:focus-visible::before { @@ -96,27 +69,13 @@ } /* hover 模式(barOnHover):分割线平时隐藏,hover / 聚焦 / 拖拽时才显示。 - 线宽固定为 --fms-splitter-bar-size-hover(居中,略宽于命中区), - 命中区保持 6px 不变,拖拽热区不随之扩大 */ + 只表达「是否常显」这一件事 —— 线宽、中央手柄、面板是否滚动一律沿用默认行为, + 调用方需要不同的线宽 / 不要手柄时另行覆盖,不在这里替它们做决定。 */ .fms-splitter-bar-hover::before { background: var(--fms-text-secondary); opacity: 0; } -.fms-splitter-horizontal .fms-splitter-bar-hover::before { - width: var(--fms-splitter-bar-size-hover); - height: 100%; -} - -.fms-splitter-vertical .fms-splitter-bar-hover::before { - width: 100%; - height: var(--fms-splitter-bar-size-hover); -} - -.fms-splitter-bar-hover .fms-splitter-bar-grip { - display: none; -} - .fms-splitter-bar-hover:hover::before, .fms-splitter-bar-hover.fms-splitter-bar-active::before, .fms-splitter-bar-hover:focus-visible::before { diff --git a/code/fms/fms-vue/src/components/ui/splitter/splitter.vue b/code/fms/fms-vue/src/components/ui/splitter/splitter.vue index 451af5b6..45cb036e 100644 --- a/code/fms/fms-vue/src/components/ui/splitter/splitter.vue +++ b/code/fms/fms-vue/src/components/ui/splitter/splitter.vue @@ -285,9 +285,7 @@ onBeforeUnmount(() => { :tabindex="canResize(index) ? 0 : undefined" @pointerdown="onBarPointerDown(index, $event)" @keydown="onBarKeydown(index, $event)" - > - - + > diff --git a/code/fms/fms-vue/src/layouts/DefaultLayout.vue b/code/fms/fms-vue/src/layouts/DefaultLayout.vue index a1b356a6..127ff249 100644 --- a/code/fms/fms-vue/src/layouts/DefaultLayout.vue +++ b/code/fms/fms-vue/src/layouts/DefaultLayout.vue @@ -187,6 +187,12 @@ provide('toggleSidebar', toggleSidebar) * ============================================================ */ /* ---- 布局骨架 ---- */ + +/* 侧栏与主区之间的灰缝宽度:由主区面板的左右 padding 产生(分隔条自身零宽,不占位)。 + * 因此分割线与命中区默认会落在灰缝左缘,需按「半个灰缝」右移才居中 —— + * 这里把灰缝宽度收成一个变量,供下面几处共用,避免以后调 padding 时漏改。 */ +$layout-gutter: 8px; + .fms-layout { display: flex; height: 100vh; @@ -221,14 +227,20 @@ provide('toggleSidebar', toggleSidebar) z-index: 1; } -/* 命中区:绝对定位在分隔条两侧各扩展 3px(共 6px),不占 flex 空间, +/* 视觉分割线:组件里用 left: 50% 把线居中于分隔条,而本处分隔条零宽、 + * 灰缝由主区面板 padding 产生,线会落在灰缝左缘 → 按半个灰缝右移,落在灰缝正中 */ +.fms-layout .fms-layout-splitter :deep(> .fms-splitter-bar)::before { + left: calc($layout-gutter / 2); +} + +/* 命中区:绝对定位在灰缝正中(分隔条位置两侧各半个命中区宽度),不占 flex 空间, * 且能正常触发 bar 的 pointerdown 拖拽 */ .fms-layout .fms-layout-splitter :deep(> .fms-splitter-bar)::after { content: ''; position: absolute; top: 0; bottom: 0; - left: calc(var(--fms-splitter-trigger-size) / -2); + left: calc($layout-gutter / 2 - var(--fms-splitter-trigger-size) / 2); width: var(--fms-splitter-trigger-size); } @@ -241,10 +253,10 @@ provide('toggleSidebar', toggleSidebar) transition: none; } -/* 主区面板:只留左右 8px padding(顶栏/内容区紧贴上边缘)。 - * 折叠态与展开态一致,保持 8px 左灰缝,避免侧栏折叠时与右侧紧贴 */ +/* 主区面板:左右各留一个灰缝宽度的 padding(顶栏/内容区紧贴上边缘)。 + * 折叠态与展开态一致,避免侧栏折叠时与右侧紧贴 */ .fms-main-panel { - padding: 0 8px; + padding: 0 $layout-gutter; } /* 覆盖 Splitter 面板默认 overflow: auto(scoped 同名规则特异性更高,需带 .fms-layout 提权), diff --git a/code/fms/fms-vue/src/layouts/components/AppTopbar.vue b/code/fms/fms-vue/src/layouts/components/AppTopbar.vue index c9a8a422..8ddd3157 100644 --- a/code/fms/fms-vue/src/layouts/components/AppTopbar.vue +++ b/code/fms/fms-vue/src/layouts/components/AppTopbar.vue @@ -257,39 +257,28 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls box-shadow: none; } -/* 顶栏图标按钮:复用组件库 Button type="ghost"(透明底/无边框/hover 高亮)。 - 用 .fms-topbar 前缀提高特异性,覆盖 ghost 的横向 padding / 次要文字色 / 图标尺寸, - 使其保持 32×32 正方形、主文字色图标。hover 沿用 ghost 的 --fms-secondary。 */ +/* 顶栏图标按钮:复用组件库 Button type="ghost"。 + 透明底 / 圆角 / 字号 / 行高 / 光标 / 纵向布局 / 焦点环都已在 .button、.button-ghost 里, + 这里只保留顶栏自身的差异:32×32 方形、无横向内边距、主文字色(ghost 默认次要色)、 + 图标固定 16px。仍需 .fms-topbar 前缀提高特异性,才压得过 .button-ghost 的 padding / color + 与 .button 的 svg 尺寸。 */ .fms-topbar { .fms-icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; width: 32px; height: 32px; padding: 0; - border: none; - border-radius: var(--fms-control-radius); - background: transparent; color: var(--fms-text); - font-size: var(--fms-font-size); - line-height: var(--fms-line-height); - cursor: pointer; - /* 走动效 token:裸写时长会绕过 prefers-reduced-motion 的归零覆盖, - 且特异性高于 .button 时会把组件库的过渡整个顶掉 */ - transition: - background-color var(--fms-duration-base) var(--fms-ease-standard), - color var(--fms-duration-base) var(--fms-ease-standard); svg { width: 16px; height: 16px; } - &:focus-visible { - outline: 2px solid var(--fms-primary); - outline-offset: 2px; - } + /* 走动效 token:裸写时长会绕过 prefers-reduced-motion 的归零覆盖, + 且特异性高于 .button 时会把组件库的过渡整个顶掉 */ + transition: + background-color var(--fms-duration-base) var(--fms-ease-standard), + color var(--fms-duration-base) var(--fms-ease-standard); } } diff --git a/code/fms/fms-vue/src/services/exportService.js b/code/fms/fms-vue/src/services/exportService.js new file mode 100644 index 00000000..c8a4411a --- /dev/null +++ b/code/fms/fms-vue/src/services/exportService.js @@ -0,0 +1,133 @@ +import http from './http' + +/** + * 导出列表数据。 + * + * 契约(与后端 ExportController 一致): + * - 成功:响应体是文件流(xlsx,分卷时是 zip),文件名在 Content-Disposition 的 filename* 里; + * - 失败:后端在**写流之前**校验失败,返回 HTTP 200 + JSON 业务错误(code != 0)。 + * 因此这里必须先看 content-type 区分「文件」与「错误 JSON」,否则用户会下到一个 + * 内容其实是 JSON 的 .xlsx。 + */ + +/** 解析 Content-Disposition 中的文件名(兼容 filename*=UTF-8''xxx 与 filename="xxx") */ +export function parseFileName(disposition, fallback) { + const text = String(disposition || '') + const extended = text.match(/filename\*\s*=\s*([^;]+)/i) + if (extended?.[1]) { + const value = extended[1].trim().replace(/^UTF-8''/i, '') + try { + return decodeURIComponent(value) + } catch { + return value || fallback + } + } + const plain = text.match(/filename\s*=\s*"?([^";]+)"?/i) + return plain?.[1]?.trim() || fallback +} + +/** 把错误 JSON 读成可抛出的 Error */ +async function readErrorMessage(blob) { + try { + const text = await blob.text() + return JSON.parse(text)?.message || '导出失败' + } catch { + return '导出失败' + } +} + +function saveBlob(blob, fileName) { + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + link.remove() + // 延迟释放:立刻 revoke 在部分浏览器会让下载中断(旧系统遗漏了这一步,blob 常驻内存) + setTimeout(() => URL.revokeObjectURL(url), 0) +} + +/** + * 导出列表数据。返回保存的文件名。 + * + * @param moduleId 模块编码(必填):后端据此推导导出列与字段权限,不接受前端传列清单 + * @param searchCondition 列表当前查询条件(不含 WHERE);后端会再 AND 数据范围 + * @param orderBy 排序子句;缺省用模块默认排序 + * @param rowKeys 勾选行主键值;非空时只导出这些行 + * @param keyField 勾选行主键列;缺省用模块默认主键 + * @param columns 勾选导出的字段;省略表示沿用列表全部列(批量导出用) + * @param rangeStart 按条数区间起始(1 基,闭区间);与 rangeEnd 同时给出才生效 + * @param rangeEnd 按条数区间结束 + * @param volumeSize 分卷行数:> 0 时返回 zip(每卷一个 xlsx),与区间互斥 + * @param withSummary 是否输出合计行,默认 true + */ +export async function exportListApi({ + moduleId, + searchCondition, + orderBy, + rowKeys, + keyField, + columns, + rangeStart, + rangeEnd, + volumeSize, + withSummary = true, +}) { + const response = await http.post( + '/export/list', + { + moduleId: String(moduleId || ''), + searchCondition: searchCondition || '', + orderBy: orderBy || '', + rowKeys: rowKeys?.length ? rowKeys : null, + keyField: keyField || null, + columns: columns?.length ? columns : null, + rangeStart: rangeStart ?? null, + rangeEnd: rangeEnd ?? null, + volumeSize: volumeSize ?? null, + withSummary, + }, + { responseType: 'blob' }, + ) + + const blob = response.data + const contentType = String(response.headers?.['content-type'] || '') + if (contentType.includes('json')) { + throw new Error(await readErrorMessage(blob)) + } + + const fallback = contentType.includes('zip') ? '导出数据.zip' : '导出数据.xlsx' + const fileName = parseFileName(response.headers?.['content-disposition'], fallback) + saveBlob(blob, fileName) + return fileName +} + +/** + * 符合条件的行数(批量导出弹窗的「共 N 条」)。 + * 与导出走同一条后端权限链路,所以数字与真正导出的行数一致 —— 不能用列表分页的 total 代替。 + * 返回 ApiResponse,调用方取 `res.data.total`。 + */ +export function countExportApi({ moduleId, searchCondition, orderBy, rowKeys, keyField }) { + return http.post('/export/count', { + moduleId: String(moduleId || ''), + searchCondition: searchCondition || '', + orderBy: orderBy || '', + rowKeys: rowKeys?.length ? rowKeys : null, + keyField: keyField || null, + }) +} + +/** + * 各合计列的聚合值(列表底部的「总合计」)。 + * 与导出共用同一条权限链路与同一套聚合 SQL,因此与导出文件里的合计行必然一致。 + */ +export function loadTotalsApi({ moduleId, searchCondition, orderBy, rowKeys, keyField }) { + return http.post('/export/totals', { + moduleId: String(moduleId || ''), + searchCondition: searchCondition || '', + orderBy: orderBy || '', + rowKeys: rowKeys?.length ? rowKeys : null, + keyField: keyField || null, + }) +} diff --git a/code/fms/fms-vue/src/services/http.js b/code/fms/fms-vue/src/services/http.js index 24c699e3..8631211b 100644 --- a/code/fms/fms-vue/src/services/http.js +++ b/code/fms/fms-vue/src/services/http.js @@ -51,6 +51,12 @@ http.interceptors.response.use( (response) => { const payload = response.data + // 文件下载(导出等):blob 响应没有 code 字段,原样透传响应本身, + // 调用方才能读到 headers(Content-Disposition 里的文件名、content-type 用于区分错误 JSON) + if (typeof Blob !== 'undefined' && payload instanceof Blob) { + return response + } + // 无 code 字段时按成功透传 if (payload?.code === undefined) { return { diff --git a/code/fms/fms-vue/src/styles/common.scss b/code/fms/fms-vue/src/styles/common.scss index d11244db..443d89f3 100644 --- a/code/fms/fms-vue/src/styles/common.scss +++ b/code/fms/fms-vue/src/styles/common.scss @@ -10,15 +10,21 @@ * 由 styles/index.css 全局引入,页面直接写 class,无需 scoped / :deep()。 * ============================================================ */ -/* ========== 一、左右分栏页面骨架 ========== - * 标准三层:.fms-page > .fms-page__body(放 Splitter)> .fms-page__main(右栏)。 - * 页面根靠 flex:1 在 DefaultLayout 的 .fms-content / .fms-page-wrap flex 链上撑满可视 - * 高度,不写 calc(100vh - xx);固定高度 + 内部滚动由各层自己的 overflow 承担。 */ +/* ========== 一、页面骨架 ========== + * 页面根固定用 .fms-page(撑满 + 裁切),内部按页面形态展开: + * - 左右分栏页:.fms-page__body(放 Splitter)> .fms-page__main(右栏主区); + * - 单纯列表页:内容直接放 .fms-page 里。 + * 高度走 flex 链:.fms-content / .fms-page-wrap 均已 flex:1 + min-height:0, + * 页面根本身只需 flex:1 + min-height:0(同 DefaultLayout 注释), + * 不写 height:100%、100vh 或 calc(100vh - xx) 魔数。 + * 裁切由页面骨架自己承担(侧栏折叠等过渡瞬间内容会短暂超宽,不裁切会闪出滚动条), + * 滚动交给内部容器(Splitter / 表格 / 内容卡)。 */ .fms-page { display: flex; flex-direction: column; flex: 1; min-height: 0; + overflow: hidden; } /* 左右分栏容器:左栏(树面板)+ 右栏(主区),由 Splitter 承担分栏 */ @@ -28,7 +34,8 @@ min-height: 0; } -/* 右栏主区:纵向堆叠「工具条 / 内容卡」,卡间 8px 灰缝(同 .fms-stack + gap) */ +/* 右栏主区:纵向堆叠「工具条 / 内容卡」,卡间 8px 灰缝。 + * height:100% 供 SplitterPanel(块级容器,高度由 splitter 拉伸确定)内的主区撑满。 */ .fms-page__main { display: flex; flex-direction: column; @@ -41,120 +48,12 @@ gap: 8px; } -/* ========== 二、撑满与堆叠容器 ========== */ - -/* 撑满父级:宽高 100% + 允许收缩(flex 子项下不会把父级撑破)。 - * 刻意不含 overflow:hidden —— 子元素带 box-shadow 时父级裁切会把阴影切掉, - * 需要裁切的场景叠加 .fms-stack 或就地写 overflow。 */ -.fms-fill { - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; -} - -/* 纵向堆叠:仅表达 flex 方向,撑满/裁切按需叠加 .fms-fill / .fms-stack */ -.fms-column { - display: flex; - flex-direction: column; -} - -/* 裁切溢出:页面根 / 分栏容器用。侧栏折叠等过渡瞬间内容宽度会短暂超出容器, - * 不裁切会把滚动条闪出来;横向滚动应由内部容器(表格 / splitter)自己处理。 */ -.fms-clip { - overflow: hidden; -} - -/* 纵向堆叠 + 撑满 + 裁切:内容卡容器(内部自带滚动)的通用形态 */ -.fms-stack { - display: flex; - flex-direction: column; - flex: 1; - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; - overflow: hidden; -} - -/* ========== 三、Splitter 视觉定制 ========== - * 面板不再留白,两张白卡紧贴;灰缝由分隔条自身的触发区宽度 - * (--fms-splitter-trigger-size)承担。常显线平时透明,hover / 拖拽 / 键盘聚焦时 - * 才显示整条细线 + 中央手柄。 - * 必须是独立修饰类 —— .fms-splitter 是 Splitter 组件自己的根类, - * 直接写会波及弹窗 / 抽屉 / 设计器里的所有分割条。 */ -.fms-splitter-clean { - > .fms-splitter-panel { - overflow: hidden; - } - - .fms-splitter-bar::before { - opacity: 0; - } - - .fms-splitter-bar:hover::before, - .fms-splitter-bar-active::before, - .fms-splitter-bar:focus-visible::before { - opacity: 1; - } -} - -/* ========== 四、左树面板(左右结构的左栏)========== - * 灰底画布上的白卡:白底 + 圆角 + 阴影,与右侧内容卡用灰缝分隔。 - * 内部纵向排列:头部(搜索 + 动作)→ 滚动区。 */ -.fms-tree-panel { - position: relative; - display: flex; - flex-direction: column; - gap: 8px; - width: 100%; - height: 100%; - min-height: 0; - padding: 8px; - background: var(--fms-card); - border-radius: var(--fms-control-radius); - box-shadow: var(--fms-shadow); - overflow: hidden; -} - -/* 头部条:搜索框撑满,动作按钮靠右且不参与压缩 */ -.fms-tree-panel__header { - display: flex; - align-items: center; - gap: 6px; - flex: none; -} - -.fms-tree-panel__search { - flex: 1; - min-width: 0; -} - -.fms-tree-panel__search-icon { - color: var(--fms-text-secondary); -} - -.fms-tree-panel__actions { - display: flex; - align-items: center; - gap: 2px; - flex-shrink: 0; -} - -/* 树滚动区 */ -.fms-tree-panel__scroll { - flex: 1; - min-height: 0; - overflow: auto; - - /* 树宽度跟随面板:内容更宽时横向滚动,而不是把卡片撑破 */ - .tree { - min-width: 100%; - } -} +/* ========== 二、通用部件 ========== */ /* 树节点名称:撑满剩余空间(.tree-node-content 是 flex 容器,需 flex:1 才有空间可撑), - * 超出单行省略。停用等状态色由使用方自己叠加。 */ + * 超出单行省略。停用等状态色由使用方自己叠加。 + * 由使用方写在自己的 #title 插槽内容里,无法随组件 scoped 生效,故留在全局。 + * 左树面板的骨架样式(白卡 / 头部 / 搜索框 / 滚动区)随 FmsTreePanel 组件走,见该组件。 */ .fms-tree-node-name { flex: 1; min-width: 0; @@ -163,8 +62,6 @@ white-space: nowrap; } -/* ========== 五、通用部件 ========== */ - /* 白卡三件套:灰底画布上的内容面。卡间用 gap/margin 留灰缝,不用 border 画分隔线 */ .fms-card { background: var(--fms-card); @@ -172,38 +69,6 @@ box-shadow: var(--fms-shadow); } -/* 描边图标按钮:树面板动作区里的工具位,正方形、不随文字缩放。 - * 必须嵌在 .fms-tree-panel__actions 下写:它挂在组件库 Button(type="ghost")上, - * 需要 (0,2,0) 才压得过 .button / .button-ghost 的 padding / border / 背景, - * 以及 (0,3,0) 的 ghost hover —— 与 AppTopbar 里 `.fms-topbar .fms-icon-btn` 同一做法。 */ -.fms-tree-panel__actions .fms-tool-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--fms-control-height); - height: var(--fms-control-height); - padding: 0; - border: 1px solid var(--fms-border); - border-radius: var(--fms-control-radius); - background: var(--fms-card); - color: var(--fms-text-secondary); - cursor: pointer; - transition: - border-color var(--fms-duration-base) var(--fms-ease-standard), - background var(--fms-duration-base) var(--fms-ease-standard), - color var(--fms-duration-base) var(--fms-ease-standard); - - &:hover:not(:disabled) { - background: var(--fms-secondary); - color: var(--fms-text); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } -} - /* 卡片内工具条:白底 + 圆角、不投影(与内容卡同一平面,靠灰缝分隔) */ .fms-toolbar { display: flex; @@ -233,10 +98,3 @@ opacity: 0.4; } } - -/* 面板内区块标题 */ -.fms-pane-title { - font-size: var(--fms-font-size); - font-weight: 600; - color: var(--fms-text); -} diff --git a/code/fms/fms-vue/src/theme/tokens.css b/code/fms/fms-vue/src/theme/tokens.css index 8272e999..289158c4 100644 --- a/code/fms/fms-vue/src/theme/tokens.css +++ b/code/fms/fms-vue/src/theme/tokens.css @@ -122,7 +122,7 @@ --fms-drawer-body-padding: 12px 20px; --fms-drawer-footer-padding: 8px 20px; - /* 分割(splitter):命中区 / 视觉条 / hover 线宽 / 中央手柄(不随主题变化) */ + /* 分割(splitter):命中区 / 视觉条 / hover 线宽(不随主题变化) */ /* --fms-splitter-bar-size-hover 同时被 modal / drawer 缩放手柄的拖拽线引用 */ --fms-splitter-trigger-size: 10px; @@ -130,7 +130,6 @@ --fms-grid-gutter: 0px; --fms-splitter-bar-size: 3px; --fms-splitter-bar-size-hover: 4px; - --fms-splitter-handle-size: 20px; /* 分段控制器(segmented):轨道 padding / 项高 / 项内边距 / 字号 / 圆角(不随主题变化) */ --fms-segmented-track-padding: 2px; diff --git a/code/fms/fms-vue/src/views/base/othercompany/index.vue b/code/fms/fms-vue/src/views/base/othercompany/index.vue index 27be921e..a8a4f561 100644 --- a/code/fms/fms-vue/src/views/base/othercompany/index.vue +++ b/code/fms/fms-vue/src/views/base/othercompany/index.vue @@ -10,7 +10,7 @@ */ import { computed, ref, watch } from 'vue' import { useRouter } from 'vue-router' -import { Pencil, Plus, Search, Trash2 } from '@lucide/vue' +import { Pencil, Plus, Trash2 } from '@lucide/vue' import { Button, Form, @@ -23,7 +23,7 @@ import { } from '@/components/ui' import { FmsModuleListPage } from '@/components/fms-module-list' import { escapeSqlText } from '@/components/fms-module-common/queryUtils' -import FmsTree from '@/components/fms-tree/FmsTree.vue' +import FmsTreePanel from '@/components/fms-tree/FmsTreePanel.vue' import { loadDataApi, pageDataApi, saveObjectApi } from '@/services/api' import { usePermissionStore } from '@/stores/permissions' import { @@ -440,58 +440,40 @@ void loadCategories()