20260902223822

This commit is contained in:
oneao committed 2026-09-02 22:38:23 +08:00
1 parent db7813a2d2
commit c8940f6e03
11 files changed
+327 -922

No files matched your search

+11 -13
View File
@@ -9,7 +9,7 @@
1. **沿用模块主体**:`s_module` 继续作为旧系统的数据配置主体,分类节点和数据节点在同一棵树中维护,不再增加 `s_entity`。
2. **模块与字段分离**:`data/query` 模块声明查询源、保存源和主键字段;字段挂模块保存物理列名、字段类型和数据库列约束,列表、表单、查询行为由字段配置子表承载。
3. **菜单与模块分离**:`s_menu` 是纯导航树,只引用数据模块或固定页面;本方案只恢复旧系统的模块数据主体,不把菜单、路由和角色授权重新塞回 `s_module`。
3. **菜单与模块分离**:`s_menu` 是纯导航树,页面菜单引用数据模块,外链菜单保存外链地址;本方案只恢复旧系统的模块数据主体,不把菜单、路由和角色授权重新塞回 `s_module`。
4. **字段定义与视图配置分责**:字段定义、列表、表单、查询是四类不同职责;本方案保留 `s_field` 作为字段定义表,并将列表、表单、查询行为分别放入 `s_field_view`、`s_field_edit`、`s_field_query`,避免一张宽表充满互不相关的空字段。
5. **配置入口与存储职责分离**:管理员可从模块树或菜单树定位数据模块,在一个配置工作台内完成数据源、字段、列表、表单、查询、编码和权限配置;底层仍按模块、菜单和权限分别存储。
@@ -35,7 +35,7 @@
| 5 | `s_field_edit` | 字段表单配置(显示、排序、分组、必填、只读、禁用) |
| 6 | `s_field_query` | 字段查询配置(关联字段、操作符、默认值) |
| 7 | `s_relation` | 模块关系(引用/主从/多对多,驱动外键校验与级联) |
| 8 | `s_menu` | 菜单/页面导航(data 菜单直接绑定数据模块) |
| 8 | `s_menu` | 菜单/页面导航(页面菜单绑定数据模块) |
| 9 | `s_autocode` | 自动编码规则 |
| 10 | `s_role` | 角色 |
| 11 | `s_user_role` | 用户-角色 |
@@ -360,7 +360,7 @@ create index ix_s_relation_target on s_relation (b_target_module_id);
### 3.8 s_menu 菜单/页面
菜单是独立导航树,不保存字段、列表或表单配置。`data` 菜单通过 `b_module_id` 绑定一个 `data/query` 模块,并直接使用该模块的字段、列表、表单和查询配置。
菜单是独立导航树,不保存字段、列表或表单配置。`page` 菜单通过 `b_module_id` 绑定一个 `data/query` 模块,并直接使用该模块的字段、列表、表单和查询配置。
```sql
create table s_menu (
@@ -369,16 +369,14 @@ create table s_menu (
b_code varchar(50) not null, -- 菜单编码
b_name nvarchar(100) not null, -- 菜单名称
b_i18n varchar(150) null, -- 显示名多语言 key
b_menu_type varchar(20) not null, -- directory/page/data/external
b_route varchar(200) null, -- 固定页面路由或外链
b_module_id bigint null, -- data 页面绑定的数据/查询模块
b_power_code varchar(50) null, -- 页面访问权限编码(对应 s_power.b_code)
b_menu_type varchar(20) not null, -- directory/page/external
b_route varchar(200) null, -- 页面路由或外链地址
b_module_id bigint null, -- page 页面绑定的数据/查询模块
b_icon nvarchar(max) null, -- 图标:lucide:Name 引用 lucide 图标;其他字符串视为 SVG 标记
b_xh int not null default 0, -- 排序号
b_canuse int not null default 1, -- 是否启用(停用后不显示且不可访问)
constraint pk_s_menu primary key (b_id),
constraint ux_s_menu_code unique (b_code),
constraint ck_s_menu_parent check (b_parent_id is null or b_parent_id <> b_id),
-- constraint fk_s_menu_parent foreign key (b_parent_id) references s_menu (b_id),
-- constraint fk_s_menu_module foreign key (b_module_id) references s_module (b_id)
);
@@ -387,9 +385,9 @@ create index ix_s_menu_parent on s_menu (b_parent_id, b_xh, b_canuse);
create index ix_s_menu_module on s_menu (b_module_id);
```
菜单类型约束由元数据校验器执行:`directory` 不填业务路由;`page` 必须填 `b_route`;`data` 必须填 `b_module_id`;`external` 必须填 `b_route` 和 `b_power_code`。目录仅在存在至少一个可访问子页面时显示。
菜单类型约束由业务层执行:`directory` 不填业务路由和模块;`page` 必须填 `b_route` 和 `b_module_id`;`external` 必须填 `b_route` 且不绑定模块。已保存菜单不允许修改菜单类型。菜单访问权限由 `b_code` 派生,不再单独配置访问权限编码。目录仅在存在至少一个可访问子页面时显示。
配置工作台按以下顺序呈现:页面基本信息 → 绑定模块 → 字段与分组 → 列表 → 表单 → 查询 → 关联模块 → 自动编码 → 权限。保存时由服务层校验菜单类型、模块类型和模块关系。
菜单配置表单按以下顺序呈现:菜单名称 → 菜单编码 → 菜单类型 → 上级菜单 → 路由/外链地址 → 绑定模块 → 图标 → 排序号。菜单类型、路由和模块关系由菜单管理的业务层校验。
没有独立菜单的子模块、费用明细、选择器和公共字典,通过主模块的 `s_relation` 挂在所属数据模块下;它们不需要重复创建菜单,也可以在同一个配置工作台中维护。
@@ -470,7 +468,7 @@ create table s_power (
b_name nvarchar(100) not null, -- 权限名称
b_i18n varchar(150) null, -- 显示名多语言 key
b_power_type varchar(20) not null, -- menu/module/action/field
b_resource varchar(128) null, -- 菜单 code/固定页面或扩展资源 code
b_resource varchar(128) null, -- 菜单 code 或扩展资源 code
b_module_id bigint null, -- module/action/field 所属模块
b_field_id bigint null, -- field 权限对应字段
b_operation varchar(30) not null, -- access/read/create/update/delete/export/execute/view/edit
@@ -557,7 +555,7 @@ create index ix_s_role_scope_role on s_role_scope (b_role_id, b_module_id, b_ope
- 数据范围在操作权限通过后追加。`creator` 匹配记录创建人,`owner/department/department-tree` 通过 `b_scope_field_id` 与当前登录上下文匹配;`custom` 使用结构化 JSON 条件并只允许引用当前模块字段。
- 用户拥有多个角色时,同一操作的数据范围取并集;同一角色内多个范围记录也取并集,任一角色/范围为 `all` 即不再追加范围条件。没有有效范围记录时,即使拥有操作权限也默认无数据,避免误放开全表。
菜单显示继承页面访问权限:用户拥有 `s_menu.b_power_code` 对应权限时菜单才显示;前端只控制显示,后端运行时必须再次校验同一个权限编码。
菜单显示继承页面访问权限:用户拥有由 `s_menu.b_code` 派生的菜单访问权限时菜单才显示;前端只控制显示,后端运行时必须再次校验同一个权限编码。
### 3.15 s_i18n 多语言
@@ -733,7 +731,7 @@ create index ix_s_log_audit_field_customer on s_log_audit_field (b_module_code,
| `s_menu` | `b_module_id` | 绑定的数据/查询模块 `s_module.b_id` |
| `s_field_group` | `b_parent_id` | 父分组必须与当前分组属于同一数据模块 |
| `s_field_query` | `b_relation_id` | 关联关系必须从查询模块可达条件字段所属模块 |
| `s_menu` | `b_power_code` | `s_power.b_code` |
| `s_menu` | `b_code` | 菜单访问权限资源编码(由菜单编码派生) |
| `s_power` | `b_resource` / `b_operation` | 权限类型、资源和操作必须匹配;field 权限必须引用对应模块字段 |
| `s_role_scope` | `b_scope_field_id` / `b_scope_value` | 数据范围字段和范围值必须与模块及范围类型匹配 |
| `s_relation` | 关系类型与字段组合 | 多对多必须有中间模块;两侧字段必须属于声明的模块 |
@@ -256,14 +256,11 @@ BEGIN
b_menu_type varchar(20) NOT NULL,
b_route varchar(200) NULL,
b_module_id bigint NULL,
b_power_code varchar(50) NULL,
b_icon nvarchar(max) NULL, -- 图标:lucide:Name 引用 lucide,否则为 SVG 标记
b_xh int NOT NULL CONSTRAINT DF_s_menu_xh DEFAULT (0),
b_canuse int NOT NULL CONSTRAINT DF_s_menu_canuse DEFAULT (1),
CONSTRAINT PK_s_menu PRIMARY KEY (b_id),
CONSTRAINT UQ_s_menu_code UNIQUE (b_code),
CONSTRAINT CK_s_menu_parent CHECK (b_parent_id IS NULL OR b_parent_id <> b_id),
CONSTRAINT CK_s_menu_type CHECK (b_menu_type IN ('directory','page','data','external'))
CONSTRAINT UQ_s_menu_code UNIQUE (b_code)
);
CREATE INDEX IX_s_menu_parent ON dbo.s_menu (b_parent_id, b_xh, b_canuse);
CREATE INDEX IX_s_menu_module ON dbo.s_menu (b_module_id);
@@ -0,0 +1,16 @@
/*
菜单访问权限统一由 s_menu.b_code 派生,不再保存独立的 b_power_code。
可重复执行:仅在旧列存在时删除。
*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF DB_NAME() <> N'FMS'
THROW 53600, N'This migration can only run against the FMS database.', 1;
IF COL_LENGTH(N'dbo.s_menu', N'b_power_code') IS NOT NULL
BEGIN
ALTER TABLE dbo.s_menu DROP COLUMN b_power_code;
END;
PRINT N'Migration 104 applied: s_menu.b_power_code removed.';
@@ -0,0 +1,39 @@
/*
菜单类型由业务层校验,不在数据库中使用 CHECK 约束限制。
现有 data 菜单统一迁移为 page:页面现在同时填写路由和绑定模块。
可重复执行:仅处理仍存在的旧数据或约束。
*/
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF DB_NAME() <> N'FMS'
THROW 53600, N'This migration can only run against the FMS database.', 1;
IF OBJECT_ID(N'dbo.s_menu', N'U') IS NOT NULL
BEGIN
UPDATE dbo.s_menu
SET b_menu_type = 'page'
WHERE b_menu_type = 'data';
IF EXISTS (
SELECT 1
FROM sys.check_constraints
WHERE parent_object_id = OBJECT_ID(N'dbo.s_menu')
AND name = N'CK_s_menu_type'
)
BEGIN
ALTER TABLE dbo.s_menu DROP CONSTRAINT CK_s_menu_type;
END;
IF EXISTS (
SELECT 1
FROM sys.check_constraints
WHERE parent_object_id = OBJECT_ID(N'dbo.s_menu')
AND name = N'CK_s_menu_parent'
)
BEGIN
ALTER TABLE dbo.s_menu DROP CONSTRAINT CK_s_menu_parent;
END;
END;
PRINT N'Migration 105 applied: s_menu uses directory/page/external and business-layer validation.';
@@ -1,303 +0,0 @@
# `/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`。交付时说明:各阶段修改的文件与行数变化、抽取的
共享符号清单、哪些同名函数被判定为"本质不同"而未合并、测试与构建结果、以及执行
过程中发现但未处理的问题。
@@ -1,228 +0,0 @@
# `/system/module-management` 页面改造计划
## 1. 改造目标
将当前“模块树 + 配置导航 + 配置面板”升级为可持续扩展的模块配置工作台,为后续表单拖拽设计预留明确空间。
本次重点是布局、信息层级和交互呈现,不实现真实拖拽业务,不修改后端 API、数据表结构和保存协议。
参考原型:`/module-management-layout-demo.html`。
## 2. 当前代码范围
主要入口:`src/views/system/module-management/index.vue`
主要子组件:
- `components/ModuleBasicPanel.vue`
- `components/ModuleFieldsPanel.vue`
- `components/ModuleListConfigPanel.vue`
- `components/ModuleEditConfigPanel.vue`
- `components/ModuleQueryConfigPanel.vue`
- `components/ModuleAutoCodePanel.vue`
- `components/ModulePowersPanel.vue`
- `components/ModuleI18nPanel.vue`
- `components/MenuDetailPanel.vue`
现有数据草稿、未保存保护、快捷保存、树选择和配置面板逻辑应尽量复用。
## 3. 目标布局
```text
顶部:模块配置 / 菜单配置
主体:模块树 | 配置导航 | 配置工作区
工作区:对象标题栏 + 设计/数据视图 + 具体内容
```
### 左侧模块树
- 保留模块树、搜索、新建、刷新和右键菜单。
- 默认宽度约 `238~260px`,继续支持 Splitter 调整。
- 节点保留名称、类型标签、停用状态和未保存状态。
- 不在本次改造中实现树节点拖拽排序或调整父子关系。
### 中间配置导航
将现有导航归并为以下分组:
```text
概览
基本信息
数据结构
字段定义
分组管理
界面配置
表单设计
列表配置
查询配置
业务能力
自动编码
权限
高级设置
多语言
```
要求:
- 每个配置项只有一个激活态。
- 保留当前 `activeSection` 机制,避免重写数据加载逻辑。
- 数据模块和查询模块按现有条件显示可用配置项。
- 分类模块只显示适用的基本信息配置。
### 右侧配置工作区
- 顶部显示图标、模块名称、类型、上级路径和未保存状态。
- 保留“重载”和“保存”操作。
- 内容区域继续复用现有子面板。
- 右侧区域内部滚动,避免页面出现多层无意义滚动条。
- 表单设计页面需要比普通表格配置获得更大的可用空间。
## 4. 表单设计页面
在 `ModuleEditConfigPanel.vue` 的基础上增加“设计视图 / 数据视图”概念。
### 设计视图
目标只做静态布局和现有数据的展示,不实现真正拖拽:
```text
字段库 / 布局组件 | 表单画布 | 属性面板
```
要求:
- 左侧显示字段库和布局组件入口,使用拖拽手柄视觉提示。
- 中间显示表单分组、字段卡片、栅格宽度和插入位置占位。
- 右侧显示选中字段的标题、控件类型、栅格宽度、排序号、必填、只读、禁用等属性。
- 字段卡片支持点击选中并显示明显焦点状态。
- 设计视图只操作当前页面已有的草稿数据,不发起独立保存请求。
- 桌面端 / 移动端按钮可以只做视觉切换,移动端预览可通过限制画布宽度展示。
### 数据视图
- 保留当前 `FmsTable` 高密度编辑表格。
- 继续支持现有行排序事件和同步到列表/查询配置按钮。
- 表格视图与设计视图共享同一组 `editConfig` 草稿数据。
- 不新增第二套字段保存逻辑。
## 5. 建议的实现顺序
### 阶段一:重排现有工作台
1. 调整 `index.vue` 的三栏布局和头部层级。
2. 归并 `configGroups` 的分组标签。
3. 保留现有树、加载、保存、未保存确认和快捷键逻辑。
4. 统一模块配置和菜单配置的外层视觉结构。
5. 确保普通配置面板行为不变。
### 阶段二:增加表单设计静态视图
1. 在 `ModuleEditConfigPanel.vue` 增加设计视图/数据视图切换。
2. 抽取字段卡片、画布分组、属性面板为局部组件或清晰的模板区块。
3. 使用当前 `editConfig`、`fields`、`groups` 生成画布内容。
4. 暂不引入新的拖拽库。
5. 暂不新增后端字段。
### 阶段三:验证和收尾
1. 更新页面测试中与导航标签相关的断言。
2. 补充设计视图/数据视图切换测试。
3. 补充字段卡片选择和移动端预览状态测试。
4. 执行 `pnpm test`、`pnpm lint`、`pnpm build`。
5. 在桌面宽度和较窄窗口下检查三栏是否溢出、遮挡或出现横向滚动。
## 6. 明确不要做的事情
- 不修改 API 调用参数和保存请求结构。
- 不修改数据库表结构。
- 不替换现有 Tree、Splitter、Tabs、FmsTable 等基础组件。
- 不删除现有保存校验、未保存切换保护和 Ctrl/Cmd + S。
- 不在本次任务实现真实跨区域拖拽、撤销重做、版本发布或权限模型。
- 不新增大型 UI 框架或拖拽依赖。
- 不把所有配置面板重写成一个巨型组件。
## 7. 视觉与交互验收标准
- 页面第一眼能区分模块树、配置导航和当前编辑工作区。
- 三栏边界清晰,配置工作区是视觉重点。
- 右侧标题栏与左侧树标题栏垂直对齐。
- 激活态、未保存态、停用态具有足够对比度,并且不只依赖颜色表达。
- 图标按钮有 `title` 或 `aria-label`。
- 可点击区域至少约 `32px`,主要操作按钮保持稳定尺寸。
- 设计视图不会挤压成不可用的窄画布。
- 普通表格配置仍适合批量扫描和编辑。
- 页面没有新增无意义的卡片嵌套或营销式大面积装饰。
## 8. 交付要求
交付时请说明:
- 修改了哪些文件。
- 哪些逻辑保持不变。
- 设计视图目前哪些是展示态,哪些是真实可编辑态。
- 测试、Lint、构建是否通过。
- 是否存在需要下一阶段实现的拖拽或数据模型工作。
## 9. 可直接交给其他 AI 的执行指令
请在 `fms-vue` 项目中按照 `docs/module-management-refactor-plan.md` 改造 `/system/module-management` 页面。先阅读现有 `index.vue`、相关子面板和测试,再分阶段修改。保持 API、保存协议、草稿机制和未保存保护不变;优先复用现有组件。完成后运行页面测试、Lint 和构建,并报告修改文件、验证结果和未实现的后续能力。不要引入新的 UI 框架或拖拽依赖,也不要实现真实拖拽业务。
## 10. 第二轮表单设计器功能计划
本轮针对实际使用中暴露的问题,允许修改表单设计器前端交互和草稿布局字段;仍然不引入第三方拖拽库,不改变现有保存 API 的整体调用方式。
### 10.1 任意位置拖拽
- 字段库字段可拖入任意分组、任意已有字段卡片前后。
- 已存在字段可通过拖拽手柄在同一分组内排序,也可拖到其他分组。
- 画布显示清晰的插入线和目标行高亮。
- 仅拖拽手柄启动排序,点击卡片仍只负责选中。
- 拖拽结束后统一重排 `b_xh`,并保留当前选中字段。
### 10.2 动态行布局
- 画布使用 12 栅格。
- 新字段插入已有行时,若当前行只有一个字段且其宽度为 12,则自动将原字段和新字段调整为 `6/12 + 6/12`。
- 同一行继续添加字段时,按剩余空间自动分配;无法平均分配时保留原布局并创建新行。
- 修改某个字段宽度后,重新计算同一行的占用,禁止超过 12 栅格。
- 不把“行”硬编码成单独后端表;前端根据顺序和 `b_colspan` 推导行,后续可再升级为显式布局模型。
### 10.3 自定义栅格宽度
- 属性面板提供 `1~12` 的数字输入或步进控件,同时保留常用快捷选项 `12/12、8/12、6/12、4/12、3/12`。
- 输入值自动限制在 `1~12`。
- 宽度改变立即反映到画布,并同步到 `b_colspan` 草稿字段。
- 数据视图继续显示并编辑 `b_colspan`。
### 10.4 Textarea 高度和同一行对齐
- 字段定义为 `textarea` 时,属性面板提供高度档位:小、中、大,或直接输入高度。
- 高度属于前端布局扩展属性;如果现有后端没有对应字段,先保存在前端草稿的 `b_height`/布局扩展对象中,并明确标注后续需要后端持久化。
- 同一行采用 CSS Grid 的 `align-items: stretch`,字段卡片和控件容器使用 `height: 100%`。
- 同一行存在 textarea 时,行高取该行最高字段高度,左右字段跟随拉伸,避免一边高、一边低。
- 不能通过强制所有字段固定高度解决;普通输入框保持最小高度,textarea 决定需要扩展的行高。
### 10.5 分组方向
- 每个分组提供“上下布局 / 左右布局”切换。
- 上下布局:分组标题在上,字段按 12 栅格从上到下排列。
- 左右布局:分组标题作为左侧区域,字段画布位于右侧;窄屏自动回退为上下布局。
- 方向先作为前端草稿布局属性处理;如果后端暂无字段,不得伪装成已持久化。
### 10.6 删除和恢复
- 选中字段后,属性面板提供删除按钮;同时支持键盘 `Delete/Backspace`(焦点在输入框时不触发)。
- 删除前保留轻量确认或撤销提示,避免误删。
- 删除只从 `localEdit` 草稿移除,并立即 `emit('update')`;最终由模块保存流程统一提交。
- 字段库中被删除的字段恢复为可拖入状态。
- 分组删除仍由 `FieldGroupManager` 管理,不能通过字段删除按钮删除分组。
### 10.7 本轮验收
- 可以把字段拖到任意已有字段前后,并看到插入位置反馈。
- 单字段 12 栅格行拖入第二个字段后自动变成 6/6。
- 栅格输入 1、4、6、8、12 均能正确渲染,超出范围会被限制。
- textarea 改变高度后,同一行另一字段高度与之对齐。
- 分组可切换上下/左右方向,窄屏不产生横向溢出。
- 删除字段后画布、属性面板和字段库状态同步更新。
- 现有保存、重载、切换未保存保护、数据视图行拖拽和同步按钮仍可用。
@@ -1,103 +0,0 @@
# `/system/module-management` 导航状态化改造计划
> 本轮只做导航与状态呈现层:让 8 个 tab 从「哑的入口」变成「会说话的入口」,并让概览页承担配置引导。
> 不改数据层、不改保存协议与 API。横排导航保留,**不做分组**(已决策:竖排浪费横向空间,8 个平铺 tab 可接受)。
## 1. 背景与问题
现状(`index.vue`):右侧横排胶囊 Tabs(`navOnly` + 面板 `v-show` 保活),由 `configGroups`/`configNavItems` 驱动;仅查询配置有「缺 N」徽标。
使用侧问题(面向开发与售后两类用户):
1. **tab 不反映状态**:改过哪些 tab、哪些 tab 缺配置,界面上看不出来,全靠用户记忆;
2. **一个任务跨多个 tab**(加字段 → 表单设计/列表/查询都要看一眼),界面不引导用户走完;
3. **概览 tab 只有基本信息**,没有承担「新模块配到哪了、还差什么」的引导职责。
## 2. 总体约束
1. 不改子面板对 index.vue 的 props/emits 契约、不改保存请求结构、不改 API 调用。
2. **不改 `ui/Tabs` 组件**:脏点与徽标全部经现有 `#label` 插槽渲染(index.vue 已在用该插槽画图标+文字+徽标)。
3. 保留 `activeSection` 机制与 `v-show` 保活,不改键盘左右切换行为。
4. 每阶段独立 commit;每阶段结束 `pnpm test && pnpm lint` 全绿。涉及导航的测试仅允许更新选择器/文案,不改行为断言。
5. 徽标/圆点不得只靠颜色表达:一律配 `title`/`aria-label`。
## 3. 阶段任务
### 阶段一:分 section 脏检查 + tab 脏点
`useModuleConfig` 新增 `dirtySections`(Set<sectionKey>):
```js
const SECTION_KEYS = ['module', 'fields', 'groups', 'listConfig', 'editConfig', 'queryConfig', 'autoCode', 'powers']
// 仅对比已加载的 section;逐 section JSON.stringify(drafts[key]) === JSON.stringify(originals[key])
```
- 现有 `hasModuleChanges` 改为 `dirtySections.size > 0` 的派生值,**行为必须等价**(切换保护、重载确认依赖它)。
- **注意「假脏」陷阱**:`loadModuleConfig` 加载时会自动补全列表/查询默认行(useModuleConfig.js:79–85 附近)。若补全只写 `drafts` 不写 `originals`,分 section 对比会立即「脏」。实现时先确认现有整树对比对这些默认行的处理方式,以「现有 `hasModuleChanges` 的判定结果」为基准写等价断言,再动手。
- index.vue `#label` 插槽给脏 tab 追加圆点(约 6px,标签右上角,`title="有未保存修改"`)。
验收:任一 tab 内容修改 → 对应 tab 出现圆点;保存成功或放弃修改 → 全部消失;切换未保存保护、重载确认行为与改造前一致。
### 阶段二:表单设计「缺 N」徽标(已实现后撤回)
> 实现于 commit 6bfa0d81,使用者试用后撤回:并非所有启用字段都应出现在表单里
> (审计字段等常有意不布局),「缺 N」是假警报。查询配置的「缺 N」徽标
> 为使用者自己的既有实现,保留。以下原始设计保留作记录,勿再实现。
数据模块下统计「启用字段中未被 `editConfig` 任何行的 `b_field_id` 引用的数量」,作为表单设计 tab 的徽标,样式与语义沿用查询配置的「缺 N」。
- 口径以 `b_field_id` 引用集合为准,不涉及布局细节(colspan/分组不影响计数)。
- `editConfig` 同时承载主表表单/子表表格两种编辑方式,此口径对两者通用。
- 查询配置现有徽标不动。
验收:字段定义新增启用字段 → 表单设计 tab 显示「缺 1」;拖入画布后徽标消失;子表表格编辑方式下口径同样成立。
### 阶段三:概览 tab 升级为完成度 checklist(已实现后撤回)
> 实现于 commit 29f98494,使用者试用后决定不要(信息与 tab 徽标重复、占概览篇幅),
> 已 revert 撤回。条目跳转与一键补充的能力需求仍由阶段一/二的 tab 徽标 +
> 查询面板内既有「一键补充」覆盖。以下原始设计保留作记录,勿再实现。
在概览 tab(basic)内增加「配置完成度」区块,新组件 `components/ModuleOverviewChecklist.vue`,数据全部由 `drafts` 派生:
| 条目 | 内容 | 状态 |
| --- | --- | --- |
| 字段定义 | 已启用 X 个字段 | 0 → 提示「先定义字段」 |
| 表单设计 | 缺 N 个未布局(复用阶段二统计) | N>0 → 警示色 |
| 列表配置 | X 列可见(加载自动补全,仅展示数量) | 中性 |
| 查询配置 | 缺 N(复用 `missingQueryCount`) | N>0 → 警示色 + 「一键补充」 |
| 权限 | 已配置 X 项 | 0 → 中性提示 |
- 每条目整行可点击 → `activeSection` 跳转对应 tab;「一键补充」直接调用现有 `fillMissingConfigs(['queryConfig'])`。
- 仅数据/查询模块渲染对应条目(与 `configGroups` 的显隐条件一致);分类模块不渲染 checklist。
- 条目按依赖顺序排列(字段 → 表单 → 列表 → 查询 → 权限),隐式表达配置路径,不做向导式交互。
验收:各计数与对应 tab 实际状态一致;点击跳转正确;新建数据模块的空配置显示引导文案;分类模块无 checklist。
### 阶段四(可选,单独决策后另开任务):字段中心旁路
字段定义面板表格追加三列状态点(表单/列表/查询:已配置/未配置),hover 显示说明,点击跳转对应 tab。
- 第一版只做「跳 tab」,不做「定位到该字段行」(跨面板选中态联动成本高,留待验证需求后再做)。
- 与阶段二/三共用同一套「字段引用状态」统计函数,单一定义点。
## 4. 明确不做的事
- 不做 tab 分组、不做竖排导航、不改 `ui/Tabs` 组件。
- 不动多语言 tab 的位置(是否移出主序列属独立决策,本轮不处理)。
- 不做向导式(wizard)配置流。
- 不动保存粒度、不做撤销/重做、不加离开守卫(属「安全网」另一条改造线,见对话记录,另行立项)。
## 5. 手工冒烟清单(每阶段结束过一遍)
```text
脏点:8 个 tab 各改一处 → 仅对应 tab 出点;保存/放弃后清除;
切换模块三选一弹窗、重载确认、Ctrl/Cmd+S 行为不变
徽标:新增字段未布局 → 表单设计「缺 1」;拖入后消失;查询「缺 N」回归
checklist:各计数正确;条目点击跳 tab;一键补充后查询条目转绿
通用:窄窗口下 tab 行横向滚动可用;键盘 ←/→ 切换不变;分类模块的 tab 集合不变
```
## 6. 可直接交给其他 AI 的执行指令
在 `fms-vue` 项目中按照 `docs/module-management-tab-status-plan.md` 改造 `/system/module-management` 的导航状态层。先阅读 `index.vue`(`configGroups`/`configNavItems`/`#label` 插槽)、`composables/useModuleConfig.js`(`hasModuleChanges` 与加载时默认行补全)及相关测试,再按阶段一至三逐步执行(阶段四默认不做):每阶段一个 commit,阶段一必须先为 `hasModuleChanges` 写等价断言再改实现。全程不改子面板契约、不改保存协议、不改 `ui/Tabs` 组件。每阶段结束运行 `pnpm test && pnpm lint` 并按第 5 节清单手工冒烟。交付时说明:修改文件、`dirtySections` 的等价性验证方式、「假脏」问题的实际结论、测试与冒烟结果。
@@ -10,6 +10,8 @@ defineOptions({ inheritAttrs: false })
const props = defineProps({
virtual: { type: Boolean, default: true },
virtualX: { type: Boolean, default: true },
// 是否在非横向虚拟滚动时自动铺满容器;默认按列配置宽度展示。
autoFitWidth: { type: Boolean, default: false },
// 列宽拖拽默认开启;false=关闭;{ disabled: (col) => boolean }=按列禁用
// (铺满容器的弹性列应禁用拖拽,见库文档「列宽铺满容器hack方式」)
colResizable: { type: [Boolean, Object], default: true },
@@ -73,7 +75,7 @@ const tableTheme = computed(() => props.theme ?? (appStore.darkMode ? 'dark' : '
// 需从 vnode.props 探测;绑定关系在模板编写时确定,运行期不会变化,无需响应式。
const columnsBound =
'onUpdate:columns' in (getCurrentInstance()?.vnode.props ?? {})
const effectiveColResizable = columnsBound ? props.colResizable : false
const effectiveColResizable = columnsBound && !props.autoFitWidth ? props.colResizable : false
// Tab 面板从隐藏恢复时,等到容器拥有有效尺寸再刷新虚拟滚动布局。
// rAF 将同一帧内的多次 ResizeObserver 通知合并,避免重复重排。
@@ -276,6 +278,14 @@ const seqConfig = computed(() => {
width: Number(cfg.width ?? 50),
}
})
const seqWidth = ref(seqConfig.value.width)
watch(
() => seqConfig.value.width,
(width) => {
seqWidth.value = width
},
{ immediate: true },
)
const columnDragConfig = computed(() => {
if (!columnsBound || !props.columnDraggable) return false
const configured = props.columnDraggable === true ? {} : props.columnDraggable
@@ -298,16 +308,18 @@ const columnDragConfig = computed(() => {
}
})
const hasRowSelection = computed(() => Boolean(props.rowSelection))
const selectionWidth = ref(44)
const dragRowColumn = {
type: 'dragRow',
dataIndex: '__drag_row__',
title: '',
width: 40,
resizable: false,
}
const dragRowWidth = ref(dragRowColumn.width)
function getHeaderAlign(column) {
if (column.headerAlign) return column.headerAlign
if (column.type === 'seq') return 'center'
if (isSelectionColumn(column)) return 'center'
if (column.dataIndex === 'actions') return 'center'
if (column.actions && column.actions.length) return 'center'
@@ -322,14 +334,13 @@ const displayColumns = computed(() => {
if (seq.enabled) {
cols.unshift({
type: 'seq',
width: seq.width,
width: seqWidth.value,
dataIndex: '',
title: seq.title,
resizable: false,
})
}
if (props.rowDraggable) cols.unshift(dragRowColumn)
if (hasRowSelection.value) cols.unshift(selectionColumn)
if (props.rowDraggable) cols.unshift({ ...dragRowColumn, width: dragRowWidth.value })
if (hasRowSelection.value) cols.unshift({ ...selectionColumn, width: selectionWidth.value })
return cols.map((column) => ({
...column,
headerAlign: getHeaderAlign(column),
@@ -338,6 +349,15 @@ const displayColumns = computed(() => {
// 剥离内部列(选择列、序号列、拖拽列)后回传,避免污染父组件 columns
function onColumnsUpdate(columns) {
for (const column of columns || []) {
if (isSelectionColumn(column) && column.width != null) {
selectionWidth.value = Number.parseInt(column.width, 10) || selectionWidth.value
} else if (column.type === 'seq' && column.width != null) {
seqWidth.value = Number.parseInt(column.width, 10) || seqWidth.value
} else if (column.type === 'dragRow' && column.width != null) {
dragRowWidth.value = Number.parseInt(column.width, 10) || dragRowWidth.value
}
}
const originalByKey = new Map(
props.columns.map((col) => [String(col.key ?? col.dataIndex ?? ''), col]),
)
@@ -592,7 +612,7 @@ defineExpose({
<div
ref="rootRef"
class="fms-table"
:class="[bordered ? 'fms-table--bordered' : '']"
:class="[bordered ? 'fms-table--bordered' : '', autoFitWidth ? 'fms-table--auto-fit-width' : '']"
@keydown.capture="handleKeydownCapture"
>
<StkTable
@@ -660,6 +680,11 @@ defineExpose({
will-change: transform;
}
}
/* 不自适应时按列配置宽度展示;自适应模式不启用列宽拖拽,使用库默认布局。 */
.fms-table:not(.fms-table--auto-fit-width) :deep(.stk-table-main) {
flex: none;
}
</style>
<!-- 非 scoped 全局样式:选择列原生 checkbox 外观模拟 antdv-next Checkbox,
@@ -684,15 +709,6 @@ defineExpose({
}
}
/* 列宽铺满容器(库文档「列宽铺满容器hack方式」):
开启 col-resizable 后库默认把 .stk-table-main 设为 flex:none + fit-content,
表格收缩到内容宽。非 virtual-x(列数 ≤ 阈值、未设宽列走原生自适应)时覆盖回
flex:1 使表格铺满容器;配合「固定列设 width、弹性列设 minWidth 且禁用其拖拽」
的列配置约定使用。宽表(virtual-x,横向滚动)保持库的收缩行为。 */
.fms-table__inner.stk-table:not(.virtual-x) .stk-table-main {
flex: 1;
}
.fms-checkbox {
display: flex;
align-items: center;
@@ -2,7 +2,7 @@
/**
* 菜单管理:管理 s_menu 导航树。
* 布局:左侧菜单树 + 右侧属性编辑面板。
* 菜单类型:directory(目录)、page(固定页面)、data(数据页面)、external(外链)。
* 菜单类型:directory(目录)、page(页面)、external(外链)。
*/
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import { useHotkeys } from '@/composables/useHotkeys'
@@ -15,7 +15,6 @@ import {
Folder,
FileText,
ExternalLink,
Database,
RefreshCw,
Languages,
} from '@lucide/vue'
@@ -24,7 +23,6 @@ import {
Checkbox,
Input,
Select,
Switch,
Splitter,
SplitterPanel,
Tree,
@@ -63,22 +61,19 @@ const nonDefaultLanguages = I18N_LANGUAGES.filter((l) => !l.default)
/* ================= 类型映射 ================= */
const menuTypeOptions = [
{ label: '目录', value: 'directory' },
{ label: '固定页面', value: 'page' },
{ label: '数据页面', value: 'data' },
{ label: '页面', value: 'page' },
{ label: '外链', value: 'external' },
]
const menuTypeLabel = {
directory: '目录',
page: '页面',
data: '数据',
external: '外链',
}
const menuTypeIconMap = {
directory: Folder,
page: FileText,
data: Database,
external: ExternalLink,
}
@@ -119,8 +114,10 @@ const parentMenuName = computed(() => {
const menuType = computed(() => selectedMenu.value?.b_menu_type || null)
const isDirectory = computed(() => menuType.value === 'directory')
const isPage = computed(() => menuType.value === 'page')
const isData = computed(() => menuType.value === 'data')
const isExternal = computed(() => menuType.value === 'external')
const menuTypeLocked = computed(
() => !!selectedMenu.value && !unsavedMenuIds.has(String(selectedMenu.value.b_id)),
)
/* ================= 树加载 ================= */
watch(menuKeyword, (kw) => {
@@ -175,7 +172,6 @@ function buildMenuContextMenu(node) {
const items = [
{ key: 'add-directory', label: '新增目录', icon: Folder, disabled: isUnsaved },
{ key: 'add-page', label: '新增页面', icon: FileText, disabled: isUnsaved },
{ key: 'add-data', label: '新增数据页面', icon: Database, disabled: isUnsaved },
{ key: 'add-external', label: '新增外链', icon: ExternalLink, disabled: isUnsaved },
]
if (node) {
@@ -189,7 +185,6 @@ function onMenuContextMenuClick({ item, node }) {
const parentId = node ? String(node.b_id) : null
if (item.key === 'add-directory') void addMenu(parentId, 'directory')
else if (item.key === 'add-page') void addMenu(parentId, 'page')
else if (item.key === 'add-data') void addMenu(parentId, 'data')
else if (item.key === 'add-external') void addMenu(parentId, 'external')
else if (item.key === 'delete') void deleteMenu(parentId)
}
@@ -198,7 +193,6 @@ function onMenuContextMenuClick({ item, node }) {
const toolbarAddOptions = [
{ key: 'directory', label: '新增目录', icon: Folder },
{ key: 'page', label: '新增页面', icon: FileText },
{ key: 'data', label: '新增数据页面', icon: Database },
{ key: 'external', label: '新增外链', icon: ExternalLink },
]
@@ -316,7 +310,6 @@ function menuRowOf(node) {
b_menu_type: node.b_menu_type,
b_route: node.b_route ?? null,
b_module_id: node.b_module_id ?? null,
b_power_code: node.b_power_code ?? null,
b_icon: node.b_icon ?? null,
b_xh: node.b_xh,
b_canuse: node.b_canuse,
@@ -335,7 +328,6 @@ async function addMenu(parentId, menuTypeValue = 'page') {
b_menu_type: menuTypeValue,
b_route: null,
b_module_id: null,
b_power_code: null,
b_icon: null,
b_xh: 10,
b_canuse: 1,
@@ -385,7 +377,7 @@ async function deleteMenu(id) {
okType: 'danger',
onOk: async () => {
try {
const menuPowerCode = menu.b_power_code
const menuPowerCode = menu.b_code ? `menu.${String(menu.b_code).trim()}` : ''
const reqs = [{ table: 's_i18n', key_field: 'b_id', deletes: [{ b_module_id: menuId }] }]
// 清理权限点:访问(type=menu 按 b_resource 关联)+ 操作/按钮(type=action 按 b_module_id 关联)
if (menuPowerCode) {
@@ -433,7 +425,7 @@ async function loadMenuConfig(menuId) {
}
i18nValues.value = i18nValuesInit
originalI18nValues.value = { ...i18nValuesInit }
// 操作/按钮权限点:仅 page/data/external 才有(目录是导航容器,无按钮权限)
// 操作/按钮权限点:仅页面/外链才有(目录是导航容器,无按钮权限)
powersLoading.value = true
try {
const powerRes =
@@ -587,22 +579,56 @@ async function saveMenu() {
const menuData = drafts.value?.menu
if (!menuData) return false
const isNew = unsavedMenuIds.has(menuId)
const allowedMenuTypes = new Set(['directory', 'page', 'external'])
if (!allowedMenuTypes.has(menuData.b_menu_type)) {
Message.warning('菜单类型只能是目录、页面或外链')
return false
}
if (!isNew && menuData.b_menu_type !== originals.value?.menu?.b_menu_type) {
Message.warning('已保存菜单不允许修改菜单类型')
return false
}
if (menuData.b_parent_id && String(menuData.b_parent_id) === menuId) {
Message.warning('上级菜单不能选择当前菜单')
return false
}
const menuTypeRules = {
directory: { route: false, module: false },
page: { route: true, module: true },
external: { route: true, module: false },
}
const typeRule = menuTypeRules[menuData.b_menu_type]
if (typeRule.route && !String(menuData.b_route || '').trim()) {
Message.warning(
menuData.b_menu_type === 'external'
? '外链菜单必须填写外链地址'
: '页面菜单必须填写路由地址',
)
return false
}
if (typeRule.module && !menuData.b_module_id) {
Message.warning('页面菜单必须绑定模块')
return false
}
if (!typeRule.route && menuData.b_route) menuData.b_route = null
if (!typeRule.module && menuData.b_module_id) menuData.b_module_id = null
// 简体中文名称 = 表单 b_name(默认语言,直接存菜单表)
// 菜单编码由用户填写,但保存时统一去除首尾空格,作为稳定标识使用。
const menuCode = String(menuData.b_code || '').trim()
menuData.b_code = menuCode
// 受管 i18n 键:菜单统一 menu. 前缀
const menuI18nKey = `menu.${menuData.b_code}`
const menuI18nKey = `menu.${menuCode}`
menuData.b_i18n = menuI18nKey
// 目录是纯导航容器:可见性由子菜单决定,自身不需要访问权限编码,也不生成权限点。
// 仅 page/data/external 需要访问权限编码;留空时按菜单编码自动生成(权限跟着菜单走)
// 目录是纯导航容器:可见性由子菜单决定,自身不生成菜单访问权限点。
// 页面类型菜单的访问权限统一由菜单编码派生。
const menuIsDirectory = menuData.b_menu_type === 'directory'
if (menuIsDirectory) {
menuData.b_power_code = null
} else if (!menuData.b_power_code) {
menuData.b_power_code = `menu.${menuData.b_code}`
}
const powerCode = menuCode ? `menu.${menuCode}` : ''
const isNew = unsavedMenuIds.has(menuId)
const req = { table: 's_menu', key_field: 'b_id' }
if (isNew) {
req.inserts = [menuData]
@@ -632,12 +658,13 @@ async function saveMenu() {
reqs.push(i18nReq)
}
// 目录是纯导航容器:自身不需要访问权限编码与权限点。
// 目录是纯导航容器:自身不需要菜单访问权限与按钮权限点。
// 若该菜单此前(或改为目录前)生成过权限记录,保存时一并清理,避免孤儿数据:
// - 访问权限(s_power.b_power_type='menu',按原权限编码关联)
// - 访问权限(s_power.b_power_type='menu',按菜单编码派生的权限字符关联)
// - 操作/按钮权限(s_power.b_power_type='action',按 b_module_id=菜单 id 关联)
if (menuIsDirectory) {
const prevPowerCode = originals.value?.menu?.b_power_code
const previousCode = String(originals.value?.menu?.b_code || '').trim()
const prevPowerCode = previousCode ? `menu.${previousCode}` : ''
if (prevPowerCode) {
reqs.push({
table: 's_power',
@@ -652,9 +679,17 @@ async function saveMenu() {
})
}
// 访问权限点(s_power.b_power_type='menu',自动同步)
const powerCode = (menuData.b_power_code || '').trim()
// 访问权限点(s_power.b_power_type='menu',由菜单编码自动同步)
if (powerCode) {
const previousCode = String(originals.value?.menu?.b_code || '').trim()
const previousPowerCode = previousCode ? `menu.${previousCode}` : ''
if (previousPowerCode && previousPowerCode !== powerCode) {
reqs.push({
table: 's_power',
key_field: 'b_id',
deletes: [{ b_power_type: 'menu', b_resource: previousPowerCode }],
})
}
// 查是否有已存在的访问权限(按 b_resource = 菜单权限编码)
const accessPowerRes = menuData.b_id
? await loadDataApi('s_power', `b_power_type = 'menu' AND b_resource = '${powerCode}'`)
@@ -814,16 +849,12 @@ function onI18nModalChange(values) {
function onMenuTypeChange(v) {
if (!drafts.value?.menu) return
if (menuTypeLocked.value) return
drafts.value.menu.b_menu_type = v
// Clear type-specific fields when switching
// 目录和外链不绑定业务模块;页面同时使用路由和模块。
if (v === 'directory') {
drafts.value.menu.b_route = null
drafts.value.menu.b_module_id = null
drafts.value.menu.b_power_code = null
} else if (v === 'page') {
drafts.value.menu.b_module_id = null
} else if (v === 'data') {
drafts.value.menu.b_route = null
} else if (v === 'external') {
drafts.value.menu.b_module_id = null
}
@@ -863,9 +894,7 @@ const powers = ref([])
const powersBaseline = ref('')
const powersLoading = ref(false)
/** 权限点表格列配置
* 列宽铺满约定(库文档「列宽铺满容器hack方式」):固定列设 width,
* 弹性列(权限名称)设 minWidth 占满剩余宽度,并在 colResizable 中禁用其拖拽。 */
/** 权限点表格列配置 */
const powerColumns = shallowRef([
{
dataIndex: 'b_code',
@@ -876,7 +905,7 @@ const powerColumns = shallowRef([
{
dataIndex: 'b_name',
title: '权限名称',
minWidth: 150,
width: 180,
editor: { type: 'input' },
},
{
@@ -911,24 +940,6 @@ const rowSelection = computed(() => ({
},
}))
/** 访问权限展示(派生自菜单,跟随 b_power_code / b_name / b_canuse,不可编辑) */
const accessPowerDesc = computed(() => {
const menu = drafts.value?.menu
if (!menu) return ''
return menu.b_power_code || `menu.${menu.b_code || ''}`
})
/** 根据菜单编码自动生成访问权限编码 */
function autoPowerCode() {
if (!drafts.value?.menu) return
const code = (drafts.value.menu.b_code || '').trim()
if (!code) {
Message.warning('请先填写菜单编码')
return
}
setField('b_power_code', `menu.${code}`)
}
/** 新增操作/按钮权限点 */
async function addPowerRow() {
if (powersLoading.value) return
@@ -972,9 +983,9 @@ const selectedLibCodes = ref([])
const libTableRef = ref(null)
const powerLibraryColumns = shallowRef([
// 列宽铺满约定同 powerColumns:编码固定宽,名称弹性占满
{ dataIndex: 'b_code', title: '权限编码', width: 100 },
{ dataIndex: 'b_name', title: '权限名称', minWidth: 80 },
// 权限库按容器自然分配列宽,不提供固定宽度,也不启用列宽拖拽
{ dataIndex: 'b_code', title: '权限编码' },
{ dataIndex: 'b_name', title: '权限名称' },
])
const libRowSelection = computed(() => ({
@@ -1037,7 +1048,7 @@ function importSelectedPowers() {
Message.success(`已导入 ${added.length} 条权限`)
}
/* ================= 加载模块列表(供 data 类型下拉选择) ================= */
/* ================= 加载模块列表(供页面类型下拉选择) ================= */
const moduleOptions = ref([])
let moduleOptionsLoaded = false
@@ -1198,112 +1209,96 @@ loadPowerLibrary()
<div class="mm-pane__title">菜单配置</div>
</div>
<Form
ref="formRef"
:model="drafts.menu"
:rules="{
b_code: [{ required: true, message: '请填写菜单编码' }],
b_name: [{ required: true, message: '请填写菜单名称' }],
b_menu_type: [{ required: true, message: '请选择菜单类型' }],
b_route:
isPage || isExternal
? [{ required: true, message: '请填写路由地址' }]
ref="formRef"
:model="drafts.menu"
:rules="{
b_code: [{ required: true, message: '请填写菜单编码' }],
b_name: [{ required: true, message: '请填写菜单名称' }],
b_menu_type: [{ required: true, message: '请选择菜单类型' }],
b_route:
isPage || isExternal
? [{ required: true, message: '请填写路由地址' }]
: [],
b_module_id: isPage
? [{ required: true, message: '请选择绑定模块' }]
: [],
b_module_id: isData ? [{ required: true, message: '请选择绑定模块' }] : [],
}"
layout="vertical"
:gap="20"
>
<FormItem label="菜单名称" name="b_name" required>
<div class="mm-name-field">
}"
layout="vertical"
:gap="20"
>
<FormItem label="菜单名称" name="b_name" required>
<div class="mm-name-field">
<Input
class="mm-name-field__input"
:model-value="drafts.menu.b_name"
@update:model-value="(v) => setField('b_name', v)"
/>
<Button
type="ghost"
class="mm-name-field__i18n"
title="多语言名称"
@click="i18nModalVisible = true"
>
<Languages :size="16" />
</Button>
</div>
</FormItem>
<FormItem label="菜单编码" name="b_code" required>
<Input
class="mm-name-field__input"
:model-value="drafts.menu.b_name"
@update:model-value="(v) => setField('b_name', v)"
:model-value="drafts.menu.b_code"
@update:model-value="(v) => setField('b_code', v)"
/>
<Button
type="ghost"
class="mm-name-field__i18n"
title="多语言名称"
@click="i18nModalVisible = true"
>
<Languages :size="16" />
</Button>
</div>
</FormItem>
<FormItem label="菜单编码" name="b_code" required>
<Input
:model-value="drafts.menu.b_code"
@update:model-value="(v) => setField('b_code', v)"
/>
</FormItem>
<FormItem label="菜单类型" name="b_menu_type" required>
<Select
:model-value="drafts.menu.b_menu_type"
:options="menuTypeOptions"
@update:model-value="onMenuTypeChange"
/>
</FormItem>
<FormItem label="上级菜单" name="b_parent_id">
<Input :model-value="parentMenuName || '根节点(顶级菜单)'" readonly />
</FormItem>
<FormItem label="图标" name="b_icon">
<IconPicker
:model-value="drafts.menu.b_icon"
@update:model-value="(v) => setField('b_icon', v)"
/>
</FormItem>
<FormItem label="排序号" name="b_xh">
<Input
:model-value="drafts.menu.b_xh"
type="number"
@update:model-value="(v) => setField('b_xh', Number(v) || 0)"
/>
</FormItem>
<!-- page: 路由地址 -->
<FormItem v-if="isPage" label="路由地址" name="b_route" required>
<Input
:model-value="drafts.menu.b_route"
@update:model-value="(v) => setField('b_route', v)"
/>
</FormItem>
<!-- data: 绑定模块 -->
<FormItem v-if="isData" label="绑定模块" name="b_module_id" required>
<Select
:model-value="drafts.menu.b_module_id"
:options="moduleOptions"
@update:model-value="(v) => setField('b_module_id', v)"
/>
</FormItem>
<!-- external: 外链地址 -->
<FormItem v-if="isExternal" label="外链地址" name="b_route" required>
<Input
:model-value="drafts.menu.b_route"
@update:model-value="(v) => setField('b_route', v)"
/>
</FormItem>
<FormItem label="启用" name="b_canuse">
<Switch
:model-value="Number(drafts.menu.b_canuse) === 1"
@update:model-value="(v) => setField('b_canuse', v ? 1 : 0)"
/>
</FormItem>
<FormItem v-if="!isDirectory" label="访问权限编码" name="b_power_code">
<div class="mm-power-code-field">
</FormItem>
<FormItem label="菜单类型" name="b_menu_type" required>
<Select
:model-value="drafts.menu.b_menu_type"
:options="menuTypeOptions"
:disabled="menuTypeLocked || busy"
@update:model-value="onMenuTypeChange"
/>
</FormItem>
<FormItem label="上级菜单" name="b_parent_id">
<Input :model-value="parentMenuName || '根节点(顶级菜单)'" readonly />
</FormItem>
<!-- page/external: 路由地址 -->
<FormItem v-if="isPage" label="路由地址" name="b_route" required>
<Input
class="mm-power-code-field__input"
:model-value="drafts.menu.b_power_code"
@update:model-value="(v) => setField('b_power_code', v)"
:model-value="drafts.menu.b_route"
@update:model-value="(v) => setField('b_route', v)"
/>
<Button type="outline" size="small" @click="autoPowerCode">
<RefreshCw :size="12" /> 自动生成
</Button>
</div>
</FormItem>
</Form>
</FormItem>
<FormItem v-if="isExternal" label="外链地址" name="b_route" required>
<Input
:model-value="drafts.menu.b_route"
@update:model-value="(v) => setField('b_route', v)"
/>
</FormItem>
<!-- page: 绑定模块 -->
<FormItem v-if="isPage" label="绑定模块" name="b_module_id" required>
<Select
:model-value="drafts.menu.b_module_id"
:options="moduleOptions"
@update:model-value="(v) => setField('b_module_id', v)"
/>
</FormItem>
<FormItem label="图标" name="b_icon">
<IconPicker
:model-value="drafts.menu.b_icon"
@update:model-value="(v) => setField('b_icon', v)"
/>
</FormItem>
<FormItem label="排序号" name="b_xh">
<Input
:model-value="drafts.menu.b_xh"
type="number"
@update:model-value="(v) => setField('b_xh', Number(v) || 0)"
/>
</FormItem>
</Form>
</div>
</SplitterPanel>
<!-- 权限面板:仅 page/data/external(目录无权限) -->
<!-- 权限面板:仅页面/外链(目录无权限) -->
<SplitterPanel v-if="!isDirectory">
<div class="mm-power-card">
<div class="mm-power-card__head">
@@ -1338,7 +1333,7 @@ loadPowerLibrary()
:editable="true"
row-key="b_id"
:row-selection="rowSelection"
:col-resizable="{ disabled: (col) => col.dataIndex === 'b_name' }"
:col-resizable="true"
@change="() => {}"
/>
</div>
@@ -1380,7 +1375,7 @@ loadPowerLibrary()
:seq="false"
row-key="b_code"
:row-selection="libRowSelection"
:col-resizable="{ disabled: (col) => col.dataIndex === 'b_name' }"
:auto-fit-width="true"
@change="() => {}"
/>
</div>
@@ -1624,18 +1619,6 @@ loadPowerLibrary()
padding: 0 8px;
}
/* 访问权限编码行 */
.mm-power-code-field {
display: flex;
align-items: center;
gap: 8px;
}
.mm-power-code-field__input {
flex: 1;
min-width: 0;
}
/* ---- 右侧主区 ---- */
.mm-main {
display: flex;
@@ -185,4 +185,76 @@ describe('fms-table 虚拟滚动验证', () => {
expect(stkTable.props('headerDrag')).toBeTruthy()
wrapper.unmount()
})
it('autoFitWidth 默认关闭表格自动铺满宽度', async () => {
const wrapper = mount(FmsTable, {
props: {
columns: makeColumns(),
dataSource: makeRows(20),
rowKey: 'id',
seq: false,
'onUpdate:columns': () => {},
},
})
await nextTick()
expect(wrapper.find('.fms-table').classes()).not.toContain('fms-table--auto-fit-width')
expect(wrapper.findComponent(StkTable).props('colResizable')).toBe(true)
wrapper.unmount()
})
it('autoFitWidth=true 时关闭列宽拖拽并使用自适应布局', async () => {
const wrapper = mount(FmsTable, {
props: {
columns: makeColumns(),
dataSource: makeRows(20),
rowKey: 'id',
autoFitWidth: true,
'onUpdate:columns': () => {},
},
})
await nextTick()
expect(wrapper.find('.fms-table').classes()).toContain('fms-table--auto-fit-width')
expect(wrapper.findComponent(StkTable).props('colResizable')).toBe(false)
wrapper.unmount()
})
it('序号列和复选框列允许调整宽度且回写后保持', async () => {
const wrapper = mount(FmsTable, {
props: {
columns: [{ title: '名称', dataIndex: 'name', width: 120 }],
dataSource: [{ id: 1, name: '测试' }],
rowKey: 'id',
rowSelection: { selectedRowKeys: [], onChange: () => {} },
'onUpdate:columns': () => {},
},
})
await nextTick()
const table = wrapper.findComponent(StkTable)
const columns = table.props('columns')
const seq = columns.find((column) => column.type === 'seq')
const selection = columns.find((column) => column.__fmsSelection)
expect(seq?.headerAlign).toBe('center')
expect(seq?.resizable).not.toBe(false)
expect(selection?.resizable).not.toBe(false)
await table.vm.$emit(
'update:columns',
columns.map((column) =>
column.type === 'seq'
? { ...column, width: '72px' }
: column.__fmsSelection
? { ...column, width: '56px' }
: column,
),
)
await nextTick()
const updated = wrapper.findComponent(StkTable).props('columns')
expect(updated.find((column) => column.type === 'seq')?.width).toBe(72)
expect(updated.find((column) => column.__fmsSelection)?.width).toBe(56)
wrapper.unmount()
})
})
-82
View File
@@ -1,82 +0,0 @@
# 模块管理重构进度交接报告(截至当前工作区状态)
## 一、整体进度
| 阶段 | 状态 | 提交 |
| -------------------------------------- | ---------------------- | ---------- |
| 阶段一:useGroupTree 抽取 | ✅ 完成 | `158eb3c0` |
| 阶段二:字段查询与共享纯函数 | ✅ 完成 | `27752351` |
| 阶段三:index.vue 拆分 composables | ✅ 完成 | `91237c4a` |
| R1 清场(删遗留文件、useConfirm 合回) | ✅ 完成 | `2dba8578` |
| R2a 小文件 UI 归一 | ✅ 完成 | `ce3a9d87` |
| R2b 表格面板 UI 归一 | ✅ 完成 | `8c506ae5` |
| R2c 表单面板 UI 归一 | ✅ 完成 | `76b12cc5` |
| **R3 阶段四:目录化拆分** | 🔄 **进行中(未提交)** | — |
R3 是当前唯一未提交的工作,工作区全部改动都属于它。
## 二、R3 当前进度(核心)
### ✅ 已完成:table-edit/ 完整拆分(测试全绿)
旧 `ModuleTableEditPanel.vue`(2614 行)**已删除**,拆为 6 个文件(均在 `components/table-edit/`):
| 新文件 | 行数 | 内容 |
| ------------------------------ | ---- | ------------------------------------------------------------ |
| `TableEditPanel.vue` | 860 | 壳:状态、分组树推导、commitColDrag/commitBandDrag、结构树编排、快捷键 |
| `TablePreview.vue` | 482 | 多级表头预览表 + mock 行 + 拖拽落点指示 |
| `TableInspector.vue` | 416 | 列/分组带/空态三态属性面板 |
| `useTablePreviewDrag.js` | 113 | 预览表拖拽(onPreviewDragStart/Over/Drop 等) |
| `useColumnResize.js` | 66 | 列宽拖拽(pointer capture 三件套) |
| `edit-panels/FieldLibrary.vue` | 212 | 共享字段库(native/sortable 双模式,两编辑器共用) |
| `edit-panels/EditDataView.vue` | 75 | 共享数据视图(工具栏 + FmsTable) |
- `index.vue` 已改为 `import ModuleTableEditPanel from './components/table-edit/TableEditPanel.vue'`
- 测试 `tests/unit/module-table-edit-panel-dnd.spec.js` 的 import 已改为新路径(断言未动)
- **验证结果:30/30 测试全绿**(dnd 14 例 + tree-external-drag 3 例 + utils 13 例);lint 总数 57 < 基线 58(**零新增告警**,还消掉 1 个)
### 🔄 进行中:form-edit/ 部分拆分(半成品,未验证编译、未接 index.vue)
旧 `ModuleFormEditPanel.vue`(3168 行)**仍在原位、未被引用改动**,index.vue 仍指向它。已新建 3 个文件:
| 新文件 | 行数 | 状态 |
| ----------------------------- | ---- | ------------------------------------------------------------ |
| `form-edit/useFormGrid.js` | 95 | ✅ 完成:collectLineIndexes / rebalanceInsert / distributeLine / rebalanceRowByIds / sectionEndIndex / spanOf(纯逻辑) |
| `form-edit/FormInspector.vue` | 350 | ✅ 完成:属性面板(占位符/栅格宽度/textarea 高度/4 开关),全部变更经 emits 转发 |
| `form-edit/FormEditPanel.vue` | 2902 | 🔄 **半成品**:从 ModuleFormEditPanel.vue 复制改造的壳组件 |
`form-edit/FormEditPanel.vue` 已完成的改造(lint 零告警):
- ✅ imports:相对路径改为 `../../`,移除 PanelLeft/Right\* 图标、Switch、FmsTable;新增 `./useFormGrid`、`./FormInspector`、`../edit-panels/FieldLibrary`、`../edit-panels/EditDataView`
- ✅ 删除本地重复定义:collectLineIndexes / rebalanceInsert / sectionEndIndex / distributeLine / rebalanceRowByIds / spanOf / TEXTAREA_HEIGHT_OPTIONS / isRowSelectedByField / libSearch / libraryFields / isFieldAdded(改为从 useFormGrid 导入或由 FieldLibrary 内部处理)
- ✅ 模板替换:字段库 → `<FieldLibrary drag-mode="sortable" :sortable="{group: libGroup, clone: libClone, filter: LIB_ADDED_FILTER, onStart: onLibDragStart, onEnd: onLibDragEnd}">`;属性面板 → `<FormInspector>`;数据视图 → `<EditDataView>`
- ✅ **注意**:toggleInspector / onColspanChange / onColspanInput / onHeightChange / onPlaceholderChange 已**重新加回壳组件**(FormInspector 只转发事件,写 localEdit 的处理器必须留在壳里)——接手的 AI 不要再删
## 三、剩余工作(明确 todo)
1. **验证 form-edit/FormEditPanel.vue 能编译**(无测试覆盖表单面板,最稳妥是 `pnpm build` 或 vite 编译该组件;也可以先接 index.vue 后手工冒烟)
2. **接 index.vue**:`ModuleFormEditPanel` 的 import 改为 `./components/form-edit/FormEditPanel.vue`
3. **删除旧文件** `components/ModuleFormEditPanel.vue`
4. **清理壳组件死样式**(无害但冗余,仍在 FormEditPanel.vue 的 `<style>` 里):`.mm-fdesign__lib*`、`.mm-fdesign__inspector*`、`.mm-fdesign__subhead*`、`.mm-fdesign__prop*`、`.mm-fdesign__span-control`、`.mm-fdesign__span-input`、`.mm-fdesign__toggles`、`.mm-fdesign__toggle-line`、`.mm-fdesign__delete`、`.mm-edit__data`、`.mm-panel__*`(这些已迁到 FieldLibrary/FormInspector/EditDataView,删的时候**只删这些,别碰 `.mm-fdesign__canvas*`、`.mm-fdesign__section*`、`.mm-fdesign__field*`、`.mm-fdesign__drag-*`、`.mm-fdesign__sortable-*`**)
5. **pnpm test + pnpm lint 回归**(验收口径:dnd spec 14 例全绿、lint 警告不新增)
6. **提交 R3**(建议 message 注明:table-edit 重组完成 + form-edit 按停损条款部分交付——FormCanvas/useFormDrag 未拆,留待下轮)
## 四、注意事项(易踩坑)
- **计划停损条款**:`docs/module-management-code-refactor-plan.md` 阶段四明确允许「完成 table-edit/ 重组 + form-edit 仅抽 useFormGrid.js」即达标。form-edit 的 FormCanvas(画布模板)与 useFormDrag(拖拽状态机 createDragBadge/updateDragIntent/rowRectAt)**交织最深且无测试保护**,建议保持现状、在交付说明记录,不要贸然继续拆。
- **FieldLibrary 双模式**:表格面板用 `drag-mode="native"`(原生 HTML5 DnD),表单面板用 `drag-mode="sortable"`(vuedraggable + sortable 配置注入);两面板原字段库样式一致,已合并进 FieldLibrary。
- **测试基线**:`module-table-edit-panel-dnd.spec.js` 只 stub 了 `draggable`,依赖壳组件真实渲染 `ModuleStructureTree`(`.mst-node` 类),壳组件 import 路径别改错(`../structure-tree/ModuleStructureTree.vue`)。
- **无关改动别动**:工作区还有 fms-api/*(Java 后端多文件)、`DefaultLayout.vue`、`dashboard/index.vue` 的未提交改动,与本重构无关,提交时只 stage module-management 相关文件。
- **R2c 遗留约定**:ui 组件覆写样式用 `.button` / `.input-wrapper` 后缀(保证 specificity 覆盖 ui 默认尺寸),新文件里已沿用。
## 五、当前未提交改动清单
```
D components/ModuleTableEditPanel.vue (已删,table-edit 顶替)
M index.vue (表格 import 已改新路径)
M tests/unit/module-table-edit-panel-dnd.spec.js(仅 import 路径,断言未动)
?? components/table-edit/ (6 个新文件,完成)
?? components/form-edit/ (useFormGrid/FormInspector 完成,FormEditPanel 半成品)
?? components/edit-panels/ (FieldLibrary/EditDataView,完成)
```
(另有 fms-api/、DefaultLayout.vue、dashboard/index.vue 的无关改动,勿碰。)