Files
workspace/code/fms/.trae/documents/分组编码手填与新建分组弹窗.md
T
2026-09-20 22:05:20 +08:00

229 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.
# 分组编码手填(新建分组弹窗)实现方案
## Context
**问题**:模块管理页(/module/module-management)界面配置设计器里的分组节点 id(形如 `group1`)由前端自动取号,用户既看不见也无法指定。这个 id 同时是分组在配置内的稳定身份、运行时归组的键、以及多语言资源键 `group.{模块编码}.{schema类型}.{id}` 的派生源。将来页面代码要按分组引用时(例如把同一主表的多个分组拆到不同 tab,参见 g3hd 旧系统 `detail.vue` 里 `groupName="提单"` 的写法),技术人员不知道哪个分组对应哪个 id。
**目标**:新建分组时**必须手填编码**,创建后**不可修改**;弹窗同时可填分组名称。
**已确认的四条决策**
1. 新建分组必填编码,不再自动取号;创建后无编辑入口。
2. 弹窗字段 = 分组编码 + 分组名称;名称填了直接完成,留空则关闭后仍进入现有的「创建后立即内联命名」。
3. 编码格式与模块编码同构:`/^[a-z][a-z0-9_]*$/`,长度 2-30。
4. 校验必须含**同配置内唯一性**(已有分组 id + 从多语言表反解的保留编号)。
**为什么唯一性校验是硬要求**:`normalizeGroupIds`([schemaJson.js](file:///d:/workspace/code/fms/fms-vue/src/utils/schemaJson.js#L126-L138))对重复 id 的处理是**静默改号**(首次出现保留、重复的重新发号,已有单测固化 `group1, group1, basic` → `group1, group2, basic`)。漏过校验时用户填的编码会被悄悄替换,其多语言键随之漂移;设计器内部还全程用 `b_id` 当 Map 键([useStructureEditing.js](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/components/design-editor/common/useStructureEditing.js#L37-L58)),重复会导致分组互相覆盖、字段渲染两遍。
**明确不做**:不给分组加 tab/area 建模(含子表的 tab 是页面编排单元,不是分组分区);不新增任何「编辑编码」入口。
---
## 改动清单
### 1. 校验纯函数 — [schemaJson.js](file:///d:/workspace/code/fms/fms-vue/src/utils/schemaJson.js)
在文件末尾新增「分组编码校验」区块:
```js
/** 编码长度上限:键 = group.(模块编码≤50).(view|edit).(编码) ≤ 93 < s_i18n.b_key varchar(150) */
export const GROUP_CODE_MAX_LENGTH = 30
const GROUP_CODE_RE = /^[a-z][a-z0-9_]*$/
/** 结构树用 'g:ungrouped' 做虚拟「未分组」节点 key,真实分组不能叫这个名字 */
const RESERVED_GROUP_CODES = new Set(['ungrouped'])
/**
* 分组编码校验,契约与 ui Form 的 validator 一致:true 通过 / 非空字符串为错误文案。
* @param {unknown} rawCode
* @param {{ existingIds?: Iterable<string>, reservedIds?: Iterable<string>, maxLength?: number }} [opts]
* @returns {true|string}
*/
export function validateGroupCode(rawCode, { existingIds = [], reservedIds = [], maxLength = GROUP_CODE_MAX_LENGTH } = {})
```
校验顺序与文案:
| # | 条件 | 返回 |
|---|---|---|
| 1 | trim 后为空 | `分组编码不能为空` |
| 2 | 长度 < 2 或 > maxLength | `分组编码长度需为 2-${maxLength} 个字符` |
| 3 | 不匹配 `GROUP_CODE_RE` | `分组编码须以小写字母开头,且只能包含小写字母、数字和下划线` |
| 4 | 命中 `RESERVED_GROUP_CODES` | `分组编码 ${code} 为系统保留字,请换一个` |
| 5 | 命中 existingIds | `分组编码 ${code} 已被本配置的其他分组使用,请换一个` |
| 6 | 命中 reservedIds | `分组编码 ${code} 曾被使用(多语言资源中仍留有记录),请换一个` |
实现要点:
- 复用文件内已有的私有 `textOf`;入参用 `new Set([...ids].map(textOf).filter(Boolean))` 归一,**同时兼容 Array 与 Set**。
- 大小写敏感比较,与 `normalizeGroupIds` 的 `claimed` 集合语义一致。
- **不传 moduleCode / schemaType**:长度上限是静态常量(推算见上方注释);view/edit 编号本就独立,传 schemaType 会引入"跨 schema 比较"的错觉,调用方只传当前 schema 的那一套。
第 4 条(保留字 `ungrouped`)略超出已确认范围,但必须加:真实分组若叫 `ungrouped`,其树 key `g:ungrouped` 会与虚拟「未分组」节点正面冲突([TableDesignEditor.vue](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/components/design-editor/TableDesignEditor.vue#L237-L240) 的删除判断会直接忽略它)。
`nextGroupId`(96-105 行)**保留导出**:移除两处调用后它只剩单测引用,但它是老数据 `group{n}` 编号规则的唯一可执行说明,且删除需连带删除 `schema-json.spec.js` 里的 4 条断言,不值得。
### 2. 新增共用弹窗 — `components/design-editor/common/GroupCreateModal.vue`
与 `FieldLibrary.vue` / `EditDataView.vue` 同目录(被两个设计器共用,满足「全局/共用样式至少两处使用」的项目约定)。
```
props: open(Boolean) / parentLabel(String) / existingIds(Array) / reservedIds(Array) / loading(Boolean)
emits: update:open / confirm({ id, title }) // 均已 trim
```
- 标题:`parentLabel` 非空 → 「新增子分组」并显示父分组名;否则「新增分组」。
- 校验用 `Form` + `FormItem` 的 `rules` + `validator`,validator 直接返回 `validateGroupCode(...)` 的结果 —— [validate.js](file:///d:/workspace/code/fms/fms-vue/src/components/ui/form/validate.js#L92-L106) 明确「返回非空字符串即错误消息」,与 [ModuleBasicPanel.vue](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/ModuleBasicPanel.vue#L36-L56) 的写法同构,无需手写 `validateStatus/help`。`rules` 用 `computed` 包一层,两集合变化时自动重算,输入即报「编码已被使用」。
- `onOk()`:`await formRef.value.validateAll()`([form.vue](file:///d:/workspace/code/fms/fms-vue/src/components/ui/form/form.vue) expose)→ 有错误则 `return`(不关弹窗)→ `emit('confirm', ...)` + `emit('update:open', false)`。
- Modal 必须显式 `:fullscreen="false" :resizable="false"`(两者默认 true),写法参照 [ModuleRuleEditModal.vue](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/ModuleRuleEditModal.vue);点确定只 `emit('ok')` 不会自动关闭,由 `onOk` 自己 emit。
- `watch(() => props.open)` 打开时重置两个字段。
- 两个输入:`分组编码`(`placeholder="如 basic_info,创建后不可修改"`,`@keydown.enter.prevent="onOk"`)、`分组名称`(`maxlength="100"`,与内联重命名一致,`placeholder="可留空,创建后立即命名"`)。
### 3. 表单设计器 — [FormDesignEditor.vue](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/components/design-editor/FormDesignEditor.vue)
1. 删除 `nextGroupCode()`(569-575)与第 54 行的 `import { nextGroupId }`(否则 `pnpm lint` 因未使用导入失败)。**保留 `nextGroupXh()`**(b_xh 仍需用于画布分区顺序)。
2. `reservedGroupIds` prop 保留,用途从「取号时避开」变为「传给弹窗做唯一性校验」。
3. 新增状态:`groupCreateOpen = ref(false)`、`groupIdList = computed(() => groups.value.map(g => String(g.b_id)))`、`openGroupCreate()`(`props.loading` 时直接 return)。
4. `addGroup()` 拆为「打开弹窗」+「确认创建」:
```js
function onGroupCreateConfirm({ id, title }) {
if (props.loading || !String(id || '').trim()) return
flushEditingGroupTitle() // 与原 addGroup 一致
const newGroup = {
b_id: String(id), b_module_id: props.moduleId, b_parent_id: null,
b_title: String(title ?? ''), b_xh: nextGroupXh(), b_canuse: 1,
b_layout_direction: 'vertical', // 必须保留新分组默认值
b_colspan: GROUP_GRID, // 必须保留
}
groups.value = [...(groups.value || []), newGroup]
if (newGroup.b_title) return // 填了名称 → 直接完成
editingGroupKey.value = `g-${newGroup.b_id}` // 没填名称 → 原双重 nextTick 内联命名
groupTitleDraft.value = ''
nextTick(() => nextTick(() => sectionEditInput.value?.focus({ cursor: 'all' })))
}
```
5. 模板:第 1422 行按钮改 `@click="openGroupCreate"`;在现有「分组设置」Modal(1805-1865 行)旁挂 `<GroupCreateModal v-model:open="groupCreateOpen" :existing-ids="groupIdList" :reserved-ids="reservedGroupIds" :loading="loading" @confirm="onGroupCreateConfirm" />`。
表单画布只建顶级分组(`b_parent_id: null`,现状不变),故不传 `parentLabel`;数据里已存在的嵌套分组其 id 也在 `groupIdList` 内,唯一性天然覆盖。
### 4. 结构编辑内核 — [useStructureEditing.js](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/components/design-editor/common/useStructureEditing.js)
唯一消费者是 TableDesignEditor(已确认),故可安全改签名。
1. 删除 `nextGroupCode()`(537-542)与第 23 行 `import { nextGroupId }`;`nextGroupXh()` 保留。
2. 删除选项参数 `reservedGroupIds`(417 行 + 402-403 行 JSDoc):内核里已无消费者,保留编号改由宿主直接喂给弹窗。
3. 收敛为单一创建函数,去掉 band 对象依赖:
```js
function addRootGroup({ id, title } = {}) { return createBand({ id, title, parentGid: null }) }
function addSubGroup(parentGid, { id, title } = {}) {
const gid = parentGid == null ? null : String(parentGid)
if (!gid || !bandMeta.value.metaMap.has(gid)) return // 父分组已被删除:静默放弃
return createBand({ id, title, parentGid: gid })
}
function createBand({ id, title, parentGid }) {
if (loading()) return
const groupId = String(id ?? '').trim()
if (!groupId) return // 域函数边界:不落空编码
const newGroup = {
b_id: groupId, b_module_id: moduleId(), b_parent_id: parentGid,
b_title: String(title ?? '').trim(),
b_xh: nextGroupXh(), b_canuse: 1, b_colspan: 48, // 列表侧无 b_layout_direction,保持现状
}
emitGroupsRenumbered([...(getGroups() || []), newGroup])
if (newGroup.b_title) selectBandAfterCreate(groupId) // 跳过内联命名,仅选中
else focusNewBandTitle(groupId) // 原行为
}
```
4. `focusNewBandTitle`(574-580) 不变,调用点改为「仅 title 为空时」;新增 `selectBandAfterCreate(gid)`:`nextTick` 内 `if (bandMeta.value.metaMap.has(gid)) selectedNodeKey.value = 'g:' + gid`(不调 `requestRename`)。
5. 删除 `onNodeAddSub()`(778-782)及 return 清单里的同名项(1002 行)—— 它原先只做 `addSubGroup(band)`,签名变更后不可用,改由宿主解析 key。
6. **不保留自动取号兜底**:兜底会让「人工编码」与「自动号」同时进入同一份 JSON,静默改号的风险重新出现。空编码只做「不创建」短路(有 bug 时表现为「点了没反应」,比编码漂移安全且好排查)。
### 5. 列表设计器 — [TableDesignEditor.vue](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/components/design-editor/TableDesignEditor.vue)
1. 弹窗状态(沿用同族 `sectionSettingsKey` 的「null 即关闭」惯例):
```js
const groupCreate = ref(null) // { parentGid, parentLabel } | null
const groupCreateOpen = computed({ get: () => groupCreate.value !== null, set: (v) => { if (!v) groupCreate.value = null } })
const groupIdList = computed(() => (groups.value || []).map((g) => String(g.b_id)))
function openGroupCreate(parentGid = null) {
if (props.loading) return
const gid = parentGid == null ? null : String(parentGid)
const band = gid ? bandMeta.value.metaMap.get(gid) : null
if (gid && !band) return
groupCreate.value = { parentGid: gid, parentLabel: band ? band.group.b_title || `分组-${gid}` : '' }
}
function openSubGroupCreate(gidOrBand) { // 兼容入口 2/4 传 band 对象、入口 3 传 gid 字符串
const gid = gidOrBand && typeof gidOrBand === 'object' ? gidOrBand.gid : gidOrBand
if (gid == null) return
openGroupCreate(gid)
}
function onGroupCreateConfirm({ id, title }) {
const parentGid = groupCreate.value?.parentGid ?? null
if (parentGid) addSubGroup(parentGid, { id, title })
else addRootGroup({ id, title })
groupCreate.value = null
}
```
2. **4 个入口全部改为打开弹窗**:
- 工具栏按钮(473-475)→ `@click="openGroupCreate()"`;
- 结构树右键(`onStructureContextMenuClick` 263-266 的 `add-sub`)→ `openSubGroupCreate(band.gid)`(band 存在性判断已内聚进 `openGroupCreate`);
- `@node-add-sub`(549)→ 不再接 hook 的 `onNodeAddSub`,改为本地 `onNodeAddSubRequest({ key })`:`key` 形如 `g:<gid>`,`!key?.startsWith('g:') || key === 'g:ungrouped'` 直接 return,否则 `openSubGroupCreate(key.slice(2))`;同时从第 204 行解构里删掉 `onNodeAddSub`;
- 属性面板 `@add-sub-group`(573)→ `openSubGroupCreate(selectedBand)`(`selectedBand` 为 null 时必须不退化成分组创建,`openSubGroupCreate` 已挡住)。
3. `useStructureEditing({...})` 里删掉 `reservedGroupIds: () => props.reservedGroupIds`(178 行);props 本体保留给弹窗用。
4. 模板挂载 `<GroupCreateModal v-model:open="groupCreateOpen" :parent-label="groupCreate?.parentLabel || ''" :existing-ids="groupIdList" :reserved-ids="props.reservedGroupIds" :loading="loading" @confirm="onGroupCreateConfirm" />`。
---
## 不变量与边界
- **编码创建后不可修改**靠三件事保证:① 弹窗只在新建路径打开(5 个入口全为 create);② `b_id` 只出现在 `createBand` / `onGroupCreateConfirm` 的构造里,写回路径(`onRenameCommit`、`onBandTitleChange`、`commitEditGroupTitle`、`commitBandDrag`、`renumberAllSiblings`)只改 `b_title` / `b_xh` / `b_canuse` / `b_parent_id`;③ 本次不把编码加进 Inspector 或「分组设置」弹窗。
- 「未分组」是虚拟分区(表单侧 `section.key === 'ungrouped'`,树侧 `g:ungrouped`),不参与创建,仅由保留字校验挡住同名冲突。
- view / edit 各自独立编号:[index.vue](file:///d:/workspace/code/fms/fms-vue/src/views/module/module-management/index.vue#L253-L265) 的 `reservedGroupIds` 已按 schema 分桶,`existingIds` 取各自设计器的 `groups`,**不跨 schema 校验**(同一编码可同时存在于列表与表单)。
- 填写名称的新分组**不再进入内联命名**;留空的仍进入,与改造前一致。
- 空分组、嵌套子分组均不受影响(子分组编码在整份 schema 内全局唯一,不分层判定)。
- 正向副作用:编码一落库,`collectSchemaGroupItems` 自动按 `group.{模块编码}.{schema类型}.{编码}` 生成受管多语言行,默认译文跟随 `b_title`,无需额外改动。
## 测试计划
在 [schema-json.spec.js](file:///d:/workspace/code/fms/fms-vue/tests/unit/schema-json.spec.js) 追加 `describe('validateGroupCode')`(沿用该文件的东西、纯数据、中文 it 描述风格):
1. 空值:`''` / `' '` / `null` / `undefined` → 非空文案。
2. 长度边界:`'a'`、`'a'.repeat(31)` → 长度文案;`'ab'`、`'a'.repeat(30)` → `true`。
3. 正则:`'1abc'` / `'Abc'` / `'a-b'` / `'a b'` / `'分组'` → 正则文案。
4. 与已有冲突:`validateGroupCode('basic', { existingIds: ['basic'] })` → 含 `basic`、不含 `多语言`。
5. 与保留编号冲突:`validateGroupCode('group2', { reservedIds: ['group2'] })` → 含 `多语言`(与第 4 条文案可区分)。
6. 合法自定义:`'basic_info'`、`'cw_fee_detail'` → `true`。
7. `group{n}` 形式不冲突即允许:`validateGroupCode('group7', { existingIds: ['group1'], reservedIds: ['group2'] })` → `true`。
8. 集合类型兼容:`existingIds: new Set(['basic'])` 与数组结果一致。
9. 首尾空白归一:`' basic '` 命中冲突;`' ok_code '` → `true`。
10. 保留字:`'ungrouped'` → 保留字文案。
11. 上限可覆盖:`{ maxLength: 10 }` 下 11 字符报错、10 字符通过。
**回归**(会经过被改文件):`schema-json.spec.js` 现有全部用例(尤其「重复 id 后来者重新发号」—— 本次风险的反向锚点);`module-table-edit-panel-dnd.spec.js`、`module-list-config-panel-dnd.spec.js`(`group1/group2` 期望值);`form-edit-panel-mount.spec.js`(新增弹窗在 `open=false` 时不得渲染、不得引入递归更新);`module-i18n-reconcile.spec.js`。
## 验证步骤
```bash
cd d:\workspace\code\fms\fms-vue
pnpm test # 或 pnpm test:file tests/unit/schema-json.spec.js
pnpm lint && pnpm fmt:check
pnpm dev # 起前端
```
手工路径(模块管理 → 选一个 data/virtual 模块):
**表单设计器**:工具栏「添加分组」→ 弹窗标题「新增分组」;点确定停住报「不能为空」;填 `A-b` 报正则;填已存在的编码报冲突;编码 `basic` + 名称留空 → 创建后进入内联命名;编码 `extra` + 名称 `附加信息` → 直接出现带标题分区、不进入命名态;保存后切走再切回,编码未被改号,重命名只改标题。
**列表设计器 4 个入口逐一验证**(都应弹窗、确定后编码即手填值):① 工具栏「添加分组」;② 结构树节点行内「新增子分组」;③ 结构树右键「新增子分组」;④ 属性面板「新增子分组」(未选中分组时点它不应弹出顶级创建弹窗)。
**交叉验证**:列表里用过的编码在表单设计器里仍可再用(view/edit 独立);新建分组后切「多语言」页签,出现 `group.{模块编码}.{view|edit}.{编码}` 行且默认语言值 = 分组名称。
## 风险
**最高**:`useStructureEditing.js` 上「删 `nextGroupCode` + 改 `addRootGroup`/`addSubGroup` 签名 + 删 `onNodeAddSub` 导出」这组联动是全案唯一的破坏性接口变更,且它服务 4 个入口(其中 2 个由 StructureTree / TableInspector 事件间接驱动)。漏改一处的后果是「点添加分组没反应」或模板调用 `undefined` 抛错,而单测只覆盖到 `schemaModel` 层、抓不到 UI 入口。缓解:创建逻辑收敛到唯一的 `createBand`,宿主与模板不再出现任何取号逻辑;4 个入口 + 第 204 行解构 + return 清单在同一提交内改完,并强制走一遍上面的 4 条点击路径。
**次高**:弹窗唯一性校验依赖宿主 `existingIds` 的实时快照(`computed` 保证与 `groups` 同步),若将来出现弹窗打开期间改写 `groups` 的新路径,已通过的编码可能瞬时冲突 —— 目前遮罩层挡住画布交互,不存在该路径。