20260920220520

This commit is contained in:
oneao committed 2026-09-20 22:05:20 +08:00
1 parent 6f03d92425
commit b3364fe6dc
44 files changed
+2313 -2737

No files matched your search

@@ -0,0 +1,229 @@
# 分组编码手填(新建分组弹窗)实现方案
## 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` 的新路径,已通过的编码可能瞬时冲突 —— 目前遮罩层挡住画布交互,不存在该路径。