From c8dad8d4a66aa6fe9af6463f2151529fe6972561 Mon Sep 17 00:00:00 2001 From: ZhangAo Date: Tue, 22 Sep 2026 17:29:39 +0800 Subject: [PATCH] 20260922172938 --- code/fms/CODEBUDDY.md | 2 +- code/fms/FMS删除策略重构设计.md | 17 + code/fms/FMS工作流与审批设计.md | 1178 +++++++++++++++++ code/fms/FMS新系统核心表结构设计.md | 9 +- .../fmsapi/service/DataDeleteService.java | 107 ++ .../fmsapi/service/DataSaveService.java | 35 + .../fmsapi/service/SqlPermissionService.java | 35 +- .../tools/migration/otherdata-probe-out.txt | 838 +----------- .../src/components/fms-tree/FmsTreePanel.vue | 2 +- .../fms-vue/src/components/ui/demo/tag.vue | 1 + .../{DropdownItem.vue => dropdown-item.vue} | 0 .../src/components/ui/dropdown/dropdown.vue | 2 +- .../ui/form/{FormGroup.vue => form-group.vue} | 0 .../fms-vue/src/components/ui/form/index.scss | 10 + code/fms/fms-vue/src/components/ui/index.js | 2 +- .../fms-vue/src/components/ui/tag/index.scss | 6 + .../fms/fms-vue/src/components/ui/tag/tag.vue | 5 +- code/fms/fms-vue/src/router/routes.js | 8 +- code/fms/fms-vue/src/styles/common.scss | 35 +- code/fms/fms-vue/src/utils/powerCodes.js | 15 + .../src/views/base/othercompany/detail.vue | 2 +- .../src/views/base/othercompany/index.vue | 10 +- .../views/module/menu-management/index.vue | 100 +- .../src/views/module/menu-management/utils.js | 5 +- .../module-management/ModuleBasicPanel.vue | 29 + .../module-management/ModuleI18nPanel.vue | 15 +- .../views/module/module-management/index.vue | 244 +++- .../views/module/module-management/utils.js | 71 +- .../views/system/user/UserDataPowerPanel.vue | 185 +++ .../views/system/user/UserFieldPowerPanel.vue | 244 ++++ .../views/system/user/UserPowerCopyModal.vue | 317 +++++ .../src/views/system/user/UserPowerDrawer.vue | 338 ++++- .../src/views/system/user/UserPowerPanel.vue | 350 ++--- .../fms-vue/src/views/system/user/index.vue | 20 +- code/fms/fms-vue/tests/menus.spec.js | 2 +- .../tests/unit/othercompany-utils.spec.js | 2 +- .../tests/views/menu-module-binding.spec.js | 16 +- .../views/module-unsaved-after-save.spec.js | 135 ++ .../fms-vue/tests/views/othercompany.spec.js | 18 +- .../tests/views/user-power-drawer.spec.js | 4 +- .../tests/views/user-power-panel.spec.js | 141 +- code/fms/sql/fms_core.sql | 4 +- code/fms/sql/fms_module_scope_field.sql | 24 + code/fms/sql/fms_module_updated_at.sql | 19 + code/fms/开发规范.md | 8 +- code/test/vue-vben-admin | 2 +- 46 files changed, 3366 insertions(+), 1246 deletions(-) create mode 100644 code/fms/FMS工作流与审批设计.md rename code/fms/fms-vue/src/components/ui/dropdown/{DropdownItem.vue => dropdown-item.vue} (100%) rename code/fms/fms-vue/src/components/ui/form/{FormGroup.vue => form-group.vue} (100%) create mode 100644 code/fms/fms-vue/src/views/system/user/UserDataPowerPanel.vue create mode 100644 code/fms/fms-vue/src/views/system/user/UserFieldPowerPanel.vue create mode 100644 code/fms/fms-vue/src/views/system/user/UserPowerCopyModal.vue create mode 100644 code/fms/sql/fms_module_scope_field.sql create mode 100644 code/fms/sql/fms_module_updated_at.sql diff --git a/code/fms/CODEBUDDY.md b/code/fms/CODEBUDDY.md index 94f078ed..b1855734 100644 --- a/code/fms/CODEBUDDY.md +++ b/code/fms/CODEBUDDY.md @@ -182,4 +182,4 @@ i18n:`module/` 下的管理界面文案固定中文、不接入业务多语言 后端测试在 `fms-api/src/test/java/...`,用 `@SpringBootTest` / 纯 JUnit 类,`mvnw test` 运行。 -改动后的验收(`开发规范.md`「变更验收」):先全局搜索确认无旧路径/旧导出/旧方法名残留;至少跑受影响模块的测试,涉及公共 store / 布局缓存 / 跨页面工具时补相关页面与状态测试;若被改动前已存在的缺失文件、旧测试或环境问题阻断,在结果中明确记录,不要为通过检查去改无关代码。 +改动后的验收(`开发规范.md`「变更验收」):开发阶段以功能可用为完成标准,**不运行测试、不跑构建检查**,测试由用户统一安排(等用户明确要求再一次性执行);但先全局搜索确认无旧路径/旧导出/旧方法名残留,被改动的类名、导出名、DOM 结构所波及的既有测试文件要同步更新到与新实现一致;若被改动前已存在的缺失文件、旧测试或环境问题阻断,在结果中明确记录,不要为通过检查去改无关代码。 diff --git a/code/fms/FMS删除策略重构设计.md b/code/fms/FMS删除策略重构设计.md index 85c152ad..fe731a40 100644 --- a/code/fms/FMS删除策略重构设计.md +++ b/code/fms/FMS删除策略重构设计.md @@ -273,6 +273,23 @@ where b_id in (:ids) 这样列表删除和表单删行不会形成两套规则。 +依赖行删除由调用方在请求里声明,服务端不按表名猜: + +```json +{ + "table": "s_power", + "key_field": "b_object_type,b_object_id", + "deletes": [{ "b_object_type": "module", "b_object_id": "sea" }], + "dependent_deletes": [ + { "table": "s_user_power", "key_field": "b_power_id", "source_field": "b_id" } + ] +} +``` + +`dependent_deletes` 的每一项是 `{table, key_field, source_field}`:对本表本次要删的每一行,用 `source_field` 的取值去删 `table` 中 `key_field` 等于该值的行;取值由后端按本行的列值条件查出(与物理删除的 `WHERE` 同口径),调用方不需要先把键值查出来再拼删除行。依赖行先于父行删除,与 `cascade` 同一执行顺序。 + +它解决“非模块表之间的引用清理”(例如权限点 `s_power` → 用户授权行 `s_user_power`,设计文档 §14.6):这类关系不登记在 `s_relation`(那是有模块归属的业务关系),也不能由服务端按表名反查——表名只出现在请求里,服务端不认识具体业务表。 + ### 6.3 文件和其他无模块删除 文件删除继续由 `/file/delete` 负责:按文件主键定位记录,先删除本地文件或 OSS 对象,再以 `target=direct`、`table=bf_files`、`key_field=subid` 删除数据库行。即使 `bf_files.mx_moduleid` 保存了业务模块编码,它也只是文件元数据,不是本次删除策略的模块目标;文件删除不需要 `moduleCode`,也不触发 `s_relation` 或 `s_delete_rule`。 diff --git a/code/fms/FMS工作流与审批设计.md b/code/fms/FMS工作流与审批设计.md new file mode 100644 index 00000000..f14e7c1c --- /dev/null +++ b/code/fms/FMS工作流与审批设计.md @@ -0,0 +1,1178 @@ +# FMS 工作流与审批设计 V1 + +## 1. 文档定位 + +本文是《FMS 新系统核心表结构设计》的工作流补充设计,重点解决业务单据的送审、审批、退回、驳回、撤回、转交、委托和审批追溯问题。 + +本文不把审批当成某个页面的附属配置,也不把审批记录直接塞进业务表。工作流被设计成独立的系统能力: + +```text +业务模块 + └── 流程绑定 + └── 已发布流程版本 + ├── 节点与连线 + ├── 审批人解析规则 + └── 条件与表单策略 + +业务单据提交 + └── 流程实例 + └── 节点运行 + └── 待办任务 + └── 审批动作 / 审计 / 通知 +``` + +本文遵循以下已有设计: + +- 表名使用小写 `snake_case`,系统表使用 `s_` 前缀; +- 关系型表保存稳定、需要查询和关联的对象;变化频繁的配置使用 `nvarchar(max)` JSON; +- 不依赖数据库外键、触发器和 `CHECK` 约束表达业务规则; +- 权限由 `s_power`、`s_user_power`、`s_user_field_power`、`s_user_data_power` 和业务层权限引擎共同完成; +- 通用审计使用 `s_log_audit` 和 `s_log_audit_field`,工作流动作历史单独使用 `s_wf_action`; +- 业务表主键可以是 bigint 雪花 ID,工作流运行对象使用字符串保存业务主键,避免依赖业务表物理主键类型。 + +相关文档: + +- [FMS 新系统核心表结构设计](./FMS新系统核心表结构设计.md) +- [FMS 删除策略重构设计](./FMS删除策略重构设计.md) + +## 2. 设计目标与边界 + +### 2.1 设计目标 + +工作流系统需要满足: + +1. 一个模块可以绑定多个流程,按照业务事件、组织和业务条件选择流程; +2. 流程定义可编辑、校验、模拟、发布和停用; +3. 已发布版本不可修改,运行中的流程固定使用提交时的版本; +4. 支持串行审批、并行审批、会签、任一人通过、全部通过和 N 人通过; +5. 支持指定用户、发起人直属领导、部门负责人、岗位、业务角色和业务字段用户; +6. 支持条件分支,条件由结构化规则表达,不允许前端直接拼接 SQL; +7. 审批权限同时受动作权限、当前待办任务和数据范围限制; +8. 所有审批动作可追溯,能够还原当时的审批版本、审批人规则和实际审批人; +9. 流程状态、业务状态、通知状态相互解耦,但关键状态变更必须在同一事务内完成; +10. 支持高并发下的幂等、乐观锁、任务抢占和重复点击保护。 + +### 2.2 设计边界 + +工作流负责: + +- 流程路由; +- 待办任务; +- 审批人解析; +- 审批动作; +- 流程状态和业务状态投影; +- 流程审计和通知事件。 + +工作流不负责: + +- 替代业务表保存业务数据; +- 替代通用权限系统; +- 替代 HR 系统保存完整组织主数据; +- 通过任意 SQL 直接修改业务表; +- 用通用流程配置代替业务领域中的复杂计算和结算逻辑。 + +## 3. 现有简单审批方案的问题 + +旧方案以 `sh_set` 保存“模块、级别、用户、条件”,以 `sh_record` 保存某张单据的审核过程。当前配置页面是一个基于表格的 CRUD 页面,核心字段为 `b_level`、`b_user_id`、`b_condition` 和 `b_bz`。 + +这种模型存在以下问题: + +| 现状 | 问题 | +| --- | --- | +| `b_level` 表示审批级别 | 只能表达固定的线性流程,不能表达分支、并行、回退和循环处理 | +| `b_user_id` 保存审批人 | 不能表达部门负责人、岗位、角色、发起人领导等动态审批人 | +| 一个级别对应一行用户 | 无法可靠表达会签、或签、N 人通过和候选人抢占 | +| `b_condition` 是字符串条件 | 规则不可校验、不可视化、难以版本化,并存在 SQL 拼接风险 | +| 多个用户使用逗号分隔 | 无法建立索引、统计待办、实现转交和委托 | +| 通过最大 `b_id` 获取最新记录 | 依赖 ID 排序,面对并发、重试和回退时语义不稳定 | +| 直接更新业务表 `b_state` | 流程实例、业务状态和审批历史互相耦合 | +| 配置直接保存到当前表 | 没有草稿、发布、版本、回滚和生效时间 | +| 前端直接调用通用保存接口 | 业务状态、权限、任务状态和审计无法由一个后端事务统一控制 | +| 模块使用旧的 `b_SH`、`b_cate_id` 等字段 | 与新版 `s_module` 模型不一致,不能作为新系统的基础配置页 | + +因此不建议在 `sh_set` 上继续增加“审批人 2、审批人 3、条件 2、审核方式”等字段。应将 `sh_set` 和 `sh_record` 作为迁移来源,建立新的工作流配置和运行模型。 + +## 4. 核心概念 + +### 4.1 流程定义(Definition) + +流程的稳定身份,例如: + +```text +expense_approval +contract_approval +customer_activation +release_order_approval +``` + +流程定义本身不直接表示某个版本,也不直接表示某张业务单据。 + +### 4.2 流程版本(Version) + +流程定义的一个可执行版本。版本状态建议为: + +```text +draft 草稿,可编辑 +published 已发布,可被新实例使用 +retired 已停用,不再接受新实例 +``` + +已发布版本不能原地编辑。修改流程时复制为新版本,重新校验并发布。已经运行的实例继续绑定原版本。 + +### 4.3 流程绑定(Binding) + +把模块、业务事件、组织范围和流程版本关联起来。例如: + +```text +模块:cw_expense +事件:submit +条件:金额大于 10000 且公司为 company_001 +流程:expense_approval v3 +``` + +一个模块可以有多个绑定,但同一业务上下文只能选择一个最终流程。绑定的优先级、有效期和条件由后端校验,不能出现多个无法确定优先级的生效流程。 + +### 4.4 节点与连线(Node / Edge) + +节点表达处理步骤,连线表达节点之间的路由和条件。 + +建议第一阶段支持: + +```text +start 开始 +user_task 人工审批 +exclusive_gateway 条件分支 +parallel_gateway 并行分支 +service_task 后端服务动作 +end 结束 +``` + +`b_level` 不再作为流程结构。流程结构由节点编码和连线表达。 + +### 4.5 流程实例(Instance) + +某张业务单据某次提交产生的一次运行流程。一个业务对象可以有多次历史实例,但同一个业务事件通常只能有一个活动实例。 + +### 4.6 节点运行(Node Run) + +某个节点在某次实例中的一次进入和处理记录。节点可能因退回、循环或重试被多次运行,因此不能只在实例上保存一个当前级别字段。 + +### 4.7 待办任务(Task) + +某次节点运行分配给某个实际用户的工作项。每一个实际审批人使用独立记录,不使用逗号分隔的用户编码。 + +### 4.8 审批动作(Action) + +用户或系统对流程执行的不可变事件,例如: + +```text +submit 送审 +approve 审批通过 +reject 驳回并结束 +return 退回修改 +withdraw 发起人撤回 +cancel 管理员或系统取消 +claim 抢占待办 +transfer 转交 +delegate 委托处理 +reassign 管理员转派 +``` + +数据库保存稳定编码,页面文案通过多语言资源显示。 + +## 5. 总体架构 + +```text +┌────────────────────────────────────────────────────────┐ +│ 流程设计与发布 │ +│ definition → version → node / edge → actor rule │ +└───────────────────────┬────────────────────────────────┘ + │ 发布版本 + ▼ +┌────────────────────────────────────────────────────────┐ +│ 流程绑定选择 │ +│ module + event + organization + condition → version │ +└───────────────────────┬────────────────────────────────┘ + │ 业务提交 + ▼ +┌────────────────────────────────────────────────────────┐ +│ 流程运行引擎 │ +│ instance → node_run → task → action │ +│ │ │ │ +│ ├── business status ├── audit log │ +│ └── notification outbox └── timeline │ +└────────────────────────────────────────────────────────┘ +``` + +运行引擎必须在后端执行,前端只能提交意图: + +```text +POST /workflow/instances/{id}/actions +{ "action": "approve", "comment": "同意", "idempotencyKey": "..." } +``` + +前端不能直接修改 `s_wf_instance`、`s_wf_task` 或业务表状态。 + +## 6. 配置模型 + +### 6.1 流程定义 + +```sql +create table dbo.s_wf_definition ( + b_id varchar(50) not null primary key, -- 稳定流程编码 + b_name nvarchar(200) not null, -- 流程名称 + b_i18n varchar(150) null, -- 多语言资源键 + b_canuse tinyint not null default 1, + b_bz nvarchar(2000) null, + b_created_by varchar(50) null, + b_created_at datetime2 null, + b_updated_by varchar(50) null, + b_updated_at datetime2 null +); + +create index ix_s_wf_definition_canuse + on dbo.s_wf_definition (b_canuse, b_id); +``` + +`b_id` 创建后原则上不可修改。流程定义是逻辑身份,具体执行内容放在 `s_wf_version`。 + +### 6.2 流程版本 + +```sql +create table dbo.s_wf_version ( + b_id varchar(50) not null primary key, -- 版本 ID + b_definition_id varchar(50) not null, -- 流程定义编码 + b_version_no int not null, -- 版本序号 + b_status varchar(20) not null default 'draft', -- draft/published/retired + b_schema_version int not null default 1, -- 配置结构版本 + b_canvas_json nvarchar(max) null, -- 设计器布局,不参与执行 + b_checksum varchar(128) null, -- 发布时计算的配置摘要 + b_published_by varchar(50) null, + b_published_at datetime2 null, + b_bz nvarchar(2000) null, + b_created_by varchar(50) null, + b_created_at datetime2 null, + b_updated_by varchar(50) null, + b_updated_at datetime2 null +); + +create unique index ux_s_wf_version_no + on dbo.s_wf_version (b_definition_id, b_version_no); + +create index ix_s_wf_version_status + on dbo.s_wf_version (b_definition_id, b_status, b_version_no); +``` + +发布时必须完成: + +1. 校验开始节点、结束节点和连线完整性; +2. 校验所有人工节点至少有一个有效审批人规则; +3. 校验条件字段存在于对应模块的 `s_field`; +4. 校验不存在不可达节点、死循环和无出口节点; +5. 计算 `b_checksum`,发布后禁止修改节点和连线; +6. 写入发布审计和版本变更日志。 + +### 6.3 流程绑定 + +```sql +create table dbo.s_wf_binding ( + b_id varchar(50) not null primary key, -- 绑定 ID + b_module_id varchar(50) not null, -- data / virtual 模块 + b_event varchar(30) not null, -- submit / resubmit / change + b_version_id varchar(50) not null, -- 已发布流程版本 + b_match_json nvarchar(max) null, -- 业务匹配条件 AST + b_org_scope_json nvarchar(max) null, -- 公司、组织范围 + b_status_field varchar(50) null, -- 业务状态字段,必须来自 s_field + b_status_map_json nvarchar(max) null, -- 流程状态到业务状态的映射 + b_priority int not null default 0, + b_effective_from datetime2 null, + b_effective_to datetime2 null, + b_canuse tinyint not null default 1, + b_bz nvarchar(2000) null, + b_created_by varchar(50) null, + b_created_at datetime2 null, + b_updated_by varchar(50) null, + b_updated_at datetime2 null +); + +create index ix_s_wf_binding_select + on dbo.s_wf_binding ( + b_module_id, + b_event, + b_canuse, + b_priority, + b_effective_from, + b_effective_to + ); +``` + +`b_status_field` 用于兼容不同业务表已有的 `b_state`、`b_status` 等字段,但字段必须在模块元数据中登记。新建可审批业务表时,优先统一使用 `b_status`。 + +### 6.4 流程节点 + +```sql +create table dbo.s_wf_node ( + b_id varchar(50) not null primary key, -- 节点 ID + b_version_id varchar(50) not null, + b_node_key varchar(80) not null, -- 版本内稳定编码 + b_name nvarchar(200) not null, + b_i18n varchar(150) null, + b_node_type varchar(30) not null, -- start/user_task/... + b_approval_mode varchar(30) null, -- single/any/all/n_of_m/percentage + b_reject_mode varchar(30) null, -- terminate/return_initiator/return_previous + b_due_hours decimal(10,2) null, + b_config_json nvarchar(max) null, -- 节点扩展配置 + b_xh int not null default 0, + b_canuse tinyint not null default 1 +); + +create unique index ux_s_wf_node_key + on dbo.s_wf_node (b_version_id, b_node_key); + +create index ix_s_wf_node_type + on dbo.s_wf_node (b_version_id, b_node_type, b_xh, b_node_key); +``` + +同一版本内 `b_node_key` 必须稳定。设计器可以显示名称,但节点关联和历史记录使用 `b_node_key`。 + +节点 `b_config_json` 可以保存以下低频配置: + +```json +{ + "schemaVersion": 1, + "allowInitiator": false, + "excludePreviousActors": true, + "allowClaim": false, + "commentRequired": true, + "requiredCount": 2, + "formPolicy": { + "readonly": ["b_amount"], + "required": ["b_reason"] + } +} +``` + +`requiredCount` 只对 `n_of_m` 生效,不能用界面约定替代后端校验。 + +### 6.5 节点连线 + +```sql +create table dbo.s_wf_edge ( + b_id varchar(50) not null primary key, + b_version_id varchar(50) not null, + b_from_node_key varchar(80) not null, + b_to_node_key varchar(80) not null, + b_condition_json nvarchar(max) null, -- 连线条件 AST + b_priority int not null default 0, + b_canuse tinyint not null default 1, + b_bz nvarchar(1000) null +); + +create index ix_s_wf_edge_from + on dbo.s_wf_edge (b_version_id, b_from_node_key, b_canuse, b_priority, b_to_node_key); + +create index ix_s_wf_edge_to + on dbo.s_wf_edge (b_version_id, b_to_node_key, b_canuse, b_priority, b_from_node_key); +``` + +同一个来源节点的条件连线按 `b_priority` 从小到大判断。发布校验要求条件分支具有明确的默认出口,避免所有条件不满足时流程卡死。 + +### 6.6 审批人规则 + +```sql +create table dbo.s_wf_actor_rule ( + b_id varchar(50) not null primary key, + b_version_id varchar(50) not null, + b_node_key varchar(80) not null, + b_actor_type varchar(30) not null, -- user/initiator/manager/position/role/field_user/custom + b_actor_ref varchar(150) null, -- 用户、岗位、角色或字段编码 + b_resolve_json nvarchar(max) null, -- 解析参数 + b_condition_json nvarchar(max) null, -- 规则适用条件 AST + b_fallback_type varchar(30) null, -- none/admin/initiator/custom + b_fallback_ref varchar(150) null, + b_priority int not null default 0, + b_canuse tinyint not null default 1, + b_bz nvarchar(1000) null +); + +create index ix_s_wf_actor_rule_node + on dbo.s_wf_actor_rule (b_version_id, b_node_key, b_canuse, b_priority, b_actor_type); +``` + +建议支持以下 `b_actor_type`: + +| 类型 | 含义 | +| --- | --- | +| `user` | 指定用户 | +| `initiator` | 流程发起人 | +| `initiator_manager` | 发起人直属领导 | +| `dept_manager` | 发起人或业务归属部门负责人 | +| `position` | 岗位或职位 | +| `org_role` | 组织业务角色,不等同于功能权限角色 | +| `field_user` | 业务单据某字段对应的用户,例如销售员、负责人 | +| `custom` | 后端注册的审批人解析器 | + +解析规则执行后,必须把规则快照和实际用户快照写入运行表。组织架构后续变化不能修改已经产生的历史审批责任。 + +## 7. 条件表达式 + +### 7.1 结构化 AST + +业务条件、绑定条件和连线条件统一使用结构化 JSON。示例: + +```json +{ + "all": [ + { "field": "b_amount", "operator": "gt", "value": 10000 }, + { "field": "b_company_id", "operator": "eq", "value": "company_001" } + ] +} +``` + +支持的基础逻辑: + +```text +all AND +any OR +not NOT +``` + +支持的基础操作符: + +```text +eq / ne / gt / ge / lt / le +in / not_in +like / is_null / is_not_null +between +``` + +### 7.2 条件安全边界 + +- `field` 必须来自模块 `s_field`,不能任意填写数据库列名; +- 字段类型、操作符和值类型由后端校验; +- 值可以引用流程上下文,例如 `{ "context": "initiator.deptId" }`; +- SQL 由后端编译为参数化查询; +- 普通管理员不能输入任意 SQL; +- 复杂业务条件使用后端注册的 `custom resolver`; +- 解析结果、命中的条件和选中的连线需要写入流程上下文或动作载荷,方便审计。 + +系统的 SQL 扩展能力仍然保留,但工作流条件不能直接采用前端字符串拼接 SQL。 + +## 8. 运行表结构 + +### 8.1 流程实例 + +```sql +create table dbo.s_wf_instance ( + b_id uniqueidentifier not null primary key, -- 应用层生成 UUIDv7 + b_definition_id varchar(50) not null, + b_version_id varchar(50) not null, + b_binding_id varchar(50) not null, + b_module_id varchar(50) not null, + b_event varchar(30) not null, + b_business_id varchar(50) not null, -- 业务主键统一按字符串保存 + b_business_no nvarchar(200) null, + b_org_id varchar(50) null, + b_initiator_id varchar(50) not null, + b_initiator_dept_id varchar(50) null, + b_status varchar(30) not null, -- running/approved/rejected/returned/... + b_context_json nvarchar(max) null, -- 运行变量 + b_snapshot_json nvarchar(max) null, -- 提交时业务快照 + b_current_summary_json nvarchar(max) null, -- 列表展示用的当前节点摘要 + b_row_version bigint not null default 0, + b_idempotency_key varchar(100) null, + b_started_at datetime2 not null, + b_completed_at datetime2 null, + b_created_by varchar(50) null, + b_created_at datetime2 null, + b_updated_by varchar(50) null, + b_updated_at datetime2 null +); + +create index ix_s_wf_instance_business + on dbo.s_wf_instance (b_module_id, b_business_id, b_event, b_status, b_id); + +create index ix_s_wf_instance_initiator + on dbo.s_wf_instance (b_initiator_id, b_status, b_started_at, b_id); + +create index ix_s_wf_instance_version + on dbo.s_wf_instance (b_version_id, b_started_at, b_id); +``` + +流程实例状态建议使用稳定编码: + +```text +running 运行中 +approved 已通过 +rejected 已驳回并结束 +returned 已退回修改 +withdrawn 已撤回 +cancelled 已取消 +expired 已过期 +error 异常待处理 +``` + +“驳回”和“退回”必须区分:驳回通常结束本次流程,退回通常允许修改后重新提交。 + +### 8.2 节点运行 + +```sql +create table dbo.s_wf_node_run ( + b_id uniqueidentifier not null primary key, + b_instance_id uniqueidentifier not null, + b_node_key varchar(80) not null, + b_run_no int not null default 1, + b_status varchar(30) not null, -- pending/running/completed/skipped + b_required_count int null, + b_completed_count int not null default 0, + b_rejected_count int not null default 0, + b_entered_at datetime2 null, + b_exited_at datetime2 null, + b_actor_snapshot_json nvarchar(max) null, + b_created_at datetime2 null +); + +create index ix_s_wf_node_run_instance + on dbo.s_wf_node_run (b_instance_id, b_status, b_node_key, b_run_no, b_id); +``` + +`b_run_no` 用于支持退回后重新进入同一节点、循环节点和重新提交。 + +### 8.3 待办任务 + +一名实际审批人一行任务。会签和或签通过同一个 `b_node_run_id` 进行聚合判断。 + +```sql +create table dbo.s_wf_task ( + b_id uniqueidentifier not null primary key, + b_instance_id uniqueidentifier not null, + b_node_run_id uniqueidentifier not null, + b_node_key varchar(80) not null, + b_actor_rule_id varchar(50) null, + b_assignee_type varchar(30) not null, -- user/position/role/代理等 + b_assignee_id varchar(50) not null, -- 实际用户编码 + b_status varchar(30) not null, -- pending/claimed/approved/... + b_due_at datetime2 null, + b_claimed_by varchar(50) null, + b_claimed_at datetime2 null, + b_completed_by varchar(50) null, + b_completed_at datetime2 null, + b_rule_snapshot_json nvarchar(max) null, + b_created_at datetime2 null, + b_updated_at datetime2 null +); + +create index ix_s_wf_task_assignee + on dbo.s_wf_task (b_assignee_id, b_status, b_due_at, b_id); + +create index ix_s_wf_task_instance + on dbo.s_wf_task (b_instance_id, b_status, b_node_run_id, b_id); + +create index ix_s_wf_task_due + on dbo.s_wf_task (b_status, b_due_at, b_id); +``` + +如果组织角色解析出大量候选人,仍然要在节点激活时形成可追溯的候选任务,不能只在页面实时计算“当前谁可以审批”。 + +### 8.4 审批动作 + +```sql +create table dbo.s_wf_action ( + b_id uniqueidentifier not null primary key, + b_instance_id uniqueidentifier not null, + b_node_run_id uniqueidentifier null, + b_task_id uniqueidentifier null, + b_action varchar(30) not null, + b_operator_id varchar(50) null, + b_from_status varchar(30) null, + b_to_status varchar(30) null, + b_comment nvarchar(2000) null, + b_payload_json nvarchar(max) null, + b_request_id varchar(64) null, + b_trace_id varchar(64) null, + b_occurdatetime datetime2 not null +); + +create index ix_s_wf_action_instance + on dbo.s_wf_action (b_instance_id, b_occurdatetime, b_id); + +create index ix_s_wf_action_operator + on dbo.s_wf_action (b_operator_id, b_occurdatetime, b_id); +``` + +`s_wf_action` 只追加,不更新历史动作。撤回、转交和管理员转派也必须形成动作记录。 + +### 8.5 委托和通知消息 + +```sql +create table dbo.s_wf_delegation ( + b_id varchar(50) not null primary key, + b_from_user_id varchar(50) not null, + b_to_user_id varchar(50) not null, + b_scope_json nvarchar(max) null, -- 模块、动作、组织范围 + b_start_at datetime2 not null, + b_end_at datetime2 not null, + b_reason nvarchar(500) null, + b_canuse tinyint not null default 1, + b_created_by varchar(50) null, + b_created_at datetime2 null, + b_updated_by varchar(50) null, + b_updated_at datetime2 null +); + +create table dbo.s_wf_outbox ( + b_id uniqueidentifier not null primary key, + b_event_code varchar(80) not null, + b_aggregate_type varchar(30) not null, -- instance/task + b_aggregate_id varchar(50) not null, + b_payload_json nvarchar(max) not null, + b_status varchar(20) not null default 'pending', -- pending/sent/failed + b_retry_count int not null default 0, + b_next_retry_at datetime2 null, + b_last_error nvarchar(2000) null, + b_occurdatetime datetime2 not null +); + +create index ix_s_wf_outbox_dispatch + on dbo.s_wf_outbox (b_status, b_next_retry_at, b_occurdatetime, b_id); +``` + +通知、站内信、邮件和企业微信等外部动作通过 outbox 异步发送,不阻塞审批事务。关键的实例、任务和业务状态仍然在同一数据库事务中提交。 + +## 9. 审批方式与路由语义 + +### 9.1 单人审批 + +节点解析出一个或多个候选人,但只有一个有效任务。管理员可以指定是否允许候选人抢占。 + +### 9.2 任一人通过 + +为所有候选人创建任务。任意一个人通过后,节点完成,其余待办自动变为 `cancelled`,并写入系统动作。 + +### 9.3 全部通过 + +所有有效任务都通过后节点完成。任一人驳回时,根据节点 `b_reject_mode` 结束或退回。 + +### 9.4 N 人通过或按比例通过 + +节点运行记录保存 `required_count`、`completed_count` 和 `rejected_count`。计数必须由后端在锁定节点运行记录后完成,不能由前端计算。 + +### 9.5 条件分支 + +条件分支通过 `s_wf_edge.b_condition_json` 表达。路由时记录: + +```text +命中的边 ID +条件上下文 +条件计算结果 +计算时间 +``` + +这样在业务条件后来变化时,历史流程仍然可以解释为什么走了某条路径。 + +## 10. 权限和安全模型 + +### 10.1 三层校验 + +一次审批动作必须同时满足: + +```text +1. 动作权限:用户拥有 action.{module}.{action} +2. 任务权限:当前存在分配给该用户的有效任务 +3. 数据权限:用户对该业务记录具有相应操作范围 +``` + +仅拥有 `action.xxx.approve` 不代表可以审批所有单据;仅出现在待办中也不代表可以绕过模块动作权限。 + +### 10.2 建议动作权限 + +```text +action.{module}.submit +action.{module}.approve +action.{module}.reject +action.{module}.return +action.{module}.withdraw +action.{module}.transfer +action.{module}.delegate +action.{module}.reassign +action.{module}.workflow_admin +``` + +已有系统使用 `audit` 的模块可以暂时做兼容映射,但新模块建议统一使用 `approve`。 + +### 10.3 职责分离 + +节点策略应支持: + +```text +allowInitiator = false +excludePreviousActors = true +excludeCreator = true +``` + +这些规则必须由后端执行。前端只负责显示不可审批原因。 + +### 10.4 数据范围 + +继续复用 `s_user_data_power`: + +- `read` 控制用户能否查看业务记录; +- `update` 控制业务修改; +- `action.{module}.approve` 等精确动作控制业务动作范围; +- 当前任务校验负责判断“是不是这一票的审批人”。 + +数据范围条件、流程条件和审批人规则不能互相替代。 + +## 11. 业务状态投影 + +工作流实例是流程事实来源,业务表的状态字段是查询和业务模块使用的投影。 + +推荐状态映射: + +```json +{ + "running": "pending_approval", + "approved": "approved", + "rejected": "rejected", + "returned": "draft", + "withdrawn": "draft", + "cancelled": "cancelled" +} +``` + +业务表中的状态字段不能由普通 CRUD 保存接口随意修改。工作流相关状态变更必须通过流程服务完成,并且在同一事务中: + +```text +锁定业务记录 +→ 校验当前状态和版本 +→ 校验用户权限与任务 +→ 写 s_wf_action +→ 更新 s_wf_task / s_wf_node_run +→ 创建下一节点任务或结束实例 +→ 更新业务表状态投影 +→ 写 s_log_audit / s_log_audit_field +→ 写 s_wf_outbox +→ 提交 +``` + +如果业务表本身还有“结算、放单、作废”等业务状态,工作流状态不能覆盖这些业务生命周期状态。必要时使用独立的 `b_workflow_status` 或在模块配置中声明状态映射。 + +## 12. 并发、幂等和异常处理 + +### 12.1 幂等 + +提交和审批接口都必须支持幂等键: + +```text +request_id + operator_id + action +``` + +同一请求重复到达时返回第一次处理结果,不重复创建实例、任务和动作。 + +### 12.2 乐观锁 + +`s_wf_instance.b_row_version` 用于防止多个管理员或审批人同时推进同一个实例。更新时必须带上读取时的版本号。 + +### 12.3 任务抢占 + +对于允许抢占的候选任务: + +1. 后端锁定待办记录; +2. 校验任务仍为 `pending`; +3. 写入 `claimed_by` 和 `claimed_at`; +4. 修改为 `claimed`; +5. 其他用户再次操作时返回任务已被处理或已被占用。 + +### 12.4 审批人为空 + +审批人解析为空时不能自动跳过节点。应进入: + +```text +error / waiting_admin_resolve +``` + +管理员可以补充审批人或转派,但必须形成管理员动作和审计记录。 + +### 12.5 外部通知失败 + +通知失败不回滚已经完成的审批事务。outbox 记录重试次数、下次重试时间和最后错误,超过阈值进入人工处理队列。 + +## 13. 后端服务接口建议 + +接口名称可根据现有 API 规范调整,但不能使用通用表保存接口直接承载流程动作。 + +```text +POST /api/workflows/definitions/{definitionId}/validate +POST /api/workflows/versions/{versionId}/publish +POST /api/workflows/instances/submit +GET /api/workflows/tasks +GET /api/workflows/instances/{instanceId} +GET /api/workflows/instances/{instanceId}/timeline +POST /api/workflows/tasks/{taskId}/actions +POST /api/workflows/instances/{instanceId}/withdraw +POST /api/workflows/tasks/{taskId}/transfer +POST /api/workflows/tasks/{taskId}/delegate +``` + +审批动作请求示例: + +```json +{ + "action": "approve", + "comment": "费用和附件已核对", + "payload": {}, + "idempotencyKey": "req-20260922-000001" +} +``` + +后端返回中应包含: + +```text +实例状态 +当前节点摘要 +下一步待办摘要 +业务状态投影 +动作结果和审计事件 ID +``` + +## 14. 前端配置和使用页面 + +### 14.1 流程管理页 + +替代原来的“模块树 + sh_set 表格”,展示: + +```text +流程名称 +所属业务模块 +触发事件 +当前发布版本 +草稿版本 +状态 +适用组织 +生效时间 +最后发布人 +``` + +操作包括:新建、复制版本、编辑草稿、校验、模拟、发布、停用、查看版本历史。 + +### 14.2 流程设计器 + +使用节点画布,而不是只编辑级别表格。节点属性面板至少包括: + +- 节点名称和节点类型; +- 审批方式; +- 审批人来源; +- 条件和默认分支; +- 驳回、退回和撤回策略; +- 审批时限; +- 表单字段只读、必填和可见策略; +- 是否允许转交、委托、加签和抢占。 + +### 14.3 发布前模拟 + +管理员可以输入一组业务样例数据,查看: + +```text +选择了哪个流程绑定 +命中了哪些条件 +每个节点解析出哪些审批人 +最终生成哪些待办 +是否触发申请人自审限制 +``` + +### 14.4 流程待办和历史 + +业务用户使用独立的: + +```text +我的待办 +我发起的 +我已处理 +流程监控 +审批历史 +``` + +业务详情页只显示当前流程状态、当前待办和时间线,不直接读取流程配置表判断按钮是否可用。 + +## 15. 旧数据迁移 + +### 15.1 配置迁移 + +| 旧字段 | 新模型 | +| --- | --- | +| `sh_set.b_module_id` | `s_wf_binding.b_module_id` | +| `sh_set.b_level` | 线性节点的顺序和节点编码 | +| `sh_set.b_user_id` | `s_wf_actor_rule` 的 `user` 规则 | +| `sh_set.b_condition` | 条件 AST;不能自动转换的进入人工复核 | +| `sh_set.b_logic`、`b_leap` | 节点类型、连线条件或退回策略,不能机械照搬 | +| `sh_set.b_bz` | 节点或审批人规则备注 | + +### 15.2 历史迁移 + +| 旧字段 | 新模型 | +| --- | --- | +| `sh_record.b_dj_id` | `s_wf_instance.b_business_id` | +| `sh_record.b_level` | `s_wf_node_run.b_node_key` / `b_run_no` | +| `b_tosh_useid` | 多条 `s_wf_task` | +| `b_link_useid` | 已处理用户快照或动作载荷 | +| `b_dosh_useid` | `s_wf_action.b_operator_id` 历史动作 | +| `b_sh_date` | `s_wf_action.b_occurdatetime` | +| `b_sh_memo` | `s_wf_action.b_comment` | +| 业务表 `b_state` | 保留为业务状态投影 | + +历史迁移不应篡改原始业务状态和原始日志。无法完整映射的旧数据可以以“历史兼容实例”导入,并在 `b_payload_json` 中保存原始字段。 + +### 15.3 切换策略 + +建议按模块逐步切换: + +1. 旧审批设置只读,停止新增配置; +2. 将旧线性配置导入新流程草稿; +3. 通过模拟和业务负责人确认审批路径; +4. 发布新版本; +5. 新提交使用新流程,历史实例继续由旧逻辑或兼容服务处理; +6. 稳定后再迁移历史查询和审批中心; +7. 最后下线旧 `sh_set`、`sh_record` 的写入入口。 + +## 16. 分阶段落地建议 + +### 第一阶段:企业级基础内核 + +建议先实现: + +- 流程定义、版本、发布和停用; +- 模块和业务事件绑定; +- 开始、人工审批、条件分支、结束节点; +- 串行、任一人、全部人; +- 指定用户、发起人领导、部门负责人; +- 提交、通过、驳回、退回、撤回; +- 流程实例、节点运行、待办和动作历史; +- 动作权限、任务权限和数据范围三层校验; +- 业务状态投影、审计日志和 outbox 通知。 + +### 第二阶段:组织和协同能力 + +- 岗位和组织角色审批人; +- 委托、转交、管理员转派; +- N 人会签、百分比会签; +- 审批期限、提醒和升级; +- 流程模拟和审批人解析预览; +- 流程监控和 SLA 报表。 + +### 第三阶段:复杂业务编排 + +- 服务节点和外部系统回调; +- 自动补数、自动校验和自动归档; +- 多公司、多租户和跨组织审批; +- 日历工作时间和节假日 SLA; +- 流程归档、分区和历史数据治理; +- 失败补偿和人工恢复机制。 + +## 17. 发布前检查清单 + +### 流程配置 + +- 是否存在且只有一个开始节点; +- 是否至少存在一个结束节点; +- 所有节点是否可达; +- 所有人工节点是否存在有效审批人规则; +- 所有条件字段是否存在于 `s_field`; +- 是否存在没有默认出口的条件分支; +- 是否存在重复生效的流程绑定; +- 是否配置了驳回和退回策略; +- 是否存在申请人自审风险; +- 是否配置审批人为空时的处理方式。 + +### 运行安全 + +- 业务记录和流程实例是否在同一事务内更新; +- 是否有幂等键和乐观锁; +- 是否校验动作权限、任务权限和数据范围; +- 是否禁止前端直接修改流程状态; +- 是否记录审批人规则和实际审批人的快照; +- 是否写入审计日志和 outbox; +- 是否能处理重复点击、超时、转交和通知失败。 + +## 18. 最终建议 + +FMS 的审批设计应采用以下边界: + +```text +权限:用户能不能做这种动作 +流程:当前轮到谁做什么 +业务:单据现在处于什么业务状态 +审计:过去发生过什么以及当时依据是什么 +``` + +不要把四个问题压缩到一张 `sh_set` 表或一个业务状态字段中。新系统应以“版本化流程定义 + 运行实例 + 独立待办 + 不可变动作历史”为核心,业务模块只负责绑定流程并展示状态投影。这样才能从简单的多级审核扩展到大公司需要的组织审批、条件路由、会签、委托、审计和流程治理。 + +## 19. 流程图编辑器选型 + +### 19.1 选型结论 + +FMS 流程设计器第一阶段选择 **LogicFlow**。 + +LogicFlow 用于实现流程定义的可视化编辑,包括节点拖拽、连线、条件分支、缩放、框选、撤销重做和流程图导入导出。它只负责前端编辑体验,不负责审批执行、权限判断、流程状态推进和数据库事务。 + +```text +LogicFlow + └── 负责流程图编辑器 + +FMS Workflow Service + └── 负责流程校验、发布、审批人解析、实例推进和事务 + +s_wf_definition / s_wf_version / s_wf_node / s_wf_edge + └── 负责保存流程领域模型 +``` + +### 19.2 备选方案比较 + +| 方案 | 定位 | FMS 适配评价 | +| --- | --- | --- | +| Vue Flow | Vue 3 通用节点画布 | 上手简单,适合原型和简单流程,但流程扩展需要自行建设 | +| LogicFlow | 业务逻辑流程图编辑器 | 与审批流、条件分支、业务流程图最匹配,作为当前首选 | +| AntV X6 | 通用图形和图编辑引擎 | 扩展性和通用性最强,但需要自行实现较多流程语义和编辑器能力 | + +如果未来扩展为统一企业图形平台,涉及组织架构图、数据血缘、ER 图、网络拓扑或复杂端口连接,可以再评估 AntV X6。当前业务目标是审批流和业务流程图,不提前引入 X6 的底层复杂度。 + +### 19.3 依赖和集成边界 + +前端依赖建议使用: + +```text +@logicflow/core +@logicflow/extension +``` + +具体版本以项目锁定的 Vue 3 和 Vite 版本兼容性验证结果为准。LogicFlow 的扩展包用于节点、控制栏、小地图、菜单、选择框和流程相关扩展;流程业务规则仍由 FMS 自己实现。 + +流程设计器建议拆成以下组件: + +```text +WorkflowDesigner +├── WorkflowToolbar +├── WorkflowNodePanel +├── WorkflowCanvas(LogicFlow) +├── WorkflowPropertyPanel +├── WorkflowValidationPanel +└── WorkflowSimulationPanel +``` + +### 19.4 领域模型和画布模型隔离 + +不能直接把 LogicFlow 的原始 JSON 当作流程定义存储。数据库和接口使用 FMS 自己的稳定模型: + +```json +{ + "schemaVersion": 1, + "nodes": [ + { + "nodeKey": "department_manager", + "nodeType": "user_task", + "name": "部门负责人审批", + "config": { + "approvalMode": "single" + } + } + ], + "edges": [ + { + "from": "start", + "to": "department_manager", + "condition": null + } + ] +} +``` + +映射关系如下: + +| FMS 领域模型 | LogicFlow 画布模型 | +| --- | --- | +| `b_node_key` | `id` | +| `b_node_type` | 自定义节点类型 | +| `b_name` | 节点显示文本 | +| `b_config_json` | 节点属性面板数据 | +| `b_from_node_key` | `sourceNodeId` | +| `b_to_node_key` | `targetNodeId` | +| `b_condition_json` | 连线属性或条件编辑器数据 | +| 节点位置和缩放 | `b_canvas_json` | + +建议建立独立的画布适配层: + +```text +workflowDomainToLogicFlow(version) +logicFlowToWorkflowDraft(graph) +validateWorkflowDomain(version) +``` + +`s_wf_node` 和 `s_wf_edge` 是可执行流程的来源,`b_canvas_json` 只保存节点坐标、画布缩放、分组位置等界面布局信息。即使以后更换为 X6,也不能改变流程节点编码、审批规则和运行实例语义。 + +### 19.5 LogicFlow 节点约定 + +画布节点类型与工作流节点类型保持一一映射: + +```text +fms-start +fms-user-task +fms-exclusive-gateway +fms-parallel-gateway +fms-service-task +fms-end +``` + +LogicFlow 的节点类型只是前端渲染类型,后端落库仍使用: + +```text +start +user_task +exclusive_gateway +parallel_gateway +service_task +end +``` + +人工审批节点的审批人、审批方式、时限和表单策略在右侧属性面板编辑,保存到 `s_wf_node` 和 `s_wf_actor_rule`,不保存为节点显示文本。 + +### 19.6 编辑器校验边界 + +LogicFlow 可以做即时交互校验,例如: + +- 禁止连接到自身; +- 禁止从结束节点继续连线; +- 限制不同节点类型的连接方向; +- 删除节点时提示是否同时删除相关连线; +- 连线重复时提示用户。 + +但以下校验必须由后端在保存和发布时再次执行: + +- 是否存在唯一开始节点; +- 是否存在可达的结束节点; +- 所有人工节点是否存在审批人规则; +- 条件字段和操作符是否合法; +- 是否存在死循环、不可达节点和无默认出口; +- 流程绑定是否存在条件冲突; +- 发布版本是否可以被当前组织范围使用。 + +前端校验用于提升体验,后端校验才是最终规则。 + +### 19.7 迁移和长期替换策略 + +当前选择 LogicFlow 不意味着将流程数据绑定到 LogicFlow。以后如果需要改用 AntV X6,只替换: + +```text +LogicFlowCanvasAdapter → X6CanvasAdapter +``` + +以下内容不应改变: + +- `s_wf_definition`; +- `s_wf_version`; +- `s_wf_node`; +- `s_wf_edge`; +- `s_wf_actor_rule`; +- `s_wf_instance`、`s_wf_task`、`s_wf_action`; +- 流程发布和运行接口; +- 审批历史和审计记录。 + +因此,LogicFlow 是当前阶段的流程设计器选型,而不是 FMS 工作流领域模型的依赖。 diff --git a/code/fms/FMS新系统核心表结构设计.md b/code/fms/FMS新系统核心表结构设计.md index 5bc82a1e..a54bf445 100644 --- a/code/fms/FMS新系统核心表结构设计.md +++ b/code/fms/FMS新系统核心表结构设计.md @@ -40,6 +40,7 @@ - 启用和停用统一使用 `b_canuse`,取值为 `0/1`。 - 排序字段统一使用 `b_xh`,默认值为 `0`;需要稳定排序时增加业务主键作为次排序字段。 +- 最后修改时间统一使用 `b_updated_at`、修改人使用 `b_updated_by`(均为 `datetime2` / `varchar`,可空);不建触发器,由写入方维护。 - 名称、备注和多语言文本使用 `nvarchar`;编码、状态、类型和 SQL 标识类字段使用 `varchar`。 - 日期时间优先使用 `datetime2`;金额、数量等数值字段根据业务精度明确设置 `precision` 和 `scale`。 - 是否允许为空及默认值应根据业务语义明确设置,不能无理由全部允许为空或全部设置默认值。 @@ -191,12 +192,14 @@ create table dbo.s_module ( b_view_table varchar(128) null, -- 默认查询表或视图 b_save_table varchar(128) null, -- 默认保存目标表 b_key_field varchar(50) null, -- 主键字段 + b_scope_field varchar(50) null, -- 数据范围字段(记录归属人字段,如 b_inputuser_id) b_order_sql varchar(500) null, -- 默认排序 SQL b_query_sql nvarchar(max) null, -- 可选查询 SQL 或 SQL 模板 b_config_json nvarchar(max) null, -- 低频模块扩展配置 b_canuse tinyint not null default 1, -- 是否启用(0/1) b_xh int not null default 0, -- 同级显示顺序 - b_bz nvarchar(2000) null -- 备注 + b_bz nvarchar(2000) null, -- 备注 + b_updated_at datetime2 null -- 最后保存时间(本模块任意配置保存时刷新,含字段 / 界面配置等子表) ); create index ix_s_module_parent @@ -253,6 +256,7 @@ create index ix_s_module_save_table - 数据之间的主子表、引用和多对多关系统一由 `s_relation` 表达。 - 多对多的中间表模块(`data` 模块)在模块树中统一挂在所属业务域 `module` 节点下,与参与多对多的主表模块平级;不挂在任一参与多对多的主表模块下——模块树只表达业务组织,不表达数据关系,混入会污染 `b_path` 子树查询、菜单挂载与权限继承。 - 模块级扩展配置使用 `b_config_json`,只保存低频、非核心的扩展属性,不重复保存字段定义、权限或界面布局。 +- `b_scope_field` 是本模块的数据范围字段(记录归属人字段,如 `b_inputuser_id`),只对 `data` / `virtual` 有意义,取值必须来自 `s_field`;它是数据范围判定的唯一字段来源,配在模块上而不是配在用户授权行上——同一个模块的所有用户必须用同一字段判范围。`self` 直接比该字段,`dept` / `dept_tree` 由当前用户反查 `b_user.b_dept_id` 得到部门再来比(见 §14.4);为空表示该模块不按字段判范围。 ### 4.4 查询来源优先级 @@ -991,7 +995,8 @@ b_condition_sql = 'b_salesman_id = {userId}' ### 14.6 与模块、菜单和关系的关系 - `s_power` 中的 `menu.*` 对应 `s_menu`,`module.*` 对应 `s_module`,`action.*` 对应业务动作;权限点编码与对象编码的对应关系由业务层维护,不建立外键; -- `s_user_field_power.b_module_id`、`s_user_data_power.b_module_id` 指向 `s_module.b_id`,字段还需在 `s_field` 中存在; +- 授权行只能引用存在的权限点:删 `s_power` 行时用 `dependent_deletes` 声明(`{table:'s_user_power', key_field:'b_power_id', source_field:'b_id'}`),保存层在同一事务里按同一组条件查出权限编码并连带删除授权行,调用方不需要先查编码(《FMS删除策略重构设计》§6.2); +- `s_user_field_power.b_module_id`、`s_user_data_power.b_module_id` 指向 `s_module.b_id`,字段还需在 `s_field` 中存在;删除模块时随模块一起删除 `s_user_field_power`、`s_user_data_power` 行,删除字段定义时删除该字段的 `s_user_field_power` 行; - 数据范围只作用于本表的 `b_user_id + b_module_id + b_operation`;是否向关联模块延伸(例如子表、明细模块)由权限引擎根据业务语义决定,不依赖 `s_relation` 自动传播; - 权限缓存、批量校验和 SQL 条件注入点由权限引擎实现,业务 SQL 生成时统一追加范围条件。 diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataDeleteService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataDeleteService.java index 50a9452e..b10a9d11 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataDeleteService.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataDeleteService.java @@ -30,6 +30,9 @@ import java.util.Set; * 不读模块关系、不跑模块 SQL 规则。普通模块列表不允许传表名。 * * + *

