20260917212907

This commit is contained in:
oneao committed 2026-09-17 21:29:08 +08:00
1 parent 6fa3d8535e
commit 5cbf745bbf
115 files changed
+4104 -85

No files matched your search

@@ -0,0 +1,164 @@
# 删除规则一期(配置侧)实现方案
## Context
「删除规则引擎」的设计已定稿([FMS删除规则引擎设计.md](file:///d:/workspace/code/fms/FMS删除规则引擎设计.md)),但代码侧**一期尚未开工**:`s_rule` 表不存在、`s_relation` 没有 `b_on_delete`、模块管理里没有规则 tab、后端无 `/data/deleteobj`。
本次做**一期:配置侧的元数据落地**(前端 + SQL 脚本,不含后端引擎)。完成后 IT 就能把规则配起来,但**不改变现有删除行为**——没有引擎去读这些配置。二期再写检查器/执行器与 `saveobjt` 接入。
职责划分(这是本次的设计主线):
| | 性质 | 落哪 | 配置入口 |
| --- | --- | --- | --- |
| 结构性删除动作(连带删除/连带归档) | 无条件,跟"谁引用谁"绑定 | `s_relation.b_on_delete` | 模块关联面板 |
| 引用检查(有引用时拒绝删除) | 无条件(存在引用即拒) | `s_rule`,`scope_type='relation'` | 模块关联面板勾选 |
| 业务条件(审核中/已销账/核销金额>0…) | 带条件、长尾 | `s_rule`,`scope_type='module'` | 模块管理「删除规则」tab(勾条件 / 写 SQL) |
关系表只说结构与结构性动作,**条件一律不进关系表**。
## 与设计文档的偏离(3 处,均为落地适配)
1. **`s_rule` 用业务联合主键** `(b_scope_type, b_scope_id, b_code)`,砍掉 `b_id`(bigint 雪花) 与 `ux_s_rule_code`。理由:全仓元数据表都用业务键/复合键,[开发规范.md](file:///d:/workspace/code/fms/开发规范.md#L187) 第 6 条要求配置表用业务键、不用雪花 ID;且文档的 `unique(b_code)` 与"规则可复制到其他模块"互相矛盾,**scope 内唯一**才能复制。
2. **`b_scope_id` 用 `varchar(250)`**:模块是编码 `varchar(50)`,关系是 4 列各 50 的复合键(拼串最长约 203),文档里的 `bigint` 装不下(这一点文档 §6.1 与 §6.2 本身不自洽)。
3. **DSL 的 `expr` 直接复用现有查询 AST**:`{"kind":"dsl","expr":buildAdvancedAst({nodes,groups})}`,即 `{type:'group',children:[{type:'condition',field,operator,value,join}…]}`。前端已有 `astToSql` 能把同构 AST 翻成 SQL 片段,避免自造第二套语法(文档 §6.3 的 `and/in` 写法是同一信息的另一种表述)。
另外遵循文档定案:**规则类型不单独存列**,由 `b_predicate` 推断——空 → 引用规则;可解析为 JSON → 勾条件;否则 → SQL 文本。`b_hook` 一期固定 `'delete.pre'`。`b_archived/b_archive_datetime` 一期**不落列**(引擎启用时才校验)。
## SQL(2 个文件)
`sql/fms_core.sql`(全新安装,紧接 §9 关系段之后新增第 9.1 节,并在 s_relation 建表段补列):
```sql
alter table dbo.s_relation add b_on_delete varchar(20) not null default 'none' -- none/cascade/archive
```
```sql
create table dbo.s_rule (
b_scope_type varchar(20) not null, -- module / relation
b_scope_id varchar(250) not null, -- module=模块编码;relation=源模块|源字段|目标模块|目标字段
b_code varchar(50) not null, -- 规则编码(scope 内唯一,复制/导入导出的稳定标识)
b_name nvarchar(100) not null, -- 规则名(如"审核中不可删")
b_hook varchar(30) not null, -- delete.pre(save.pre 留位)
b_predicate nvarchar(max) null, -- 空=引用规则;JSON=勾条件;其余=SQL 文本
b_message nvarchar(500) null, -- 提示文案(写 SQL 必填)
b_canuse tinyint not null default 1,
b_xh int not null default 0,
b_created_by varchar(50) null, b_created_at datetime2 null, -- 前端不提交,二期由服务端维护
b_updated_by varchar(50) null, b_updated_at datetime2 null,
primary key (b_scope_type, b_scope_id, b_code)
);
create index ix_s_rule_scope on dbo.s_rule (b_scope_type, b_scope_id, b_hook, b_canuse, b_xh);
```
- `b_on_delete` 用 `not null default 'none'`:存量行 alter 时回填 none,新增行与存量行取值一致 → 打开即不脏。
- 新增增量脚本 `sql/fms_delete_rule.sql`(沿用 [fms_rename_othercompany_category.sql](file:///d:/workspace/code/fms/sql/fms_rename_othercompany_category.sql) 的风格:头部注释写背景与前置核查「确认无 s_rule、s_relation 无 b_on_delete」,正文 `alter table` + `create table/index`,GO 分批)。已有库执行走 `fms-api/tools/migration/RunSqlFile.java`(GO 分批、单事务、失败整体回滚),先 `--dry-run` 再实跑。
- 后端**零改动**:`loaddata/saveobjt` 无表白名单,复合主键用逗号拼 `key_field`,deletes 按行内列值 AND 匹配。
## 前端改动
### 新增 3 个文件
**`src/views/module/module-management/ruleUtils.js`**
`relationScopeId(row)`(四列 `|` 拼串,与主键同序)/ `parseRulePredicate(row)`(三态)/ `ruleSummary(row, itemByField, t)`(dsl 走 `describeAdvancedQuery`,sql 取单行化前 80 字)/ `nextRuleCode(rules)`(`rule_{最大序号+1}`,可改)/ `createRuleRow(moduleId, seq)` / `createRefRuleRow(scopeId)`(`b_code='referenced'`,一个关系最多一条)/ `buildRuleQueryItems(fields, querySchema)`。
**`ModuleRulePanel.vue`**(样板:[ModuleRelationPanel.vue](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/ModuleRelationPanel.vue))
- props:`moduleId / fields / querySchema / relations / refRulesReverse / loading`;`defineModel` 行数组 `rules`;`defineExpose({ validate })`。
- 列:`b_name` / `_typeText`(条件|SQL)/ `_summaryText`(条件中文摘要或 SQL 片段)/ `b_canuse`(checkbox 行内编辑)/ `b_xh`(拖拽重排 `(i+1)*10`)/ `actions`(编辑、删除)。派生列在 `watch(rules, deep)` 里就地补(`_` 前缀不落库、不判脏),补完换数组引用触发表格刷新。
- 工具栏:新增规则、批量删除。行点击/编辑 → 打开编辑弹窗。
- 只读区「来自模块关联」:列出 `refRulesReverse`(关系上的引用规则),列 源模块/源字段/目标模块/目标字段/说明;操作「查看」→ 当前模块的规则 `emit('navigate')` 由父级切到关系 tab,其它模块走 `onModuleSelect`(带未保存守卫)。
- `validate()`:规则名非空;`b_code` 非空、合法标识符、scope 内不重复;dsl 行至少一个有效条件;sql 行 `b_message` 非空。
**`ModuleRuleEditModal.vue`**(受控弹窗,参考 [UnsavedChangesModal.vue](file:///d:/workspace/code/fms/fms-vue/src/components/UnsavedChangesModal.vue) + `ui/Modal`)
- props `open / row / moduleId / items / existingCodes`;emits `update:open / ok`。
- 表单:规则名、规则编码(默认自动生成,可改)、类型 radio(勾条件 / 写 SQL)、启用、提示文案(sql 必填)。
- 勾条件分支:显示条件摘要 + 「编辑条件」→ 以**嵌套抽屉**打开 `FmsAdvancedQueryPanel`(`v-model:open` + `:items` + `v-model` 条件状态;Modal 与 Drawer 的滚动锁是引用计数、z-index 同层后挂载,已确认安全),`@apply` 回写状态与摘要。
- ok 时组装:dsl → `{"kind":"dsl","expr":buildAdvancedAst(state)}`;sql → 文本。
### 复用契约:`items` 怎么造(这是接条件面板的关键)
照抄 [FmsModuleListPage.vue](file:///d:/workspace/code/fms/fms-vue/src/components/fms-module-list/FmsModuleListPage.vue#L315-L334) 的组装方式:先 `buildQueryRender(query_schema)`(`@/components/fms-module-common/schemaRender`)取到 `conditions`(含 `operator/component/options/defaultValue`),与当前模块 `fields` 合并,产出
```js
{ config: { b_field, b_operator, b_component, b_default_value }, field, optionConfig, options }
```
`b_component` 缺省按字段类型推导(select→select、date/datetime→date-picker、number/money→number、checkbox→checkbox、其余 input);select 字段若查询配置里没有 options,降级为 input(提示直接填值),避免空下拉死路。**首版先做到这一步**——字段权限裁剪(查询侧有 `loadFieldPower`)本次不接,规则面板直接列全部启用字段。
### `queryUtils.js` 增一个反向函数
`src/components/fms-module-common/queryUtils.js` 已有 `buildAdvancedAst`(界面 → AST)但**没有反向**,回显已保存规则需要:
```js
export function advancedQueryFromAst(ast, { idFactory } = {})
// → { nodes, groups, degraded }
```
`condition` → node(`join` 缺省 'and');`group` → 组;组内再出现 group(>1 层)时把内层 children 拍平进该组并置 `degraded: true`,面板据此提示一次"原条件含多层分组,已按单层展开,请确认后保存"。
### `ModuleRelationPanel.vue` 加两列
- `b_on_delete`「删除连带」:select,选项 不连带/连带删除/连带归档;`many_to_one` 行 `disabled`,且 `onCellChange` 把关系类型切成 many_to_one 时自动置回 `none` 并 warning(防脏数据绕过校验)。
- `_hasRefRule`「有引用时拒绝删除」:checkbox(`_` 前缀,不落 s_relation、不参与关系行的 diff)。勾选/取消 → 在 `refRules` 草稿里增/删一条规则行(`scope_type='relation'`、`scope_id=relationScopeId(row)`)。
- **主键列变化处理**:关系行的源字段/目标模块/目标字段一旦改动,`scope_id` 就变了 → 自动取消勾选并删除对应规则行,提示"关系主键已变化,请重新勾选引用保护"。避免规则挂在旧键上产生孤儿行。
- props 增加 `v-model:refRules`(父级持有 `refRules/refRules_org` 成对草稿,走正常 diff 与脏判定,不需要任何 `_` 前缀的脏判定特例)。
### `index.vue` 接入点(9 处)
1. import `ModuleRulePanel`;`configGroups` 的 capability 组追加 `{ key: 'rule', label: '删除规则', icon: CircleSlash }`(已确认 @lucide/vue 有该图标)。
2. 数据对:`rules / rules_org`(scope=module)、`refRules / refRules_org`(scope=relation,源侧)、`refRulesReverse`(只读)。
3. `clearModuleData` 清空上述 ref;面板 ref `rulePanelRef`。
4. 模板挂载(relation 面板之后):`<ModuleRulePanel ref="rulePanelRef" v-model="rules" v-model:ref-rules="refRules" … @navigate="onRuleNavigate" />`,`onRuleNavigate` 切 `activeSection` 或 `onModuleSelect`。
5. `loadModuleConfig` 加第 7 张表:`s_rule`,条件 `b_scope_type='module' AND b_scope_id = <模块编码>`,排序 `b_xh, b_code`;关系侧规则**必须第二次查询**(规则表没有模块列,deletes 也不支持 LIKE 前缀):关系加载完后用双向关系键集合查 `b_scope_type='relation' AND b_scope_id IN (…)`,按 `scope_id` 属于源侧/目标侧拆成 `refRules_org` 与 `refRulesReverse`(无关系时跳过这次查询)。
6. `buildSaveData` 在 s_relation 段之后加两段(都先物化 scope 两列):
```js
for (const row of rules.value) { row.b_scope_type = 'module'; if (row.b_scope_id !== moduleId) row.b_scope_id = moduleId }
const ruleReq = tableChange('s_rule',
diffRows(stripInternalKeys(rules.value), stripInternalKeys(rules_org.value),
{ keyFields: ['b_scope_type', 'b_scope_id', 'b_code'] }),
['b_scope_type', 'b_scope_id', 'b_code'])
if (hasChanges(ruleReq)) saveReqs.push(ruleReq)
for (const row of refRules.value) { row.b_scope_type = 'relation'; row.b_hook = 'delete.pre' }
const refRuleReq = tableChange('s_rule',
diffRows(stripInternalKeys(refRules.value), stripInternalKeys(refRules_org.value),
{ keyFields: ['b_scope_type', 'b_scope_id', 'b_code'] }),
['b_scope_type', 'b_scope_id', 'b_code'])
if (hasChanges(refRuleReq)) saveReqs.push(refRuleReq)
```
7. `beforeSave`:接 `rulePanelRef.value?.validate?.()`,失败则 `activeSection='rule'` + 首条文案。
8. `hasUnsavedChanges` 的 pairs 追加 `[rules, rules_org]` 与 `[refRules, refRules_org]`。
9. `deleteModule`:追加模块级清理 `{ table:'s_rule', key_field:'b_scope_type,b_scope_id', deletes:[{ b_scope_type:'module', b_scope_id:id2 }] }`;关系级需**先查一次** `s_relation`(双向)拿到关系键,再一条批量 delete;确认文案补「删除规则」。
- 顺带:在关联面板删掉某条关系并保存时,规则行由 `refRules` 的 diff 自然删除(勾选态随行消失),无需额外逻辑。
## 边界与风险
- **many_to_one 禁连带**:面板 disabled + 改类型自动清 + `validate()` 三层拦截。
- **归档列校验点**:一期前端查不了 `sys.columns`,选「连带归档」时给一次性 `Message.warning('连带归档要求目标表含 b_archived / b_archive_datetime 列,引擎启用时会校验')`,真正的校验留在二期服务层。
- **打开即干净**:`b_on_delete` 非空默认值 + 派生列一律 `_` 前缀 + `refRules` 走常规 pairs(无脏判定特例)。
- **删除规则 tab 只对 data 模块显示**(与自动编码/权限/模块关联一致)。
## 验证
手工(`pnpm dev` → `/module/module-management`,需 g3soft 账号):
1. `RunSqlFile --dry-run` 后实跑增量脚本,核对 `s_rule` 与 `s_relation.b_on_delete`。
2. 关联面板新增 one_to_many 关系选「连带删除」→ 保存 → 库里 `b_on_delete='cascade'`。
3. 改成 many_to_one → 下拉只剩「不连带」且原值被清。
4. 勾「有引用时拒绝删除」→ 保存 → `s_rule` 出现 `scope_type='relation'`、`scope_id` 为四段竖线串的一行;取消勾选再保存 → 该行删除。
5. 改该关系的目标模块 → 勾选自动取消并提示;保存后库里无旧键孤儿规则。
6. 「删除规则」tab 新增勾条件规则(两条件 and/or 混用)→ 保存 → `predicate` 为 `{"kind":"dsl",…}`;重开模块条件与摘要一致。
7. 新增 SQL 规则不填提示文案 → 被拦。
8. 打开模块立即保存 → 提示「没有需要保存的变更」(不脏)。
9. 删除该模块 → `s_rule`/`s_relation` 残留为 0(含关系级规则)。
测试(一源文件一 spec,见 [tests/README.md](file:///d:/workspace/code/fms/fms-vue/tests/README.md)):
- 改 `tests/unit/query-ast.spec.js`:`advancedQueryFromAst` 空 AST / 单层往返 / 二层拍平置 `degraded`。
- 改 `tests/unit/module-relation-panel.spec.js`:`b_on_delete` 加载不置脏、many_to_one disabled 与自动清、cascade+many_to_one 被 validate 拦、勾选增删 `refRules` 行、改主键自动取消勾选。
- 新增 `tests/unit/module-rule-panel.spec.js`:回填只补 `_` 键、dsl 无条件被拦、sql 缺文案被拦、编码重复被拦、只读区渲染与 navigate。
- 新增 `tests/unit/module-rule-utils.spec.js`:scopeId 往返、predicate 三态(含非法 JSON 降级为 sql)、`nextRuleCode` 递增、items 推导。
- 改 `tests/views/module-unsaved-after-save.spec.js`:加载 7 张表 + 二次规则查询、`buildSaveData` 产出两条 `s_rule` 请求、`deleteModule` 的模块级与关系级清理。
## 明确不做(一期之外)
后端检查器/执行器与连带展开、`POST /data/deleteobj`(预览/错误列表)、`saveobjt` deletes 分支接入、`b_archived` 归档列、规则复制到其他模块、系统管理全局规则列表页、「值 vs 另一字段」比较操作符、条件面板的字段权限裁剪、`rule.edit` 权限点(页面已由 `meta.adminOnly` 兜底)。
@@ -0,0 +1,197 @@
# 模块关联(s_relation)配置面板实现方案
## Context
`s_relation` 是模块关系表,用于表达主子表、引用关系:
- 建表脚本:[fms_core.sql](file:///d:/workspace/code/fms/sql/fms_core.sql#L202-L225),7 列 + 4 列复合主键(`b_source_module_id, b_source_field, b_target_module_id, b_target_field`),另有 `b_relation_type / b_canuse / b_xh` 与两个索引;无种子数据。
- 设计定义:[FMS新系统核心表结构设计.md](file:///d:/workspace/code/fms/FMS新系统核心表结构设计.md#L519-L548) 第 9 节,`one_to_one / one_to_many / many_to_one`,不提供 `many_to_many`。
- 现状:`fms-vue/src` 与 `fms-api/src` **零引用**,模块管理 8 个配置面板里没有入口 → 数据库有表、设计有定义,但用户无法配置。
- 附带缺陷:删除模块的级联清理([index.vue](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/index.vue#L715-L745))漏了 `s_relation`,删模块会残留悬空关系行。
本次目标:在「模块管理 → 业务能力」下新增「模块关联」面板,读写现有 7 列,并补齐删除清理与临时 id 重映射。
## 已确认的决策
1. **双向可见、源侧可编辑**:上区「本模块 → 其他模块」(`b_source_module_id` = 当前模块)可编辑;下区「其他模块 → 本模块」(`b_target_module_id` = 当前模块)只读,避免对方配置的引用完全不可见。
2. **目标模块**用 `ui/Select`,选项来自一次性加载的全量模块;停用/已删模块降级显示裸编码。
3. **导航位置**:「业务能力」分组,与自动编码、权限并列;仅 data 模块显示(复用 `isDataModule` 门控)。
4. **不含 `b_on_delete`**(属删除规则引擎规划,SQL 里尚无此列)。
5. **不拦截被引用模块的删除**:连带清理双向关系行即可(`s_relation` 属配置元数据)。
6. **后端零改动**:`DataSaveService` 已支持逗号拼接复合 key,`DbUtils` 用「行内列值 AND」生成 DELETE 条件,`s_relation` 可直接读写。
## 数据流
```
loadModuleConfig(id) ── 1 条查询 b_source_module_id = id OR b_target_module_id = id
├─ relations (source = id) ← 可编辑,参与 diff 与脏判定
├─ relations_org (source = id) ← 基线快照
└─ reverseRelations (source ≠ id) ← 只读展示,不参与 diff / 脏判定
buildSaveData() ── 回写 b_source_module_id = 当前模块编码 → diffRows(4 列主键) → tableChange
beforeSave() ── 面板 validate() + 目标模块必须已落库
hasUnsavedChanges() ── pairs 追加 [relations, relations_org]
deleteModule() ── 追加两条按 b_source_module_id / b_target_module_id 的清理
afterSave() ── 临时 id → 真实编码时重映射三份数据
```
## 改动清单
### 1. 新增 `ModuleRelationPanel.vue`
路径:`fms-vue/src/views/module/module-management/ModuleRelationPanel.vue`
**接口**
- props:`moduleId: String`(当前模块编码,父级 `String(maindata.b_id || '')`)、`fields: Array`(当前模块字段,源字段选项)、`modules: Array`(全量模块,含停用项,用于选项与名称解析)、`reverseRows: Array`(反向只读行)、`loading: Boolean`
- model:`const relations = defineModel({ type: Array, default: () => [] })`
- expose:`validate(): string[]`(父级只看 `errors?.length` 与首条文案)
**内部状态**:`activeKey`、`tableRef`、`selectedRowKeys`;模块编码 → 字段选项缓存 `fieldOptionsCache`(`reactive(new Map())`)+ 进行中的 `inflightFields`(Set,防重复请求)。
**行本地键**:`watch(relations, ..., { deep: true, immediate: true })` 逐行补 `_rowKey = createTempId()`。`_` 前缀不参与 diff,加载回填不会置脏(同 ModulePowerPanel 手法)。
**列定义**(`shallowRef`,参照 [ModuleQueryConfigPanel.vue](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/ModuleQueryConfigPanel.vue#L209-L242))
| dataIndex | 标题 | 宽 | editor |
| --- | --- | --- | --- |
| `b_source_field` | 源字段 | 190 | `select`,options = 当前模块字段(停用标注「(停用)」) |
| `b_target_module_id` | 目标模块 | 220 | `select`,options = 启用模块「名称(编码)」 |
| `b_target_field` | 目标字段 | 190 | `select`,**options 按行取该行目标模块的字段**(见改动 2) |
| `b_relation_type` | 关系类型 | 130 | `select`:一对一 / 一对多 / 多对一 |
| `b_canuse` | 启用 | 70 | `checkbox` |
| `b_xh` | 排序号 | 80 | 无(拖拽重排后回写) |
| `actions` | — | 60 | `actions:[{ type:'delete', label:'删除', onClick: removeRow }]` |
**交互**(照搬 ModuleQueryConfigPanel 的既有约定)
- `addRow()`:换新数组引用(stk-table 对同引用 push 不敏感)→ 新行 `b_source_module_id = props.moduleId`、其余键为 null、`b_relation_type = 'many_to_one'`、`b_canuse = 1`、`b_xh = (length + 1) * 10`、`_rowKey = createTempId()` → `nextTick` 后 `tableRef.editRow(key, 'b_source_field')`
- `removeRow()` / `batchDelete()`:同步清 `activeKey`、`selectedRowKeys`
- `onRowOrderChange(rows)`:`rows.map((r, i) => ({ ...r, b_xh: (i + 1) * 10 }))`
- `onCellChange(row, col)`:就地 patch 行对象 → 换新数组引用触发渲染;**当 `b_target_module_id` 变化时**:清空该行 `b_target_field`,并 `void ensureTargetFields(code)`,完成后换引用并 `editRow(row._rowKey, 'b_target_field')`
- 惰性加载:`ensureTargetFields(code)` 查缓存 / in-flight 直接返回,否则 `loadDataApi('s_field', \`b_module_id = ${sqlStringLiteral(code)}\`, 'b_xh ASC, b_field ASC')` 写缓存;反向下只读行的源模块用同一缓存预热(去重、失败静默降级为裸编码)
**只读区**:用 `editable=false` 的 FmsTable,列不含 editor;展示文案由 computed 派生行(模块名 / 字段名 / 关系类型中文 / 启用状态)预先算成普通字段,命中缓存后随响应式刷新,未命中回退裸编码。
**validate()**:逐行校验 `b_source_field / b_target_module_id / b_target_field` 非空、`b_relation_type` 取值合法、4 列组合唯一(用 `|` 拼接的 key,与 `diffRows` 行身份一致)。空列/重复必须拦——`diffRows.keyOf` 对空值用空串占位,两行未填完的新行会撞成同一身份,落库直接主键冲突。
**样式**:scoped;flex 链(`flex: 1` + `min-height: 0`)撑高,不写 `height: 100%`;表格用 FmsTable 默认 `bordered` 外框。
### 2. FmsTable 编辑器小扩展:`select.options` 支持按行解析
`src/components/fms-table/utils/createEditableCellRenderer.js` 三处(改动 3 行,向后兼容——现有 `options: () => fieldOptions.value` 忽略入参):
```js
function resolveOptions(editor, row) {
return typeof editor.options === 'function' ? editor.options(row) : (editor.options ?? [])
}
// displayValue(row, col, editor) 内:resolveOptions(editor, row)
// createSelect(row, col, editor, ctx) 内:resolveOptions(editor, row)
```
这是给组件库补能力(而非用选择器权重绕过),后续「目标字段」等按行联动的下拉都可复用。
### 3. 导出 SQL 字面量助手
`module-management/utils.js:451` 的 `sqlStringLiteral` 加 `export`,供面板拼 `s_field` 查询条件(它已是本地函数,属第二个真实调用方)。`index.vue:94` 的 `sqlText` 保持不动(不在本次范围)。
### 4. `index.vue` 十处改动
1) import:`ModuleRelationPanel from './ModuleRelationPanel.vue'`;图标 `Waypoints`(@lucide/vue 已确认存在)。
2) `configGroups`(L270-279)的 `isDataModule` 分支 items 追加 `{ key: 'relation', label: '模块关联', icon: Waypoints }`。
3) 数据对:`const relations = ref([])` / `const relations_org = ref([])` / `const reverseRelations = ref([])`。
4) `relationModules` computed:递归扁平 `moduleTreeData`(保留停用项用于名称解析),排除 `unsavedModuleIds` 与 `/^-\d+$/` 草稿 id;零额外请求(树已加载)。
5) `const relationPanelRef = ref(null)`。
6) 模板挂载(放在 power 面板之后,样板同 L1191-1202):
```html
<div v-if="isDataModule" v-show="activeSection === 'relation'" class="mm-config-pane">
<ModuleRelationPanel
ref="relationPanelRef"
v-model="relations"
:module-id="String(maindata.b_id || '')"
:fields="fields"
:modules="relationModules"
:reverse-rows="reverseRelations"
:loading="busy"
/>
</div>
```
7) `clearModuleData()`:三份数据一并清空。
8) `loadModuleConfig` 的 `Promise.all` 追加第 6 项(与 auto-code 同 `type === 'data'` 门控),并在 `maindata_org` 赋值段后拆分:
```js
const rows = relationRes.data || []
const isSource = (r) => String(r.b_source_module_id ?? '').trim() === id
relations_org.value = cloneData(rows.filter(isSource))
reverseRelations.value = cloneData(rows.filter((r) => !isSource(r)))
relations.value = cloneData(relations_org.value)
```
竞态沿用既有守卫(同一 `Promise.all` 内,无需新增)。只把 source 侧放入 `relations`,diff 的 deletes 不会误删反向行。
9) `buildSaveData` 的 `if (isDataModule.value)` 块内(权限之后):
```js
for (const row of relations.value) {
if (String(row.b_source_module_id ?? '').trim() !== moduleId) row.b_source_module_id = moduleId
}
const relationReq = tableChange(
's_relation',
diffRows(relations.value, relations_org.value, {
keyFields: ['b_source_module_id', 'b_source_field', 'b_target_module_id', 'b_target_field'],
}),
['b_source_module_id', 'b_source_field', 'b_target_module_id', 'b_target_field'],
)
if (hasChanges(relationReq)) saveReqs.push(relationReq)
```
10) 其余四处:
- `beforeSave`:`relationPanelRef.value?.validate?.()` 有错则 `activeSection = 'relation'` + `Message.warning`;追加守卫 `relations.value.some((r) => unsavedModuleIds.has(String(r.b_target_module_id ?? '')))` → 「目标模块尚未保存,请先保存目标模块」。
- `hasUnsavedChanges` 的 `pairs`(L967-972)追加 `[relations, relations_org]`(`reverseRelations` 不参与)。
- `deleteModule` 的 `reqs` 追加两条,并把确认文案(L709)补上「模块关联」:
```js
{ table: 's_relation', key_field: 'b_source_module_id', deletes: [{ b_source_module_id: id2 }] },
{ table: 's_relation', key_field: 'b_target_module_id', deletes: [{ b_target_module_id: id2 }] },
```
- `afterSave`:与 `remapDraftParentId(treeId, savedKey)` 并列调用 `remapRelationModuleIds(treeId, savedKey)`,把 `relations` / `relations_org` / `reverseRelations` 三份数据中的源侧与目标侧临时 id 改为真实编码(幂等;主路径仍是保存后按真实编码重载)。
## 边界与约定
| 场景 | 处理 |
| --- | --- |
| 改主键列(源字段 / 目标模块) | diff 判为 delete + insert,可接受(同事务、无子引用、不涉雪花回灌);仅要求换目标模块时清空 `b_target_field` |
| 目标模块是未落库草稿 | 选项里不出现 + `beforeSave` 拦截(双保险) |
| 停用 / 已删模块、已删字段 | 选项缺失时 Select 回退显示裸值;文案统一「名称(编码)」 |
| 四列为空 / 组合重复 | 面板 `validate()` 拦截 |
| 删除被引用模块 | 不拦截,双向连带清理 |
| `_rowKey` | `_` 前缀,不参与 diff / 脏判定,不提交 |
## 验证
按 [开发规范.md](file:///d:/workspace/code/fms/开发规范.md) 第 2 节,改业务代码时不主动执行构建/测试;以下供确认后执行。
手工验证:`pnpm dev` → `/module/module-management`(`meta.adminOnly`,需 g3soft 账号)→ 选一个 data 模块 → 业务能力 →「模块关联」
1. 上区新增一行 → 选源字段、目标模块(目标字段下拉需在选完目标模块后可选)、关系类型 → 保存 → 刷新确认落库
2. 切到目标模块,下区应出现该行(只读,含模块名 / 字段名 / 类型)
3. 停用或删除目标模块后重开面板:选项缺失但显示完整、行不丢
4. 拖拽排序 + 启用开关 → 保存后 `b_xh` / `b_canuse` 更新
5. 新建模块未填编码时选目标模块 → 保存被拦;填编码保存后 `s_relation.b_source_module_id` 是真实编码
6. 删除被引用模块 → 双向关联行随之消失
7. 反复切换树节点 / 打开关闭面板,不应误报「未保存」
测试(约定见 [tests/README.md](file:///d:/workspace/code/fms/fms-vue/tests/README.md),每个源文件最多一个 spec):
- 新增 `tests/unit/module-relation-panel.spec.js`:增行、换目标模块清空目标字段、`validate()` 空列与重复拦截、`_rowKey` 不置脏
- 页面级断言(`table: 's_relation'`、`key_field` 为 4 列、删除模块含两条 `s_relation`):复用 [module-unsaved-after-save.spec.js](file:///d:/workspace/code/fms/fms-vue/tests/views/module-unsaved-after-save.spec.js) 的装配(在 `buildDb()` 补 `s_relation: []`),或按「一源文件一 spec」新建 `tests/views/module-management.spec.js`
- 跑法:`pnpm test:file tests/unit/module-relation-panel.spec.js` → `pnpm test` → `pnpm test:coverage`
## 明确不做
- `s_relation.b_on_delete`(属删除规则引擎规划,SQL 尚无此列)
- 模块下拉的服务端搜索(库内 select 编辑器固定 `filterable: false`;如需,后续对该列用 `customCell` + `ui/Select(filterable)`)
- 字段重命名(`b_field`)后重映射关联行
- 被引用模块的删除拦截
- 反向只读区的编辑 / 删除入口
@@ -12,6 +12,83 @@
全套 10 个用例通过;oxlint 干净(oxfmt 全仓库既有 format issues,未动)。
- 已核对仓库内另 3 处 `pageDataApi` 调用(FileListPanel / useLookupOptions / FmsModuleListPage)都传了 orderBy。
## 新增:UI 组件库全局配置(uiConfig)
- 背景:组件库此前**没有任何全局配置层**(`ui/index.js` 只是具名导出,无 `install`/`app.use`;
组件间只有 form/form-group/radio-group/tree/dropdown 五个局部 provide)。Form 的
`layout` 等默认值硬编码在 prop 里,想改只能逐处传。
- 新增 `fms-vue/src/components/ui/config.js`:`uiConfig`(reactive)+ `setUIConfig(patch)`(按组件维度浅合并)
+ `resetUIConfig()`;三者也从 `@/components/ui` 具名导出。
- 回落顺序统一为 **自身 prop > 全局配置 > 组件内置默认**。凡要支持全局覆盖的 prop,
`default` 必须改成 `undefined` 且 validator 放行 `undefined`(Vue 会校验 default 值)。
- Form(`form/form.vue`):`layout`/`labelAlign`/`labelWidth`/`validateTrigger` 改为经
`resolvedXxx` computed 回落,provide 下发的是**回落后的最终值**,FormItem 不用再管全局配置;
FormItem 独立使用(无 Form 包裹)时也读一次 `uiConfig`。
- 语言联动写在 `src/App.vue`:`watch(() => i18nStore.locale)` → `layout = /^zh/i.test(locale) ? 'horizontal' : 'vertical'`
(中文横排、其他语言竖排),`immediate: true` 覆盖持久化 locale 的刷新场景。
- 存量 8 处 `layout="vertical"` 已全部删除(login、base/othercompany、system/user、
menu-management/MenuBasicPanel、module-management 的 Basic/AutoCode/FormPreview、FmsModuleEditModal),
统一跟随语言;`ui/demo/form.vue` 保留(它是组件展示页,故意演示各布局)。
风险点:FmsModuleEditModal(columns=24 网格)与 FormPreview(columns=48)在中文下变横排可能拥挤,待目测。
- 测试:`tests/components/form.spec.js` 新增 5 例(全局默认、prop 覆盖、局部合并、validateTrigger、独立 FormItem),
15 例全通过;oxlint `--deny-warnings` 干净;`vite build` 通过。
- 已知非本次引入:`tests/views/login.spec.js` 套件级失败(favicon.svg URL 解析,环境问题,收集 0 用例);
`module-list-components.spec.js` 的 `.filter-joiner`/`.filter-row` 断言是过期基线。
## 修复:顶栏语言切换不生效(i18n 只有 locale,没有译文)
- 根因:`setMessages` 在 `src/` 下**从无调用点** → `messagesRef` 永远为空,`t(key, fallback)` 恒返回中文兜底;
顶栏按钮只是把 `locale` 在 zh-CN/en-US 间翻一下,界面毫无变化。
另查库:`s_i18n_type` 有 zh-CN(默认)/en-US;`s_i18n` **134 行全是 zh-CN,en-US 为 0 行**。
- 改动:
- `stores/i18n.js` 新增 `loadMessages(locale, force)`:查 `s_i18n`(`b_canuse = 1 and b_locale = N'..'`,
`sqlQuote` 转义单引号),按 locale 缓存 + 并发 Promise 复用 + 失败静默;`applyMessages` 只认**当前** locale
(防止请求回来时语言已被切走导致串台),合并顺序 **前端内置 < 库表译文**;`clearMessages` 顺带清缓存。
- 新增 `src/i18n/builtin-messages.js`:内置 en-US 的 `common.*`(45 条,代码里已用 `t('common.x','中文')` 约定)。
中文不内置(fallback 即中文)。库里登记同名 key 可覆盖内置。
- `App.vue` 的 locale watcher 增加 `i18nStore.loadMessages(locale)`;`router/index.js` 守卫在
`loadLanguages()` 后再 `loadMessages()` 兜首次进入。
- 顶栏:按钮改为 Dropdown,语种取自 `s_i18n_type` 清单(不再硬编码互切),当前项用 `shortcut: '✓'` 标出
(Dropdown item 没有 active 态)。
- 测试新增 12 例:`tests/stores/i18n-messages.spec.js`(7)、`tests/components/app-topbar-language.spec.js`(3)、
`tests/unit/app-locale-sync.spec.js`(2)。全量 497 通过 / 13 失败(**均为既有基线**:
login 套件 + router-auth 3 例都是 `favicon.svg` 环境问题——`views/login/index.vue:98` 的 `<img src="/favicon.svg">`;
其余 9 例是查询面板改版后的过期断言)。
- 注:`vite build` 冒烟本次被用户拒绝执行(含 find -delete),改用 vitest 挂载 App/AppTopbar 验证编译与行为。
## 查询区改用 Form/FormItem(跟随语言布局)
- 现象:切中文后查询条件区仍是上下结构。原因:`FmsQueryToolbar.vue` **没用 Form**,是自己写的
`<div class="query-field"><label class="query-field__label">`,CSS 写死 `flex-direction: column`。
`fms-module-list/` 下完全没有 `<Form>`(高级查询面板也是自绘 `.filter-*`)。
- 改法:字段网格改成 `<Form class="query-fields" :model="filters" label-width="72px">` + `<FormItem class="query-field" :name :label>`。
关键:**`.query-fields { display: contents }`** —— 让 Form 根透明化,FormItem 才能直接成为 `.query-grid`
(`repeat(n, minmax(0,200px))`)的网格单元,保持原有列宽与 gap 不变。
另加 `.query-field{margin-bottom:0}`(去掉 FormItem 自带 6px)与 `:deep(.fms-form-item-label)`
次要色/单行省略(对齐原 `.query-field__label` 观感)。label-width 取 72px:200px 列宽下比全局 88px 更省地方。
- 微调(横向间距过大):Form 改 `label-width="auto" label-align="left"`,标签按内容收缩;
只在横向态加 `.query-fields.fms-form-horizontal .query-field :deep(.fms-form-item-label){max-width:72px;padding-right:6px}`
(组件库默认 label padding-right 12px;纵向态不设 max-width,标签占满列宽)。
若嫌**字段之间**的列间距大,改 `.query-grid { gap: 8px 16px }` 的第二个值。
- 测试:`module-list-components.spec.js` 新增 1 例(横排/竖排跟随 uiConfig + 标签 width:auto);
`fms-module-list-page.spec.js` 里断言旧标签 `.query-field__label` 的用例改为 `.query-field .fms-form-item-label`。
全量回到 13 失败 / 498 通过(均为既有基线)。
## 修复:自动编码面板拨「启用」报错 + 控件被压扁
- 报错 `Cannot set properties of undefined (setting 'b_canuse')`(ModuleAutoCodePanel.toggleEnabled):
**与语言/布局无关,是既有 bug**。`ensureRow()` 里 `autoCode.value = [row]` 之后立刻回读
`autoCode.value[0]`——`defineModel` 写了只 emit,prop 要下一轮才回传,此刻读到的仍是空数组。
修法:先 `const rows = reactive([createDraft(...)])` 再 `autoCode.value = rows`,返回 `rows[0]`(代理行)。
注意已有行时是**就地改属性、不 emit 整个数组**(父级 ref 深度响应式),测试要按这个预期写。
- 控件宽度:该面板是 `columns=5 + max-width 720px`(每列约 130px),横排时 88px 标签 + 12px 内边距把控件压到
~30px。已给这个 Form 固定 `layout="vertical"`(prop 优先于全局配置)。
**结论:多列紧凑网格的表单不适合横排,要显式钉 layout。**
同类待观察:`ModuleBasicPanel`(columns=3,单列项控件约 140px)、`FormPreview`(columns=48)、
`FmsModuleEditModal`(columns=24,b_colspan 默认 24 即整行,多数不受影响)。
- 新增 `tests/unit/module-auto-code-panel.spec.js` 3 例(惰性建行写回、已有行就地改、固定竖排)。
全量 528 通过 / 13 失败(仍是既有基线)。
## 维护
- `MEMORY.md` 超限被截断,已按主题重写精简(合并重复项、删掉过期与可推导内容)。
+27
View File
@@ -83,6 +83,33 @@
描边 `color-mix(primary 40%, #fff)`;「浅底+白字」对比度只有 1.4~2.7:1,不可用。
- Teleport 到 body 的浮层(如 `.fms-team-menu-*`)不在侧栏内,保持全局浅色 token。
## UI 组件库全局配置
- 唯一入口 `fms-vue/src/components/ui/config.js`:`uiConfig`(reactive)+ `setUIConfig(patch)`(按组件维度浅合并)
+ `resetUIConfig()`,也从 `@/components/ui` 具名导出。**没有 ConfigProvider、没有 app.use 插件**。
- 回落顺序固定 **自身 prop > uiConfig > 组件内置默认**;要支持全局覆盖的 prop,`default` 必须写 `undefined`
且 validator 要放行 `undefined`(Vue 会拿 default 跑校验)。
- Form 的 `layout`/`labelAlign`/`labelWidth`/`validateTrigger` 已接入;provide 下发回落后的最终值。
语言联动在 `src/App.vue`:中文 `horizontal`、其他语言 `vertical`(判定 `/^zh/i`)。
- **多列紧凑网格的表单必须显式 `layout="vertical"`**(如自动编码面板 columns=5),
否则横排的 88px 标签会把控件压到几十像素。prop 优先于全局配置。
- `defineModel` 写完**不能立刻回读**:写了只 emit,prop 下一轮才回来;要取刚写入的值就自己先
`reactive()` 包一层再赋值,返回其中的代理对象。
## i18n 运行时
- 语言清单 `s_i18n_type`(`b_id` 即语种编码,zh-CN 默认 / en-US);译文 `s_i18n(b_key, b_locale, b_value)`。
- store `stores/i18n.js`:`loadLanguages()` 拉清单、**`loadMessages(locale)` 拉译文**(按语种缓存 + 并发去重 +
失败静默;只把「当前语种」的译文写进运行时)。**别只看 locale——不调 loadMessages 界面不会变。**
- 合并顺序 **前端内置 < 库表译文**:内置只有 en-US 的 `common.*`(`src/i18n/builtin-messages.js`,
代码约定 `t('common.x', '中文兜底')`);业务文案/字段名/菜单名的译文一律进 `s_i18n` 由实施维护。
- 现状:`s_i18n` 只登记了 zh-CN(约 134 条),en-US 尚无数据 → 英文界面只有框架按钮/提示是英文,
字段名等仍回落中文,需要补录。
- 顶栏语种下拉取自 `s_i18n_type` 清单,不要硬编码语言编码。
- 查询区(`FmsQueryToolbar`)已改用 Form/FormItem,因此也跟随该配置;Form 根靠 `display: contents`
透明化,让 FormItem 直接参与 `.query-grid`(`minmax(0,200px)`)布局,label 宽局部 72px。
高级查询面板(`FmsAdvancedQueryPanel`)仍是自绘 `.filter-*`,**不受**表单布局配置影响。
## 导出(P0 已落地,摘要)
`POST /api/export/list` 返回文件流:`SqlPermissionService` → `ExportSchemaService`(列与列表同源推导)
+23 -1
View File
@@ -1,10 +1,32 @@
<script setup>
import { defineAsyncComponent } from 'vue'
import { defineAsyncComponent, watch } from 'vue'
import { setUIConfig } from '@/components/ui'
import { useI18nStore } from '@/stores/i18n'
// 业务应用入口:仅渲染当前路由页面
// FPS 性能监控浮层全环境挂载(生产环境同样用于现场性能排查);
// 仍走异步组件,单独分包,不进主包
const FpsMonitor = defineAsyncComponent(() => import('@/components/FpsMonitor.vue'))
// 表单布局随语言:中文横排(label 在左、控件在右,节省纵向空间),
// 其他语言标签更长,竖排(label 在上)避免 label 挤压控件。
// 写在应用根上,locale 已持久化,刷新后由 immediate 立即同步一次。
const i18nStore = useI18nStore()
function formLayoutOf(locale) {
const isChinese = /^zh/i.test(String(locale || ''))
return isChinese ? 'horizontal' : 'vertical'
}
watch(
() => i18nStore.locale,
(locale) => {
setUIConfig({ form: { layout: formLayoutOf(locale) } })
// 译文跟着语言走:内置文案兜底,接口失败也不阻断渲染
i18nStore.loadMessages(locale).catch(() => {})
},
{ immediate: true },
)
</script>
<template>
@@ -454,3 +454,72 @@ export function describeAdvancedQuery(state, itemByField, translate) {
}
return joinPreviewParts(parts, translate)
}
/* ---------- AST → 高级条件界面状态(buildAdvancedAst 的反向,用于回显已保存的条件) ---------- */
let queryRowSeq = 0
function nextQueryRowId() {
queryRowSeq += 1
return `${Date.now()}-${queryRowSeq}`
}
/**
* AST → 高级条件界面状态。
* 界面只支持「根层 + 一层条件组」(与 FmsAdvancedQueryPanel 一致):组内再出现组时,
* 把内层 children 拍平进该组并置 degraded=true,由调用方决定是否提示「条件已按单层展开」。
* @param {Object} ast 条件 AST({ type:'group'|'condition', children, join, field, operator, value })
* @param {Object} [options] { idFactory } 自定义界面行 id 生成器(测试用)
* @returns {{ nodes: Array, groups: Array, degraded: boolean }}
*/
export function advancedQueryFromAst(ast, { idFactory } = {}) {
const makeId = typeof idFactory === 'function' ? idFactory : nextQueryRowId
const nodes = []
const groups = []
let degraded = false
let at = 0
const nextAt = () => {
at += 1
return at
}
const toNode = (condition, join) => ({
id: makeId(),
at: nextAt(),
join: join === 'or' ? 'or' : 'and',
field: condition.field == null ? '' : String(condition.field),
operator: condition.operator || 'eq',
value:
condition.value === undefined
? emptyConditionValue(undefined, condition.operator || 'eq')
: condition.value,
})
/** 组内子项 → 条件行(界面组只有一行层,嵌套组在这里被拍平) */
const flattenInto = (children, target) => {
for (const child of children || []) {
if (child?.type === 'group') {
degraded = true
flattenInto(child.children, target)
} else if (child?.type === 'condition') {
target.push(toNode(child, child.join))
}
}
}
const children = ast?.type === 'group' ? ast.children || [] : ast ? [ast] : []
for (const child of children) {
if (child?.type === 'group') {
const innerNodes = []
flattenInto(child.children, innerNodes)
groups.push({
id: makeId(),
at: nextAt(),
join: child.join === 'or' ? 'or' : 'and',
nodes: innerNodes,
})
} else if (child?.type === 'condition') {
nodes.push(toNode(child, child.join))
}
}
return { nodes, groups, degraded }
}
@@ -484,7 +484,7 @@ function controlPropsOf(row) {
<div v-if="!hasAnyField" class="fme-empty">
该模块未配置可编辑字段(请在模块管理的表单设计中添加)
</div>
<Form v-else :model="formData" layout="vertical" :columns="24" :gap="{ row: 16, col: 12 }">
<Form v-else :model="formData" :columns="24" :gap="{ row: 16, col: 12 }">
<FormGroup
v-for="section in sections"
:key="section.key"
@@ -1,7 +1,7 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { ChevronDown, Filter, Search, Settings } from '@lucide/vue'
import { Button } from '@/components/ui'
import { Button, Form, FormItem } from '@/components/ui'
import FmsQueryControl from './FmsQueryControl.vue'
import {
buildQuickAst,
@@ -82,15 +82,26 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true })
<div class="fms-query-toolbar">
<div class="query-card">
<div class="query-layout">
<!-- 条件字段走组件库 Form/FormItem:label 排版与全系统一致(并跟随全局布局配置),
Form 根用 display:contents 透明化,真正参与 .query-grid 网格的是里面的 FormItem -->
<div class="query-grid" :style="gridStyle">
<div v-for="item in visibleQuickItems" :key="item.field.b_field" class="query-field">
<label class="query-field__label">{{ labelOf(item) }}</label>
<FmsQueryControl
v-model="filters[item.field.b_field]"
:item="item"
@enter="handleSearch"
/>
</div>
<!-- 横向布局下标签按内容收缩(定宽会让短标签拖出一段空白),
长标签由 .query-field 里的 max-width 截断,不会独占整列 -->
<Form class="query-fields" :model="filters" label-width="auto" label-align="left">
<FormItem
v-for="item in visibleQuickItems"
:key="item.field.b_field"
class="query-field"
:name="item.field.b_field"
:label="labelOf(item)"
>
<FmsQueryControl
v-model="filters[item.field.b_field]"
:item="item"
@enter="handleSearch"
/>
</FormItem>
</Form>
</div>
<div class="query-actions">
<div class="action-group">
@@ -153,19 +164,30 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true })
gap: 8px 16px;
align-items: end;
}
/* Form 只作上下文容器:不产生盒模型,子项直接成为 .query-grid 的网格单元 */
.query-fields {
display: contents;
}
.query-field {
display: flex;
min-width: 0;
flex-direction: column;
/* 网格已用 gap 控制间距,去掉 FormItem 自带的下外边距 */
margin-bottom: 0;
gap: 4px;
}
.query-field__label {
/* 条件标签沿用查询区原有观感:次要色 + 单行省略(组件库默认是主文字色)。
纵向时标签独占一行、占满列宽,不做宽度限制 */
.query-field :deep(.fms-form-item-label) {
overflow: hidden;
color: var(--fms-text-secondary, #6f7784);
font-size: var(--fms-font-size);
white-space: nowrap;
text-overflow: ellipsis;
}
/* 横向:宽度由内容决定(Form 传 label-width="auto"),只兜上限让超长字段名截断、
不把控件挤没;标签与控件的间距从组件库默认 12px 收到 6px */
.query-fields.fms-form-horizontal .query-field :deep(.fms-form-item-label) {
max-width: 72px;
padding-right: 6px;
}
/* 操作区:紧跟字段右侧、排成一行,右对齐;分「执行」「查询方式/配置」两组,中间竖分隔线 */
.query-actions {
display: flex;
@@ -48,15 +48,16 @@ function preserveInputDraft(row, dataIndex) {
clearInputDraft(row, dataIndex)
}
function resolveOptions(editor) {
return typeof editor.options === 'function' ? editor.options() : (editor.options ?? [])
// options 允许传函数并接收当前行:列选项依赖同行其它列时(如按目标模块列出目标字段)用它
function resolveOptions(editor, row) {
return typeof editor.options === 'function' ? editor.options(row) : (editor.options ?? [])
}
function displayValue(row, col, editor) {
const value = row[col.dataIndex]
const text =
editor.type === 'select'
? (resolveOptions(editor).find((item) => String(item.value) === String(value))?.label ??
? (resolveOptions(editor, row).find((item) => String(item.value) === String(value))?.label ??
value)
: value
const content = text ?? ''
@@ -135,7 +136,7 @@ function createInput(row, col, editor, ctx) {
}
function createSelect(row, col, editor, ctx) {
const options = resolveOptions(editor)
const options = resolveOptions(editor, row)
const focus = createFocusHandler(ctx, row, col)
return h(Select, {
modelValue: row[col.dataIndex] ?? undefined,
@@ -0,0 +1,51 @@
/**
* UI 组件库全局配置(运行时可改,reactive)
*
* 组件读取优先级统一为:自身 prop > 全局配置 > 组件内置默认。
* 需要支持全局覆盖的 prop,其 default 一律写 undefined,由组件自己按上面顺序回落,
* 这样「显式传值」永远压过全局配置。
*
* 用法:
* import { setUIConfig } from '@/components/ui'
* setUIConfig({ form: { layout: 'vertical' } }) // 局部合并,未提及的键保持原值
*/
import { reactive } from 'vue'
/** 组件库内置默认值,同时也是 resetUIConfig 的基线 */
function createDefaults() {
return {
/** Form 相关默认值 */
form: {
/** 'horizontal'(label 在左)| 'vertical'(label 在上) */
layout: 'horizontal',
/** 'left' | 'right' */
labelAlign: 'right',
/** 横向布局的 label 宽度;undefined 表示使用样式默认值 --fms-form-label-width */
labelWidth: undefined,
/** 'change' | 'blur' */
validateTrigger: 'change',
},
}
}
export const uiConfig = reactive(createDefaults())
/**
* 合并式更新:按组件维度浅合并,只覆盖传入的键。
* @param {Object} patch { form: { layout: 'vertical' }, ... }
*/
export function setUIConfig(patch) {
Object.entries(patch || {}).forEach(([scope, value]) => {
if (!value || typeof value !== 'object') return
uiConfig[scope] = { ...uiConfig[scope], ...value }
})
}
/** 恢复内置默认值(测试隔离、切换租户级配置时用) */
export function resetUIConfig() {
const defaults = createDefaults()
Object.keys(uiConfig).forEach((key) => {
if (!(key in defaults)) delete uiConfig[key]
})
Object.assign(uiConfig, defaults)
}
@@ -1,6 +1,7 @@
<script setup>
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { isEmptyValue, validateRules } from './validate.js'
import { uiConfig } from '../config'
/* ---------- props / emits / slots ---------- */
const props = defineProps({
@@ -67,19 +68,28 @@ const errorMessage = computed(() => errors.value[0] || '')
const helpText = computed(() => errorMessage.value || props.help)
const showHelp = computed(() => helpText.value !== '')
const labelAlign = computed(() => props.labelAlign ?? form?.labelAlign?.value ?? 'left')
const labelWidth = computed(() => props.labelWidth ?? form?.labelWidth?.value)
const validateTrigger = computed(() => form?.validateTrigger?.value ?? 'change')
const labelAlign = computed(
() => props.labelAlign ?? form?.labelAlign?.value ?? uiConfig.form.labelAlign ?? 'left',
)
const labelWidth = computed(
() => props.labelWidth ?? form?.labelWidth?.value ?? uiConfig.form.labelWidth,
)
const validateTrigger = computed(
() => form?.validateTrigger?.value ?? uiConfig.form.validateTrigger ?? 'change',
)
// Form 已下发回落后的 layout;独立使用(无 Form 包裹)时再读一次全局配置
const formLayout = computed(() => form?.layout?.value ?? uiConfig.form.layout ?? 'horizontal')
const rootClasses = computed(() => [
'fms-form-item',
form?.layout?.value === 'vertical' ? 'fms-form-item-vertical' : '',
formLayout.value === 'vertical' ? 'fms-form-item-vertical' : '',
hasError.value ? 'fms-form-item-error' : '',
])
// 横向布局才应用定宽 label;vertical 下 label 自然占一行
const labelStyle = computed(() => {
if (form?.layout?.value === 'vertical' || labelWidth.value == null) return undefined
if (formLayout.value === 'vertical' || labelWidth.value == null) return undefined
return { width: labelWidth.value }
})
@@ -179,7 +189,7 @@ onBeforeUnmount(() => {
v-if="label !== '' || $slots.label"
class="fms-form-item-label"
:class="{
'fms-form-label-vertical': form?.layout?.value === 'vertical',
'fms-form-label-vertical': formLayout === 'vertical',
'fms-form-label-right': labelAlign === 'right',
}"
:style="labelStyle"
@@ -1,6 +1,7 @@
<script setup>
import { computed, provide } from 'vue'
import { fluidColumnGap, normalizeGap } from './form-utils'
import { uiConfig } from '../config'
/* ---------- props / emits / slots ---------- */
const props = defineProps({
@@ -8,17 +9,18 @@ const props = defineProps({
model: { type: Object, required: true },
/** 表单级规则:{ [name]: Rule[] },FormItem 级 rules 优先级更高 */
rules: { type: Object, default: () => ({}) },
/** 布局:horizontal(label 在左)| vertical(label 在上) */
/** 布局:horizontal(label 在左)| vertical(label 在上)
* 不传则取全局配置 uiConfig.form.layout */
layout: {
type: String,
default: 'horizontal',
validator: (value) => ['horizontal', 'vertical'].includes(value),
default: undefined,
validator: (value) => value === undefined || ['horizontal', 'vertical'].includes(value),
},
/** label 文本对齐方式 */
/** label 文本对齐方式;不传则取全局配置 uiConfig.form.labelAlign */
labelAlign: {
type: String,
default: 'right',
validator: (value) => ['left', 'right'].includes(value),
default: undefined,
validator: (value) => value === undefined || ['left', 'right'].includes(value),
},
/** label 宽度(仅横向布局生效;默认使用 --fms-form-label-width) */
labelWidth: { type: [String, Number], default: undefined },
@@ -31,11 +33,11 @@ const props = defineProps({
maxWidth: { type: [String, Number], default: undefined },
/** 必填红星标记 */
requiredMark: { type: Boolean, default: true },
/** 校验时机:change(值变化即校验)| blur(失焦时校验) */
/** 校验时机:change(值变化即校验)| blur(失焦时校验);不传则取全局配置 */
validateTrigger: {
type: String,
default: 'change',
validator: (value) => ['change', 'blur'].includes(value),
default: undefined,
validator: (value) => value === undefined || ['change', 'blur'].includes(value),
},
/** 表单项间距(参考 antdv-next Row gutter):单个数字表示行/列等距,或对象 { row, col } 分别控制行/列间距,单位 px。
* 单列布局(columns <= 1)时 row 生效(纵向堆叠);多列网格(columns > 1)时 row+col 都生效。
@@ -45,6 +47,14 @@ const props = defineProps({
const emit = defineEmits(['submit', 'submitFailed', 'reset'])
/* ---------- 默认值回落:自身 prop > 全局配置 > 内置默认 ---------- */
const resolvedLayout = computed(() => props.layout ?? uiConfig.form.layout ?? 'horizontal')
const resolvedLabelAlign = computed(() => props.labelAlign ?? uiConfig.form.labelAlign ?? 'right')
const resolvedLabelWidth = computed(() => props.labelWidth ?? uiConfig.form.labelWidth)
const resolvedValidateTrigger = computed(
() => props.validateTrigger ?? uiConfig.form.validateTrigger ?? 'change',
)
/* ---------- 布局样式 ---------- */
// 列数 / 最大宽度 / 间距(gap)统一在此应用到根元素。
// 单列纵向布局:display:flex + flex-direction:column + row-gap;
@@ -128,15 +138,17 @@ function onReset(event) {
/* ---------- 上下文 ---------- */
// 下发给 FormItem:model / rules 用 computed 包裹——父级可能整体替换这两个 prop 的对象
// (如切换编辑目标、克隆草稿),provide 只执行一次,直接传引用会让 FormItem 校验读到过期对象
// layout / labelAlign / labelWidth / validateTrigger 下发的是回落后的最终值,
// 让 FormItem 不必再关心全局配置
provide('fms-form', {
model: computed(() => props.model),
rules: computed(() => props.rules),
layout: computed(() => props.layout),
labelAlign: computed(() => props.labelAlign),
labelWidth: computed(() => props.labelWidth),
layout: resolvedLayout,
labelAlign: resolvedLabelAlign,
labelWidth: resolvedLabelWidth,
columns: computed(() => props.spanColumns || props.columns),
requiredMark: computed(() => props.requiredMark),
validateTrigger: computed(() => props.validateTrigger),
validateTrigger: resolvedValidateTrigger,
registerField,
unregisterField,
})
@@ -148,7 +160,10 @@ defineExpose({ validateAll })
<template>
<form
class="fms-form"
:class="[`fms-form-${layout}`, { 'fms-form-grid': columns > 1, 'fms-form-gap': hasGap }]"
:class="[
`fms-form-${resolvedLayout}`,
{ 'fms-form-grid': columns > 1, 'fms-form-gap': hasGap },
]"
:style="formStyle"
@submit="onSubmit"
@reset="onReset"
@@ -1,6 +1,7 @@
/**
* UI 组件库统一出口
* 用法:import { Button, Input, Modal, Message } from '@/components/ui'
* 全局组件默认值:import { setUIConfig, uiConfig } from '@/components/ui'
*/
import Button from './button/button.vue'
import ButtonGroup from './button-group/button-group.vue'
@@ -34,6 +35,7 @@ import Tooltip from './tooltip/tooltip.vue'
import Tree from './tree/tree.vue'
import { Message } from './message/message-manager'
import { resetUIConfig, setUIConfig, uiConfig } from './config'
/** 命令式确认对话框:Modal.confirm({ title, content, okText, onOk, ... }) */
Modal.confirm = modalConfirm
@@ -69,4 +71,8 @@ export {
Tag,
Tooltip,
Tree,
// 全局默认值配置
uiConfig,
setUIConfig,
resetUIConfig,
}
@@ -0,0 +1,60 @@
/**
* 前端内置 UI 文案(兜底层)
*
* 系统译文的权威来源是后端 `s_i18n` 表(模块管理/菜单管理里登记的业务文案、字段名、菜单名都走库)。
* 这里只放**框架自带的通用界面词**(按钮、提示、表格表头等 `common.*`),
* 原因是这些词条随代码发布、不该要求实施人员先往库里录一遍才能用多语言。
*
* 合并顺序(见 stores/i18n.js 的 applyMessages):内置 < 库表译文,
* 即库里登记了同名 key 时以库为准,方便按项目覆盖。
*
* 中文不需要内置:代码里 `t(key, '中文')` 的第二个参数就是中文兜底。
*/
export const BUILTIN_MESSAGES = {
'en-US': {
'common.addCondition': 'Add condition',
'common.advancedQuery': 'Advanced query',
'common.advancedQueryActive': 'Advanced query active',
'common.advancedQueryEmpty': 'No valid conditions',
'common.applyQuery': 'Apply',
'common.backToQuick': 'Back to basic query',
'common.batchExport': 'Batch export',
'common.cancel': 'Cancel',
'common.clear': 'Clear',
'common.columnSettingsReset': 'Column settings restored',
'common.condition': 'Condition',
'common.conditionCount': '{n} conditions',
'common.conditionGroup': 'Group',
'common.editAdvanced': 'Edit',
'common.enabled': 'Enabled',
'common.expand': 'Expand',
'common.exportCurrentPageTip': 'Export current page; only selected rows if any are checked',
'common.exporting': 'Exporting...',
'common.field': 'Field',
'common.globalQuerySettingsSaved': 'Saved as global default',
'common.less': 'Collapse',
'common.matchAll': 'Match all',
'common.matchAny': 'Match any',
'common.moduleConfigLoadFailed': 'Failed to load module config',
'common.moreActions': 'More actions',
'common.noQueryPermission': 'No query permission',
'common.operation': 'Actions',
'common.queryPreview': 'Condition preview',
'common.querySettings': 'Query settings',
'common.querySettingsSaveFailed': 'Failed to save query settings: ',
'common.querySettingsSaved': 'Query settings saved',
'common.refreshData': 'Refresh data',
'common.reload': 'Reload',
'common.remove': 'Remove',
'common.removeGroup': 'Remove group',
'common.reset': 'Reset',
'common.resetColumnSettings': 'Restore column settings',
'common.restoreDefault': 'Restore global default',
'common.saveAsGlobal': 'Save as global',
'common.saveAsPersonal': 'Save as personal',
'common.saveColumnSettings': 'Save column settings',
'common.search': 'Search',
'common.selectedRows': '{n} selected',
'common.totalRecords': '{n} records total',
},
}
@@ -83,6 +83,24 @@ function onUserMenuClick({ key }) {
}
}
/* ---- 语言切换:语种来自 s_i18n_type(语言清单),不再硬编码 zh-CN/en-US 互切 ---- */
const languageMenuItems = computed(() => {
const list = i18nStore.languages.length
? i18nStore.languages
: [{ b_id: i18nStore.locale, b_name: i18nStore.locale }]
return list.map((item) => ({
key: String(item.b_id),
label: item.b_name || String(item.b_id),
// 当前语种打勾(下拉项没有 active 态,用右侧 shortcut 槽位标示)
shortcut: String(item.b_id) === String(i18nStore.locale) ? '✓' : '',
}))
})
function onLanguageMenuClick({ key }) {
if (String(key) === String(i18nStore.locale)) return
i18nStore.setLocale(key)
}
/* ---- 全屏切换(Fullscreen API) ---- */
const isFullscreen = ref(false)
function syncFullscreen() {
@@ -122,14 +140,18 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
<!-- 右侧:语言 / 主题色板 / 深浅色 / 通知 / 用户 -->
<div class="fms-topbar-right">
<Button
type="ghost"
class="fms-icon-btn"
:title="i18nStore.locale === 'zh-CN' ? 'English' : '中文'"
@click="i18nStore.toggleLocale()"
<Dropdown
placement="bottom-end"
:offset="8"
:items="languageMenuItems"
@click="onLanguageMenuClick"
>
<Languages />
</Button>
<template #trigger>
<Button type="ghost" class="fms-icon-btn" title="语言 / Language">
<Languages />
</Button>
</template>
</Dropdown>
<!-- 主题色板 -->
<Dropdown placement="bottom-end" :offset="8">
+4 -2
View File
@@ -44,10 +44,12 @@ router.beforeEach(async (to) => {
menuStore.load().catch(() => {}),
])
// 业务数据多语言配置(s_i18n_type 语言清单):随权限一并加载,
// 供菜单/模块等多语言编辑读取;内部去重、失败不阻断页面
// 业务数据多语言配置(s_i18n_type 语言清单 + s_i18n 译文):随权限一并加载,
// 供菜单/模块等多语言编辑读取;内部去重、失败不阻断页面。
// 译文在这里兜一次底:App.vue 的 watcher 负责语言切换,这里负责登录后首次进入
const languagesStore = useI18nStore()
await languagesStore.loadLanguages().catch(() => {})
await languagesStore.loadMessages().catch(() => {})
const moduleId = to.meta.moduleId
if (moduleId && !permissionStore.canAccess(moduleId)) {
+61
View File
@@ -1,6 +1,22 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { loadDataApi } from '@/services/api'
import { BUILTIN_MESSAGES } from '@/i18n/builtin-messages'
/** SQL 字符串字面量转义(b_locale 来自 s_i18n_type,仍按不可信输入处理) */
function sqlQuote(value) {
return `N'${String(value).replace(/'/g, "''")}'`
}
// 已加载的语言译文缓存:locale -> { key: value }(只存库表译文,内置兜底在合并时再加)
const messageCache = new Map()
// 同一语言的并发请求复用同一个 Promise,避免切换语言瞬间打出一串相同请求
const pendingMessages = new Map()
function clearMessageCache() {
messageCache.clear()
pendingMessages.clear()
}
// UI 翻译运行时与业务语言清单共用一个入口。
const localeRef = ref('zh-CN')
@@ -24,6 +40,7 @@ export function setMessages(messages, locale = undefined) {
export function clearMessages() {
messagesRef.value = {}
clearMessageCache()
}
export const useI18nStore = defineStore('i18n', () => {
@@ -78,6 +95,49 @@ export const useI18nStore = defineStore('i18n', () => {
return loadLanguages(true)
}
/* ---------- 译文(s_i18n) ---------- */
// 把某份语言的译文写进运行时;库表译文覆盖前端内置兜底文案
function applyMessages(locale) {
// 异步请求回来时语言可能又被切走了,只认当前语言,避免串台
if (String(localeRef.value) !== String(locale)) return
setMessages({ ...BUILTIN_MESSAGES[locale], ...messageCache.get(locale) }, locale)
}
/**
* 加载指定语言的译文;不传则取当前语言。
* 已加载过直接复用缓存(无请求),并发复用同一个 Promise,失败不抛出(界面回落内置/兜底文案)。
*/
async function loadMessages(locale = localeRef.value, force = false) {
const target = String(locale || '').trim()
if (!target) return {}
if (!force && messageCache.has(target)) {
applyMessages(target)
return messageCache.get(target)
}
if (pendingMessages.has(target)) return pendingMessages.get(target)
const task = loadDataApi('s_i18n', `b_canuse = 1 and b_locale = ${sqlQuote(target)}`, 'b_key ASC')
.then((res) => {
const map = {}
for (const row of res.data || []) {
if (row.b_key) map[String(row.b_key)] = row.b_value == null ? '' : String(row.b_value)
}
messageCache.set(target, map)
applyMessages(target)
return map
})
.catch((e) => {
error.value = e?.message || String(e)
return {}
})
.finally(() => {
pendingMessages.delete(target)
})
pendingMessages.set(target, task)
return task
}
function toggleLocale() {
setLocale(localeRef.value === 'zh-CN' ? 'en-US' : 'zh-CN')
}
@@ -98,6 +158,7 @@ export const useI18nStore = defineStore('i18n', () => {
clearMessages,
toggleLocale,
loadLanguages,
loadMessages,
refresh,
}
}, {
@@ -516,7 +516,6 @@ void loadCategories()
ref="categoryFormRef"
:model="categoryDraft"
:rules="categoryFormRules"
layout="vertical"
:gap="16"
>
<FormItem label="上级分类" name="b_parent_id">
@@ -151,7 +151,6 @@ function onFailed() {
class="login-form"
:model="formModel"
:rules="formRules"
layout="vertical"
:gap="20"
@submit="onSubmit"
@submit-failed="onFailed"
@@ -107,7 +107,6 @@ function onMenuTypeChange(menuType) {
ref="formRef"
:model="maindata"
:rules="formRules"
layout="vertical"
:gap="20"
>
<FormItem label="菜单编码" name="b_id" required>
@@ -9,7 +9,7 @@
* ├ 预览区:分段着色的编码预览,居中展示
* └ 底部:重置周期
*/
import { computed, ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import { Input, Select, Switch, Form, FormItem } from '@/components/ui'
const props = defineProps({
@@ -151,8 +151,12 @@ const fieldOptions = computed(() => {
* 返回 model 中的行对象(响应式代理),就地改其属性即可同时驱动视图与父级草稿。 */
function ensureRow() {
if (autoCode.value.length) return autoCode.value[0]
autoCode.value = [createDraft(props.moduleId, props.fields, false)]
return autoCode.value[0]
// defineModel 写入后 prop 要下一轮才回传,紧接着回读 autoCode.value 仍是旧数组(空),
// 直接取 [0] 会得到 undefined 再写属性就炸。先包成 reactive 再写回,
// 返回其中的代理行,保证后续就地改字段能被追踪。
const rows = reactive([createDraft(props.moduleId, props.fields, false)])
autoCode.value = rows
return rows[0]
}
function toggleEnabled(checked) {
@@ -209,6 +213,8 @@ const preview = computed(() => {
<template>
<div v-if="formRow" class="mm-auto-code">
<!-- 固定竖排:这里是 5 列紧凑网格(每列约 130px),横排时 88px 的标签会把控件压到几十像素。
显式传 layout 优先于全局配置(中文默认横排),只锁这一个面板 -->
<Form
ref="formRef"
:model="formRow"
@@ -115,7 +115,6 @@ function onModuleTypeChange(v) {
ref="formRef"
:model="maindata"
:rules="formRules"
layout="vertical"
:columns="3"
max-width="720px"
>
@@ -0,0 +1,550 @@
<script setup>
/**
* 模块关联面板(s_relation):维护当前模块与其他模块之间的主子表 / 引用关系。
*
* 数据模型(复合主键 4 列):
* b_source_module_id + b_source_field → b_target_module_id + b_target_field,
* 另有 b_relation_type(one_to_one / one_to_many / many_to_one)、b_on_delete、b_canuse、b_xh。
*
* 删除行为(两列):
* - 「删除连带」写 s_relation.b_on_delete:删源记录时对目标行不连带 / 连带删除 / 连带归档;
* 多对一是「多条指向同一条」,只允许不连带(连带会波及其他引用方)。
* - 「有引用时拒绝删除」不写 s_relation:勾选时在 refRules 草稿(s_rule,scope_type='relation')
* 里增/删一条引用规则,由父级随保存一并提交。
*
* 方向性:上区只编辑「本模块作为源」的关系(父级已按 b_source_module_id 拆好);
* 下区只读展示「其他模块指向本模块」的引用,避免对方配置的关系完全不可见。
*
* 数据契约:默认 v-model 与父级 relations 双向绑定;父级另持 relations_org 基线用于保存 diff,
* 反向只读行走 props.reverseRows(不参与 diff 与脏判定)。
*/
import { computed, nextTick, reactive, ref, shallowRef, watch } from 'vue'
import { Plus, Trash2 } from '@lucide/vue'
import { Button, Message, Spin } from '@/components/ui'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { loadDataApi, nextIdApi } from '@/services/api'
import { createTempId } from '@/utils/common'
import { sqlStringLiteral } from './utils'
import { createRefRuleRow, isCompleteRelationKey, relationScopeId } from './ruleUtils'
const props = defineProps({
/** 当前模块编码:源侧关系的 b_source_module_id */
moduleId: { type: String, default: '' },
/** 当前模块字段(源字段下拉的数据源) */
fields: { type: Array, default: () => [] },
/** 全量模块(含停用项):用于目标模块下拉与名称解析 */
modules: { type: Array, default: () => [] },
/** 其他模块指向本模块的引用行(只读展示) */
reverseRows: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
/** 当前模块作为源的关系行(s_relation),父级 v-model 绑定 relations */
const relations = defineModel({ type: Array, default: () => [] })
/**
* 关系上的引用规则草稿(s_rule,scope_type='relation'),父级 v-model:ref-rules 绑定。
* 它不属于 s_relation:勾选「有引用时拒绝删除」时在这里增/删行,走规则表的正常 diff。
*/
const refRules = defineModel('refRules', { type: Array, default: () => [] })
const RELATION_TYPE_OPTIONS = [
{ label: '一对一', value: 'one_to_one' },
{ label: '一对多', value: 'one_to_many' },
{ label: '多对一', value: 'many_to_one' },
]
const RELATION_TYPE_VALUES = RELATION_TYPE_OPTIONS.map((item) => item.value)
const RELATION_TYPE_LABELS = new Map(RELATION_TYPE_OPTIONS.map((item) => [item.value, item.label]))
/** 删除连带策略:连带删除/连带归档只在源→目标(一对多 / 一对一)上成立 */
const DELETE_POLICY_OPTIONS = [
{ label: '不连带', value: 'none' },
{ label: '连带删除', value: 'cascade' },
{ label: '连带归档', value: 'archive' },
]
const DELETE_POLICY_VALUES = DELETE_POLICY_OPTIONS.map((item) => item.value)
/** 关系主键列(改动即换业务键,挂在旧键上的引用规则必须作废) */
const KEY_COLUMNS = new Set(['b_source_field', 'b_target_module_id', 'b_target_field'])
function isManyToOne(row) {
return String(row?.b_relation_type ?? '') === 'many_to_one'
}
/* ================= 行本地派生列 ================= */
// FmsTable 的行身份用 _rowKey;_hasRefRule / _refScopeId 是引用规则勾选态与旧业务键的记忆,
// 三者都是 _ 前缀:不落 s_relation、不参与 diff 与脏判定,加载回填不会置脏
function hasRefRule(row) {
if (!isCompleteRelationKey(row)) return 0
const key = relationScopeId(row)
return refRules.value.some((rule) => String(rule.b_scope_id ?? '') === key) ? 1 : 0
}
function syncRelationRows() {
let changed = false
for (const row of relations.value || []) {
if (row._rowKey == null) {
row._rowKey = createTempId()
changed = true
}
const nextKey = isCompleteRelationKey(row)
? relationScopeId(row)
: String(row._refScopeId ?? '')
if (row._refScopeId !== nextKey) {
row._refScopeId = nextKey
changed = true
}
const nextRef = hasRefRule(row)
if (row._hasRefRule !== nextRef) {
row._hasRefRule = nextRef
changed = true
}
}
if (changed) relations.value = [...relations.value]
}
watch([() => relations.value, () => refRules.value], syncRelationRows, {
deep: true,
immediate: true,
})
/** 勾选态与规则草稿的联动:勾上建行(先取雪花号)、取消删行 */
async function setRefRule(row, enabled) {
const key = relationScopeId(row)
if (!key || !isCompleteRelationKey(row)) {
row._hasRefRule = 0
return
}
const exists = refRules.value.some((rule) => String(rule.b_scope_id ?? '') === key)
if (enabled && !exists) {
const idRes = await nextIdApi()
refRules.value = [...refRules.value, createRefRuleRow(key, String(idRes?.data ?? ''))]
} else if (!enabled && exists) {
refRules.value = refRules.value.filter((rule) => String(rule.b_scope_id ?? '') !== key)
}
}
/** 按业务键删掉引用规则(删行 / 换主键时调用) */
function removeRefRulesOf(keys) {
const set = new Set(keys.filter(Boolean).map(String))
if (!set.size) return false
const before = refRules.value.length
refRules.value = refRules.value.filter((rule) => !set.has(String(rule.b_scope_id ?? '')))
return refRules.value.length !== before
}
/* ================= 模块选项与名称解析 ================= */
const moduleById = computed(
() => new Map((props.modules || []).map((item) => [String(item.b_id), item])),
)
/** 目标模块下拉:仅启用模块;停用/已删模块不在选项里,展示回退为裸编码 */
const moduleOptions = computed(() =>
(props.modules || [])
.filter((item) => Number(item.b_canuse) === 1)
.map((item) => ({
label: `${item.b_name || item.b_id}(${item.b_id})`,
value: String(item.b_id),
})),
)
function moduleText(code) {
const id = String(code ?? '')
if (!id) return ''
const module = moduleById.value.get(id)
return module ? `${module.b_name || id}(${id})` : id
}
/* ================= 字段选项(按模块惰性加载并缓存) ================= */
const fieldOptionsCache = reactive(new Map())
const inflightFields = new Set()
/** 字段行 → 下拉选项;停用字段标注「停用」,保证历史行仍能解析出标题 */
function toFieldOption(field) {
const name = field.b_name || field.b_field
const text = `${name}(${field.b_field})`
return {
label: Number(field.b_canuse) === 1 ? text : `${text}[停用]`,
value: String(field.b_field),
}
}
function fieldOptionsOf(code) {
const id = String(code ?? '')
return id ? fieldOptionsCache.get(id) || [] : []
}
/** 按模块编码加载字段下拉选项;已有缓存或请求在途时直接返回,加载失败降级为裸编码 */
async function ensureTargetFields(code) {
const id = String(code ?? '')
if (!id || fieldOptionsCache.has(id) || inflightFields.has(id)) return
inflightFields.add(id)
try {
const res = await loadDataApi(
's_field',
`b_module_id = ${sqlStringLiteral(id)}`,
'b_xh ASC, b_field ASC',
)
fieldOptionsCache.set(id, (res.data || []).map(toFieldOption))
} catch {
// 字段名仅用于下拉与展示,加载失败静默降级
fieldOptionsCache.set(id, [])
} finally {
inflightFields.delete(id)
}
}
/* ================= 源字段选项(当前模块字段) ================= */
const sourceFieldOptions = computed(() => (props.fields || []).map(toFieldOption))
/** 字段展示文案:本模块走 props.fields,其他模块走字段缓存,未命中回退裸编码 */
function fieldText(moduleCode, fieldCode) {
const code = String(fieldCode ?? '')
if (!code) return ''
const options =
String(moduleCode) === String(props.moduleId)
? sourceFieldOptions.value
: fieldOptionsOf(moduleCode)
return options.find((item) => item.value === code)?.label || code
}
/* ================= 字段预热 ================= */
// 面板常驻挂载(v-show),预热由 moduleId / 行数据变化驱动,不放在 onMounted
watch(
[() => props.moduleId, () => relations.value, () => props.reverseRows],
() => {
const codes = new Set()
for (const row of relations.value || []) {
if (row.b_target_module_id) codes.add(String(row.b_target_module_id))
}
for (const row of props.reverseRows || []) {
if (row.b_source_module_id) codes.add(String(row.b_source_module_id))
}
codes.forEach((code) => void ensureTargetFields(code))
},
{ deep: true, immediate: true },
)
/* ================= 可编辑表格 ================= */
const tableRef = ref(null)
const activeKey = ref(null)
const selectedRowKeys = ref([])
const columns = shallowRef([
{
dataIndex: 'b_source_field',
title: '源字段',
width: 190,
editor: { type: 'select', options: () => sourceFieldOptions.value },
},
{
dataIndex: 'b_target_module_id',
title: '目标模块',
width: 220,
editor: { type: 'select', options: () => moduleOptions.value },
},
{
dataIndex: 'b_target_field',
title: '目标字段',
width: 190,
// 选项依赖同行的目标模块,故按行解析
editor: { type: 'select', options: (row) => fieldOptionsOf(row.b_target_module_id) },
},
{
dataIndex: 'b_relation_type',
title: '关系类型',
width: 130,
editor: { type: 'select', options: RELATION_TYPE_OPTIONS },
},
{
dataIndex: 'b_on_delete',
title: '删除连带',
width: 130,
// 多对一是「多条指向同一条」:删源连带删目标会波及其他引用方,只允许不连带
editor: {
type: 'select',
options: DELETE_POLICY_OPTIONS,
disabled: (row) => isManyToOne(row),
},
},
{
dataIndex: '_hasRefRule',
title: '有引用时拒绝删除',
width: 140,
align: 'center',
editor: { type: 'checkbox', disabled: (row) => !isCompleteRelationKey(row) },
},
{
dataIndex: 'b_canuse',
title: '启用',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{ dataIndex: 'b_xh', title: '排序号', width: 80 },
{
dataIndex: 'actions',
title: '操作',
align: 'center',
width: 70,
actions: [
{
type: 'delete',
label: '删除',
onClick: (row) => removeRow(row),
},
],
},
])
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys
},
}))
function onRowOrderChange(rows) {
relations.value = rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
}
function onCellChange(row, col) {
const dataIndex = col?.dataIndex
// 换目标模块后原目标字段必然失效,清空并加载新模块字段供选择
if (dataIndex === 'b_target_module_id') {
row.b_target_field = null
void ensureTargetFields(row.b_target_module_id)
}
// 关系类型切到多对一时连带策略不再成立,清回不连带
if (dataIndex === 'b_relation_type' && isManyToOne(row) && row.b_on_delete !== 'none') {
row.b_on_delete = 'none'
Message.warning('多对一关系不支持连带删除/连带归档,已重置为不连带')
}
// 勾选/取消「有引用时拒绝删除」:在规则草稿里增/删一条引用规则
if (dataIndex === '_hasRefRule') {
void setRefRule(row, row._hasRefRule === 1)
}
// 关系主键列改动 → 业务键变了,挂在旧键上的引用规则必须作废(否则库里留下孤儿规则)
if (KEY_COLUMNS.has(dataIndex)) {
const prevKey = String(row._refScopeId ?? '')
const nextKey = isCompleteRelationKey(row) ? relationScopeId(row) : ''
if (prevKey && prevKey !== nextKey) {
const removed = removeRefRulesOf([prevKey])
row._hasRefRule = 0
if (removed) Message.info('关系主键已变化,「有引用时拒绝删除」已取消,请重新勾选')
}
}
// 行对象已就地 patch,换新数组引用触发表格重渲染
relations.value = [...relations.value]
}
async function addRow() {
if (props.loading) return
const row = {
_rowKey: createTempId(),
b_source_module_id: String(props.moduleId || ''),
b_source_field: null,
b_target_module_id: null,
b_target_field: null,
b_relation_type: 'many_to_one',
b_on_delete: 'none',
b_canuse: 1,
b_xh: (relations.value.length + 1) * 10,
}
relations.value = [...relations.value, row]
activeKey.value = String(row._rowKey)
await nextTick()
// 聚焦第一个可编辑列(源字段)
tableRef.value?.editRow?.(String(row._rowKey), 'b_source_field')
}
function removeRow(row) {
relations.value = relations.value.filter((item) => item !== row)
// 关系行没了,挂在它上面的引用规则也要一起消失
removeRefRulesOf([row._refScopeId])
if (String(row._rowKey) === String(activeKey.value)) activeKey.value = null
selectedRowKeys.value = selectedRowKeys.value.filter((key) => key !== String(row._rowKey))
}
function batchDelete() {
if (props.loading) return
const keys = selectedRowKeys.value
if (!keys.length) return
const delSet = new Set(keys.map(String))
const removed = relations.value.filter((row) => delSet.has(String(row._rowKey)))
relations.value = relations.value.filter((row) => !delSet.has(String(row._rowKey)))
removeRefRulesOf(removed.map((row) => row._refScopeId))
selectedRowKeys.value = []
if (activeKey.value && delSet.has(String(activeKey.value))) activeKey.value = null
Message.success(`已删除 ${keys.length} 条关联`)
}
/* ================= 反向只读表格 ================= */
// 展示文案预先派生为普通列值;字段缓存命中后随响应式刷新,未命中回退裸编码
const reverseDisplayRows = computed(() =>
(props.reverseRows || []).map((row, index) => ({
_rowKey: [
'reverse',
index,
row.b_source_module_id,
row.b_source_field,
row.b_target_field,
].join('-'),
sourceModuleText: moduleText(row.b_source_module_id),
sourceFieldText: fieldText(row.b_source_module_id, row.b_source_field),
targetFieldText: fieldText(props.moduleId, row.b_target_field),
relationTypeText:
RELATION_TYPE_LABELS.get(String(row.b_relation_type || '')) ||
String(row.b_relation_type || ''),
canuseText: Number(row.b_canuse) === 1 ? '启用' : '停用',
})),
)
const reverseColumns = shallowRef([
{ dataIndex: 'sourceModuleText', title: '源模块', width: 220 },
{ dataIndex: 'sourceFieldText', title: '源字段', width: 190 },
{ dataIndex: 'targetFieldText', title: '目标字段', width: 190 },
{ dataIndex: 'relationTypeText', title: '关系类型', width: 130 },
{ dataIndex: 'canuseText', title: '启用', width: 70, align: 'center' },
])
/* ================= 校验 ================= */
/** 行身份:与保存 diff 的四列复合主键一致 */
function relationKeyOf(row) {
return [row.b_source_module_id, row.b_source_field, row.b_target_module_id, row.b_target_field]
.map((value) => String(value ?? '').trim())
.join('|')
}
/** 保存前校验:四列非空、关系类型合法、组合不重复、删除连带合法(空列会撞行身份,落库即主键冲突) */
function validate() {
const errors = []
const seen = new Set()
for (const [index, row] of (relations.value || []).entries()) {
const label = `第 ${index + 1} 行`
if (!String(row.b_source_field ?? '').trim()) errors.push(`${label}:请选择源字段`)
if (!String(row.b_target_module_id ?? '').trim()) errors.push(`${label}:请选择目标模块`)
if (!String(row.b_target_field ?? '').trim()) errors.push(`${label}:请选择目标字段`)
if (!RELATION_TYPE_VALUES.includes(String(row.b_relation_type ?? ''))) {
errors.push(`${label}:关系类型不合法`)
}
const policy = String(row.b_on_delete ?? 'none')
if (!DELETE_POLICY_VALUES.includes(policy)) {
errors.push(`${label}:删除连带取值不合法`)
} else if (policy !== 'none' && isManyToOne(row)) {
errors.push(`${label}:多对一关系不支持连带删除/连带归档`)
}
const key = relationKeyOf(row)
if (seen.has(key)) errors.push(`${label}:与前面的关联重复`)
seen.add(key)
}
return errors
}
defineExpose({ validate })
</script>
<template>
<div class="mm-relation">
<div class="mm-relation__section mm-relation__section--main">
<div class="mm-relation__toolbar">
<Button type="primary" :disabled="loading" @click="addRow">
<Plus :size="14" /> 新增关联
</Button>
<Button type="danger" :disabled="loading || !selectedRowKeys.length" @click="batchDelete">
<Trash2 :size="14" /> 批量删除
</Button>
</div>
<div v-if="loading" class="mm-relation__state"><Spin /></div>
<div v-else class="mm-relation__table">
<FmsTable
ref="tableRef"
v-model:columns="columns"
:data-source="relations"
:editable="!loading"
:row-draggable="!loading"
:row-selection="rowSelection"
row-key="_rowKey"
:active-row-key="activeKey"
:col-resizable="true"
@row-click="(_, row) => (activeKey = String(row._rowKey))"
@change="onCellChange"
@row-order-change="onRowOrderChange"
/>
</div>
</div>
<div
v-if="reverseDisplayRows.length"
class="mm-relation__section mm-relation__section--reverse"
>
<div class="mm-relation__caption">其他模块指向本模块(只读)</div>
<div class="mm-relation__table">
<FmsTable
v-model:columns="reverseColumns"
:data-source="reverseDisplayRows"
:editable="false"
:seq="false"
row-key="_rowKey"
/>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
/* 上下两区:上区可编辑(撑满剩余高度),下区只读(内容少时按自身高度) */
.mm-relation {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.mm-relation__section {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.mm-relation__section--main {
flex: 1;
}
.mm-relation__section--reverse {
flex: none;
max-height: 45%;
}
.mm-relation__toolbar {
display: flex;
flex: none;
align-items: center;
gap: 8px;
padding: 0 0 8px;
}
.mm-relation__caption {
flex: none;
padding: 0 0 6px;
color: var(--fms-text-secondary);
font-size: var(--fms-font-size);
}
.mm-relation__state {
padding: 24px 0;
color: var(--fms-text-secondary);
font-size: var(--fms-font-size);
text-align: center;
}
/* FmsTable 内部 height: 100%,宿主必须给出确定高度 */
.mm-relation__table {
flex: 1;
min-height: 0;
}
.mm-relation__section--reverse .mm-relation__table {
min-height: 120px;
}
</style>
@@ -0,0 +1,235 @@
<script setup>
/**
* 删除规则编辑弹窗(s_rule)。
*
* 规则没有名称/编码,界面上只填"什么情况不许删"和"拦下来说什么":
* - 勾条件:复用高级查询抽屉(FmsAdvancedQueryPanel)编辑,保存为 {"kind":"dsl","expr":AST},
* 提示文案可留空(引擎按条件自动生成人话);
* - 写 SQL:文本直存,`:ids` 会自动替换为待删记录 id,查出有结果即拦截;提示文案必填。
* 主键 b_id 在打开弹窗前由面板取号,本弹窗不涉及。
* 编辑对象是父级传入的行副本,取消不污染草稿。
*/
import { computed, ref, watch } from 'vue'
import { Button, Form, FormItem, Input, Modal, Radio, RadioGroup, Switch } from '@/components/ui'
import FmsAdvancedQueryPanel from '@/components/fms-module-list/FmsAdvancedQueryPanel.vue'
import {
advancedQueryFromAst,
buildAdvancedAst,
describeAdvancedQuery,
} from '@/components/fms-module-common/queryUtils'
import { useI18nStore } from '@/stores/i18n'
import { RULE_HOOK_DELETE, itemMapOf, parseRulePredicate } from './ruleUtils'
const props = defineProps({
open: { type: Boolean, default: false },
/** 编辑中的规则行副本(弹窗内直接改;取消不落草稿) */
row: { type: Object, default: null },
/** 新增还是编辑:只影响标题 */
isNew: { type: Boolean, default: false },
/** 条件面板 items(当前模块字段 + 查询配置) */
items: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:open', 'ok'])
const { t } = useI18nStore()
const RULE_TYPE_OPTIONS = [
{ value: 'dsl', label: '勾条件' },
{ value: 'sql', label: '写 SQL' },
]
const ruleType = ref('dsl')
const conditionState = ref({ nodes: [], groups: [] })
const conditionOpen = ref(false)
const conditionDegraded = ref(false)
const sqlText = ref('')
const conditionError = ref('')
const sqlError = ref('')
const itemByField = computed(() => itemMapOf(props.items))
const conditionSummary = computed(() =>
describeAdvancedQuery(conditionState.value, itemByField.value, t),
)
const modalTitle = computed(() => (props.isNew ? '新增删除规则' : '编辑删除规则'))
// 每次打开按当前行还原表单(predicate 三态 → 类型 + 各自内容)
watch(
() => [props.open, props.row],
() => {
if (!props.open || !props.row) return
const parsed = parseRulePredicate(props.row)
ruleType.value = parsed.kind === 'sql' ? 'sql' : 'dsl'
sqlText.value = parsed.kind === 'sql' ? parsed.sql : ''
if (parsed.kind === 'dsl') {
const state = advancedQueryFromAst(parsed.expr)
conditionState.value = { nodes: state.nodes, groups: state.groups }
conditionDegraded.value = state.degraded
} else {
conditionState.value = { nodes: [], groups: [] }
conditionDegraded.value = false
}
conditionError.value = ''
sqlError.value = ''
},
{ immediate: true },
)
function onTypeChange() {
conditionError.value = ''
sqlError.value = ''
}
function onConditionChange() {
conditionError.value = ''
}
function onConditionApply() {
conditionOpen.value = false
}
function close() {
emit('update:open', false)
}
async function onOk() {
const row = props.row
if (!row) return close()
conditionError.value = ''
sqlError.value = ''
if (ruleType.value === 'sql') {
const sql = sqlText.value.trim()
if (!sql) {
sqlError.value = '请填写规则 SQL'
return
}
if (!String(row.b_message ?? '').trim()) {
sqlError.value = '写 SQL 的规则必须填写提示文案'
return
}
row.b_predicate = sql
} else {
const ast = buildAdvancedAst(conditionState.value)
if (!(ast.children || []).length) {
conditionError.value = '勾条件规则至少要有一个条件'
return
}
row.b_predicate = JSON.stringify({ kind: 'dsl', expr: ast })
}
row.b_hook = RULE_HOOK_DELETE
emit('ok', row)
emit('update:open', false)
}
</script>
<template>
<Modal
:open="open"
:title="modalTitle"
:width="640"
centered
:resizable="false"
:fullscreen="false"
:mask-closable="false"
@update:open="(value) => emit('update:open', value)"
@ok="onOk"
>
<Form v-if="row" :model="row" :columns="1">
<FormItem label="规则类型" name="_rule_type">
<RadioGroup v-model="ruleType" @change="onTypeChange">
<Radio v-for="option in RULE_TYPE_OPTIONS" :key="option.value" :value="option.value">
{{ option.label }}
</Radio>
</RadioGroup>
</FormItem>
<FormItem
v-if="ruleType === 'dsl'"
label="判定条件"
name="_condition"
:validate-status="conditionError ? 'error' : ''"
:help="conditionError"
>
<div class="mre-condition">
<span class="mre-condition__text">{{ conditionSummary || '尚未配置条件' }}</span>
<Button type="outline" @click="conditionOpen = true">编辑条件</Button>
</div>
<div v-if="conditionDegraded" class="mre-condition__warn">
原条件含多层分组,已按单层展开,保存前请确认
</div>
</FormItem>
<FormItem
v-else
label="规则 SQL"
name="_sql"
:validate-status="sqlError ? 'error' : ''"
:help="sqlError"
extra=":ids 会自动替换为本次待删记录的 id 列表;查出有结果即拦截,每次执行都会记技术日志"
>
<Input
type="textarea"
:rows="6"
:model-value="sqlText"
placeholder="select b_id from bill where b_id in (:ids) and b_settled = 1"
@update:model-value="(value) => (sqlText = value)"
/>
</FormItem>
<FormItem
label="提示文案"
name="b_message"
:extra="
ruleType === 'sql'
? '写 SQL 时必填,可用 {列名} 引用查询结果'
: '留空则由系统按条件自动生成'
"
>
<Input
:model-value="row.b_message"
:placeholder="ruleType === 'sql' ? '如:单据 {b_no} 已销账,不能删除' : '可留空'"
@update:model-value="(value) => (row.b_message = value)"
/>
</FormItem>
<FormItem label="启用" name="_canuse">
<Switch
:model-value="Number(row.b_canuse) === 1"
@update:model-value="(value) => (row.b_canuse = value ? 1 : 0)"
/>
</FormItem>
</Form>
<!-- 条件抽屉:挂在弹窗之上(滚动锁是引用计数,关闭抽屉不会解锁弹窗) -->
<FmsAdvancedQueryPanel
v-model="conditionState"
v-model:open="conditionOpen"
:items="items"
@apply="onConditionApply"
@update:model-value="onConditionChange"
/>
</Modal>
</template>
<style lang="scss" scoped>
.mre-condition {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.mre-condition__text {
flex: 1;
min-width: 0;
color: var(--fms-text);
font-size: var(--fms-font-size);
overflow-wrap: anywhere;
}
.mre-condition__warn {
margin-top: 6px;
color: var(--fms-warning, #d97706);
font-size: 12px;
}
</style>
@@ -0,0 +1,367 @@
<script setup>
/**
* 模块删除规则面板(s_rule,scope_type='module'):当前模块下"什么情况不许删"的规则表。
*
* 规则没有名称/编码:列表靠内容自述识别——勾条件显示条件中文摘要,写 SQL 显示它的提示文案,
* 主键是新增时取号的雪花 b_id(IT 不可见)。
*
* 数据契约:默认 v-model 与父级 rules 双向绑定(父级另持 rules_org 基线用于保存 diff);
* 关系上的引用规则(scope_type='relation')不在本面板编辑,只在只读区展示并可跳转到模块关联。
*
* 列派生说明:_rowKey / _typeText / _labelText 都是界面派生列(_ 前缀不落库、不判脏),
* 由 watch 就地补齐后换数组引用触发表格刷新。
*/
import { computed, ref, shallowRef, watch } from 'vue'
import { Plus, Trash2 } from '@lucide/vue'
import { Button, Message, Spin } from '@/components/ui'
import FmsTable from '@/components/fms-table/FmsTable.vue'
import { nextIdApi } from '@/services/api'
import { useI18nStore } from '@/stores/i18n'
import { createTempId } from '@/utils/common'
import ModuleRuleEditModal from './ModuleRuleEditModal.vue'
import {
REF_RULE_HINT,
buildRuleQueryItems,
createRuleRow,
itemMapOf,
parseRelationScopeId,
parseRulePredicate,
ruleHasCondition,
ruleLabel,
ruleTypeText,
} from './ruleUtils'
const props = defineProps({
/** 当前模块编码:规则的 b_scope_id */
moduleId: { type: String, default: '' },
/** 当前模块字段(勾条件的可选字段) */
fields: { type: Array, default: () => [] },
/** 查询配置 JSON:字段的操作符 / 控件 / 选项从它继承 */
querySchema: { type: Object, default: () => ({}) },
/** 其他模块指向本模块的关系上挂的引用规则(只读展示) */
refRulesReverse: { type: Array, default: () => [] },
/** 全量模块:把引用规则里的模块编码解析成名称 */
modules: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
})
/** 当前模块的删除规则行(s_rule,scope_type='module'),父级 v-model 绑定 rules */
const rules = defineModel({ type: Array, default: () => [] })
/** navigate:跳到关系所属模块(父级决定切 tab 还是切模块) */
const emit = defineEmits(['navigate'])
const { t } = useI18nStore()
/** 条件面板 items 与字段索引(勾条件的字段下拉与中文摘要共用) */
const items = computed(() => buildRuleQueryItems(props.fields, props.querySchema))
const itemByField = computed(() => itemMapOf(items.value))
/* ================= 派生列 ================= */
function syncRuleRows() {
let changed = false
for (const row of rules.value || []) {
if (row._rowKey == null) {
row._rowKey = createTempId()
changed = true
}
const typeText = ruleTypeText(row)
if (row._typeText !== typeText) {
row._typeText = typeText
changed = true
}
const labelText = ruleLabel(row, itemByField.value, t)
if (row._labelText !== labelText) {
row._labelText = labelText
changed = true
}
}
if (changed) rules.value = [...rules.value]
}
watch([() => rules.value, itemByField], syncRuleRows, { deep: true, immediate: true })
/* ================= 表格 ================= */
const tableRef = ref(null)
const activeKey = ref(null)
const selectedRowKeys = ref([])
const columns = shallowRef([
{ dataIndex: '_typeText', title: '类型', width: 80 },
{ dataIndex: '_labelText', title: '判定条件 / 提示文案', minWidth: 320 },
{
dataIndex: 'b_canuse',
title: '启用',
width: 70,
align: 'center',
editor: { type: 'checkbox' },
},
{ dataIndex: 'b_xh', title: '排序号', width: 80 },
{
dataIndex: 'actions',
title: '操作',
align: 'center',
width: 110,
actions: [
{ type: 'edit', label: '编辑', onClick: (row) => openRuleModal(row) },
{ type: 'delete', label: '删除', onClick: (row) => removeRow(row) },
],
},
])
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys
},
}))
function onRowOrderChange(rows) {
rules.value = rows.map((row, index) => ({ ...row, b_xh: (index + 1) * 10 }))
}
function onCellChange(row) {
// 就地 patch 后换新数组引用触发表格重渲染
rules.value = [...rules.value]
}
function removeRow(row) {
rules.value = rules.value.filter((item) => item !== row)
if (String(row._rowKey) === String(activeKey.value)) activeKey.value = null
selectedRowKeys.value = selectedRowKeys.value.filter((key) => key !== String(row._rowKey))
}
function batchDelete() {
if (props.loading) return
const keys = selectedRowKeys.value
if (!keys.length) return
const delSet = new Set(keys.map(String))
rules.value = rules.value.filter((row) => !delSet.has(String(row._rowKey)))
selectedRowKeys.value = []
if (activeKey.value && delSet.has(String(activeKey.value))) activeKey.value = null
Message.success(`已删除 ${keys.length} 条规则`)
}
/* ================= 编辑弹窗 ================= */
// 弹窗编辑的是行副本:取消不污染草稿;确定后按 _rowKey 替换(新增则追加)
const ruleModalOpen = ref(false)
const editingRow = ref(null)
const editingIsNew = ref(false)
/** 新增规则:先取雪花号(与业务表一致),再进弹窗填内容 */
async function openRuleModal(row) {
if (props.loading) return
if (row) {
editingIsNew.value = false
editingRow.value = { ...row }
} else {
const idRes = await nextIdApi()
editingIsNew.value = true
editingRow.value = createRuleRow(props.moduleId, {
id: String(idRes?.data ?? ''),
xh: ((rules.value || []).length + 1) * 10,
})
}
ruleModalOpen.value = true
}
function onRuleOk(row) {
if (editingIsNew.value) {
rules.value = [...rules.value, row]
} else {
rules.value = rules.value.map((item) =>
String(item._rowKey) === String(row._rowKey) ? row : item,
)
}
activeKey.value = String(row._rowKey)
}
/* ================= 引用规则只读区 ================= */
const moduleById = computed(
() => new Map((props.modules || []).map((item) => [String(item.b_id), item])),
)
function moduleText(code) {
const id = String(code ?? '')
if (!id) return ''
const module = moduleById.value.get(id)
return module ? `${module.b_name || id}(${id})` : id
}
const reverseDisplayRows = computed(() =>
(props.refRulesReverse || []).map((row, index) => {
const key = parseRelationScopeId(row.b_scope_id)
return {
_rowKey: `ref-${index}-${row.b_scope_id}`,
sourceModuleText: moduleText(key.sourceModuleId),
sourceFieldText: key.sourceField,
targetModuleText: moduleText(key.targetModuleId),
targetFieldText: key.targetField,
summaryText: REF_RULE_HINT,
ownerModuleId: key.sourceModuleId,
}
}),
)
const reverseColumns = shallowRef([
{ dataIndex: 'sourceModuleText', title: '源模块(谁配的)', width: 200 },
{ dataIndex: 'sourceFieldText', title: '源字段', width: 160 },
{ dataIndex: 'targetModuleText', title: '目标模块', width: 200 },
{ dataIndex: 'targetFieldText', title: '目标字段', width: 160 },
{ dataIndex: 'summaryText', title: '说明', minWidth: 160 },
{
dataIndex: 'actions',
title: '操作',
align: 'center',
width: 80,
actions: [
{
type: 'view',
label: '查看',
onClick: (row) => emit('navigate', row.ownerModuleId),
},
],
},
])
/* ================= 校验 ================= */
/** 保存前校验:规则要有主键;勾条件要真有条件、写 SQL 要有提示文案(引用规则不归本面板管) */
function validate() {
const errors = []
for (const [index, row] of (rules.value || []).entries()) {
const label = `第 ${index + 1} 条规则`
if (!String(row.b_id ?? '').trim()) errors.push(`${label}:缺少主键(请重新新增)`)
const parsed = parseRulePredicate(row)
if (parsed.kind === 'sql') {
if (!String(row.b_message ?? '').trim()) {
errors.push(`${label}:写 SQL 的规则必须填写提示文案`)
}
if (!String(row.b_predicate ?? '').trim()) {
errors.push(`${label}:请填写规则 SQL`)
}
} else if (parsed.kind === 'reference') {
errors.push(`${label}:引用规则请到「模块关联」面板维护`)
} else if (!ruleHasCondition(row)) {
errors.push(`${label}:至少配置一个判定条件`)
}
}
return errors
}
defineExpose({ validate })
</script>
<template>
<div class="mm-rule">
<div class="mm-rule__section mm-rule__section--main">
<div class="mm-rule__toolbar">
<Button type="primary" :disabled="loading" @click="openRuleModal(null)">
<Plus :size="14" /> 新增规则
</Button>
<Button type="danger" :disabled="loading || !selectedRowKeys.length" @click="batchDelete">
<Trash2 :size="14" /> 批量删除
</Button>
</div>
<div v-if="loading" class="mm-rule__state"><Spin /></div>
<div v-else class="mm-rule__table">
<FmsTable
ref="tableRef"
v-model:columns="columns"
:data-source="rules"
:editable="!loading"
:row-draggable="!loading"
:row-selection="rowSelection"
row-key="_rowKey"
:active-row-key="activeKey"
:col-resizable="true"
@row-click="(_, row) => (activeKey = String(row._rowKey))"
@change="onCellChange"
@row-order-change="onRowOrderChange"
/>
</div>
</div>
<div v-if="reverseDisplayRows.length" class="mm-rule__section mm-rule__section--reverse">
<div class="mm-rule__caption">
其他模块在本模块上配置的引用规则(只读):这些关系指向本模块,存在引用时源侧记录不许删除
</div>
<div class="mm-rule__table">
<FmsTable
v-model:columns="reverseColumns"
:data-source="reverseDisplayRows"
:editable="false"
:seq="false"
row-key="_rowKey"
/>
</div>
</div>
<ModuleRuleEditModal
v-model:open="ruleModalOpen"
:row="editingRow"
:is-new="editingIsNew"
:items="items"
@ok="onRuleOk"
/>
</div>
</template>
<style lang="scss" scoped>
/* 上区可编辑规则表(撑满剩余高度),下区只读引用规则(内容少时按自身高度) */
.mm-rule {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.mm-rule__section {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.mm-rule__section--main {
flex: 1;
}
.mm-rule__section--reverse {
flex: none;
max-height: 45%;
}
.mm-rule__toolbar {
display: flex;
flex: none;
align-items: center;
gap: 8px;
padding: 0 0 8px;
}
.mm-rule__caption {
flex: none;
padding: 0 0 6px;
color: var(--fms-text-secondary);
font-size: var(--fms-font-size);
}
.mm-rule__state {
padding: 24px 0;
color: var(--fms-text-secondary);
font-size: var(--fms-font-size);
text-align: center;
}
/* FmsTable 内部 height: 100%,宿主必须给出确定高度 */
.mm-rule__table {
flex: 1;
min-height: 0;
}
.mm-rule__section--reverse .mm-rule__table {
min-height: 120px;
}
</style>
@@ -76,7 +76,6 @@ const hasAnyField = computed(() => (props.rows || []).length > 0)
<template>
<Form
:model="{}"
layout="vertical"
:columns="48"
:gap="{ row: 20, col: 12 }"
>
@@ -1,7 +1,7 @@
<script setup>
/**
* 模块管理:模块配置(V2 单分区)。
* 模块配置:左侧模块树 + 右侧分节面板(基础/字段/列表/表单/查询/自动编码/权限/多语言)。
* 模块配置:左侧模块树 + 右侧分节面板(基础/字段/列表/表单/查询/自动编码/权限/模块关联/删除规则/多语言)。
* 数据直接经通用 API(loadDataApi / saveObjectApi)读写元数据表。
*
* 本文件是编排层:每份模块配置数据都以「当前编辑值 / _org 原始基线」两个 ref 成对持有
@@ -20,6 +20,7 @@
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { useHotkeys } from '@/composables/useHotkeys'
import {
CircleSlash,
FolderTree,
Globe,
Hash,
@@ -30,6 +31,7 @@ import {
Search,
ShieldCheck,
Table2,
Waypoints,
} from '@lucide/vue'
import { Button, Splitter, SplitterPanel, Tabs, Modal, Message, Spin } from '@/components/ui'
import { loadDataApi, saveObjectApi } from '@/services/api'
@@ -86,9 +88,12 @@ import ModuleEditPanel from './ModuleEditPanel.vue'
import ModuleQueryConfigPanel from './ModuleQueryConfigPanel.vue'
import ModuleAutoCodePanel from './ModuleAutoCodePanel.vue'
import ModulePowerPanel from './ModulePowerPanel.vue'
import ModuleRelationPanel from './ModuleRelationPanel.vue'
import ModuleRulePanel from './ModuleRulePanel.vue'
import ModuleI18nPanel from './ModuleI18nPanel.vue'
import { useI18nStore } from '@/stores/i18n'
import { createTempId } from '@/utils/common'
import { relationScopeId } from './ruleUtils'
/** 生成安全 SQL 字符串字面量(search_condition 为后端拼 SQL,需要转义引号) */
function sqlText(value) {
@@ -124,6 +129,16 @@ const autocode = ref([]) // 自动编码(s_autocode)
const autocode_org = ref([])
const powers = ref([]) // 模块权限点(s_power,仅 data 模块)
const powers_org = ref([])
const relations = ref([]) // 本模块作为源的关系行(s_relation,仅 data 模块)
const relations_org = ref([])
/** 其他模块指向本模块的引用行(s_relation 目标侧):只读展示,不参与 diff 与脏判定 */
const reverseRelations = ref([])
const rules = ref([]) // 模块级删除规则(s_rule,scope_type='module')
const rules_org = ref([])
/** 关系上的引用规则(s_rule,scope_type='relation'):源侧可编辑,目标侧只读展示 */
const refRules = ref([])
const refRules_org = ref([])
const refRulesReverse = ref([])
const i18n = ref([]) // 多语言透视行(s_i18n)
const i18n_org = ref([])
/** 多语言草稿的加载基线指纹:s_i18n 不在下面的逐行 pairs 里比较,用稳定指纹判脏 */
@@ -150,6 +165,14 @@ function clearModuleData() {
autocode_org.value = []
powers.value = []
powers_org.value = []
relations.value = []
relations_org.value = []
reverseRelations.value = []
rules.value = []
rules_org.value = []
refRules.value = []
refRules_org.value = []
refRulesReverse.value = []
i18n.value = []
i18n_org.value = []
i18nBaselineFingerprint.value = ''
@@ -167,6 +190,27 @@ function initDefaultSchemas() {
const basicPanelRef = ref(null)
const autoCodePanelRef = ref(null)
const relationPanelRef = ref(null)
const rulePanelRef = ref(null)
/**
* 全量模块(供模块关联面板的目标模块下拉与名称解析):树数据已加载,零额外请求。
* 保留停用模块用于解析历史行名称,面板内自行过滤出可选项;
* 未落库草稿(临时负数 id)不参与——它们还没有可供引用的真实编码。
*/
const relationModules = computed(() => {
const list = []
const walk = (nodes) => {
for (const node of nodes || []) {
const id = String(node.b_id ?? '')
if (!unsavedModuleIds.has(id) && !/^-\d+$/.test(id)) list.push(node)
walk(node.children)
}
}
walk(moduleTreeData.value)
return list
})
// 新建模块时跳过 watch(moduleSelectedId) 的自动加载;非响应式 holder
const skipModuleLoad = { value: false }
@@ -274,6 +318,8 @@ const configGroups = computed(() => {
items: [
{ key: 'auto-code', label: '自动编码', icon: Hash },
{ key: 'power', label: '权限', icon: ShieldCheck },
{ key: 'relation', label: '模块关联', icon: Waypoints },
{ key: 'rule', label: '删除规则', icon: CircleSlash },
],
})
}
@@ -344,6 +390,18 @@ async function beforeSave() {
Message.warning('请修正表单中标红的错误项')
return false
}
const relationErrors = relationPanelRef.value?.validate?.()
if (relationErrors?.length) {
activeSection.value = 'relation'
Message.warning(relationErrors[0])
return false
}
const ruleErrors = rulePanelRef.value?.validate?.()
if (ruleErrors?.length) {
activeSection.value = 'rule'
Message.warning(ruleErrors[0])
return false
}
}
const autoCodeError = validateAutoCode()
if (autoCodeError) {
@@ -372,6 +430,15 @@ async function beforeSave() {
Message.warning('父模块尚未保存,请先保存父模块')
return false
}
// 目标模块同样必须是已落库的真实编码:模块关联行会成为指向不存在模块的孤儿关系
const hasUnsavedTarget = relations.value.some((row) =>
unsavedModuleIds.has(String(row.b_target_module_id ?? '')),
)
if (hasUnsavedTarget) {
activeSection.value = 'relation'
Message.warning('目标模块尚未保存,请先保存目标模块')
return false
}
// 字段同步:受管 i18n 键物化(写回字段草稿,保存 diff 以物化后的行与基线比较)
if (supportsConfig.value) {
syncModuleItemI18nKeys(fields.value, 'field', moduleCode, 'b_field')
@@ -466,6 +533,56 @@ async function buildSaveData() {
['b_id'],
)
if (hasChanges(powerReq)) saveReqs.push(powerReq)
// 模块关联(s_relation):四列复合主键(源模块 / 源字段 / 目标模块 / 目标字段)。
// 源模块由编排层统一回写为当前模块编码(与子表回写 b_module_id 同一手法);
// 只提交 source 侧草稿,反向引用行不在 relations 里,不会误删。
for (const row of relations.value) {
if (String(row.b_source_module_id ?? '').trim() !== moduleId) {
row.b_source_module_id = moduleId
}
}
const relationReq = tableChange(
's_relation',
diffRows(relations.value, relations_org.value, {
keyFields: [
'b_source_module_id',
'b_source_field',
'b_target_module_id',
'b_target_field',
],
}),
['b_source_module_id', 'b_source_field', 'b_target_module_id', 'b_target_field'],
)
if (hasChanges(relationReq)) saveReqs.push(relationReq)
// 模块级删除规则(s_rule,scope_type='module'):scope 两列物化后按雪花主键 diff
for (const row of rules.value) {
row.b_scope_type = 'module'
if (String(row.b_scope_id ?? '').trim() !== moduleId) row.b_scope_id = moduleId
}
const ruleReq = tableChange(
's_rule',
diffRows(stripInternalKeys(rules.value), stripInternalKeys(rules_org.value), {
keyFields: ['b_id'],
}),
['b_id'],
)
if (hasChanges(ruleReq)) saveReqs.push(ruleReq)
// 关系上的引用规则(s_rule,scope_type='relation'):行由关联面板勾选增删,这里只物化 scope
for (const row of refRules.value) {
row.b_scope_type = 'relation'
if (!String(row.b_hook ?? '').trim()) row.b_hook = 'delete.pre'
}
const refRuleReq = tableChange(
's_rule',
diffRows(stripInternalKeys(refRules.value), stripInternalKeys(refRules_org.value), {
keyFields: ['b_id'],
}),
['b_id'],
)
if (hasChanges(refRuleReq)) saveReqs.push(refRuleReq)
}
}
@@ -489,7 +606,10 @@ async function afterSave() {
unsavedModuleIds.delete(treeId)
// 新建模块落库后主键由临时 id 换成真实编码:树里未保存的子孙草稿要跟着改挂到新编码,
// 否则树重载后它们的 b_parent_id 指向已被丢弃的临时 id
if (String(savedKey) !== String(treeId)) remapDraftParentId(treeId, savedKey)
if (String(savedKey) !== String(treeId)) {
remapDraftParentId(treeId, savedKey)
remapRelationModuleIds(treeId, savedKey)
}
const prevSection = activeSection.value
await loadModuleTree()
if (savedKey === String(moduleSelectedId.value)) {
@@ -573,6 +693,21 @@ function remapDraftParentId(oldId, newId) {
walk(moduleTreeData.value)
}
/**
* 把模块关联草稿里的模块引用从旧 id 改到新 id(新建模块保存后临时负数 id → 真实编码)。
* 源侧与目标侧都要改;幂等,主要作为兜底——主路径是保存后按真实编码重载模块配置。
*/
function remapRelationModuleIds(oldId, newId) {
const from = String(oldId)
const to = String(newId)
for (const rows of [relations.value, relations_org.value, reverseRelations.value]) {
for (const row of rows || []) {
if (String(row.b_source_module_id ?? '') === from) row.b_source_module_id = to
if (String(row.b_target_module_id ?? '') === from) row.b_target_module_id = to
}
}
}
/** 递归重算子树的位置派生列(b_parent_id 为权威关系,b_depth / b_path 由它派生) */
function syncSubtreePosition(node, parentNode) {
applyTreePosition(node, parentNode)
@@ -706,7 +841,7 @@ async function deleteModule(id) {
}
showConfirm({
title: '删除模块',
content: `确定删除模块「${module.b_name || module.b_id}」?将同时删除该模块的字段、界面配置、自动编码、权限点、菜单绑定与多语言资源。`,
content: `确定删除模块「${module.b_name || module.b_id}」?将同时删除该模块的字段、界面配置、自动编码、权限点、模块关联、删除规则、菜单绑定与多语言资源。`,
okText: '删除',
okType: 'danger',
onOk: async () => {
@@ -719,13 +854,48 @@ async function deleteModule(id) {
// 菜单绑定(s_menu_module,主键 b_menu_id + b_module_id):模块删除后残留的绑定行
// 会让菜单继续引用一个不存在的模块,必须一并清理
{ table: 's_menu_module', key_field: 'b_module_id', deletes: [{ b_module_id: id2 }] },
// 模块关联(s_relation):模块引用分散在源 / 目标两列,没有 b_module_id,
// 任一方向残留都会让关联指向不存在的模块,两列各清一次
{
table: 's_relation',
key_field: 'b_source_module_id',
deletes: [{ b_source_module_id: id2 }],
},
{
table: 's_relation',
key_field: 'b_target_module_id',
deletes: [{ b_target_module_id: id2 }],
},
// 权限点挂在模块上,随模块删除;共用动作动词键(action.*)为跨模块公用资源,不在此清理
{
table: 's_power',
key_field: 'b_object_type,b_object_id',
deletes: [{ b_object_type: 'module', b_object_id: id2 }],
},
// 删除规则(s_rule):模块级规则按 scope 两列清;关系级规则挂在关系业务键上,
// 规则表没有模块列,需先查一次双向关系拿到键再批量删
{
table: 's_rule',
key_field: 'b_scope_type,b_scope_id',
deletes: [{ b_scope_type: 'module', b_scope_id: id2 }],
},
]
const relationRes = await loadDataApi(
's_relation',
`b_source_module_id = ${sqlText(id2)} OR b_target_module_id = ${sqlText(id2)}`,
'b_source_module_id ASC, b_xh ASC, b_source_field ASC',
)
const relationDeletes = (relationRes.data || []).map((row) => ({
b_scope_type: 'relation',
b_scope_id: relationScopeId(row),
}))
if (relationDeletes.length) {
reqs.push({
table: 's_rule',
key_field: 'b_scope_type,b_scope_id',
deletes: relationDeletes,
})
}
// s_i18n 无 b_module_id:按模块资源键(module.*/field.*/分组键)逐格删除;
// 权限点的 action.* 动词键不在模块资源键范围内,天然不被误删
const keys = await collectModuleI18nKeys(id2)
@@ -821,36 +991,86 @@ async function loadModuleConfig(moduleId) {
const type = findTreeNode(moduleTreeData.value, id)?.b_module_type
configLoading.value = true
try {
const [moduleRes, fieldsRes, schemaRes, autoCodeRes, powerRes] = await Promise.all([
loadDataApi('s_module', `b_id = ${sqlText(id)}`),
type === 'data' || type === 'virtual'
? loadDataApi('s_field', `b_module_id = ${sqlText(id)}`, 'b_xh ASC, b_field ASC')
: Promise.resolve({ data: [] }),
type === 'data' || type === 'virtual'
? loadDataApi('s_module_schema', `b_module_id = ${sqlText(id)}`, 'b_schema_type ASC')
: Promise.resolve({ data: [] }),
type === 'data'
? loadDataApi('s_autocode', `b_module_id = ${sqlText(id)}`, 'b_field ASC')
: Promise.resolve({ data: [] }),
// 权限点:挂在模块上(b_object_type='module'),仅 data 模块维护
type === 'data'
? loadDataApi(
's_power',
`b_object_type = 'module' AND b_object_id = ${sqlText(id)}`,
'b_xh ASC, b_id ASC',
)
: Promise.resolve({ data: [] }),
])
const [moduleRes, fieldsRes, schemaRes, autoCodeRes, powerRes, relationRes, ruleRes] =
await Promise.all([
loadDataApi('s_module', `b_id = ${sqlText(id)}`),
type === 'data' || type === 'virtual'
? loadDataApi('s_field', `b_module_id = ${sqlText(id)}`, 'b_xh ASC, b_field ASC')
: Promise.resolve({ data: [] }),
type === 'data' || type === 'virtual'
? loadDataApi('s_module_schema', `b_module_id = ${sqlText(id)}`, 'b_schema_type ASC')
: Promise.resolve({ data: [] }),
type === 'data'
? loadDataApi('s_autocode', `b_module_id = ${sqlText(id)}`, 'b_field ASC')
: Promise.resolve({ data: [] }),
// 权限点:挂在模块上(b_object_type='module'),仅 data 模块维护
type === 'data'
? loadDataApi(
's_power',
`b_object_type = 'module' AND b_object_id = ${sqlText(id)}`,
'b_xh ASC, b_id ASC',
)
: Promise.resolve({ data: [] }),
// 模块关联(s_relation):无 b_module_id,模块编码出现在源 / 目标两列,
// 一次查询取双向行后按源侧拆分(源侧可编辑、目标侧只读)
type === 'data'
? loadDataApi(
's_relation',
`b_source_module_id = ${sqlText(id)} OR b_target_module_id = ${sqlText(id)}`,
'b_source_module_id ASC, b_xh ASC, b_source_field ASC',
)
: Promise.resolve({ data: [] }),
// 模块级删除规则(s_rule,scope_type='module'):无模块列,按 scope 两列定位
type === 'data'
? loadDataApi(
's_rule',
`b_scope_type = 'module' AND b_scope_id = ${sqlText(id)}`,
'b_xh ASC, b_id ASC',
)
: Promise.resolve({ data: [] }),
])
if (String(moduleSelectedId.value) !== id) return
maindata_org.value = moduleRes.data?.[0] || null
fields_org.value = fieldsRes.data || []
autocode_org.value = autoCodeRes.data || []
powers_org.value = powerRes.data || []
rules_org.value = ruleRes.data || []
// 模块关联:源侧进可编辑草稿与基线,目标侧只读展示(不参与 diff)
const relationRows = relationRes.data || []
const isSourceRow = (row) => String(row.b_source_module_id ?? '').trim() === id
relations_org.value = relationRows.filter(isSourceRow)
reverseRelations.value = relationRows.filter((row) => !isSourceRow(row))
// 关系上的引用规则(s_rule,scope_type='relation'):规则表没有模块列,
// 必须拿双向关系的业务键再查一次;源侧可编辑、目标侧只读展示
if (relationRows.length) {
const sourceKeys = relations_org.value.map(relationScopeId)
const targetKeys = reverseRelations.value.map(relationScopeId)
const refRuleRes = await loadDataApi(
's_rule',
`b_scope_type = 'relation' AND b_scope_id IN (${[...sourceKeys, ...targetKeys]
.map(sqlText)
.join(', ')})`,
'b_scope_id ASC, b_id ASC',
)
if (String(moduleSelectedId.value) !== id) return
const sourceKeySet = new Set(sourceKeys)
const refRows = refRuleRes.data || []
refRules_org.value = refRows.filter((row) => sourceKeySet.has(String(row.b_scope_id ?? '')))
refRulesReverse.value = refRows.filter(
(row) => !sourceKeySet.has(String(row.b_scope_id ?? '')),
)
} else {
refRules_org.value = []
refRulesReverse.value = []
}
maindata.value = cloneData(maindata_org.value)
fields.value = cloneData(fields_org.value)
autocode.value = cloneData(autocode_org.value)
powers.value = cloneData(powers_org.value)
relations.value = cloneData(relations_org.value)
rules.value = cloneData(rules_org.value)
refRules.value = cloneData(refRules_org.value)
for (const schemaType of SCHEMA_TYPES) {
const row = (schemaRes.data || []).find((item) => String(item.b_schema_type) === schemaType)
@@ -969,6 +1189,9 @@ function hasUnsavedChanges() {
[fields, fields_org],
[autocode, autocode_org],
[powers, powers_org],
[relations, relations_org],
[rules, rules_org],
[refRules, refRules_org],
]
return pairs.some(
([current, original]) =>
@@ -1052,6 +1275,17 @@ function onModuleAdd({ parentId, moduleType: newType }) {
void addModule(parentId, newType)
}
/* ================= 删除规则只读区的跳转 ================= */
/** 引用规则跳转:本模块的关系直接切到「模块关联」;其他模块则切换模块(走未保存守卫) */
function onRuleNavigate(targetModuleId) {
const target = String(targetModuleId ?? '')
if (!target || target === String(moduleSelectedId.value)) {
activeSection.value = 'relation'
return
}
onModuleSelect(target)
}
/* ================= 初始化 ================= */
void langStore.loadLanguages()
loadModuleTree()
@@ -1201,6 +1435,37 @@ loadModuleTree()
/>
</div>
<div
v-if="isDataModule"
v-show="activeSection === 'relation'"
class="mm-config-pane"
>
<ModuleRelationPanel
ref="relationPanelRef"
v-model="relations"
v-model:ref-rules="refRules"
:module-id="String(maindata.b_id || '')"
:fields="fields"
:modules="relationModules"
:reverse-rows="reverseRelations"
:loading="busy"
/>
</div>
<div v-if="isDataModule" v-show="activeSection === 'rule'" class="mm-config-pane">
<ModuleRulePanel
ref="rulePanelRef"
v-model="rules"
:module-id="String(maindata.b_id || '')"
:fields="fields"
:query-schema="query_schema"
:ref-rules-reverse="refRulesReverse"
:modules="relationModules"
:loading="busy"
@navigate="onRuleNavigate"
/>
</div>
<div v-if="moduleType" v-show="activeSection === 'i18n'" class="mm-config-pane">
<ModuleI18nPanel
v-model:rows="i18n"
@@ -0,0 +1,195 @@
/**
* 删除规则(s_rule)配置工具。
*
* 规则类型不单独存列(设计定案):b_predicate 为空 = 引用规则;可解析为 JSON 且 kind='dsl'
* = 勾条件;其余 = SQL 文本。scope_type='module' 挂模块(勾条件 / 写 SQL),
* scope_type='relation' 挂关系(存在引用即拒绝删除,由模块关联面板勾选生成,一个关系最多一条)。
* 规则没有业务键:主键是雪花 b_id(新增时前端取号),界面上不出现任何"编码/名称"字段,
* 列表里靠自动摘要(勾条件)或提示文案(写 SQL)识别。
*
* 条件面板(FmsAdvancedQueryPanel)需要的 items 在这里按当前模块字段 + 查询配置组装。
*/
import {
advancedQueryFromAst,
buildAdvancedAst,
defaultOperator,
describeAdvancedQuery,
} from '@/components/fms-module-common/queryUtils'
import { buildQueryRender } from '@/components/fms-module-common/schemaRender'
import { parseOptions } from '@/components/fms-module-common/fieldUtils'
import { createTempId } from '@/utils/common'
export const RULE_SCOPE_MODULE = 'module'
export const RULE_SCOPE_RELATION = 'relation'
export const RULE_HOOK_DELETE = 'delete.pre'
/** 引用规则(predicate 为空)在列表与只读区里的固定说明 */
export const REF_RULE_HINT = '存在其他模块引用时拒绝删除'
/** 关系行的业务键(与 s_relation 四列主键同序):源模块|源字段|目标模块|目标字段 */
export function relationScopeId(row) {
return [
row?.b_source_module_id,
row?.b_source_field,
row?.b_target_module_id,
row?.b_target_field,
]
.map((value) => String(value ?? '').trim())
.join('|')
}
/** 关系行是否已填全四列(拼出的键才可用) */
export function isCompleteRelationKey(row) {
return [
row?.b_source_module_id,
row?.b_source_field,
row?.b_target_module_id,
row?.b_target_field,
].every((value) => String(value ?? '').trim())
}
/** 关系业务键 → 四列(与 relationScopeId 互逆),用于把引用规则还原成可读列 */
export function parseRelationScopeId(scopeId) {
const [sourceModuleId = '', sourceField = '', targetModuleId = '', targetField = ''] = String(
scopeId ?? '',
).split('|')
return { sourceModuleId, sourceField, targetModuleId, targetField }
}
/**
* 规则类型三态:reference(引用规则)/ dsl(勾条件)/ sql(写 SQL)。
* 非法 JSON(以 { 开头但解析失败)按 SQL 文本处理,避免把内容吞掉。
*/
export function parseRulePredicate(row) {
const text = String(row?.b_predicate ?? '').trim()
if (!text) return { kind: 'reference' }
if (text.startsWith('{')) {
try {
const parsed = JSON.parse(text)
if (parsed?.kind === 'dsl' && parsed.expr) return { kind: 'dsl', expr: parsed.expr }
} catch {
// 解析失败继续按 SQL 处理
}
}
return { kind: 'sql', sql: text }
}
/** 类型列文案 */
export function ruleTypeText(row) {
const { kind } = parseRulePredicate(row)
if (kind === 'dsl') return '条件'
if (kind === 'sql') return 'SQL'
return '引用'
}
/** 条件摘要:勾条件走中文描述,SQL 取单行化后的前 80 字,引用规则固定文案 */
export function ruleSummary(row, itemByField, translate) {
const parsed = parseRulePredicate(row)
if (parsed.kind === 'dsl') {
const state = advancedQueryFromAst(parsed.expr)
return describeAdvancedQuery(state, itemByField, translate)
}
if (parsed.kind === 'sql') return parsed.sql.replace(/\s+/g, ' ').trim().slice(0, 80)
return REF_RULE_HINT
}
/**
* 列表里识别规则用的文案(没有规则名,靠内容自述):
* 勾条件 → 条件中文摘要;写 SQL → 提示文案(写 SQL 必填,是人话),没填时退回 SQL 片段;引用 → 固定说明。
*/
export function ruleLabel(row, itemByField, translate) {
const parsed = parseRulePredicate(row)
if (parsed.kind === 'sql') {
const message = String(row?.b_message ?? '').trim()
return message || ruleSummary(row, itemByField, translate)
}
return ruleSummary(row, itemByField, translate)
}
/**
* 规则是否真有可执行内容:勾条件要有至少一个填好值的条件;写 SQL 要有文本;引用规则恒真。
* 用于保存前拦住"改空了条件"的历史数据(弹窗入口也会拦一次)。
*/
export function ruleHasCondition(row) {
const parsed = parseRulePredicate(row)
if (parsed.kind === 'reference') return true
if (parsed.kind === 'sql') return true
const state = advancedQueryFromAst(parsed.expr)
return (buildAdvancedAst(state).children || []).length > 0
}
/** 新增模块级规则行(勾条件类型起步,条件在编辑弹窗里填);id 由调用方取号传入 */
export function createRuleRow(moduleId, { id, xh = 10 } = {}) {
return {
_rowKey: createTempId(),
b_id: String(id ?? ''),
b_scope_type: RULE_SCOPE_MODULE,
b_scope_id: String(moduleId || ''),
b_hook: RULE_HOOK_DELETE,
b_predicate: null,
b_message: null,
b_canuse: 1,
b_xh: xh,
}
}
/** 引用规则行:由模块关联面板勾选「有引用时拒绝删除」时生成 */
export function createRefRuleRow(scopeId, id, xh = 10) {
return {
_rowKey: createTempId(),
b_id: String(id ?? ''),
b_scope_type: RULE_SCOPE_RELATION,
b_scope_id: String(scopeId || ''),
b_hook: RULE_HOOK_DELETE,
b_predicate: null,
b_message: null,
b_canuse: 1,
b_xh: xh,
}
}
/** 字段类型 → 条件值控件(与查询配置的默认控件对齐) */
const COMPONENT_BY_TYPE = {
select: 'select',
date: 'date-picker',
datetime: 'date-picker',
number: 'number',
money: 'number',
checkbox: 'checkbox',
}
/**
* 组装条件面板(FmsAdvancedQueryPanel)需要的 items:
* 当前模块启用字段,叠加查询配置里的操作符 / 控件 / 选项。
* select 字段若在查询配置里没有选项定义,降级为输入框(避免空下拉无路可走)。
*/
export function buildRuleQueryItems(fields, querySchema) {
const fromQuery = new Map()
for (const condition of buildQueryRender(querySchema).conditions) {
fromQuery.set(String(condition.field), condition)
}
const items = []
for (const field of fields || []) {
if (Number(field.b_canuse) !== 1) continue
const condition = fromQuery.get(String(field.b_field))
const optionInfo = parseOptions({ options: condition?.options })
let component = condition?.component || COMPONENT_BY_TYPE[field.b_type] || 'input'
if (component === 'select' && !(optionInfo.options || []).length) component = 'input'
items.push({
config: {
b_field: String(field.b_field),
b_operator: condition?.operator || defaultOperator(field),
b_component: component,
b_default_value: null,
},
field,
optionConfig: optionInfo.optionConfig,
options: optionInfo.options,
})
}
return items
}
/** items → 字段编码 Map(describeAdvancedQuery 与摘要展示用) */
export function itemMapOf(items) {
return new Map((items || []).map((item) => [String(item.field.b_field), item]))
}
@@ -448,7 +448,7 @@ function enrichI18nRow(row) {
}
/** SQL 字符串字面量(转义单引号);用于拼接只读查询条件 */
function sqlStringLiteral(value) {
export function sqlStringLiteral(value) {
return `N'${String(value ?? '').replace(/'/g, "''")}'`
}
@@ -577,7 +577,7 @@ void loadDepts()
:confirm-loading="deptModalSaving"
@ok="onDeptModalOk"
>
<Form ref="deptFormRef" :model="deptDraft" :rules="deptFormRules" layout="vertical" :gap="16">
<Form ref="deptFormRef" :model="deptDraft" :rules="deptFormRules" :gap="16">
<FormItem label="上级部门" name="b_parent_id">
<Input :model-value="deptParentName" disabled />
</FormItem>
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more