Files
workspace/code/fms/fms-vue/docs/module-management-code-refactor-plan.md
T

304 lines
18 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.
# `/system/module-management` 代码重构计划(还债专项)
> 本文是**纯代码重构**计划:不改任何行为、UI 文案、API 调用和保存协议。
> 与 `docs/module-management-refactor-plan.md`(功能改造历史记录)无关,请勿混淆。
> 执行前请先通读本文第 1、2 节和第 8 节(禁改清单)。
## 1. 背景与问题
模块管理功能已基本完成,但代码存在三类结构性债务:
1. **巨型文件**:`ModuleFormEditPanel.vue` 3709 行、`ModuleTableEditPanel.vue` 2962 行、
`index.vue` 1522 行,全模块约 1.2 万行。
2. **三处复制的"同源"逻辑**:分组树推导在面板与结构树组件间整段复制(注释自述
"与面板 bandMeta 同源");两个编辑面板共享约 20 个同名函数。靠人肉同步,改一处漏
一处就是 bug。
3. **index.vue 职责过重**:树管理、草稿加载、保存编排、删除级联、i18n 协调、确认弹窗
全部平铺在一个 `<script setup>` 里,`saveModule` 单函数约 120 行。
目标:在不改变任何外部行为的前提下消除上述债务,让后续功能开发有清晰的落点。
## 2. 当前代码范围(行数为 2026-08 时点,执行时以符号名为准)
```text
src/views/system/module-management/
index.vue 1522 容器:树 + 草稿 + 保存编排
common.js 6
components/
ModuleFormEditPanel.vue 3709 主表表单设计器
ModuleTableEditPanel.vue 2962 子表表格设计器
ModuleBasicPanel.vue / ModuleFieldsPanel.vue / ModuleListConfigPanel.vue
ModuleQueryConfigPanel.vue / ModuleAutoCodePanel.vue / ModulePowersPanel.vue
ModuleI18nPanel.vue / MenuDetailPanel.vue / I18nQuickSetModal.vue / IconSelect.vue
structure-tree/
ModuleStructureTree.vue 963 专用结构编辑树(拖拽/右键/重命名)
StructureTreeNode.vue 584 树节点(递归渲染)
tests/unit/
module-table-edit-panel-dnd.spec.js 443 拖拽语义测试(重构的安全网)
tree-external-drag.spec.js
utils.spec.js
```
现有 utils/composables 层(新代码遵循同样习惯):
`@/utils/tree.js`(buildTree/sortTree)、`@/utils/dataChanges.js`(diffRows/tableChange/
allocateTemporaryIds/remapForeignKeys)、`@/utils/i18n.js`、`@/utils/tempId.js`、
`@/composables/useHotkeys.js`。
## 3. 同源复制清单(重构的核心对象)
### 3.1 分组树推导:`ModuleTableEditPanel.vue` ⟷ `structure-tree/ModuleStructureTree.vue`
以下符号在两个文件中各有一份、语义一致(面板版 bandMeta 额外计算 maxLevel):
| 符号 | 作用 |
| --- | --- |
| `sortGroupNodesByXh` | 按 b_xh 排序分组节点(注意两处都注释了 toSorted 必须回写原数组) |
| `buildGroupTree` | 分组扁平数组 → 树 |
| `groupTree` / `groupNodeMap` | 树 computed / gid→节点 Map |
| `groupColumnsMap` | gid→直属列 Map + 未分组列数组 |
| `bandMeta` | 层级/ownCount/span/children 元信息(面板版含 maxLevel) |
| `isDescendantOf` / `isDescendantGroup` | 判断分组祖先-子孙关系(防拖入自身子树成环) |
| `FIELD_TYPE_LABELS` + `fieldNameMap` / `fieldMap` / `fieldName` / `controlTypeLabel` / `groupIdOfRow` | 字段查询与标签 |
### 3.2 两个编辑面板的共享函数:`ModuleFormEditPanel.vue` ⟷ `ModuleTableEditPanel.vue`
同名(或仅改名)且核心语义一致的符号:
`onEditModeChange`、`emitUpdate`、`onRowOrderChange`、`renumberRows`、
`groupIdOfRow`/`groupIdOf`、`fieldNameMap`、`fieldMap`、`fieldOf`、`fieldName`、
`controlTypeLabel`、`FIELD_TYPE_LABELS`、`libraryFields`、`isFieldAdded`、`nextGroupXh`、
`selectedRow`、`selectRow`、`createEditRow`、`insertField`、`selectField`、
`copyRowProps`、`pastePropsToSelected`、`toggleInspector`/`toggleRowFlag`、
`syncToList`、`syncToQuery`、`scrollToRowCell`/`scrollToRowCard`
**注意:同名不等于同体。** 例如 `createEditRow` 在表格面板写 `b_width`/`b_height`,
在表单面板写 `b_colspan` 等;`insertField` 的落点参数也不完全相同。抽取前必须逐对
diff 函数体,按第 5.2 节的三条规则归类,禁止强行合并。
## 4. 总体约束(每个阶段都适用)
1. **纯重构**:不改行为、不改 UI 结构与文案、不改 props/emits 契约(子面板对父级的
接口保持不变)、不改 API 调用与保存请求结构、不引入任何新依赖(包括拖拽库)。
2. **现有测试是安全网**:`tests/unit/module-table-edit-panel-dnd.spec.js` 及其它测试
必须全程绿;除 import 路径外不允许修改测试断言。若某步导致断言失败,说明该步
改变了行为,应回退重做。
3. **每阶段一个独立 commit**,commit message 注明阶段号。开始前若工作区有未提交
改动,先提交或暂存,保证基线干净。
4. **保持注释纪律**:项目注释是"为什么"型中文注释;搬运代码时注释随行;删除复制
体后,同步删除"与面板 bandMeta 同源"这类描述复制关系的注释(已不成立)。
5. **不重写响应式模型**:`localEdit` 本地副本 + watch props、`localGroups` 绕过回流
等模式虽然别扭但经过调试,本次只搬运不重新设计。
6. 每阶段结束运行:`pnpm test && pnpm lint`(最终再跑一次 `pnpm check`)。
## 5. 阶段任务
### 阶段一:抽取分组树推导 composable(风险最低、收益最大,先做)
新建 `src/views/system/module-management/composables/useGroupTree.js`:
```text
useGroupTree(groupsRef, rowsRef)
入参:groups 的 ref/computed(面板传 props 派生,结构树传 localGroups),
rows 的 ref/computed(编辑行数组)
返回:groupTree、groupNodeMap、groupColumnsMap、bandMeta(统一含 maxLevel)、
isDescendantGroup(ancestorGid, gid)、groupIdOfRow(row)
纯函数另行导出:buildGroupTree(groups)、sortGroupNodesByXh(nodes)
```
改造点:
- `ModuleTableEditPanel.vue`:删除本地 `sortGroupNodesByXh`/`buildGroupTree`/
`groupTree`/`groupNodeMap`/`groupColumnsMap`/`bandMeta`/`isDescendantOf`,改用
composable。`bandMeta` 统一含 `maxLevel` 后,`bandRows` 等消费处照常解构。
- `structure-tree/ModuleStructureTree.vue`:同样替换;其 `bandMeta` 消费处不使用
maxLevel,多余字段无害。
- **保持 watch 注册顺序**:`ModuleTableEditPanel.vue` 中 `watch(() => props.editConfig, …,
{ immediate: true })` 必须位于 bandMeta 等 computed 定义之后(原文件有 TDZ 注释,
搬运时不得把 watch 上移)。
验收:`pnpm test` 全绿;两个文件合计减少约 250 行;拖拽/右键/分组增删改手工冒烟
(见第 7 节清单)无变化。
### 阶段二:抽取字段查询与共享纯函数
1. 新建 `src/views/system/module-management/composables/useFieldLookup.js`:
```text
useFieldLookup(fieldsRef)
返回:fieldNameMap、fieldMap、fieldOf(row)、fieldName(row)、controlTypeLabel(row)
常量:FIELD_TYPE_LABELS
```
2. 新建 `src/views/system/module-management/editPanelShared.js`(纯函数,无 Vue 依赖):
```text
renumberRows(rows) // (i+1)*10 重排
buildClipboardProps(row, fieldNameOf) // 复制列属性的键集合(两面板 diff 后统一)
applyClipboardProps(row, props)
buildVisibilityMap(rows) // syncToList/syncToQuery 共用的可见性 Map
```
3. 两个面板中 3.2 节列出的每个函数:先逐对 diff 函数体,再归三类处理——
- **完全相同** → 移入共享文件,原位改为导入;
- **语义相同、细节不同**(如 `createEditRow` 的默认值差异)→ 抽参数化工厂
(如 `createEditRowFactory(defaults)`),两面板各自传参;
- **本质不同** → 保留各自实现,不强行合并,在 PR 说明中列出。
4. `syncToList`/`syncToQuery` 结构高度一致(仅目标数组与写回字段不同):用
`buildVisibilityMap` 共享取数部分,emit 与提示文案留在各自面板。
验收:`pnpm test` 全绿;两面板各减约 150–250 行;数据视图"同步到列表/查询配置"
行为与提示文案不变。
### 阶段三:拆分 index.vue
目标:index.vue 只保留模板、状态接线和面板编排(预期 ≤ 700 行)。抽出的
composables 放 `src/views/system/module-management/composables/`:
```text
useModuleTree.js 树状态与操作:moduleTreeData/Loading、moduleKeyword/ExpandedKeys、
unsavedModuleIds、loadModuleTree、markCategoryNonLeaf、moduleTypeIconMap、
filterTree/filteredModuleTree、collectAllKeys、expandAncestors、
findModuleNode、insertModuleNode、removeModuleNode、
hasSavedModuleDescendant、collectUnsavedModuleIds、
buildBreadcrumb/moduleBreadcrumb、addModule、deleteModule
useModuleConfig.js 草稿加载与脏检查:configLoading、originals/drafts、loadedModuleId、
loadModuleConfig、hasModuleChanges、reloadModule、
watch(moduleSelectedId) 及 skipModuleLoad 约定、onModuleSelect
useModuleSave.js 保存编排:saveModule、validateAutoCode、subTables 常量、
临时 id 分配与外键重映射、i18n reconcile 调用
useConfirm.js 两套确认弹窗:通用 showConfirm/onConfirmOk/confirmState 与
切换保护 switchConfirm/onSwitch*
```
要求:
- composable 返回 index.vue 模板所需的最小集合;跨 composable 共享的状态
(如 `selectedModule`)由 index.vue 组装后传入,避免 composable 互相 import。
- `saveModule` 依赖 `basicPanelRef`/`autoCodePanelRef`/`i18nPanelRef` 与
`finalI18nItems`,以参数(refs + getter)注入 `useModuleSave`,不移动模板。
- `onFieldsUpdate`/`applyDisabledFieldDefaults`/`onAutoCodeUpdate` 属于草稿派生
联动,随 `useModuleConfig` 或留在 index.vue 均可,以"内聚"为准并在 commit 说明。
验收:`pnpm test` 全绿;保存(含跨表外键重映射)、重载、切换未保存保护、删除模块
(含 i18n 孤儿键清理)手工冒烟无变化。
### 阶段四:编辑器目录化拆分(本计划的维护性核心,目标单文件 ≤ 700 行)
两个编辑面板各 3000+ 行、script/template/style 混在一个文件,是维护痛点的主要
来源。本阶段按"职责切分、只搬运不重写"把两个编辑器重组为目录:
```text
components/table-edit/
TableEditPanel.vue 壳:视图切换(设计/数据)、编辑方式切换(主表/子表)、布局编排 ≤ 300
FieldLibrary.vue 字段库:搜索 + 字段列表 + 拖出源(application/fms-field) ≤ 250
TablePreview.vue 多级表头预览表:band 行渲染、mock 行、预览拖拽落点指示 ≤ 500
TableInspector.vue 属性面板:列属性 / 分组带属性 / 空态三态 ≤ 400
useTablePreviewDrag.js 预览表拖拽:onPreviewDragStart/Over/Drop、落点指示状态 ≤ 180
useColumnResize.js 列宽拖拽:pointer capture 三件套 + 40–600 边界 ≤ 100
components/form-edit/
FormEditPanel.vue 壳:视图切换、编辑方式切换、布局编排 ≤ 300
FormCanvas.vue 画布:分组区块 + 字段卡片 + 24 栅格行 + 插入线 ≤ 700
FormInspector.vue 属性面板:栅格 / textarea 高度 / 状态开关等 ≤ 450
useFormDrag.js 拖拽:跟手卡片、insert line 计算、落点提交(updateDragIntent 等) ≤ 600
useFormGrid.js 栅格分配纯逻辑:collectLineIndexes/rebalanceInsert/distributeLine ≤ 200
components/edit-panels/
EditDataView.vue 两编辑器共享的数据视图:工具栏 + FmsTable + 行拖拽排序 ≤ 150
```
切分规则:
1. **壳组件保留对外契约**:`TableEditPanel.vue` / `FormEditPanel.vue` 对 index.vue 的
props/emits 完全不变,index.vue 只改 import 路径。
2. **状态归属**:`localEdit`、`localGroups`、选中态(selectedNodeKey/selectedRow)
留在壳组件,子组件通过 props/事件交互;`useXxx` composable 接收壳的 refs、返回
handlers 与状态,不自带数据源。
3. **样式随组件迁移**(scoped),BEM 类名前缀(`mm-fdesign-` / `mm-tdesign-`)保持
不变,避免测试选择器与深层样式选择器失效;全局样式(如 `.mst-drag-badge`)留在
原文件并保留注释说明。
4. `FieldLibrary` 两编辑器都需要但文案/点击行为有差异:优先做成参数化共享组件放在
`edit-panels/` 下;若 diff 后发现耦合过深,允许 form-edit 下保留一份薄的私有实现,
在交付说明中说明选择。
5. 原 `components/ModuleTableEditPanel.vue` / `ModuleFormEditPanel.vue` 删除,由
壳组件顶替(路径变化后同步更新 index.vue import 与测试中的 import 路径,仅此
一处允许改测试文件,断言不动)。
6. `structure-tree/` 已是独立目录,本阶段不动。
停损:`FormCanvas` + `useFormDrag` 是全计划最大风险点(表单画布模板与拖拽状态机
交织最深)。若拆 FormEditPanel 时出现难定位的行为差异,允许降级交付:完成
table-edit/ 目录重组 + form-edit 仅抽 `useFormGrid.js`(纯逻辑无 UI 搬迁,风险低),
其余记录到交付说明留待下轮。
验收:`pnpm test` 全绿(dnd spec 通过 StubDraggable 定位 `.mst-node`,不受影响);
所有新文件 ≤ 700 行(参考目标);设计/数据视图切换、属性面板编辑、列宽拖拽、
字段库点击/拖出手工冒烟无变化。
### 阶段五:收尾与小修
1. 硬编码颜色收敛到 token:`ModuleTableEditPanel.vue` 中 `#d95757`(危险红)、
`#fafbfc`(列头底色)、`#e7f6ed`/`#146c41`(已加入勾选)等,替换为
`--fms-*` 变量(项目已有 `--fms-danger` 类 token,先查 `src/styles/` 再定值;
没有合适 token 则新增,不硬编码第二次)。
2. `index.vue` 的 `deleteModule` 中 i18n 孤儿键清理 SQL 拼接(`b_key LIKE
'module.${code}.%'` 等)抽为 `@/utils/i18n.js` 的
`buildModuleI18nCleanupFilter(moduleType, moduleCode)`,并把"前缀匹配安全性依赖
`.` 与 `_` 不互撞"这一约定写成函数注释。调用处行为不变。
3. 检查全模块不再有"`module-management` 内重复定义 `FIELD_TYPE_LABELS`"等残留
(grep 确认单一定义点)。
### 阶段六:补测试
1. `tests/unit/module-form-edit-panel.spec.js`:参照
`module-table-edit-panel-dnd.spec.js` 的手法(StubDraggable、
`document.elementFromPoint` mock、defineProperty 注入坐标)覆盖 FormEditPanel 的
核心纯逻辑:栅格 rebalance(`collectLineIndexes`/`rebalanceInsert`/`distributeLine`)、
行内移动 `moveSelectedRow`、复制/粘贴属性。
2. 为 `composables/useGroupTree.js` 写直接单测:band 的 span/ownCount/层级计算、
参差深度、空分组占位、`isDescendantGroup` 防环。
3. 若阶段三已把保存编排中的纯计算(subTables diff 收集、外键重映射输入输出)
拆为可测函数,为其补 spec;否则跳过不强求。
验收:新增测试与既有测试全部通过;`pnpm check`(lint + fmt:check + build)通过。
## 6. 建议的执行顺序与停损点
- 顺序即阶段编号:一 → 二 → 三 → 四 → 五 → 六,每阶段独立 commit。
- 阶段四风险最高(模板与拖拽状态机大搬家),其内部自带停损规则(见阶段四末尾);
最低交付线为 table-edit/ 重组 + useFormGrid 抽取。阶段一~三、五不允许跳过。
- 任何阶段若发现测试失败且 30 分钟内无法定位根因,回退该阶段 commit 并在交付
说明中记录现象。
## 7. 手工冒烟清单(每阶段结束过一遍,重点阶段一、三、四)
```text
模块树:搜索展开/收起、右键新增三种模块、未保存节点置灰不可作父级、删除级联
草稿:修改后切换模块弹三选一(保存并切换/放弃/取消)、重载确认、Ctrl/Cmd+S
表格设计器:拖列换位/入组/出组、拖分组嵌套与排序、拖入自身子树被禁止、
右键增删改分组、分组内联重命名、列宽拖拽与步进、Ctrl+C/X/V、Delete、
未分组悬浮落点(无未分组列时)、字段库点击加入/定位
表单设计器:字段拖入任意位置、插入线、栅格自动分配(12→6/6)、跨组移动、
textarea 高度、删除与恢复、同步到列表/查询
保存:新增字段+新增分组后保存一次成功(验证临时 id 分配与 b_field_id/b_group_id 重映射)
```
## 8. 明确不要做的事情
- 不改 `props/emits` 契约、不改保存请求结构、不改 API 参数。
- 不重写 `localEdit`/`localGroups` 的响应式数据流,不把双快照(originals/drafts)
模式换成其它脏检查方案。
- 不引入 TS、不引入新依赖、不做大规模重命名(`b_` 前缀数据库字段命名保持)。
- 不把 `ModuleFormEditPanel` 与 `ModuleTableEditPanel` 合并为一个编辑器组件
(允许共享叶子组件如 `EditDataView`/`FieldLibrary`,但两个编辑器本体各自独立)。
- 不删除任何"为什么"型注释;不修改既有测试的断言。
- 不顺手修 UI 细节/交互问题(发现则记录到交付说明,另开任务)。
## 9. 可直接交给其他 AI 的执行指令
在 `fms-vue` 项目中按照 `docs/module-management-code-refactor-plan.md` 执行代码重构。
先完整阅读该计划、`src/views/system/module-management/` 下的 index.vue、两个编辑面板、
structure-tree/ 两个组件以及 `tests/unit/module-table-edit-panel-dnd.spec.js`,再按
阶段一至阶段六逐步执行:每阶段一个 commit,阶段内先 diff 再抽取(同名函数不等于
同体),全程保持行为不变。每阶段结束运行 `pnpm test && pnpm lint` 并按第 7 节清单
手工冒烟;最终运行 `pnpm check`。交付时说明:各阶段修改的文件与行数变化、抽取的
共享符号清单、哪些同名函数被判定为"本质不同"而未合并、测试与构建结果、以及执行
过程中发现但未处理的问题。