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

18 KiB
Raw Blame History

/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 时点,执行时以符号名为准)

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:

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:

    useFieldLookup(fieldsRef)
      返回:fieldNameMap、fieldMap、fieldOf(row)、fieldName(row)、controlTypeLabel(row)
    常量:FIELD_TYPE_LABELS
    
  2. 新建 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. 两个面板中 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/:

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

切分规则:

  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. 手工冒烟清单(每阶段结束过一遍,重点阶段一、三、四)

模块树:搜索展开/收起、右键新增三种模块、未保存节点置灰不可作父级、删除级联
草稿:修改后切换模块弹三选一(保存并切换/放弃/取消)、重载确认、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。交付时说明:各阶段修改的文件与行数变化、抽取的 共享符号清单、哪些同名函数被判定为"本质不同"而未合并、测试与构建结果、以及执行 过程中发现但未处理的问题。