另外,{@code saveobjt} 的删除行可以带 {@code dependent_deletes} 声明,把引用这些行的 + * 依赖行一并删除(见 {@link #deleteDependents}):依赖关系由调用方声明,服务端不按表名猜。 + * *

计划、检查、删除在同一事务中完成(设计 §5.3):任一步骤失败整批回滚。 */ @Service @@ -178,6 +181,110 @@ public class DataDeleteService { return deleted; } + /** + * 依赖行删除:本表被删的行被别的表引用时,连引用行一起删(《FMS删除策略重构设计》§6.2)。 + * + *

例如删除权限点({@code s_power})要连引用它的用户授权行({@code s_user_power}) + * 一起删:授权行只记录“拥有”,没有外键约束,权限点没了、授权行留着就是幽灵授权 —— + * 权限判定会读到它,授权界面也无从解释。这类关系不登记在 {@code s_relation} 里 + * (那是有模块归属的业务关系),也不由服务端按表名反查:调用方在请求里声明依赖表、 + * 键列和取值列,这里按声明执行。表名只出现在请求里,服务端不认识具体业务表。 + * + *

声明格式 {@code {table, key_field, source_field}}:对本表本次要删的每一行, + * 用取值列的值得出要删的键值,再删依赖表里键列等于该值的行。取值用本表这一行的列值 + * 条件查出(与物理删除的 WHERE 同口径),所以调用方不需要先查一遍再拼删除行。 + * + * @param parentTable 本表(正在删除的表)的元数据 + * @param parentRows 本次要删除的行,列值即删除条件 + * @param dependents 依赖声明列表 + * @return 实际删除的依赖行数 + */ + public int deleteDependents( + Connection connection, + DbUtils.TableMetadata parentTable, + List> parentRows, + List> dependents + ) throws SQLException { + int total = 0; + for (Map dependent : dependents) { + String table = text(dependent.get("table")); + String keyField = text(dependent.get("key_field")); + String sourceField = text(dependent.get("source_field")); + // 声明不完整时直接拒绝:静默跳过等于把该清的依赖行留下,成了没人解释得了的孤儿行 + if (table == null || keyField == null || sourceField == null) { + throw new BusinessException( + "依赖删除声明不完整,需要 table / key_field / source_field"); + } + + DbUtils.TableMetadata dependentTable = dbUtils.loadTableMetadata(connection, table); + DbUtils.ColumnMetadata keyColumn = requireKeyColumn(dependentTable, keyField); + DbUtils.ColumnMetadata sourceColumn = dbUtils.getColumn(parentTable, sourceField); + if (sourceColumn == null) { + throw new BusinessException("依赖删除的取值列不存在: " + + parentTable.requestedName() + "." + sourceField); + } + + List values = querySourceValues( + connection, parentTable, parentRows, sourceColumn); + if (values.isEmpty()) { + continue; + } + int deleted = deleteRecords(connection, dependentTable, keyColumn, values); + total += deleted; + writeDeleteLog(null, parentTable.requestedName(), table, + keyColumn.name(), values.size(), deleted); + } + return total; + } + + /** + * 取出本表待删行在取值列上的值(去重)。 + * + *

匹配条件与物理删除同口径:行内有本表可写列的列值做 AND;一行里没有任何本表列时 + * 拒绝执行,和 {@code DbUtils.delete} 一样不允许从这条路径形成无条件匹配。 + */ + private List querySourceValues( + Connection connection, + DbUtils.TableMetadata table, + List> rows, + DbUtils.ColumnMetadata sourceColumn + ) throws SQLException { + Set values = new LinkedHashSet<>(); + for (Map row : rows) { + List conditions = new ArrayList<>(); + for (Map.Entry entry : row.entrySet()) { + DbUtils.ColumnMetadata column = columnOf(table, entry.getKey()); + if (column == null || !column.writable()) { + continue; + } + conditions.add(dbUtils.quoteQualifiedIdentifier(column.name()) + + " = " + literal(entry.getValue(), column)); + } + if (conditions.isEmpty()) { + throw new BusinessException("删除条件不能为空: " + table.requestedName()); + } + + String sql = "select distinct " + dbUtils.quoteQualifiedIdentifier(sourceColumn.name()) + + " from " + table.quotedName() + + " where " + String.join(" and ", conditions); + for (Map found : dbUtils.loadDataBySql(connection, sql)) { + Object value = found.get(sourceColumn.name()); + if (value != null) { + values.add(value); + } + } + } + return new ArrayList<>(values); + } + + /** 按列名取元数据(大小写不敏感);不是本表列时返回 null,与物理删除忽略未知列同口径 */ + private DbUtils.ColumnMetadata columnOf(DbUtils.TableMetadata table, String fieldName) { + if (fieldName == null) { + return null; + } + return table.columnsByName().get(fieldName.trim().toLowerCase(Locale.ROOT)); + } + // ── target=module:计划 → 检查 → 执行 ────────────────────────────────── private Map executeModuleTarget( diff --git a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataSaveService.java b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataSaveService.java index 02da073d..5405cdd8 100644 --- a/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataSaveService.java +++ b/code/fms/fms-api/src/main/java/cn/g3soft/fmsapi/service/DataSaveService.java @@ -25,6 +25,13 @@ import java.util.StringJoiner; @Service public class DataSaveService { + /** + * 依赖行删除声明(请求可选):本表本次要删的行被别的表引用时,连引用行一起删。 + * 声明为 {@code [{table, key_field, source_field}]},由删除服务按声明执行 + * (《FMS删除策略重构设计》§6.2)。表名只出现在请求里,服务端不按表名猜关系。 + */ + private static final String DEPENDENT_DELETES = "dependent_deletes"; + @Resource private DataSource dataSource; @@ -430,6 +437,15 @@ public class DataSaveService { "update", rows(request, "updates") ); + // 依赖行随本表删除:请求显式声明依赖表(如删权限点连引用它的授权行一起删), + // 由删除服务按声明查出引用行的键值再删(设计《删除策略》§6.2)。声明通用 —— + // 服务端不认识具体表名,依赖关系由调用方声明,不做“按表名猜关系”的特殊处理。 + // 依赖行先于父行删除(设计 §5.3),所以放在物理删除之前;本表没有删除行时 + // 没有依赖可清,直接跳过。与本次删除同事务,任一步失败整体回滚。 + List> dependentDeletes = rows(request, DEPENDENT_DELETES); + if (!deleteRows.isEmpty() && !dependentDeletes.isEmpty()) { + executeDependentDeletes(connection, table, deleteRows, dependentDeletes); + } if (deleteTarget != null && !deleteTarget.isBlank() && !deleteRows.isEmpty()) { executeDeletes(connection, request, tableName, keyField, deleteTarget, deleteRows); } else { @@ -450,6 +466,25 @@ public class DataSaveService { ); } + /** + * 执行请求声明的依赖行删除(设计《删除策略》§6.2)。 + * + *

具体匹配与删除走删除服务:那里有物理删除执行器、主键类型校验和删除日志, + * 与其它删除路径同一套实现,不在这里再写一份。 + */ + private void executeDependentDeletes( + Connection connection, + DbUtils.TableMetadata table, + List> deleteRows, + List> dependentDeletes + ) { + try { + dataDeleteService.deleteDependents(connection, table, deleteRows, dependentDeletes); + } catch (SQLException exception) { + throw new SaveObjectException(table.requestedName(), "delete", -1, exception); + } + } + /** * 通过删除服务执行 saveobjt 的删除行(设计 §6.2)。 * 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 index 6899e95c..124a3448 100644 --- 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 @@ -29,7 +29,9 @@ import java.util.regex.Pattern; * 三层,与《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。 + * 3. 数据范围(14.4):s_user_data_power,按操作精确匹配优先、多条规则 OR、整体再与其它条件 AND; + * 按范围判的规则所用的字段取自模块配置 s_module.b_scope_field(记录归属人字段), + * self 直接比该字段,dept / dept_tree 由当前用户反查部门后再比。 */ @Service public class SqlPermissionService { @@ -107,7 +109,7 @@ public class SqlPermissionService { * 无生效规则返回 null,表示该模块无数据范围限制。 */ public String buildDataScopeCondition(String moduleId, String userId, String operation) { - String sql = "SELECT [b_operation], [b_scope_type], [b_scope_field], [b_condition_sql] " + String sql = "SELECT [b_operation], [b_scope_type], [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] = '*') " @@ -128,20 +130,29 @@ public class SqlPermissionService { List> effective = exact.isEmpty() ? wildcard : exact; String deptId = null; + // 判断字段只来自模块配置(s_module.b_scope_field,记录归属人字段):同一个模块的 + // 所有用户必须用同一字段判范围,所以授权行上的同名列不参与判定。按需读一次, + // 只有按范围判的规则(self / dept / dept_tree)才需要它 + String moduleScopeField = 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); + if (moduleScopeField == null) { + moduleScopeField = loadModuleScopeField(connection, moduleId); + } + String field = requireScopeField(moduleScopeField); fragments.add(quote(field) + " = " + dbUtils.toSqlStringLiteral(userId)); } case "dept", "dept_tree" -> { - String field = requireScopeField(scopeField); + if (moduleScopeField == null) { + moduleScopeField = loadModuleScopeField(connection, moduleId); + } + String field = requireScopeField(moduleScopeField); if (deptId == null) { deptId = loadDeptId(connection, userId); } @@ -197,6 +208,20 @@ public class SqlPermissionService { return rows.isEmpty() ? null : text(rows.get(0).get("b_dept_id")); } + /** + * 模块的数据范围字段(s_module.b_scope_field,记录归属人字段如 b_inputuser_id)。 + * 配在模块上而不是授权行上:同一个模块的所有用户必须用同一字段判范围。 + * 未配置时返回空串,由 requireScopeField 明确报错,不静默放行。 + */ + private String loadModuleScopeField(Connection connection, String moduleId) throws SQLException { + List> rows = query( + connection, + "SELECT [b_scope_field] FROM [s_module] WHERE [b_id] = ?", + moduleId + ); + return rows.isEmpty() ? "" : text(rows.get(0).get("b_scope_field")); + } + private String loadDeptPath(Connection connection, String deptId) throws SQLException { List> rows = query( connection, diff --git a/code/fms/fms-api/tools/migration/otherdata-probe-out.txt b/code/fms/fms-api/tools/migration/otherdata-probe-out.txt index cf236c0b..5f282702 100644 --- a/code/fms/fms-api/tools/migration/otherdata-probe-out.txt +++ b/code/fms/fms-api/tools/migration/otherdata-probe-out.txt @@ -1,837 +1 @@ -=== 新库 FMS:表结构 === - ---- b_cargo_damage_status --- - (表不存在) - ---- b_bulkforkliftpersonname --- - (表不存在) - ---- b_bulktype --- - (表不存在) - ---- b_bulkpersonname --- - (表不存在) - ---- b_bulkstevedorepersonname --- - (表不存在) - ---- b_charging_method --- - (表不存在) - ---- b_unloading_method --- - (表不存在) - ---- b_sys_b_lessfull --- - (表不存在) - ---- b_sys_edittype --- - (表不存在) - ---- b_vessel --- - (表不存在) - ---- b_freightpayment --- - (表不存在) - ---- b_con_type --- - (表不存在) - ---- b_jsfs --- - (表不存在) - ---- b_city --- - (表不存在) - ---- b_ivtype --- - (表不存在) - ---- b_sys_sex --- - (表不存在) - ---- b_sys_copies --- - (表不存在) - ---- b_cztype --- - (表不存在) - ---- b_sys_level --- - (表不存在) - ---- b_sys_operator --- - (表不存在) - ---- b_blstate --- - (表不存在) - ---- b_sys_state --- - (表不存在) - ---- b_sys_feesf --- - (表不存在) - ---- bc_priceList_type --- - (表不存在) - ---- b_sys_yesno --- - (表不存在) - ---- b_sys_pricelisttype --- - (表不存在) - ---- b_ystype --- - (表不存在) - ---- b_billtype --- - (表不存在) - ---- b_bgtype --- - (表不存在) - ---- b_httype --- - (表不存在) - ---- b_transway --- - (表不存在) - ---- b_sys_hdtype --- - (表不存在) - ---- b_package --- - (表不存在) - ---- b_progress --- - (表不存在) - ---- b_zxstate --- - (表不存在) - ---- b_depot --- - (表不存在) - ---- b_depotouttype --- - (表不存在) - ---- b_depotintype --- - (表不存在) - ---- car_daily_feetype --- - (表不存在) - ---- car_jiayou_jsfs --- - (表不存在) - ---- car_jiayou_type --- - (表不存在) - ---- b_cdtype --- - (表不存在) - ---- b_shfs --- - (表不存在) - ---- car_jiayou_youliao --- - (表不存在) - ---- b_thfs --- - (表不存在) - ---- b_wangdian --- - (表不存在) - ---- b_ywfrom --- - (表不存在) - ---- b_ywtype_tc --- - (表不存在) - ---- b_servicetype --- - (表不存在) - ---- b_contype --- - (表不存在) - ---- b_ywtype_ht --- - (表不存在) - ---- b_dcterm --- - (表不存在) - ---- b_ywtype_ky --- - (表不存在) - -=== 旧库 G3HY2025:表结构 === - ---- b_cargo_damage_status --- - b_id varchar 50 not null - b_name varchar 50 null - ---- b_bulkforkliftpersonname --- - b_id varchar 50 not null - b_name varchar 50 null - ---- b_bulktype --- - b_id varchar 50 not null - b_name varchar 50 null - ---- b_bulkpersonname --- - b_id varchar 50 not null - b_name varchar 50 null - ---- b_bulkstevedorepersonname --- - b_id varchar 50 not null - b_name varchar 50 null - ---- b_charging_method --- - b_id varchar 50 not null - b_name varchar 50 null - ---- b_unloading_method --- - b_id varchar 50 not null - b_name varchar 50 null - ---- b_sys_b_lessfull --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 50 null - ---- b_sys_edittype --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 200 null - ---- b_vessel --- - b_id varchar 50 not null - b_vessel_name varchar 100 null - b_edicode varchar 50 null - b_voy varchar 100 null - b_bz varchar 200 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_freightpayment --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 50 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_con_type --- - b_id varchar 50 not null - b_name varchar 50 null - b_edicode varchar 50 null - b_bz varchar 200 null - b_type varchar 50 null - b_linkcon varchar 50 null - b_default varchar 1 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_jsfs --- - b_id varchar 50 not null - b_name varchar 50 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_city --- - b_id varchar 50 not null - b_name_c varchar 50 null - b_name_e varchar 50 null - b_country_name_e varchar 50 null - b_country_name_c varchar 50 null - b_bz varchar 500 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_ivtype --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 50 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_sys_sex --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 200 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_sys_copies --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 200 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_cztype --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 200 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_sys_level --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 200 null - ---- b_sys_operator --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 200 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_blstate --- - b_id varchar 50 not null - b_name varchar 50 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_sys_state --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 50 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_sys_feesf --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 50 null - ---- bc_priceList_type --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 50 null - ---- b_sys_yesno --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 50 null - ---- b_sys_pricelisttype --- - b_id varchar 50 not null - b_name varchar 50 null - b_dw_id varchar 50 null - b_dw_name varchar 50 null - ---- b_ystype --- - b_id varchar 50 null - b_name varchar 50 null - b_bz varchar 200 null - ---- b_billtype --- - b_id varchar 50 null - b_name varchar 50 null - ---- b_bgtype --- - b_id varchar 50 null - b_name varchar 50 null - ---- b_httype --- - b_id varchar 50 null - b_name varchar 50 null - b_bz varchar 50 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_transway --- - b_id varchar 50 not null - b_name varchar 100 null - b_edicode varchar 50 null - b_bz varchar 100 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_sys_hdtype --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 200 null - ---- b_package --- - b_id varchar 50 not null - b_name varchar 50 null - b_edicode varchar 50 null - b_bz varchar 100 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_progress --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 500 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_zxstate --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 200 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_depot --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 200 null - ---- b_depotouttype --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 50 null - ---- b_depotintype --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 50 null - ---- car_daily_feetype --- - b_id varchar 50 null - b_name varchar 50 null - ---- car_jiayou_jsfs --- - b_id varchar 50 null - b_name varchar 50 null - ---- car_jiayou_type --- - b_id varchar 50 null - b_name varchar 50 null - ---- b_cdtype --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 50 null - ---- b_shfs --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 50 null - ---- car_jiayou_youliao --- - b_id varchar 50 null - b_name varchar 50 null - ---- b_thfs --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 50 null - ---- b_wangdian --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 50 null - ---- b_ywfrom --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 50 null - ---- b_ywtype_tc --- - b_id varchar 50 not null - b_name varchar 50 null - b_bz varchar 50 null - ---- b_servicetype --- - b_id varchar 50 not null - b_name varchar 50 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_contype --- - b_id varchar 50 not null - b_name varchar 50 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_ywtype_ht --- - b_id varchar 50 not null - b_name varchar 50 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_dcterm --- - b_id varchar 50 not null - b_name varchar 50 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - ---- b_ywtype_ky --- - b_id varchar 50 not null - b_name varchar 50 null - b_inputuser_id varchar 50 null - b_inputdatetime datetime 8 null - b_updateuser_id varchar 50 null - b_updatedatetime datetime 8 null - -=== 旧库 G3HY2025:数据样例(每表前 3 行) === - ---- b_cargo_damage_status --- - b_id b_name - 001 完好 - 002 轻微破损 - (2 行) - ---- b_bulkforkliftpersonname --- - b_id b_name - 001 小张 - (1 行) - ---- b_bulktype --- - b_id b_name - 001 散货 - (1 行) - ---- b_bulkpersonname --- - b_id b_name - 001 张三 - 002 李四 - (2 行) - ---- b_bulkstevedorepersonname --- - b_id b_name - 001 小王 - (1 行) - ---- b_charging_method --- - b_id b_name - 001 按件收费 - 002 按托收费 - (2 行) - ---- b_unloading_method --- - b_id b_name - 001 吊车卸货 - 002 铲车卸货 - (2 行) - ---- b_sys_b_lessfull --- - b_id b_name b_bz - 001 整箱 (null) - 002 拼箱 (null) - (2 行) - ---- b_sys_edittype --- - b_id b_name b_bz - 001 selectid (null) - 002 selectname (null) - 003 date (null) - (3 行) - ---- b_vessel --- - b_id b_vessel_name b_edicode b_voy b_bz b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 YM FOUNTAIN V.85W (null) N012 (null) (null) (null) (null) (null) - 002 YM FOUNTAIN V.75W (null) N45 (null) (null) (null) (null) (null) - 003 COSCO BOSTON (null) 202N (null) (null) (null) (null) - (3 行) - ---- b_freightpayment --- - b_id b_name b_bz b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 FREIGHT PREPAID (null) (null) (null) (null) - 002 FREIGHT COLLECT (null) (null) (null) (null) - 003 Shipping LOYal (null) (null) (null) (null) - (3 行) - ---- b_con_type --- - b_id b_name b_edicode b_bz b_type b_linkcon b_default b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 40GP 大箱 40GP 1 (null) (null) g3soft 2026-07-14 11:09:20.0 - 002 20GP 小箱 20GP 1 (null) (null) g3soft 2026-07-14 11:09:21.0 - 003 45HQ 大箱 45HQ 1 (null) (null) g3soft 2026-07-14 11:09:22.0 - (3 行) - ---- b_jsfs --- - b_id b_name b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 汇票 (null) (null) (null) (null) - 002 转账 (null) (null) (null) (null) - (2 行) - ---- b_city --- - b_id b_name_c b_name_e b_country_name_e b_country_name_c b_bz b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - CNNGB 宁波 NINGBO (null) (null) (null) (null) (null) (null) - CS001 阿拉木图 阿拉木图 (null) (null) (null) (null) (null) (null) (null) - CS002 阿腾科里 阿腾科里 (null) (null) (null) (null) (null) (null) (null) - (3 行) - ---- b_ivtype --- - b_id b_name b_bz b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 增值税专票 (null) (null) (null) (null) (null) - 002 增值税普票 (null) (null) (null) (null) (null) - (2 行) - ---- b_sys_sex --- - b_id b_name b_bz b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 男 (null) (null) (null) (null) (null) - 002 女 (null) (null) (null) (null) (null) - (2 行) - ---- b_sys_copies --- - b_id b_name b_bz b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 ZERO (null) (null) (null) (null) (null) - 002 ONE (null) (null) (null) (null) (null) - 003 TWO (null) (null) (null) (null) (null) - (3 行) - ---- b_cztype --- - b_id b_name b_bz b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 过磅称重法M1 (null) (null) (null) (null) (null) - 002 累计相加称重法M2 (null) (null) (null) (null) (null) - (2 行) - ---- b_sys_level --- - b_id b_name b_bz - 001 1 (null) - 002 2 (null) - 003 3 (null) - (3 行) - ---- b_sys_operator --- - b_id b_name b_bz b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 等于 (null) (null) (null) (null) (null) - 002 不等于 (null) (null) (null) (null) (null) - 003 为空 (null) (null) (null) (null) (null) - (3 行) - ---- b_blstate --- - b_id b_name b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 电放 (null) (null) (null) (null) - 002 正本 (null) (null) (null) (null) - (2 行) - ---- b_sys_state --- - b_id b_name b_bz b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 草拟 (null) (null) (null) (null) (null) - 002 送审 (null) (null) (null) (null) (null) - 003 审核通过 (null) (null) (null) (null) (null) - (3 行) - ---- b_sys_feesf --- - b_id b_name b_bz - 001 应收 (null) - 002 应付 (null) - (2 行) - ---- bc_priceList_type --- - b_id b_name b_bz - 001 客户价目 (null) - 002 供应商 (null) - (2 行) - ---- b_sys_yesno --- - b_id b_name b_bz - 001 是 (null) - 002 否 (null) - (2 行) - ---- b_sys_pricelisttype --- - b_id b_name b_dw_id b_dw_name - 001 委托单位 b_customer_id v_customer_company - 002 订舱单位 b_dcdw_id v_dc_company - 003 报关行 b_bgcompany_id v_bg_company - (3 行) - ---- b_ystype --- - b_id b_name b_bz - 001 应收款 (null) - 002 应付款 (null) - 003 预收款 (null) - (3 行) - ---- b_billtype --- - b_id b_name - 001 实际账单 - 002 虚拟账单 - (2 行) - ---- b_bgtype --- - b_id b_name - 001 一般贸易 - 002 市场采购 - (2 行) - ---- b_httype --- - b_id b_name b_bz b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 客户合同 (null) (null) (null) (null) (null) - 002 供应商合同 (null) (null) (null) (null) (null) - (2 行) - ---- b_transway --- - b_id b_name b_edicode b_bz b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 CY-CY (null) (null) (null) (null) (null) (null) - 002 DOOR-DOOR (null) (null) (null) (null) (null) (null) - 003 CFS-CFS (null) (null) (null) (null) (null) (null) - (3 行) - ---- b_sys_hdtype --- - b_id b_name b_bz - 001 纸质 (null) - 002 电子 (null) - (2 行) - ---- b_package --- - b_id b_name b_edicode b_bz b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - BAGS BAGS (null) (null) (null) (null) (null) (null) - BALES BALES (null) (null) (null) (null) (null) (null) - CASES CASES (null) (null) (null) (null) (null) (null) - (3 行) - ---- b_progress --- - b_id b_name b_bz b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 Active (null) (null) (null) (null) - 002 Confirmed (null) (null) (null) (null) - 003 Completed (null) (null) (null) (null) - (3 行) - ---- b_zxstate --- - b_id b_name b_bz b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 待处理 (null) (null) (null) (null) - 002 处理中 (null) (null) (null) (null) - 003 已处理 (null) (null) (null) (null) - (3 行) - ---- b_depot --- - b_id b_name b_bz - 001 杭州 - 002 宁波 - (2 行) - ---- b_depotouttype --- - b_id b_name b_bz - 001 领用出库 - (1 行) - ---- b_depotintype --- - b_id b_name b_bz - 001 采购入库 - (1 行) - ---- car_daily_feetype --- - b_id b_name - 001 过路费 - (1 行) - ---- car_jiayou_jsfs --- - b_id b_name - 001 计算金额 - 002 计算升数 - 003 计算单价 - (3 行) - ---- car_jiayou_type --- - b_id b_name - 001 现金加油 - 002 油卡加油 - (2 行) - ---- b_cdtype --- - b_id b_name b_bz - 001 外用司机 (null) - 002 自有车 (null) - (2 行) - ---- b_shfs --- - b_id b_name b_bz - 001 自提 (null) - 002 送货上门 (null) - (2 行) - ---- car_jiayou_youliao --- - b_id b_name - 001 柴油 - 002 尿素 - (2 行) - ---- b_thfs --- - b_id b_name b_bz - 001 客户送货 (null) - 002 上门提货 (null) - (2 行) - ---- b_wangdian --- - b_id b_name b_bz - 001 宁波 - 002 西安 - 003 乌鲁木齐 - (3 行) - ---- b_ywfrom --- - b_id b_name b_bz - 001 非系统 (null) - 002 同行 (null) - (2 行) - ---- b_ywtype_tc --- - b_id b_name b_bz - 001 零担 (null) - 002 整车 (null) - 003 专车 (null) - (3 行) - ---- b_servicetype --- - b_id b_name b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 1 全程散货 g3soft 2026-08-17 13:57:47.0(null) (null) - 2 全程买箱 g3soft 2026-08-17 13:59:27.0(null) (null) - 3 全程车皮 g3soft 2026-08-17 14:00:04.0(null) (null) - (3 行) - ---- b_contype --- - b_id b_name b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 1 租箱 g3soft 2026-08-17 14:05:51.0(null) (null) - 2 买箱 g3soft 2026-08-17 14:06:07.0(null) (null) - 3 铁路箱 g3soft 2026-08-17 14:06:20.0(null) (null) - (3 行) - ---- b_ywtype_ht --- - b_id b_name b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - (0 行) - ---- b_dcterm --- - b_id b_name b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 条款1 g3soft 2026-08-31 15:48:02.0(null) (null) - (1 行) - ---- b_ywtype_ky --- - b_id b_name b_inputuser_id b_inputdatetime b_updateuser_id b_updatedatetime - 001 进口 g3soft 2026-08-31 15:46:48.0(null) (null) - (1 行) + \ No newline at end of file diff --git a/code/fms/fms-vue/src/components/fms-tree/FmsTreePanel.vue b/code/fms/fms-vue/src/components/fms-tree/FmsTreePanel.vue index 45c58773..7579bef7 100644 --- a/code/fms/fms-vue/src/components/fms-tree/FmsTreePanel.vue +++ b/code/fms/fms-vue/src/components/fms-tree/FmsTreePanel.vue @@ -2,7 +2,7 @@ /** * FmsTreePanel —— 左树面板(白卡 + 搜索框 + 动作区 + 可滚动树区)。 * - * 往来单位 / 其他数据 / 用户管理 / 模块管理 / 菜单管理五个页面的左栏结构完全一致, + * 合作伙伴 / 其他数据 / 用户管理 / 模块管理 / 菜单管理五个页面的左栏结构完全一致, * 差异只在搜索关键字、动作按钮和树的交互事件上,故收敛为本组件: * 1. 面板骨架(白卡 / 头部 / 搜索框 / 滚动区)的样式随组件走,不再放全局样式层; * 2. 搜索关键字由本组件持有(v-model:keyword),「搜索过滤中禁止拖拽」这条规则随之只写一次; diff --git a/code/fms/fms-vue/src/components/ui/demo/tag.vue b/code/fms/fms-vue/src/components/ui/demo/tag.vue index 990de966..dee53218 100644 --- a/code/fms/fms-vue/src/components/ui/demo/tag.vue +++ b/code/fms/fms-vue/src/components/ui/demo/tag.vue @@ -24,6 +24,7 @@ function removeTag(tag) { 默认 次要 描边 + 成功 危险 幽灵 diff --git a/code/fms/fms-vue/src/components/ui/dropdown/DropdownItem.vue b/code/fms/fms-vue/src/components/ui/dropdown/dropdown-item.vue similarity index 100% rename from code/fms/fms-vue/src/components/ui/dropdown/DropdownItem.vue rename to code/fms/fms-vue/src/components/ui/dropdown/dropdown-item.vue diff --git a/code/fms/fms-vue/src/components/ui/dropdown/dropdown.vue b/code/fms/fms-vue/src/components/ui/dropdown/dropdown.vue index b7f8afa5..b33072db 100644 --- a/code/fms/fms-vue/src/components/ui/dropdown/dropdown.vue +++ b/code/fms/fms-vue/src/components/ui/dropdown/dropdown.vue @@ -2,7 +2,7 @@ import { computed, nextTick, provide, ref, useSlots } from 'vue' import Popover from '../popover/popover.vue' import DropdownMenu from './menu.vue' -import DropdownItem from './DropdownItem.vue' +import DropdownItem from './dropdown-item.vue' /* ---------- props / emits / slots ---------- */ const props = defineProps({ diff --git a/code/fms/fms-vue/src/components/ui/form/FormGroup.vue b/code/fms/fms-vue/src/components/ui/form/form-group.vue similarity index 100% rename from code/fms/fms-vue/src/components/ui/form/FormGroup.vue rename to code/fms/fms-vue/src/components/ui/form/form-group.vue diff --git a/code/fms/fms-vue/src/components/ui/form/index.scss b/code/fms/fms-vue/src/components/ui/form/index.scss index 8e8d8d21..bd8b4819 100644 --- a/code/fms/fms-vue/src/components/ui/form/index.scss +++ b/code/fms/fms-vue/src/components/ui/form/index.scss @@ -97,6 +97,16 @@ min-width: 0; } +/* 无控件壳的小控件(switch 18px / checkbox 16px,均矮于控件高): + 它们是行内级元素,按文字基线摆放 —— 视觉框底部贴基线,框中心落在 ~9px 处, + 而 label 的行高是控件高(28px)、文字居中在 14px,于是看起来比 label 高一截。 + 这里给它们补足控件高:控件自身是 align-items: center,视觉框会被居中到 14px, + 与 input / select 的 28px 框体同一基准(只加高,不改宽度,也不影响控件区内的其它子元素)。 */ +.fms-form-item-content > :deep(.switch), +.fms-form-item-content > :deep(.checkbox) { + min-height: var(--fms-control-height); +} + /* ---------- 提示文字 ---------- */ .fms-form-item-help, .fms-form-item-extra { diff --git a/code/fms/fms-vue/src/components/ui/index.js b/code/fms/fms-vue/src/components/ui/index.js index 089a7835..0662b5a2 100644 --- a/code/fms/fms-vue/src/components/ui/index.js +++ b/code/fms/fms-vue/src/components/ui/index.js @@ -12,7 +12,7 @@ import TimeSelect from './date/time-select.vue' import Drawer from './drawer/drawer.vue' import Dropdown from './dropdown/dropdown.vue' import Form from './form/form.vue' -import FormGroup from './form/FormGroup.vue' +import FormGroup from './form/form-group.vue' import FormItem from './form/form-item.vue' import Input from './input/input.vue' import Modal from './modal/modal.vue' diff --git a/code/fms/fms-vue/src/components/ui/tag/index.scss b/code/fms/fms-vue/src/components/ui/tag/index.scss index 055066e2..8594b39d 100644 --- a/code/fms/fms-vue/src/components/ui/tag/index.scss +++ b/code/fms/fms-vue/src/components/ui/tag/index.scss @@ -36,6 +36,12 @@ color: var(--fms-text); } +/* 变体:success 成功淡底绿字(与 danger 同构,用「启用」这类正向状态) */ +.tag-success { + background: color-mix(in srgb, var(--fms-success) 10%, transparent); + color: var(--fms-success); +} + /* 变体:danger 危险淡底红字(参考 shadcn destructive) */ .tag-danger { background: color-mix(in srgb, var(--fms-danger) 10%, transparent); diff --git a/code/fms/fms-vue/src/components/ui/tag/tag.vue b/code/fms/fms-vue/src/components/ui/tag/tag.vue index a5f9caeb..dcba89d3 100644 --- a/code/fms/fms-vue/src/components/ui/tag/tag.vue +++ b/code/fms/fms-vue/src/components/ui/tag/tag.vue @@ -5,7 +5,7 @@ import { X } from '@lucide/vue' /* ---------- props / emits / slots ---------- */ /** * 标签视觉变体 - * @typedef {'default' | 'secondary' | 'outline' | 'danger' | 'ghost'} TagVariant + * @typedef {'default' | 'secondary' | 'outline' | 'success' | 'danger' | 'ghost'} TagVariant */ const props = defineProps({ @@ -14,7 +14,8 @@ const props = defineProps({ /** @type {TagVariant} */ type: String, default: 'default', - validator: (value) => ['default', 'secondary', 'outline', 'danger', 'ghost'].includes(value), + validator: (value) => + ['default', 'secondary', 'outline', 'success', 'danger', 'ghost'].includes(value), }, /** 是否可关闭:显示关闭按钮,点击后触发 close 并隐藏自身 */ closable: { type: Boolean, default: false }, diff --git a/code/fms/fms-vue/src/router/routes.js b/code/fms/fms-vue/src/router/routes.js index 1abc286f..3043f8ce 100644 --- a/code/fms/fms-vue/src/router/routes.js +++ b/code/fms/fms-vue/src/router/routes.js @@ -98,23 +98,23 @@ export const routes = [ }, ], }, - // 基础数据:往来单位 / 联系人 / 其他数据等业务主数据维护页 + // 基础数据:合作伙伴 / 联系人 / 其他数据等业务主数据维护页 // 分组节点(见上方「路由分组」注释:不写 component、不写 redirect,子页写相对路径) { path: '/base', children: [ - // 往来单位(合作伙伴)—— 左分类树 + 右模块列表 + // 合作伙伴 —— 左分类树 + 右模块列表 { path: 'othercompany', name: 'base-othercompany', component: () => import('@/views/base/othercompany/index.vue'), meta: { - title: '往来单位', + title: '合作伙伴', componentName: 'BaseOthercompany', moduleId: 'v_b_othercompany', }, }, - // 往来单位详情:新增与编辑都在此页完成(不带参数为新增,?id=xxx 为编辑)。 + // 合作伙伴详情:新增与编辑都在此页完成(不带参数为新增,?id=xxx 为编辑)。 // 静态路径,靠 EXCLUDED_PATHS 排除出菜单管理的「路由地址」下拉。 { path: 'othercompany/detail', diff --git a/code/fms/fms-vue/src/styles/common.scss b/code/fms/fms-vue/src/styles/common.scss index 443d89f3..2e4e953e 100644 --- a/code/fms/fms-vue/src/styles/common.scss +++ b/code/fms/fms-vue/src/styles/common.scss @@ -62,22 +62,45 @@ white-space: nowrap; } -/* 白卡三件套:灰底画布上的内容面。卡间用 gap/margin 留灰缝,不用 border 画分隔线 */ +/* 白卡三件套:灰底画布上的内容面。卡间用 gap/margin 留灰缝,不用 border 画分隔线。 + * 边框是「面板边界」而非分隔线:与左树面板(FmsTreePanel 同样 1px 边框)保持一致, + * 线 + 影叠成双层边界,避免左右两栏一个有线一个只有影、左右轻重不一。 */ .fms-card { background: var(--fms-card); + border: 1px solid var(--fms-border); border-radius: var(--fms-control-radius); box-shadow: var(--fms-shadow); } -/* 卡片内工具条:白底 + 圆角、不投影(与内容卡同一平面,靠灰缝分隔) */ -.fms-toolbar { +/* 内容卡头部条:左标题 + 右动作区。卡头常驻、正文区滚动, + * 动作按钮不随内容滚出视野(模块管理 / 菜单管理的右栏内容卡共用)。 + * 内边距由使用方的卡片自己给(有的卡自带 padding),这里只定排布。 */ +.fms-card__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex: none; + min-width: 0; +} + +/* 头部标题:被动作区挤窄时单行省略 */ +.fms-card__title { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: var(--fms-font-size); + font-weight: 600; + color: var(--fms-text); +} + +/* 头部动作区:不参与压缩、不换行 */ +.fms-card__actions { display: flex; align-items: center; gap: 8px; flex: none; - padding: 8px 10px; - background: var(--fms-card); - border-radius: var(--fms-control-radius); } /* 状态占位:加载中 / 空数据的居中提示,无背景; diff --git a/code/fms/fms-vue/src/utils/powerCodes.js b/code/fms/fms-vue/src/utils/powerCodes.js index 6adc74e3..b038ebd4 100644 --- a/code/fms/fms-vue/src/utils/powerCodes.js +++ b/code/fms/fms-vue/src/utils/powerCodes.js @@ -57,3 +57,18 @@ export function buildActionPowerCode(moduleCode, action) { if (!moduleId || !actionCode) return null return `action.${moduleId}.${actionCode}` } + +/** + * 删除权限点行的依赖声明(保存请求的 dependent_deletes)。 + * + * 权限点行被授权行引用(s_user_power.b_power_id → s_power.b_id):删权限点时授权行必须 + * 一起消失,否则用户会继续"拥有"一个不存在的权限点(设计文档 §14.6)。编码由后端按同一组 + * 删除条件查出,前端不查询、不拼编码 —— 只声明「依赖表 / 键列 / 取值列」交给保存层执行。 + * + * 删权限点的三处入口(菜单删除、模块删除、权限面板删动作)共用本声明,避免各写一份漂移。 + * + * @returns {Array} 直接赋给保存请求的 dependent_deletes 字段 + */ +export function buildPowerDeleteDependents() { + return [{ table: 's_user_power', key_field: 'b_power_id', source_field: 'b_id' }] +} diff --git a/code/fms/fms-vue/src/views/base/othercompany/detail.vue b/code/fms/fms-vue/src/views/base/othercompany/detail.vue index b27559fc..9cc4416d 100644 --- a/code/fms/fms-vue/src/views/base/othercompany/detail.vue +++ b/code/fms/fms-vue/src/views/base/othercompany/detail.vue @@ -1,6 +1,6 @@ + + + + diff --git a/code/fms/fms-vue/src/views/system/user/UserFieldPowerPanel.vue b/code/fms/fms-vue/src/views/system/user/UserFieldPowerPanel.vue new file mode 100644 index 00000000..b7bb246e --- /dev/null +++ b/code/fms/fms-vue/src/views/system/user/UserFieldPowerPanel.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/code/fms/fms-vue/src/views/system/user/UserPowerCopyModal.vue b/code/fms/fms-vue/src/views/system/user/UserPowerCopyModal.vue new file mode 100644 index 00000000..f7dc9e07 --- /dev/null +++ b/code/fms/fms-vue/src/views/system/user/UserPowerCopyModal.vue @@ -0,0 +1,317 @@ + + + diff --git a/code/fms/fms-vue/src/views/system/user/UserPowerDrawer.vue b/code/fms/fms-vue/src/views/system/user/UserPowerDrawer.vue index 76c410cb..9cabce5c 100644 --- a/code/fms/fms-vue/src/views/system/user/UserPowerDrawer.vue +++ b/code/fms/fms-vue/src/views/system/user/UserPowerDrawer.vue @@ -4,7 +4,7 @@ * * s_user_power 功能权限(菜单 / 模块 / 动作三层权限点的勾选,即 menu.* / module.* / action.*) * s_user_field_power 字段权限(按模块 × 字段的 查看 / 编辑 / 查询 / 导出) - * s_user_data_power 数据范围(按模块 × 操作的范围类型与条件) + * s_user_data_power 数据范围(按模块一档范围,本阶段一个模块一条 b_operation = '*' 的规则) * * 布局:右侧 Drawer。头部是常规形态 —— 左侧账号标识(给谁配)、右侧关闭按钮; * 三个页签在内容区顶部,与模块管理页的配置区同一形态(页签只管导航, @@ -12,12 +12,31 @@ * 账号级的「全授权 / 全禁用」在功能权限面板自己的工具栏里(见 UserPowerPanel), * 不占头部,也不会在切到其它页签时仍显示。 * - * 当前「功能权限」已接入;字段权限与数据范围仍为空面板。 + * 左树两种口径:功能权限按菜单(入口 → 页内模块),字段权限 / 数据范围按模块 + * (字段与数据范围都挂在模块上,与菜单无关)。模块树由本组件渲染并持有选中项, + * 两个页签共用同一棵树 —— 切页签时选中模块与展开状态都保留,不用重新找一遍模块。 + * + * 数据契约:四张表的数据都在本组件加载(s_module / s_field / s_power 是全量配置, + * 一次性取回后在前端按模块分组,避免每选一个模块发一次请求);三个面板只做展示与 + * 编辑,结果经 v-model 回传(功能权限是权限编码数组,字段权限与数据范围是行数组), + * 差异比对、校验与保存都在本组件。 + * + * 关闭(遮罩 / Esc / 右上角关闭按钮 / 底部取消)统一走 requestClose:任一份权限有未保存的 + * 改动时先弹确认(保存并离开 / 放弃修改 / 取消),确认后才真正关闭。Drawer 自己 + * emit 的 update:open(false) 不转发 —— 受控的 open 不被置 false 就关不掉,拦截才成立。 + * + * 三个页签均已接入。 */ import { computed, ref, watch } from 'vue' import { Button, Drawer, Message, Tabs } from '@/components/ui' import { loadDataApi, saveObjectApi } from '@/services/api' +import { cloneData, diffRows, hasChanges, tableChange } from '@/utils/dataChanges' +import { buildSortedTree, filterTreeByText } from '@/utils/tree' +import UnsavedChangesModal from '@/components/UnsavedChangesModal.vue' +import FmsTreePanel from '@/components/fms-tree/FmsTreePanel.vue' import UserPowerPanel from './UserPowerPanel.vue' +import UserFieldPowerPanel from './UserFieldPowerPanel.vue' +import UserDataPowerPanel from './UserDataPowerPanel.vue' const props = defineProps({ open: { type: Boolean, default: false }, @@ -51,7 +70,7 @@ const tabItems = [ { key: 'data', label: '数据范围' }, ] -/* ================= 功能权限:加载 ================= */ +/* ================= 数据状态 ================= */ /** 安全 SQL 字符串字面量(search_condition 由后端拼 SQL,需转义单引号) */ function sqlText(value) { return `N'${String(value ?? '').replace(/'/g, "''")}'` @@ -60,14 +79,27 @@ function sqlText(value) { const loading = ref(false) const saving = ref(false) -/** 菜单树(s_menu 全量,b_parent_id 是权威父子关系) */ -const menuTree = ref([]) -/** 菜单编码 → s_menu_module 行数组 */ -const menuModules = ref({}) +/** 待授权用户的编码:按用户过滤的三张授权表都要它,面板新建行时也要写进主键 */ +const userId = computed(() => String(props.user?.b_id || '').trim()) + +/* ---------- 模块树:字段权限 / 数据范围两个页签共用 ---------- */ +/** 模块树行(s_module 全量);名称映射与树数据同一份 */ +const moduleRows = ref([]) /** 模块编码 → 模块行(s_module) */ const moduleById = ref(new Map()) /** 模块编码 → 权限点数组(s_power) */ const powerByModule = ref({}) + +/** 模块树的 UI 状态:搜索词、展开键、选中模块(两个页签共用一套) */ +const moduleKeyword = ref('') +const moduleExpandedKeys = ref([]) +const selectedModuleId = ref('') + +/* ---------- 功能权限 ---------- */ +/** 菜单树(s_menu 全量,b_parent_id 是权威父子关系) */ +const menuTree = ref([]) +/** 菜单编码 → s_menu_module 行数组 */ +const menuModules = ref({}) /** 全部启用的权限点编码(s_power 全量,含 menu.*),供面板「全授权」一次勾满 */ const allPowerIds = ref([]) @@ -76,25 +108,72 @@ const checkedPowerIds = ref([]) /** 保存基线:打开时的已授权集合,保存时据此算增删 */ let checkedBaseline = new Set() +/* ---------- 字段权限 / 数据范围 ---------- */ +/** s_field 全量(b_canuse = 1):字段权限的字段清单 + 数据范围的判断字段候选 */ +const fields = ref([]) +/** 字段权限行(s_user_field_power)与其保存基线 */ +const fieldPowers = ref([]) +const fieldPowers_org = ref([]) +/** 数据范围行(s_user_data_power)与其保存基线 */ +const dataPowers = ref([]) +const dataPowers_org = ref([]) + +/* ---------- 模块树派生 ---------- */ +const moduleTree = computed(() => buildSortedTree(moduleRows.value)) +const filteredModuleTree = computed(() => + filterTreeByText(moduleTree.value, moduleKeyword.value, ['b_name', 'b_id']), +) +const selectedModule = computed(() => moduleById.value.get(selectedModuleId.value) || null) +/** 选中模块的字段(s_field 按模块分组,顺序由加载时的 order_by 保证) */ +const selectedModuleFields = computed(() => + fields.value.filter((field) => String(field.b_module_id) === selectedModuleId.value), +) + /** - * 加载功能权限所需的四份数据。 + * 模块树选中。与功能权限面板的菜单树同一套语义:点虚拟根清空选中, + * 重复点击已选中的模块不取消(右侧内容以"选中一个模块"为前提,没有取消这一说)。 + */ +function onModuleSelect(keys, info) { + if (info?.node?.isFmsRoot) { + selectedModuleId.value = '' + return + } + if (!keys.length) return + selectedModuleId.value = String(keys[0]) +} + +/* ================= 加载 ================= */ +/** + * 加载三类权限所需的全部数据。 * - * s_menu_module 与 s_power 没有按用户过滤的概念(它们是全局配置), - * 一次性取回后在前端按模块编码分组,避免逐个模块发请求(48 个模块就是 48 次)。 + * s_menu_module / s_power / s_field 没有按用户过滤的概念(它们是全局配置), + * 一次性取回后在前端按模块分组,避免逐模块发请求(48 个模块就是 48 次)。 + * 只有三张授权表按用户过滤。 */ async function loadPowerData() { - const user = props.user - const userId = String(user?.b_id || '').trim() - if (!userId) return + const id = userId.value + if (!id) return loading.value = true try { - const [menuRes, bindRes, moduleRes, powerRes, grantedRes] = await Promise.all([ + const [ + menuRes, + bindRes, + moduleRes, + powerRes, + grantedRes, + fieldRes, + fieldPowerRes, + dataPowerRes, + ] = await Promise.all([ loadDataApi('s_menu', 'b_canuse = 1', 'b_depth ASC, b_xh ASC, b_id ASC'), loadDataApi('s_menu_module', 'b_canuse = 1', 'b_menu_id ASC, b_xh ASC'), - loadDataApi('s_module', 'b_canuse = 1', 'b_id ASC'), + loadDataApi('s_module', 'b_canuse = 1', 'b_depth ASC, b_xh ASC, b_id ASC'), loadDataApi('s_power', 'b_canuse = 1', 'b_object_id ASC, b_xh ASC, b_id ASC'), - loadDataApi('s_user_power', `b_user_id = ${sqlText(userId)}`, 'b_power_id ASC'), + loadDataApi('s_user_power', `b_user_id = ${sqlText(id)}`, 'b_power_id ASC'), + loadDataApi('s_field', 'b_canuse = 1', 'b_module_id ASC, b_xh ASC, b_field ASC'), + loadDataApi('s_user_field_power', `b_user_id = ${sqlText(id)}`, 'b_module_id ASC, b_field_id ASC'), + loadDataApi('s_user_data_power', `b_user_id = ${sqlText(id)}`, 'b_module_id ASC, b_operation ASC, b_xh ASC'), ]) menuTree.value = menuRes.data || [] @@ -103,6 +182,7 @@ async function loadPowerData() { for (const row of moduleRes.data || []) { moduleMap.set(String(row.b_id), row) } + moduleRows.value = moduleRes.data || [] moduleById.value = moduleMap // s_menu_module → { [b_menu_id]: [行] };只保留模块确实存在的绑定 @@ -139,14 +219,30 @@ async function loadPowerData() { .map((row) => String(row.b_power_id)) checkedPowerIds.value = granted checkedBaseline = new Set(granted) + + // 字段权限 / 数据范围:两张授权表按用户过滤;b_canuse = 0 的行不参与(与功能权限同一口径)。 + // 这两份是"行"而不是"编码",基线与当前值都留整行,保存时按主键 diff。 + fields.value = fieldRes.data || [] + fieldPowers.value = (fieldPowerRes.data || []).filter((row) => Number(row.b_canuse) === 1) + fieldPowers_org.value = cloneData(fieldPowers.value) + dataPowers.value = (dataPowerRes.data || []).filter((row) => Number(row.b_canuse) === 1) + dataPowers_org.value = cloneData(dataPowers.value) } catch (error) { Message.error('权限数据加载失败:' + (error?.message || error)) menuTree.value = [] menuModules.value = {} + moduleRows.value = [] + moduleById.value = new Map() powerByModule.value = {} allPowerIds.value = [] checkedPowerIds.value = [] checkedBaseline = new Set() + fields.value = [] + fieldPowers.value = [] + fieldPowers_org.value = [] + dataPowers.value = [] + dataPowers_org.value = [] + selectedModuleId.value = '' } finally { loading.value = false } @@ -162,12 +258,17 @@ watch( { immediate: true }, ) -/* ================= 功能权限:保存 ================= */ +/* ================= 保存:三张表一次提交 ================= */ +/** 字段权限 / 数据范围的主键列(行身份 = 主键列值组合,见 diffRows) */ +const FIELD_POWER_KEYS = ['b_user_id', 'b_module_id', 'b_field_id'] +const DATA_POWER_KEYS = ['b_user_id', 'b_module_id', 'b_operation', 'b_xh'] + /** - * 保存功能权限:勾选 = 拥有(s_user_power 插入),取消 = 撤销(删除行)。 - * s_user_power 是「拥有」语义,不置 b_canuse = 0。 + * 功能权限的变更:勾选 = 拥有(s_user_power 插入),取消 = 撤销(删除行)。 + * 它的模型是权限编码数组(不是行),所以自己算增删;s_user_power 是「拥有」语义, + * 不置 b_canuse = 0。没有变化时返回 null,避免空请求。 */ -async function savePower(userId) { +function buildPowerSaveReq(userId) { const current = new Set(checkedPowerIds.value.map(String)) const inserts = [...current] .filter((id) => !checkedBaseline.has(id)) @@ -176,13 +277,52 @@ async function savePower(userId) { .filter((id) => !current.has(id)) .map((b_power_id) => ({ b_user_id: userId, b_power_id })) - if (!inserts.length && !deletes.length) return false + const changes = {} + if (inserts.length) changes.inserts = inserts + if (deletes.length) changes.deletes = deletes + return hasChanges(changes) ? tableChange('s_user_power', changes, ['b_user_id', 'b_power_id']) : null +} - const request = { table: 's_user_power', key_field: 'b_user_id,b_power_id' } - if (inserts.length) request.inserts = inserts - if (deletes.length) request.deletes = deletes - await saveObjectApi([request]) - return true +/** 字段权限的变更:按行 diff(行里只有四项能力,改一格就是一次 update) */ +function buildFieldPowerSaveReq() { + const changes = diffRows(fieldPowers.value, fieldPowers_org.value, { + keyFields: FIELD_POWER_KEYS, + }) + return hasChanges(changes) ? tableChange('s_user_field_power', changes, FIELD_POWER_KEYS) : null +} + +/** 数据范围的变更:按行 diff(主键含规则序号 b_xh,新增行的序号由面板分配) */ +function buildDataPowerSaveReq() { + const changes = diffRows(dataPowers.value, dataPowers_org.value, { + keyFields: DATA_POWER_KEYS, + }) + return hasChanges(changes) ? tableChange('s_user_data_power', changes, DATA_POWER_KEYS) : null +} + +/** 未保存确认弹窗(遮罩 / Esc / 右上角关闭 / 底部取消 都先经过它) */ +const leaveConfirmOpen = ref(false) + +/** + * 是否有未保存的改动:三类权限分别与自己的基线比。 + * 功能权限是「拥有」语义的编码集合,只比集合本身、不比顺序,基线是普通 Set + * 不参与渲染,所以用函数而不是 computed —— 只有"要不要拦一次关闭"需要问它。 + */ +function hasUnsavedChanges() { + if (hasPowerChanges()) return true + if (hasChanges(diffRows(fieldPowers.value, fieldPowers_org.value, { keyFields: FIELD_POWER_KEYS }))) + return true + return hasChanges( + diffRows(dataPowers.value, dataPowers_org.value, { keyFields: DATA_POWER_KEYS }), + ) +} + +function hasPowerChanges() { + const current = new Set(checkedPowerIds.value.map(String)) + if (current.size !== checkedBaseline.size) return true + for (const id of current) { + if (!checkedBaseline.has(id)) return true + } + return false } /* ---------- 抽屉动作 ---------- */ @@ -190,20 +330,66 @@ function close() { emit('update:open', false) } +/** + * 关闭抽屉的唯一入口。有未保存的改动时先弹确认,确认后才真关; + * 所有关闭意图(遮罩 / Esc / 右上角关闭按钮 / 底部取消)都汇到这里 —— + * Drawer 自己 emit 的 update:open(false) 不往外转发(见模板),否则一次误点 + * 遮罩就把没落库的授权丢掉了。 + */ +function requestClose() { + if (saving.value) return + if (hasUnsavedChanges()) { + leaveConfirmOpen.value = true + return + } + close() +} + +/** 「保存并离开」:保存成功才关闭;失败留在抽屉里让用户处理 */ +async function onLeaveSave() { + const done = await save() + if (!done) return + leaveConfirmOpen.value = false + close() +} + +/** 「放弃修改」:直接关闭,重开时按库里的数据重新加载(基线不更新) */ +function onLeaveDiscard() { + leaveConfirmOpen.value = false + close() +} + +/** + * 保存三类权限。三张表在同一个事务里提交(后端 saveobjt 即一个事务), + * 只有真正变化的表才进请求;全部无变化时提示而不发请求。 + * 返回是否已处理完(失败返回 false,调用方据此决定能否离开)。 + */ async function save() { - const userId = String(props.user?.b_id || '').trim() - if (!userId || saving.value) return + const id = userId.value + if (!id || saving.value) return false + saving.value = true try { - const changed = await savePower(userId) - if (changed) { - checkedBaseline = new Set(checkedPowerIds.value.map(String)) - Message.success('权限已保存') - } else { + const requests = [ + buildPowerSaveReq(id), + buildFieldPowerSaveReq(), + buildDataPowerSaveReq(), + ].filter(Boolean) + if (!requests.length) { Message.info('没有需要保存的改动') + return true } + + await saveObjectApi(requests) + // 保存成功后三份基线一起跟上当前值(下次保存只提交新的变化) + checkedBaseline = new Set(checkedPowerIds.value.map(String)) + fieldPowers_org.value = cloneData(fieldPowers.value) + dataPowers_org.value = cloneData(dataPowers.value) + Message.success('权限已保存') + return true } catch (error) { Message.error('保存失败:' + (error?.message || error)) + return false } finally { saving.value = false } @@ -211,14 +397,15 @@ async function save() {