18 KiB
/system/module-management 代码重构计划(还债专项)
本文是纯代码重构计划:不改任何行为、UI 文案、API 调用和保存协议。 与
docs/module-management-refactor-plan.md(功能改造历史记录)无关,请勿混淆。 执行前请先通读本文第 1、2 节和第 8 节(禁改清单)。
1. 背景与问题
模块管理功能已基本完成,但代码存在三类结构性债务:
- 巨型文件:
ModuleFormEditPanel.vue3709 行、ModuleTableEditPanel.vue2962 行、index.vue1522 行,全模块约 1.2 万行。 - 三处复制的"同源"逻辑:分组树推导在面板与结构树组件间整段复制(注释自述 "与面板 bandMeta 同源");两个编辑面板共享约 20 个同名函数。靠人肉同步,改一处漏 一处就是 bug。
- index.vue 职责过重:树管理、草稿加载、保存编排、删除级联、i18n 协调、确认弹窗
全部平铺在一个
<script setup>里,saveModule单函数约 120 行。
目标:在不改变任何外部行为的前提下消除上述债务,让后续功能开发有清晰的落点。
2. 当前代码范围(行数为 2026-08 时点,执行时以符号名为准)
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. 总体约束(每个阶段都适用)
- 纯重构:不改行为、不改 UI 结构与文案、不改 props/emits 契约(子面板对父级的 接口保持不变)、不改 API 调用与保存请求结构、不引入任何新依赖(包括拖拽库)。
- 现有测试是安全网:
tests/unit/module-table-edit-panel-dnd.spec.js及其它测试 必须全程绿;除 import 路径外不允许修改测试断言。若某步导致断言失败,说明该步 改变了行为,应回退重做。 - 每阶段一个独立 commit,commit message 注明阶段号。开始前若工作区有未提交 改动,先提交或暂存,保证基线干净。
- 保持注释纪律:项目注释是"为什么"型中文注释;搬运代码时注释随行;删除复制 体后,同步删除"与面板 bandMeta 同源"这类描述复制关系的注释(已不成立)。
- 不重写响应式模型:
localEdit本地副本 + watch props、localGroups绕过回流 等模式虽然别扭但经过调试,本次只搬运不重新设计。 - 每阶段结束运行:
pnpm test && pnpm lint(最终再跑一次pnpm check)。
5. 阶段任务
阶段一:抽取分组树推导 composable(风险最低、收益最大,先做)
新建 src/views/system/module-management/composables/useGroupTree.js:
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 节清单)无变化。
阶段二:抽取字段查询与共享纯函数
-
新建
src/views/system/module-management/composables/useFieldLookup.js:useFieldLookup(fieldsRef) 返回:fieldNameMap、fieldMap、fieldOf(row)、fieldName(row)、controlTypeLabel(row) 常量:FIELD_TYPE_LABELS -
新建
src/views/system/module-management/editPanelShared.js(纯函数,无 Vue 依赖):renumberRows(rows) // (i+1)*10 重排 buildClipboardProps(row, fieldNameOf) // 复制列属性的键集合(两面板 diff 后统一) applyClipboardProps(row, props) buildVisibilityMap(rows) // syncToList/syncToQuery 共用的可见性 Map -
两个面板中 3.2 节列出的每个函数:先逐对 diff 函数体,再归三类处理——
- 完全相同 → 移入共享文件,原位改为导入;
- 语义相同、细节不同(如
createEditRow的默认值差异)→ 抽参数化工厂 (如createEditRowFactory(defaults)),两面板各自传参; - 本质不同 → 保留各自实现,不强行合并,在 PR 说明中列出。
-
syncToList/syncToQuery结构高度一致(仅目标数组与写回字段不同):用buildVisibilityMap共享取数部分,emit 与提示文案留在各自面板。
验收:pnpm test 全绿;两面板各减约 150–250 行;数据视图"同步到列表/查询配置"
行为与提示文案不变。
阶段三:拆分 index.vue
目标:index.vue 只保留模板、状态接线和面板编排(预期 ≤ 700 行)。抽出的
composables 放 src/views/system/module-management/composables/:
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 混在一个文件,是维护痛点的主要 来源。本阶段按"职责切分、只搬运不重写"把两个编辑器重组为目录:
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
切分规则:
- 壳组件保留对外契约:
TableEditPanel.vue/FormEditPanel.vue对 index.vue 的 props/emits 完全不变,index.vue 只改 import 路径。 - 状态归属:
localEdit、localGroups、选中态(selectedNodeKey/selectedRow) 留在壳组件,子组件通过 props/事件交互;useXxxcomposable 接收壳的 refs、返回 handlers 与状态,不自带数据源。 - 样式随组件迁移(scoped),BEM 类名前缀(
mm-fdesign-/mm-tdesign-)保持 不变,避免测试选择器与深层样式选择器失效;全局样式(如.mst-drag-badge)留在 原文件并保留注释说明。 FieldLibrary两编辑器都需要但文案/点击行为有差异:优先做成参数化共享组件放在edit-panels/下;若 diff 后发现耦合过深,允许 form-edit 下保留一份薄的私有实现, 在交付说明中说明选择。- 原
components/ModuleTableEditPanel.vue/ModuleFormEditPanel.vue删除,由 壳组件顶替(路径变化后同步更新 index.vue import 与测试中的 import 路径,仅此 一处允许改测试文件,断言不动)。 structure-tree/已是独立目录,本阶段不动。
停损:FormCanvas + useFormDrag 是全计划最大风险点(表单画布模板与拖拽状态机
交织最深)。若拆 FormEditPanel 时出现难定位的行为差异,允许降级交付:完成
table-edit/ 目录重组 + form-edit 仅抽 useFormGrid.js(纯逻辑无 UI 搬迁,风险低),
其余记录到交付说明留待下轮。
验收:pnpm test 全绿(dnd spec 通过 StubDraggable 定位 .mst-node,不受影响);
所有新文件 ≤ 700 行(参考目标);设计/数据视图切换、属性面板编辑、列宽拖拽、
字段库点击/拖出手工冒烟无变化。
阶段五:收尾与小修
- 硬编码颜色收敛到 token:
ModuleTableEditPanel.vue中#d95757(危险红)、#fafbfc(列头底色)、#e7f6ed/#146c41(已加入勾选)等,替换为--fms-*变量(项目已有--fms-danger类 token,先查src/styles/再定值; 没有合适 token 则新增,不硬编码第二次)。 index.vue的deleteModule中 i18n 孤儿键清理 SQL 拼接(b_key LIKE 'module.${code}.%'等)抽为@/utils/i18n.js的buildModuleI18nCleanupFilter(moduleType, moduleCode),并把"前缀匹配安全性依赖.与_不互撞"这一约定写成函数注释。调用处行为不变。- 检查全模块不再有"
module-management内重复定义FIELD_TYPE_LABELS"等残留 (grep 确认单一定义点)。
阶段六:补测试
tests/unit/module-form-edit-panel.spec.js:参照module-table-edit-panel-dnd.spec.js的手法(StubDraggable、document.elementFromPointmock、defineProperty 注入坐标)覆盖 FormEditPanel 的 核心纯逻辑:栅格 rebalance(collectLineIndexes/rebalanceInsert/distributeLine)、 行内移动moveSelectedRow、复制/粘贴属性。- 为
composables/useGroupTree.js写直接单测:band 的 span/ownCount/层级计算、 参差深度、空分组占位、isDescendantGroup防环。 - 若阶段三已把保存编排中的纯计算(subTables diff 收集、外键重映射输入输出) 拆为可测函数,为其补 spec;否则跳过不强求。
验收:新增测试与既有测试全部通过;pnpm check(lint + fmt:check + build)通过。
6. 建议的执行顺序与停损点
- 顺序即阶段编号:一 → 二 → 三 → 四 → 五 → 六,每阶段独立 commit。
- 阶段四风险最高(模板与拖拽状态机大搬家),其内部自带停损规则(见阶段四末尾); 最低交付线为 table-edit/ 重组 + useFormGrid 抽取。阶段一~三、五不允许跳过。
- 任何阶段若发现测试失败且 30 分钟内无法定位根因,回退该阶段 commit 并在交付 说明中记录现象。
7. 手工冒烟清单(每阶段结束过一遍,重点阶段一、三、四)
模块树:搜索展开/收起、右键新增三种模块、未保存节点置灰不可作父级、删除级联
草稿:修改后切换模块弹三选一(保存并切换/放弃/取消)、重载确认、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。交付时说明:各阶段修改的文件与行数变化、抽取的
共享符号清单、哪些同名函数被判定为"本质不同"而未合并、测试与构建结果、以及执行
过程中发现但未处理的问题。