# 工作进度-1:报表中心模块(Grid++Report 6 集成) > 时间:2026-10-19 > 环境:后端 .NET 8 + FreeSql + MediatR(Windows),前端 Vue3 + Element Plus + Vite > 本机 Grid++Report 版本:**6.8.23.0618**(设计器 `grdesigner6.exe`) --- ## 一、目标 在系统(智慧缫丝 / F9WebAiTest)中嵌入报表系统: 1. 报表中心作为菜单页面,支持报表的**新增 / 删除 / 修改** 2. 每张报表有独立权限,管理员需在「角色管理 → 权限分配」中给角色授权后,用户才能看到 3. 报表支持 F9 的 `ReportConfig` 约定:动态查询控件 + 按 `DsName` 解析后端查询 --- ## 二、Grid++Report 6 Web 嵌入方案调研(结论) 本机安装目录 `C:\Grid++Report 6` 关键发现: | 项 | 结论 | |---|---| | 浏览器插件方案(NPAPI/ActiveX) | Chrome / Edge 已不支持,**弃用** | | `grhtml5-6.8-min.js` | HTML5 浏览器内渲染,免插件、跨浏览器 ✅ | | `grwebapp.js` + `grwebapp6.exe` | 本机 WEB 报表客户端,支持 preview/print/pdf/xls/csv/img/grd 等 | | `.grf` 文件格式 | **UTF-8 BOM + JSON 文本**(可程序化生成) | | `.grf` 版本 | 官方设计器保存为 `6.8.5.0`;`WebSamples` 里的样本是 `6.6.8.1` | | `.grf` 文件关联 | `GR.Template` → `"C:\Grid++Report 6\grdesigner6.exe" "%1"` | | 已有 URL 协议 | `grwebapp://` → `grwebapp6.exe` | 采用方案:**HTML5 网页内预览为主 + 本机客户端打印导出为辅 + f9designer 协议唤起设计器编辑**。 --- ## 三、后端实现 ### 1. 领域实体 | 文件 | 说明 | |---|---| | `Domain/Entities/SYS/Reports/Report.cs` | 报表实体(表 `sys_reports`),继承 `AggregateRoot` 自动建表 | | `Domain/Events/SYS/Reports/Report_ChangedDomainEvent.cs` | 报表变更领域事件 | `Report` 字段:`Code / Name / Category / Description / DataSql / DataParams / Config / TemplateData(byte[]) / TemplateName / TemplateSize / SortOrder / IsActive` ### 2. 权限模型(与现有 RBAC 完全契合) 现有 `Resource_Type_Enum` 已内置 `报表 = 3`,直接复用: - 新建报表 → 自动创建 `Permission`(`ResourceType=报表`, `ResourceId=Report.Id`, `Code=report:{code}:visble`) - 改编码 → 同步权限 Code;删除报表 → 级联删除权限单元 + `RolePermission` 关联 - 授权入口:角色管理 → 权限分配 → 「报表权限」tab(复用现有 `resourceType=3` 接口) - 用户可见性:`AuthService.GetUserPermissionsAsync` 原生返回所有类型权限码 → 前端按 `report:{code}:visble` 过滤 ### 3. 数据查询架构(F9 同构) ``` 报表 Config.DsName → IReportDataProvider 注册表(静态 ReportName 解析) → MediatR CQRS Query → QueryHandler(注入业务读仓储) → ReportDto_DataSource(Report_Data: 数据集名 → ReportDataDto_Data) → ToGridReportJson() 转成 Grid++ JSON 数据 ``` | 文件 | 说明 | |---|---| | `Application/Dtos/SYS/Reports/ReportDataSourceDto.cs` | `ReportDto_DataSource` + `ReportDataDto_Data`(`Field_Data` / `Parameter_Data`) | | `Application/Interfaces/Reports/IReportDataProvider.cs` | `ReportName` + `GetData(pairs, mediator)` | | `Application/CQRS/SYS/Reports/Queries/GetEmployeeDirectoryRepQuery.cs` | 职工花名册查询(注入 `IEmployeeReadRepository`) | | `Application/Services/Reports/Providers/RepDt_Sys_Users.cs` | 示例 Provider(DsName = `RepDt_Sys_Users`) | | `Application/Services/Reports/ReportService.cs` | CRUD + 权限联动 + 数据执行 + JSON 转换 | | `Application/Services/Reports/IReportService.cs` | 服务接口 | | `Application/Dtos/SYS/Reports/ReportDtos.cs` | 列表/详情/视图/增删改 DTO | | `Api/Controllers/SysReportController.cs` | REST 控制器(详见下) | ### 4. 控制器端点 | 端点 | 说明 | |---|---| | `GET api/sys/report` | 分页(管理) | | `GET api/sys/report/all` | 全部启用报表(角色授权树用) | | `GET api/sys/report/{id}` | 详情(含 DataSql / Config) | | `POST api/sys/report` | 新增(自动创建权限单元) | | `PUT api/sys/report/{id}` | 修改(同步权限 Code) | | `DELETE api/sys/report/{id}` | 删除(级联清理权限) | | `POST api/sys/report/{id}/template` | 上传 .grf(multipart,限 .grf,50MB) | | `GET api/sys/report/{id}/template/download` | 下载 .grf | | `GET api/sys/report/{id}/template[.json\|.grf]` | 模板下载(供 HTML5 / 客户端,支持 `?access_token=`) | | `GET/POST api/sys/report/{id}/data[.json]` | 报表数据(Grid++ JSON 格式) | | `GET api/sys/report/visible` | 当前用户可见报表(按权限过滤) | | `GET api/sys/report/{id}/preview` | 预览(模板 URL + 数据,包 `ApiResult`) | ### 5. 种子 `Infrastructure/Data/DbSeeder.cs`: - `SeedReportMenus()`:`/report` 目录 + `/report/center`(报表中心)+ `/report/manage`(报表管理),自动授权 admin - `SeedSampleReport()`:示例报表「职工花名册」(code `employee_directory`) - `LoadEmbeddedTemplate()`:从嵌入资源读取 `.grf` ### 6. 其他后端改动 | 文件 | 改动 | |---|---| | `Api/Program.cs` | JwtBearer `OnMessageReceived` 支持 `/api/sys/report` 路径的 query token | | `Application/DependencyInjection.cs` | 注册 `IReportService` + 自动扫描注册所有 `IReportDataProvider` | | `Infrastructure/F9WebAiTest.Infrastructure.csproj` | `.grf` 模板作为嵌入资源 | | `Application/F9WebAiTest.Application.csproj` | 新增 `System.Data.SQLite.Core`(与 FreeSql 驱动一致) | | `Infrastructure/Assets/ReportTemplates/employee_directory.grf` | 基于官方 6.8.5.0 结构派生的示例模板 | --- ## 四、前端实现 | 文件 | 说明 | |---|---| | `web/public/grlib/grhtml5-6.8-min.js` | 官方 HTML5 渲染库(246KB) | | `web/src/utils/gridreport/grwebapp.js` | 官方 WEB 客户端通信库 | | `web/src/utils/gridreport/loadGrhtml5.js` | 动态加载 HTML5 库脚本 | | `web/src/utils/gridreport/reportConfig.js` | F9 ReportConfig 解析 + 动态控件渲染映射 | | `web/src/views/report/center/index.vue` | 报表中心(左侧分类树 + 动态查询面板,右侧多 Tab 报表区) | | `web/src/views/report/manage/index.vue` | 报表管理(列表 + 打开全屏编辑器) | | `web/src/views/report/manage/editor.vue` | 全屏报表编辑器(配置 / 设计 / 源码 / 预览) | | `web/src/router/index.js` | componentMap 增加 `report/center/index`、`report/manage/index` | | `web/src/views/sys/role/index.vue` | 权限分配对话框增加「报表权限」tab(`RESOURCE_TYPE.report = 3`) | ### 报表编辑器(editor.vue)页面结构 ``` ┌─────────────┬──────────────────────────────────────────┐ │ [配置][参数] │ [设计][源码 JSON][预览视图] │ │ │ 本机设计器 | 上传.grf | 下载.grf | 保存 │ │ 分组 │ 设计视图:报表标题 + 明细字段表格 │ │ 编号 │ 源码视图:完整 .grf JSON │ │ 名称 │ 预览视图:grhtml5 实时渲染(切换即渲染) │ │ 顺序 │ │ │ 数据源DsName │ │ │ 数据SQL │ │ │ 备注 │ │ │ Config JSON │ │ └─────────────┴──────────────────────────────────────────┘ ``` --- ## 五、本机设计器直连(f9designer:// 协议) | 文件 | 说明 | |---|---| | `tools/report-designer/F9ReportDesignerHandler.ps1` | 协议处理器:下载模板 → 打开设计器 → 关闭后自动回传 | | `tools/report-designer/install-f9designer-protocol.reg` | 注册协议(**HKCU 用户级,无需管理员**,已在本机注册) | 流程: ``` 浏览器 --f9designer://open?base=..&rid=..&token=..&code=..--> Windows Shell --> .ps1:curl 下载 .grf 到 %TEMP%\F9ReportDesigner\ --> 启动 grdesigner6.exe(等待关闭) --> 关闭后 curl POST 回传模板 + 弹窗提示 ``` --- ## 六、踩坑与解决方案(重要) | # | 问题 | 根因 | 解决 | |---|---|---|---| | 1 | 点击报表报「报表加载失败 请求失败」 | `/preview` 返回裸对象,而 `request.js` 拦截器要求 `data.code === 200` | 后端 Preview 包 `ApiResult` | | 2 | 报表中心页面白屏 | `buildTree` 中根节点未初始化 `reports`,`node.reports.forEach` 抛 TypeError | 根/目录节点补 `reports: []` + 防御性兜底;目录 id 改为基于路径稳定值 | | 3 | HTML5 取模板报 400「请选择要上传的 .grf 模板文件」 | grhtml5 库约定:**URL 不含 `.json` 就用 POST**(`/\.json/.test(url) ? "GET" : "POST"`),POST 撞上上传接口 | 模板增加 `.json` 路由别名,前端 URL 以 `.json` 结尾 | | 4 | 客户端打印导出报「报表模板已经被破坏」 | grwebapp.js 同样约定(`.grf/.txt/.xml/.json` 才用 GET),且传给客户端的是相对 URL | 增加 `template.grf` / `data.json` 别名;前端传绝对 URL(`location.origin`) | | 5 | 下载的 .grf 设计器打不开 | 手写模板标 `Version: 6.6.8.1` 且结构不完整,设计器 6.8 无法解析 | 改为基于官方 6.8.5.0 模板派生,作为嵌入资源 | | 6 | 报表参数不生效 | 键名写成小写 `parameter`;且 HTML5 不解析文本内 `[#Parameter(xx)#]` 表达式 | 数据键名改 **`Parameter`**(大写);模板改用 `StaticBox` 的 `"Parameter":"关键字"` 属性绑定,并在模板顶层声明 `Parameter` 节点 | | 7 | 字段值渲染为空 | ASP.NET 默认 camelCase 序列化把 `EmployeeName` 输出成 `employeeName`,与模板字段不符 | 数据行改用 `Dictionary` 承载(字典键不受命名策略影响),反射 + PropertyInfo 缓存 | | 8 | 切到预览视图空白 | `watch(activeView)` 未触发渲染 | 切换到 preview 时自动调用 `renderPreview()` | | 9 | 协议唤起脚本不执行 | `.ps1` 为 UTF-8 **无 BOM**,PowerShell 5.1 按 GBK 读取致中文乱码、语法破坏 | 转为 **UTF-8 BOM** | | 10 | 注册表写入失败 | `HKEY_CLASSES_ROOT` 需管理员 | 改注册到 `HKEY_CURRENT_USER\Software\Classes`(免管理员) | | 11 | 第一次进编辑报表页报「请求失败」,明细字段/模板参数暂无数据 | 编辑器 `GET /sys/report/{id}/template` 返回**裸 .grf JSON**(无 ApiResult 包装),`request.js` 拦截器要求 `data.code === 200`,判定失败 reject → 前端兜底为空模板(字段/参数全空) | 拦截器增加放行:响应无 `code` 字段(非 ApiResult 包装,如模板原文/文件流)直接原样返回 | ### Grid++ 数据 JSON 格式约定(已验证) ```jsonc // 单数据集 { "recordset": [ {...}, {...} ] } // 多数据集(顶层键 = 数据集名) { "Master": [ {...} ], "Detail": [ {...} ] } // 报表参数(键名必须与模板 Parameter 节点一致,渲染器取数组第一项) { "recordset": [...], "Parameter": [ { "账期": "2026年10月", "车间": "前缫" } ] } ``` --- ## 七、新增报表的标准流程 1. 建 CQRS:`XXXRepQuery` + Handler,Handler 内注入业务**读仓储**查询,组装 `ReportDto_DataSource` - `Field_Data`:每行匿名对象的属性名 = 模板字段名 - `Parameter_Data`:账期 / 车间 / 查询时间等报表参数 2. 建 Provider:`RepDt_XXX : IReportDataProvider`,`ReportName` 常量 + 解析 `pairs` → `mediator.Send(query)`(DI 自动发现,无需注册) 3. 报表管理 → 新增报表,填 `Config`: ```json { "DsName": "RepDt_XXX", "ViewerType": 0, "Parms": [ { "Name": "q_yearmonth", "Label": "账期", "Prompt": "", "IsRequired": false, "ControlType": "AntdUI.DatePickerYearMonth", "Ds_Static": null, "Ds_Service": null, "DefaultValue": "默认值" } ] } ``` 4. 用设计器做 `.grf`(记录集字段对齐 `Field_Data`;显示查询条件的控件设 `"Parameter":"账期"`,模板顶层声明 `Parameter` 节点) 5. 上传模板 → 角色管理分配报表权限 → 打开即可 **ControlType 支持**:`AntdUI.Input` / `AntdUI.Select` / `AntdUI.SelectMultiple` / `AntdUI.DatePicker` / `AntdUI.DatePickerYearMonth` / `AntdUI.DatePickerYear`;`F9-*` 业务控件见下方「自定义控件模块」。 --- ## 七之二、Web 自定义控件模块(对应 F9 `Page_ReportQueryTool.GetParamControl`) WinForms 端按 `ControlType` 字符串 if/else 分发创建控件(`GetParamControlOfF9` / `GetParamControlOfAntdUI`), Web 端同构为**注册中心 + 统一渲染组件**:新增控件只注册一次,查询面板与编辑器下拉同时生效。 | 文件 | 说明 | 对应 WinForms | |---|---|---| | `web/src/utils/gridreport/controls/registry.js` | 注册中心:`registerControl / getControlDef / groupedControlOptions / resolveControlDef / fallbackDef` + 取值工具(`toOptions / apiOptions / registryOptions / pickRows`) | `_paramGetValudFuncs` 注册表 | | `web/src/utils/gridreport/controls/antdControls.js` | AntdUI 基础控件(一个 type 一个函数 + `ANTD_BUILDERS` 分发) | `GetParamControlOfAntdUI` | | `web/src/utils/gridreport/controls/f9Controls.js` | F9 业务控件(一个 type 一个函数 + `F9_BUILDERS` 分发) | `GetParamControlOfF9` | | `web/src/utils/gridreport/controls/index.js` | 装配入口:注册 AntdUI + F9 两组控件;`parseStaticDs / fetchServiceDs / loadParamOptions` | `GetParamControl` | | `web/src/components/report/F9ParamControl.vue` | 统一渲染:input / select / multiselect / date / yearmonth / year / picker(选择页 / 远程搜索 / 降级输入) | 控件实例 | ### 选择页(对应 F9.Common.Report.SelectPages) | 文件 | 说明 | |---|---| | `web/src/components/select/F9SelectPage.vue` | 通用选择页弹窗:过滤条件 + 关键字 + 分页表格 + 单选/多选 + 校验(对应 `F9ModalPageUserControl_Medium` + `f9AntdUITable` 的 `QueryData` / `ValidCustom`) | | `web/src/components/select/CraftZhuangKouSelect.vue` | 工艺庄口选择页(对应 `Page_CraftZhuangKou_SelectV2`:庄口编号/名称/产地/排序/备注/年度,年度过滤,数据 `GET /api/craftzhuangkou`) | | `web/src/components/select/index.js` | 选择页注册中心:`registerSelectPage / getSelectPage` | 控件绑定选择页:控件定义里写 `page: 'CraftZhuangKou'`,`F9ParamControl` 即打开该页(优先级:选择页 > 内建远程搜索 > 降级文本输入)。 业务页面直接复用:``(**落丝记录 lsjl 的庄口已改为打开选择页**)。 控件定义字段:`{ type, kind, label, group, hint, useDs, options, loader(prm), search(kw), columns, valueField, labelField, multiple }` ### kind 与 F9 的映射 | Web kind | 渲染 | F9 对应 | 取值 | |---|---|---|---| | `input` | el-input | `AntdUI.Input` | 文本 | | `select` | el-select | `AntdUI.Select` / 各 `F9-*-SELECT` | key(部门为 Id) | | `multiselect` | el-select multiple | `AntdUI.SelectMultiple` / `F9-WMS-SS-PICI-KCSTATUS` | `"a,b,c"`(对齐 `string.Join(",")`) | | `date` / `yearmonth` / `year` | el-date-picker | `AntdUI.DatePicker*` | `YYYY-MM-DD` / `YYYY-MM` / `YYYY` | | `picker` | 只读框 + 选择按钮 → 弹窗表格远程搜索 | `ShowModalPage(Page_XXX_Select)` | 记录 **Id**(与 F9 一致) | | `picker-multiple` | 同上多选 | `F9-GYZK-SELECT-MUTIPLE` | `"1,2,3"` | 选项优先级:`Ds_Static` → `Ds_Service` → 控件 `loader` → 静态 `options`;picker 无 `search` 实现时自动降级为文本输入。 ### 部门控件:Web 版 F9GlobalUIHelper WinForms 端部门下拉不是一把全量,而是**按口径取数 + ParentId 建树**(`F9GlobalUIHelper` + 各 `GetDepartment*Query`)。 Web 端同构实现:`web/src/utils/departments.js` | F9 方法 / 查询 | Web 实现 | |---|---| | `Department_GetAllCacheAsync` | `getAllDeps()`(缓存,`GET /api/sys/department`) | | `GetDepartmentByDutyTypeWithoutRightQuery(types)` | `depsByDuty(deps, types)`(只保留自身 DutyType 命中) | | `GetDepartmentOfSiyeQuery(includeSiye)` | `depsOfSiye(deps, true)`(丝业中心 + 全部子孙) | | `GetDepartmentForEmployee/Kaoqin/XinziQuery` | `depsOfLoginUser()`(本部门 + 子孙;管理员=全部)+ `removeCompany()` | | `Dep_GenerateSelectItems` | `buildDepTree()`(SortOrder → Code → Name,父不在集合内即为根) | | `Dep_Select_SelectDep(sel, depId)` | `defaultFromUser: true` → 默认选中登录用户部门 | | 控件 | 口径 | 实测结果 | |---|---|---| | `F9-DEP-QS-SELECT` | DutyType=前缫(25) | 前缫车间#26 → 前缫一~四车间#31-34 | | `F9-DEPARTMENT_SIYE` | 丝业 | 丝业生产中心#2 + 全部子孙 | | `F9-DEP-EMPLOYEE-SELECT` / `F9-DEP-XZ-SELECT` / `F9-DEPARTMENT_KAOQIN` | 职工 / 薪资 / 考勤口径(默认登录部门) | 管理员=全部,否则本部门+子孙 | | `F9-DEP-ALL-SELECT`(新增) | 全部 | 完整部门树 | 渲染:控件定义 `tree: true` → `F9ParamControl` 用 `el-tree-select`(层级展示,取值仍为部门 Id)。 落丝记录(lsjl)的车间下拉也改为 `depOptions({ duty: [DUTY.前缫] }, { flat: true })`,替换掉原来的 `name.includes('前缫')`。 ### 已内置的 F9 控件 - 庄口:`F9-GYZK-SELECT`(**打开工艺庄口选择页**)、`F9-GYZK-SELECT-MUTIPLE`(多选,取值 `"1,2,3"`) - 部门/职工:`F9-DEP-QS-SELECT`、`F9-DEPARTMENT_SIYE`、`F9-DEP-EMPLOYEE-SELECT`、`F9-DEP-XZ-SELECT`、`F9-DEPARTMENT_KAOQIN`、`F9-EMPLOYEE-SELECT`(picker)、`F9-EMPLOYEE-WORKSTATUS`、`F9-EMPLOYEE-CHANGE_TYPE_SELECT`、`F9-Employee-isXuegong` - 物资/原料:`F9-WZ-SS/FP/ZJP/WJ-SELECT`(picker)、`F9-YLZK-SELECT`(picker)、`F9-WMS-YL-CD/JIBIE/CANPINZHONG`(注册表) - 生产/质量:`F9-YEXIAN-SOURCE-SELECT`、`F9-GUIGE-SELECT`、`F9-PROD-QS-LSCD-YICHANG`、`F9-PROD-QS-ZHJJ-COUNT`、`F9-QC-YJSS-YLXY/ZHY-SELECT`(降级输入) - 仓储:`F9-WMS-CRKTYPE`、`F9-WMS-SS-PICI-KCSTATUS`(多选)、`F9-WMS-SS-PICI-SSTYPE`、`F9-WMS-YL-RK/CK-TYPE-SELECT` Web 端无对应枚举/接口的走注册表或 `Ds_Static`,并在编辑器/查询面板显示降级提示。 ### 扩展新控件(两步,无需改页面) ```js import { registerControl, KIND } from '@/utils/gridreport/controls'; registerControl({ type: 'F9-MY-SELECT', kind: KIND.SELECT, group: 'F9 自定义', label: '我的选择', loader: async () => [{ key: '1', value: '选项一' }], }); // 弹窗选择类:再给 search / columns / valueField / labelField ``` 报表管理 → 参数 → 「控件类型」下拉自动按 group 分组列出所有已注册控件。 --- ## 八、当前状态 | 项 | 状态 | |---|---| | 后端编译 | 0 错误 0 警告 ✅ | | 后端服务 | 运行中 `http://localhost:3000` ✅ | | 前端服务 | 运行中 `http://localhost:5173` ✅ | | 报表中心页面 | 正常(分类树 + 查询面板 + 多 Tab 预览)✅ | | 报表管理 + 全屏编辑器 | 正常(配置/设计/源码/预览)✅ | | 职工花名册示例 | 942 条职工数据 + 报表参数正常渲染 ✅ | | 本机设计器协议 | 已注册,下载→编辑→自动回传全链路验证通过 ✅ | | 角色授权(报表权限 tab) | 已实现 ✅ | ### 启动方式 ```powershell # 后端 cd backend/F9WebAiTest.Api dotnet run # 前端 cd web npm run dev ``` 默认账号:`admin / 123456` ### 备注 - 日志目录 `.run-logs/`(backend.log、frontend.log、start-web.bat) - 协议脚本日志:`%TEMP%\F9ReportDesigner\handler.log`