From 6b9cda54242c956f3ce2b024623ad077d328c105 Mon Sep 17 00:00:00 2001 From: oneao Date: Tue, 1 Sep 2026 22:20:55 +0800 Subject: [PATCH] 20260901222055 --- code/fms/FMS删除规则引擎设计.md | 290 +++ .../fms-vue/src/components/I18nNameModal.vue | 111 ++ .../src/layouts/components/AppSidebar.vue | 1 + code/fms/fms-vue/src/router/index.js | 7 + code/fms/fms-vue/src/theme/tokens.css | 2 +- .../views/system/menu-management/index.vue | 1588 +++++++++++++++++ .../module-management/ModuleFieldsPanel.vue | 5 +- .../module-management/ModuleI18nPanel.vue | 30 +- .../module-management/ModulePowersPanel.vue | 172 -- .../ModuleQueryConfigPanel.vue | 6 +- .../views/system/module-management/index.vue | 157 +- .../unit/delete-confirm-regression.spec.js | 35 - 12 files changed, 2129 insertions(+), 275 deletions(-) create mode 100644 code/fms/FMS删除规则引擎设计.md create mode 100644 code/fms/fms-vue/src/components/I18nNameModal.vue create mode 100644 code/fms/fms-vue/src/views/system/menu-management/index.vue delete mode 100644 code/fms/fms-vue/src/views/system/module-management/ModulePowersPanel.vue diff --git a/code/fms/FMS删除规则引擎设计.md b/code/fms/FMS删除规则引擎设计.md new file mode 100644 index 00000000..d42771f1 --- /dev/null +++ b/code/fms/FMS删除规则引擎设计.md @@ -0,0 +1,290 @@ +# FMS 删除规则设计 + +> 版本:v1.0。关联:`FMS新系统核心表结构设计.md`。 +> 一句话:**删之前先查一查,查不过就拒绝;查什么、怎么查,由 IT 配置,不写代码。** + +## 这个设计解决什么问题 + +业务上「能不能删」的规矩五花八门: + +- 单据还在审核中,不许删 +- 已经销账了,不许删 +- 核销过钱的,不许删 +- 费用已经做到账单里了,不许删 +- 删报销单时,下面的费用明细要跟着一起删 + +以前这些规矩要么写死在代码里(改一条要发版),要么干脆没有(误删了才发现)。现在的做法:**IT 在界面上配置,配完立即生效**。 + +## 1. 整体图景 + +一个用户点「删除」,系统背后做四件事: + +``` +1. 找出连带要删的记录(删报销单 → 连费用明细、附件一起) +2. 把配置的所有删除规则挨条查一遍 +3. 任何一条不过 → 整批拒绝,告诉用户为什么 +4. 全过 → 先弹预览给用户确认,再一个事务里一起删 +``` + +IT 要做的事只有一件:**配规则**。配的地方在模块管理。 + +## 2. 规则:一条就是一句「什么情况不许删」 + +每条规则就是一句话: + +> **当【什么条件】时,不许删,提示【什么话】** + +规则有三种写法,按配的难度从易到难: + +### 2.1 勾条件(日常用这个,占绝大多数) + +在模块管理 → 「删除规则」tab 点「新增规则」,弹出来的界面和**查询配置长得一样**: + +``` +满足以下条件时,不许删除: +┌──────────┬────────┬─────────────────┐ +│ 字段 │ 操作符 │ 值 │ +│ 单据状态 │ 属于 │ [审核中, 已生效] │ +│ 已销账 │ 等于 │ 1 │ +└──────────┴────────┴─────────────────┘ +``` + +- 字段下拉就是该模块字段定义里的字段 +- 值可以直接填,也可以选另一个字段比(比如「已核销金额 大于 0」) +- 提示文案**不用写**——系统自动生成「单据 SKD-2026-001 状态为审核中,不能删除」 + +会配查询条件的人,零学习成本。 + +### 2.2 勾引用(防误删最常用) + +在**关系配置**上勾一下「有引用时拒绝删除」。 + +效果:这模块的记录只要被别的单据引用着(费用被账单引用),就删不掉,提示「费用【海运费 ¥2,000】已被账单 ZD-2026-001 引用」。 + +模块的删除规则 tab 里能看到这个勾的只读展示和跳转,不会迷路。 + +### 2.3 写 SQL(兜底,藏在角落) + +界面勾不出来的判断——比如「核销金额加起来大于 0」这种要查别的表的——在规则类型的「高级」选项里直接写一句 SQL: + +```sql +select b.b_id, b.b_no from bill b +join bill_detail d on d.bill_id = b.b_id +where b.b_id in (:ids) and d.writeoff > 0 +``` + +约定就两条:`:ids` 会自动换成当前删的这批单据的 id;**查出来有结果就拦**。提示文案自己填,可以 `{b_no}` 这样引用查询结果的列。 + +平时用不到,会写的人自己找得到,不会打扰别人。谁改过这条 SQL、每次执行了什么,系统日志里全程有记录(内部系统,不做限制,只做留痕)。 + +## 3. 连带删除:删主单时子表跟着删 + +这个不用配条件,在模块关系上选一个「删除时连带」的策略: + +| 选项 | 意思 | +|---|---| +| 连带删除 | 删报销单,费用明细一起物理删掉 | +| 连带归档 | 明细不删,打个「已归档」标记,列表里不再显示,账单引用还能追溯 | +| 不连带 | 明细自己管自己 | + +关键行为: + +- **查连带时也过规则**:删报销单连带要删费用,但费用被账单引用着 → 整个删除(包括报销单)被拒绝,提示定位到那笔费用和那个账单 +- **一荣俱荣一损俱损**:任何一条卡住,整批都删不了,不存在删一半的中间状态 +- 关系套关系(单→明细→附件)会自动一层层找下去;层数太多(比如配置成了环)会直接报配置错误,不会死循环 + +## 4. 删除的操作流程(业务用户视角) + +列表页勾几行点删除,系统**先预览后真删**: + +``` +第一步:系统先算一遍 + ↓ 有问题? → 弹错误列表:「这 3 条审核中删不了」「这笔费用被账单引用」 + ↓ 没问题? → 弹确认框:「将删除 1 张报销单 + 3 条费用明细,确认?」 + +第二步:用户确认后才真删(删之前系统再快速查一遍,防止确认的几秒里有变化) +``` + +错误提示都是人话,不用猜。批量删 500 行时提示按规则归类显示,不会刷屏。 + +## 5. 配置入口在哪 + +**配规则**(IT 日常): + +- 模块管理 → 找到模块 → 「删除规则」tab → 新增 +- 每条规则 3 个格子一分钟;改完立即生效,**不用发版** +- 常见规则可以**复制到其他模块**(多选目标模块批量克隆)——「审核中不可删」这种几十个模块通用的规矩,配一次、勾一圈目标模块就完事 + +**看全貌**(排查、盘点): + +- 系统管理 → 「业务规则」:全系统所有规则的列表——哪个模块的、什么类型、谁最后改的、什么时候。点行跳到对应模块 +- 排查「这单为什么删不掉」:错误提示里带规则名 → 全局列表找到它 → 看条件/SQL → 完事 + +**谁可以配**:规则的增删改走权限控制(和菜单权限同体系)。看规则不受限。 + +## 6. 数据落库(给开发看的部分) + +### 6.1 s_rule 规则表(新增) + +```sql +create table s_rule ( + b_id bigint not null, + b_code varchar(50) not null, -- 稳定标识(复制/导入导出用) + b_name nvarchar(100) not null, -- 规则名(如"审核中不可删") + b_scope_type varchar(20) not null, -- module / relation:挂在模块还是关系上 + b_scope_id bigint not null, -- 挂在哪个模块/哪条关系上 + b_hook varchar(30) not null, -- delete.pre(一期只做这个) + -- save.pre(将来做"已审核不能改") + b_predicate nvarchar(max) null, -- 条件(勾条件=JSON;写SQL=SQL文本;勾引用=空) + b_message nvarchar(500) null, -- 提示文案(写SQL的必填;勾条件的可留空自动生成) + b_canuse int not null default 1, + b_xh int not null default 0, -- 多条规则谁先查(并列按 b_xh, b_id) + b_inputuser_id bigint null, + b_inputdatetime datetime2 null default sysutcdatetime(), + b_updateuser_id bigint null, + b_updatedatetime datetime2 null, + constraint pk_s_rule primary key (b_id), + constraint ux_s_rule_code unique (b_code) +); + +-- b_scope_type / b_hook 的取值范围不建 CHECK 约束,由服务层校验 +-- (与全系统「元数据合法性走服务层」的原则一致,新增取值不用改表) + +create index ix_s_rule_scope on s_rule (b_scope_type, b_scope_id, b_hook, b_canuse, b_xh); +``` + +要点: + +- **模块上没有任何规则 = 直接删**(无约束) +- 想要旧「restrict 主数据」的效果?一键模板:给所有入边关系批量勾上引用规则 +- 错误类型不用存——勾条件报「状态不符」、勾引用报「被引用」、写 SQL 报自定义文案,写法本身决定了报什么 +- 提示文案只存这一列,不重复塞进条件 JSON 里 +- **删模块/删关系时,服务层把挂着的规则一起删掉**(和字段、权限点同一套清理逻辑,全系统本来就不建物理外键) +- 删规则 = 物理删 + 日志记全文;临时不想用某条规则 → 停用(b_canuse=0),不删 + +### 6.2 老表的变化 + +```sql +-- s_module:删掉 b_delete_policy(就是那个 restrict/lifecycle/hard 三选一,表达力不够,废弃) +-- s_relation:删掉 b_delete_policy,新增: +alter table s_relation add + b_on_delete varchar(20) null; -- cascade连带删 / archive连带归档 / none不连带(默认) +``` + +存量数据转换:旧 cascade→cascade;旧 archive→archive;旧 restrict→不连带+生成引用规则;旧 set-null→丢弃(本就不该有)。 + +### 6.3 勾条件那部分的格式(JSON,界面生成,人不手写) + +```json +{ "kind": "dsl", "expr": + { "and": [ + { "in": [ { "field": "b_status" }, { "const": ["draft", "rejected"] } ] }, + { "eq": [ { "field": "b_settled" }, { "const": 0 } ] } + ] } +} +``` + +开发注意三条: + +- 只能引用本模块的字段(想跨表?去写 SQL 规则),后端翻译成**参数化 SQL**,没有注入风险 +- 常量按字段类型校验(数字/日期/文本),和查询配置的校验规则共用一套 +- 防手滑:条件嵌套最多 8 层、最多 100 个节点(界面上分组最多 3 层,比后端还紧) + +### 6.4 后端删除流程 + +**引擎拆成两个部件:检查器 和 执行器**。执行器管连带和真删;检查器只查规则(一个方法:`check(连接, 模块, ids, 小闭包)`)。两个入口共用同一个检查器: + +``` +删除(模块, ids, 是否预览): + + ① 找连带:从这批 ids 出发,沿"连带删/连带归档"的关系一层层查, + 全部装进删除清单(已装过的跳过——防关系成环;超过10层报配置错误) + ② 查规则:按顺序逐条执行挂在清单上的删除规则 + 勾条件 → 按条件查清单内记录,不符的记一条错误(带实际值) + 写SQL → 拿这批 ids 跑那条 SQL,查出结果每行记一条错误(全程写日志) + 勾引用 → 查有没有清单外的单据引用清单内记录,有则记错误(带引用方单号) + ③ 有错误 → 整批拒绝,返回错误列表(按规则归类,每类带数量和前几条示例) + ④ 全过 → 只是预览?返回"将删除 X 张单 + Y 条明细"摘要 + 真删?→ 开事务:再快速过一遍规则(防确认间隙有变化) + → 归档的打标记 → 按先子后父的顺序物理删 + → 记审计日志(按删的这一批记一条 + 明细) +``` + +开发注意: + +- 查库全部按批 IN,不逐行循环 +- ids 数量不设上限——引擎内部把整批 id 装成一个数组参数(`OPENJSON`),不受绑定变量 2100 个的限制 +- 真删前在事务里重查一遍规则(预览的通过**不算数**),防确认的几秒里有人改了规则或新增了引用 +- 写 SQL 规则的每次执行:实际 SQL + 参数 + 结果行数都写进技术日志,关联本次请求——排查「为什么删不掉」从错误提示一路能追到原始 SQL + +### 6.5 saveobjt 保存时删行,规则同样生效(重要) + +**问题**:海运编辑这类主子表表单,保存走通用接口 `saveobjt`——前端把主表 + 子表(费用)的增删改 diff 成一个请求数组,一个事务提交。用户在表单里删掉一行费用,就是请求数组里子表的 `deletes` 段。**这条删除路径如果不过规则,配置就白配了**——列表页删除被拦,表单里删行却畅通无阻。 + +**接法**(对前端完全透明,入参不变): + +``` +DataSaveService.saveTable() 的 delete 分支,执行真删之前: + + ① 按表名反查模块(b_savetable = 这张表的 data 模块);查不到模块(如日志表)→跳过 + ② 该模块挂有删除规则 → 调检查器,传当前 connection(同一个事务!) + + 待删的这批行 + ③ 任一规则不过 → 抛 BusinessException(文案就是规则生成的提示) + → 整个 saveobjt 回滚——主表的修改和子表的删除一起回滚, + 用户的前端草稿还在,知道哪行费用删不掉、为什么 + ④ 无规则或全过 → 照常执行 deletes +``` + +三个关键点: + +- **同事务检查**:检查器用的就是 saveobjt 自己的 connection——表单里先改了主表状态、再删子表行,规则查到的是**改过之后**的值,不会出现「检查用旧数据、删的是新数据」的错位 +- **小闭包语义**:勾引用规则检查子表行时,要把「本次 saveobjt 所有 deletes 覆盖的记录」当作一个小闭包——引用方也在被删之列就不算外部引用。场景:删主单连带删子表行(前端编排的),子表对主单的引用指向一个同批正在删除的记录,不该拦。实现:把请求数组里所有 deletes 的 id 收集起来传给检查器 +- **不需要预览**:表单保存是「保存」动作,用户预期就是要么全成功要么报错,不做两阶段确认 + +### 6.6 接口 + +``` +POST /data/deleteobj +{ + "moduleCode": "receipt", + "ids": [1, 2, 3], + "dryRun": true // true=只预览(前端删除按钮的第一步) +} + +POST /data/saveobjt // 不变,deletes 分支内部自动走规则检查 +``` + +## 7. 其他约定 + +- **字段联动**:规则里用了的字段,删除该字段时会被拦住并提示规则名——不让规则悄悄失效 +- **配置权限**:改规则要权限点(如 `rule.edit`),和菜单权限同一套体系;SQL 不限制内容,但改它的人必须有配置权限 +- **归档标记**:连带归档要求子表有通用列 `b_archived` + `b_archive_datetime`(配归档时服务层校验列存在);通用查询默认不显示已归档行 +- **将来扩展**(表结构已留位,暂不实现): + - 「已审核的单不能**改**」——同一套规则换个挂点(save.pre)就行,界面和错误提示全复用 + - 「删之前先自动红冲」这种要执行动作的——预留了挂 Java 代码的口子 + - 核销总额想在列表上显示成列——那是展示需求,将来单独做,跟删除无关 + +## 8. 开工顺序 + +1. 建表 s_rule;删老字段、加 `b_on_delete`(存量按 §6.2 转换);前端基本信息表单同步去掉「删除策略」下拉 +2. 后端:条件(JSON → 参数化 SQL)+ 检查器 + 执行器(连带展开 → 规则 → 事务删除) +3. **saveobjt 接入检查器**:delete 分支反查模块 → 同事务规则检查 → 不过整体回滚(见 §6.5) +4. 错误提示:条件自动生成人话 + 字段格式化(金额带 ¥、日期带格式)+ SQL 占位符填充 +5. 前端:删除先预览后确认、错误列表展示;模块管理「删除规则」tab(条件构造 + 复制到模块 + SQL 角落入口);系统管理全局规则列表页 +6. 二期:「已审核不能改」、Java 钩子、聚合展示 + +## 9. 定案记录 + +| # | 事项 | 结论 | +|---|---|---| +| 1 | 节奏 | 一期只做删除规则;表结构给保存校验留好位 | +| 2 | 跨表判断 | 不做计算字段,直接写 SQL 兜底 | +| 3 | SQL | 兜底逃生舱:不限制内容、不做体验优化、入口藏角落;**每次执行全程写日志** | +| 4 | 旧 set-null | 废弃(财务上不该有悬空引用) | +| 5 | 归档 | 通用列 `b_archived` + `b_archive_datetime` | +| 6 | 条件规模 | 嵌套 ≤ 8 层、节点 ≤ 100;界面分组 ≤ 3 层 | +| 7 | 错误类型 | 不单独存列,由规则写法决定 | +| 8 | 级联清理 | 删模块/关系时业务层连带删规则(同权限点模式) | +| 9 | 批量 | ids 不设上限;整批一个数组参数 | +| 10 | 复制 | 规则可多选模块批量克隆;引用规则除外 | +| 11 | saveobjt 集成 | 引擎拆检查器/执行器;saveobjt 的 deletes 分支同事务调检查器,不过则整批回滚(§6.5);前端入参不变 | diff --git a/code/fms/fms-vue/src/components/I18nNameModal.vue b/code/fms/fms-vue/src/components/I18nNameModal.vue new file mode 100644 index 00000000..361009bc --- /dev/null +++ b/code/fms/fms-vue/src/components/I18nNameModal.vue @@ -0,0 +1,111 @@ + + + + + \ No newline at end of file diff --git a/code/fms/fms-vue/src/layouts/components/AppSidebar.vue b/code/fms/fms-vue/src/layouts/components/AppSidebar.vue index 0fa43dc2..83f865c2 100644 --- a/code/fms/fms-vue/src/layouts/components/AppSidebar.vue +++ b/code/fms/fms-vue/src/layouts/components/AppSidebar.vue @@ -56,6 +56,7 @@ const navMain = computed(() => { icon: Settings, items: [ { title: '模块管理', url: '/system/module-management' }, + { title: '菜单管理', url: '/system/menu-management' }, ], }) diff --git a/code/fms/fms-vue/src/router/index.js b/code/fms/fms-vue/src/router/index.js index 5f9536fb..4e4a3593 100644 --- a/code/fms/fms-vue/src/router/index.js +++ b/code/fms/fms-vue/src/router/index.js @@ -101,6 +101,13 @@ const router = createRouter({ component: () => import('@/views/system/module-management/index.vue'), meta: { title: '模块管理', componentName: 'ModuleManagement' }, }, + // 菜单管理:占位页面,待实现 + { + path: '/system/menu-management', + name: 'system-menu-management', + component: () => import('@/views/system/menu-management/index.vue'), + meta: { title: '菜单管理', componentName: 'MenuManagement' }, + }, // 业务页面后续在此追加,配置 meta.moduleId 后自动走权限校验 ], }, diff --git a/code/fms/fms-vue/src/theme/tokens.css b/code/fms/fms-vue/src/theme/tokens.css index f5504f49..422d6ed2 100644 --- a/code/fms/fms-vue/src/theme/tokens.css +++ b/code/fms/fms-vue/src/theme/tokens.css @@ -107,7 +107,7 @@ /* 栅格(grid):默认水平/垂直间距(不随主题变化) */ --fms-grid-gutter: 0px; - --fms-splitter-bar-size: 1px; + --fms-splitter-bar-size: 3px; --fms-splitter-bar-size-hover: 4px; --fms-splitter-handle-size: 20px; diff --git a/code/fms/fms-vue/src/views/system/menu-management/index.vue b/code/fms/fms-vue/src/views/system/menu-management/index.vue new file mode 100644 index 00000000..dcd24d2c --- /dev/null +++ b/code/fms/fms-vue/src/views/system/menu-management/index.vue @@ -0,0 +1,1588 @@ + + + + + \ No newline at end of file diff --git a/code/fms/fms-vue/src/views/system/module-management/ModuleFieldsPanel.vue b/code/fms/fms-vue/src/views/system/module-management/ModuleFieldsPanel.vue index a080e3ac..e022c6ac 100644 --- a/code/fms/fms-vue/src/views/system/module-management/ModuleFieldsPanel.vue +++ b/code/fms/fms-vue/src/views/system/module-management/ModuleFieldsPanel.vue @@ -201,8 +201,8 @@ watch( function buildColumns() { const baseColumns = [ - { dataIndex: 'b_field', title: '字段名', width: 150 }, - { dataIndex: 'b_name', title: '字段名称', width: 140, editor: { type: 'input' } }, + { dataIndex: 'b_field', title: '字段名', width: 150, sorter: true }, + { dataIndex: 'b_name', title: '字段名称', width: 140, sorter: true, editor: { type: 'input' } }, { dataIndex: 'b_type', title: '控件类型', @@ -251,6 +251,7 @@ function buildColumns() { [] }, - /** 权限草稿(b_code / b_name),受管键依赖它 */ - powers: { type: Array, default: () => [] }, /** 分组草稿(b_id / b_title / b_i18n),受管键依赖它 */ groups: { type: Array, default: () => [] }, /** index.vue 加载的原始 s_i18n 扁平行([{b_id,b_key,b_locale,b_value}]) */ @@ -49,13 +47,12 @@ const defaultLocale = computed(() => langOptions.value[0]?.code || '') const localRows = ref([]) /** 保存 diff 基线:加载时的原始扁平行 */ const originalsFlat = ref([]) -const searchKey = ref('') const activeKey = ref(null) const tableRef = ref(null) const selectedRowKeys = ref([]) /* ================= 受管键收集 ================= */ -// 模块 / 字段 / 权限 / 分组的翻译键是系统自动生成的,必须始终存在: +// 模块 / 字段 / 分组的翻译键是系统自动生成的,必须始终存在: // 收集这些键(含默认名称),供 reconcile 补行、跟随名称、清理失效键。 const finalI18nItems = computed(() => { const module = props.module @@ -74,10 +71,6 @@ const finalI18nItems = computed(() => { const key = buildModuleItemI18nKey('field', module.b_code, field.b_field) if (key) items.push({ key, managed: true, defaultValue: field.b_name || '', id: `field:${key}` }) } - for (const power of props.powers || []) { - const key = buildModuleItemI18nKey('power', module.b_code, power.b_code) - if (key) items.push({ key, managed: true, defaultValue: power.b_name || '', id: `power:${key}` }) - } // 分组标题多语言:b_i18n 为用户手填 key(空则运行时直接用标题原文,不纳入受管) for (const group of props.groups || []) { const key = String(group.b_i18n || '').trim() @@ -236,18 +229,13 @@ function batchDelete() { if (activeKey.value && delIds.has(String(activeKey.value))) activeKey.value = null } -const filteredRows = computed(() => { - const kw = String(searchKey.value || '').trim().toLowerCase() - if (!kw) return localRows.value - return localRows.value.filter((row) => String(row.b_key || '').toLowerCase().includes(kw)) -}) - const columns = computed(() => { const cols = [ { dataIndex: 'b_key', title: '多语言键', width: 260, + sorter: true, editor: { type: 'input', disabled: (row) => isManagedRow(row) }, }, ] @@ -256,6 +244,8 @@ const columns = computed(() => { dataIndex: lang.code, title: lang.name, width: 200, + // 默认语言列(简体中文)开启排序 + sorter: lang.code === defaultLocale.value, editor: { type: 'input' }, }) } @@ -344,9 +334,6 @@ defineExpose({ buildSaveRequest, applyInsertedIds, removeByManagedIds })