Files
workspace/code/fms/.trae/documents/模块关联配置面板实现方案.md
T
2026-09-17 21:29:08 +08:00

197 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 模块关联(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`)后重映射关联行
- 被引用模块的删除拦截
- 反向只读区的编辑 / 删除入口