20260917212907
This commit is contained in:
1 parent
6fa3d8535e
commit
5cbf745bbf
115 files changed
+4104
-85
No files matched your search
@@ -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`)后重映射关联行
|
||||
- 被引用模块的删除拦截
|
||||
- 反向只读区的编辑 / 删除入口
|
||||
Reference in new issue
Block a user