18 KiB
分组编码手填(新建分组弹窗)实现方案
Context
问题:模块管理页(/module/module-management)界面配置设计器里的分组节点 id(形如 group1)由前端自动取号,用户既看不见也无法指定。这个 id 同时是分组在配置内的稳定身份、运行时归组的键、以及多语言资源键 group.{模块编码}.{schema类型}.{id} 的派生源。将来页面代码要按分组引用时(例如把同一主表的多个分组拆到不同 tab,参见 g3hd 旧系统 detail.vue 里 groupName="提单" 的写法),技术人员不知道哪个分组对应哪个 id。
目标:新建分组时必须手填编码,创建后不可修改;弹窗同时可填分组名称。
已确认的四条决策
- 新建分组必填编码,不再自动取号;创建后无编辑入口。
- 弹窗字段 = 分组编码 + 分组名称;名称填了直接完成,留空则关闭后仍进入现有的「创建后立即内联命名」。
- 编码格式与模块编码同构:
/^[a-z][a-z0-9_]*$/,长度 2-30。 - 校验必须含同配置内唯一性(已有分组 id + 从多语言表反解的保留编号)。
为什么唯一性校验是硬要求:normalizeGroupIds(schemaJson.js)对重复 id 的处理是静默改号(首次出现保留、重复的重新发号,已有单测固化 group1, group1, basic → group1, group2, basic)。漏过校验时用户填的编码会被悄悄替换,其多语言键随之漂移;设计器内部还全程用 b_id 当 Map 键(useStructureEditing.js),重复会导致分组互相覆盖、字段渲染两遍。
明确不做:不给分组加 tab/area 建模(含子表的 tab 是页面编排单元,不是分组分区);不新增任何「编辑编码」入口。
改动清单
1. 校验纯函数 — schemaJson.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 的删除判断会直接忽略它)。
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 明确「返回非空字符串即错误消息」,与 ModuleBasicPanel.vue 的写法同构,无需手写validateStatus/help。rules用computed包一层,两集合变化时自动重算,输入即报「编码已被使用」。 onOk():await formRef.value.validateAll()(form.vue expose)→ 有错误则return(不关弹窗)→emit('confirm', ...)+emit('update:open', false)。- Modal 必须显式
:fullscreen="false" :resizable="false"(两者默认 true),写法参照 ModuleRuleEditModal.vue;点确定只emit('ok')不会自动关闭,由onOk自己 emit。 watch(() => props.open)打开时重置两个字段。- 两个输入:
分组编码(placeholder="如 basic_info,创建后不可修改",@keydown.enter.prevent="onOk")、分组名称(maxlength="100",与内联重命名一致,placeholder="可留空,创建后立即命名")。
3. 表单设计器 — FormDesignEditor.vue
- 删除
nextGroupCode()(569-575)与第 54 行的import { nextGroupId }(否则pnpm lint因未使用导入失败)。保留nextGroupXh()(b_xh 仍需用于画布分区顺序)。 reservedGroupIdsprop 保留,用途从「取号时避开」变为「传给弹窗做唯一性校验」。- 新增状态:
groupCreateOpen = ref(false)、groupIdList = computed(() => groups.value.map(g => String(g.b_id)))、openGroupCreate()(props.loading时直接 return)。 addGroup()拆为「打开弹窗」+「确认创建」:
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' })))
}
- 模板:第 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
唯一消费者是 TableDesignEditor(已确认),故可安全改签名。
- 删除
nextGroupCode()(537-542)与第 23 行import { nextGroupId };nextGroupXh()保留。 - 删除选项参数
reservedGroupIds(417 行 + 402-403 行 JSDoc):内核里已无消费者,保留编号改由宿主直接喂给弹窗。 - 收敛为单一创建函数,去掉 band 对象依赖:
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) // 原行为
}
focusNewBandTitle(574-580) 不变,调用点改为「仅 title 为空时」;新增selectBandAfterCreate(gid):nextTick内if (bandMeta.value.metaMap.has(gid)) selectedNodeKey.value = 'g:' + gid(不调requestRename)。- 删除
onNodeAddSub()(778-782)及 return 清单里的同名项(1002 行)—— 它原先只做addSubGroup(band),签名变更后不可用,改由宿主解析 key。 - 不保留自动取号兜底:兜底会让「人工编码」与「自动号」同时进入同一份 JSON,静默改号的风险重新出现。空编码只做「不创建」短路(有 bug 时表现为「点了没反应」,比编码漂移安全且好排查)。
5. 列表设计器 — TableDesignEditor.vue
- 弹窗状态(沿用同族
sectionSettingsKey的「null 即关闭」惯例):
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
}
- 4 个入口全部改为打开弹窗:
- 工具栏按钮(473-475)→
@click="openGroupCreate()"; - 结构树右键(
onStructureContextMenuClick263-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已挡住)。
- 工具栏按钮(473-475)→
useStructureEditing({...})里删掉reservedGroupIds: () => props.reservedGroupIds(178 行);props 本体保留给弹窗用。- 模板挂载
<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 的
reservedGroupIds已按 schema 分桶,existingIds取各自设计器的groups,不跨 schema 校验(同一编码可同时存在于列表与表单)。 - 填写名称的新分组不再进入内联命名;留空的仍进入,与改造前一致。
- 空分组、嵌套子分组均不受影响(子分组编码在整份 schema 内全局唯一,不分层判定)。
- 正向副作用:编码一落库,
collectSchemaGroupItems自动按group.{模块编码}.{schema类型}.{编码}生成受管多语言行,默认译文跟随b_title,无需额外改动。
测试计划
在 schema-json.spec.js 追加 describe('validateGroupCode')(沿用该文件的东西、纯数据、中文 it 描述风格):
- 空值:
''/' '/null/undefined→ 非空文案。 - 长度边界:
'a'、'a'.repeat(31)→ 长度文案;'ab'、'a'.repeat(30)→true。 - 正则:
'1abc'/'Abc'/'a-b'/'a b'/'分组'→ 正则文案。 - 与已有冲突:
validateGroupCode('basic', { existingIds: ['basic'] })→ 含basic、不含多语言。 - 与保留编号冲突:
validateGroupCode('group2', { reservedIds: ['group2'] })→ 含多语言(与第 4 条文案可区分)。 - 合法自定义:
'basic_info'、'cw_fee_detail'→true。 group{n}形式不冲突即允许:validateGroupCode('group7', { existingIds: ['group1'], reservedIds: ['group2'] })→true。- 集合类型兼容:
existingIds: new Set(['basic'])与数组结果一致。 - 首尾空白归一:
' basic '命中冲突;' ok_code '→true。 - 保留字:
'ungrouped'→ 保留字文案。 - 上限可覆盖:
{ 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。
验证步骤
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 的新路径,已通过的编码可能瞬时冲突 —— 目前遮罩层挡住画布交互,不存在该路径。