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

18 KiB
Raw Permalink Blame History

分组编码手填(新建分组弹窗)实现方案

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)对重复 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

  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() 拆为「打开弹窗」+「确认创建」:
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' })))
}
  1. 模板:第 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(已确认),故可安全改签名。

  1. 删除 nextGroupCode()(537-542)与第 23 行 import { nextGroupId };nextGroupXh() 保留。
  2. 删除选项参数 reservedGroupIds(417 行 + 402-403 行 JSDoc):内核里已无消费者,保留编号改由宿主直接喂给弹窗。
  3. 收敛为单一创建函数,去掉 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)                        // 原行为
}
  1. focusNewBandTitle(574-580) 不变,调用点改为「仅 title 为空时」;新增 selectBandAfterCreate(gid):nextTick 内 if (bandMeta.value.metaMap.has(gid)) selectedNodeKey.value = 'g:' + gid(不调 requestRename)。
  2. 删除 onNodeAddSub()(778-782)及 return 清单里的同名项(1002 行)—— 它原先只做 addSubGroup(band),签名变更后不可用,改由宿主解析 key。
  3. 不保留自动取号兜底:兜底会让「人工编码」与「自动号」同时进入同一份 JSON,静默改号的风险重新出现。空编码只做「不创建」短路(有 bug 时表现为「点了没反应」,比编码漂移安全且好排查)。

5. 列表设计器 — TableDesignEditor.vue

  1. 弹窗状态(沿用同族 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
}
  1. 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 已挡住)。
  2. useStructureEditing({...}) 里删掉 reservedGroupIds: () => props.reservedGroupIds(178 行);props 本体保留给弹窗用。
  3. 模板挂载 <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 描述风格):

  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。

验证步骤

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 的新路径,已通过的编码可能瞬时冲突 —— 目前遮罩层挡住画布交互,不存在该路径。