20260917212907
This commit is contained in:
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` 超限被截断,已按主题重写精简(合并重复项、删掉过期与可推导内容)。
|
||||
@@ -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`(列与列表同源推导)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -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">
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
-1
@@ -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
Reference in new issue
Block a user