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

13 KiB
Raw Permalink Blame History

模块关联(s_relation)配置面板实现方案

Context

s_relation 是模块关系表,用于表达主子表、引用关系:

  • 建表脚本:fms_core.sql,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 第 9 节,one_to_one / one_to_many / many_to_one,不提供 many_to_many。
  • 现状:fms-vue/src 与 fms-api/src 零引用,模块管理 8 个配置面板里没有入口 → 数据库有表、设计有定义,但用户无法配置。
  • 附带缺陷:删除模块的级联清理(index.vue)漏了 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)

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 忽略入参):

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):
<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>
  1. clearModuleData():三份数据一并清空。
  2. loadModuleConfig 的 Promise.all 追加第 6 项(与 auto-code 同 type === 'data' 门控),并在 maindata_org 赋值段后拆分:
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) 块内(权限之后):

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)
  1. 其余四处:
  • 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)补上「模块关联」:
{ 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 第 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,每个源文件最多一个 spec):

  • 新增 tests/unit/module-relation-panel.spec.js:增行、换目标模块清空目标字段、validate() 空列与重复拦截、_rowKey 不置脏
  • 页面级断言(table: 's_relation'、key_field 为 4 列、删除模块含两条 s_relation):复用 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)后重映射关联行
  • 被引用模块的删除拦截
  • 反向只读区的编辑 / 删除入口