diff --git a/docs/superpowers/plans/2026-08-26-二七王前端A-地基与逻辑层.md b/docs/superpowers/plans/2026-08-26-二七王前端A-地基与逻辑层.md index 0f65438..5e37b3e 100644 --- a/docs/superpowers/plans/2026-08-26-二七王前端A-地基与逻辑层.md +++ b/docs/superpowers/plans/2026-08-26-二七王前端A-地基与逻辑层.md @@ -1533,17 +1533,23 @@ EOF ```js EQW_Sprites. = { - Layer: <图层常量>, // 该 View 用到的图层;用到多个时写 Layer1 / Layer2 … - Group: <群组常量>, // 该 View 用到的群组;用到多个时写 Group1 / Group2 … + Layer: <图层常量>, // 该 View 用到的图层;用到多个时写 Layer1 / Layer2 … - <精灵键名>: <精灵ID>, // 精灵只写 id;所属 Layer/Group 写在注释里 - ... + : { // 一个群组容器:群组 ID + 它名下的全部精灵 + id: <群组常量>, + <精灵键名>: <精灵ID>, + ... + }, + <另一个GroupName>: { id: ..., ... } }; ``` -- **保留键约定**(`SpriteIndex` 与守卫测试据此机械区分):View 内键名为 `Layer` / `Group`,或以二者为前缀(`Layer1`、`Group2`…)的,是 **View 级声明**;**其余键一律是精灵 id**。 -- 精灵条目**不重复写 Layer/Group**——它们扁平放在 View 定义下,精灵的所属关系在**注释**里说明。 -- 键名与 ID 一律照抄清单 §5 各表,**不改名、不改号**(Task 11 的 `SpriteIndex` 与 Task 12 的布局配置都按键名引用) +- **结构约定**(`SpriteIndex` 与守卫测试据此机械区分): + - View 内:键名 `Layer`(或 `Layer1`/`Layer2`…)是 **View 级声明**;**其余键的值必须是对象**,即一个 group 容器。View 内不允许出现别的标量键。 + - group 容器内:键名 `id` 是**群组 ID**;**其余键一律是精灵 id(数字)**。 +- **精灵的所属群组由结构本身表达**,注释里不必再重复写属于哪个 group;但仍要写类型(图片/文字)+ 用途 + 资源键 + 帧说明。 +- group 容器名用大驼峰、体现它是哪一块(`TopInfo`、`LeftMark`、`Footer`、`HandCards`…),不必与群组常量同名。 +- 键名与 ID 一律照抄清单 §5 各表,**不改名、不改号**(Task 11 的 `SpriteIndex` 与 Task 12 的布局配置都按键名引用)。**精灵键名全局唯一**——索引器会把整棵树展平成一张扁平表,重名照样报错。 - [ ] **Step 1: 写 `Sprites_Table.js`** @@ -1562,30 +1568,39 @@ var EQW_Sprites = EQW_Sprites || {}; //顶部信息条:主 / 叫分 / 抓分 三列 EQW_Sprites.TopInfoView = { - Layer: EQW_Layers.TABLE_STATIC, //101 - Group: EQW_Groups.TOP_INFO, //201 + Layer: EQW_Layers.TABLE_STATIC, //101 - TOP_INFO_BG: 1001, //图片:三列表格底,资源 PANEL_TOP_INFO - TOP_SUIT_ICON: 1002, //图片:「主」列花色,资源 SUIT_ICON_S,帧 = flower(1方块 2梅花 3红心 4黑桃) - TOP_CALL_TEXT: 1003, //文字:叫分数值 - TOP_CALL_BADGE_BG: 1004, //图片:叫分角标底,资源 BADGE_MULTIPLE 帧1 - TOP_CALL_BADGE_TEXT: 1005, //文字:「N子」 - TOP_GRADE_TEXT: 1006, //文字:抓分数值 - TOP_GRADE_BADGE_BG: 1007, //图片:抓分角标底,资源 BADGE_MULTIPLE 帧2 - TOP_GRADE_BADGE_TEXT: 1008 //文字:「N倍」,取 Math.abs(curmultiple)(T-16/S-8) + TopInfo: { + id: EQW_Groups.TOP_INFO, //201 + + TOP_INFO_BG: 1001, //图片:三列表格底,资源 PANEL_TOP_INFO + TOP_SUIT_ICON: 1002, //图片:「主」列花色,资源 SUIT_ICON_S,帧 = flower(1方块 2梅花 3红心 4黑桃) + TOP_CALL_TEXT: 1003, //文字:叫分数值 + TOP_CALL_BADGE_BG: 1004, //图片:叫分角标底,资源 BADGE_MULTIPLE 帧1 + TOP_CALL_BADGE_TEXT: 1005, //文字:「N子」 + TOP_GRADE_TEXT: 1006, //文字:抓分数值 + TOP_GRADE_BADGE_BG: 1007, //图片:抓分角标底,资源 BADGE_MULTIPLE 帧2 + TOP_GRADE_BADGE_TEXT: 1008 //文字:「N倍」,取 Math.abs(curmultiple)(T-16/S-8) + } }; -//三家玩家位附加标记:跨三个群组,故写 Group1/2/3 +//三家玩家位附加标记:跨三个群组,就是三个 group 容器 EQW_Sprites.PlayerMarkView = { - Layer: EQW_Layers.TABLE_STATIC, //101 - Group1: EQW_Groups.P_LEFT_MARK, //202 左上家 - Group2: EQW_Groups.P_RIGHT_MARK, //203 右上家 - Group3: EQW_Groups.P_SELF_MARK, //204 自己 + Layer: EQW_Layers.TABLE_STATIC, //101 - //—— Group1 左上家(群组 202)—— - P_LEFT_BANKER: 1100, //图片:「庄」印章,资源 MARK_BANKER - P_LEFT_ZHU_BG: 1101, //图片:「主N」角标底,资源 BADGE_ZHU_PAIR 帧1 - // ... 其余按清单 §5.1 续 + LeftMark: { + id: EQW_Groups.P_LEFT_MARK, //202 左上家 + P_LEFT_BANKER: 1100, //图片:「庄」印章,资源 MARK_BANKER + P_LEFT_ZHU_BG: 1101 //图片:「主N」角标底,资源 BADGE_ZHU_PAIR 帧1 + // ... 其余按清单 §5.1 续 + }, + RightMark: { + id: EQW_Groups.P_RIGHT_MARK, //203 右上家 + P_RIGHT_BANKER: 1110, + P_RIGHT_ZHU_BG: 1111 + // ... 其余按清单 §5.1 续 + } + // ... SelfMark(群组 204)同理 }; // ... 其余 View 按清单 §5.1 续:亮牌条(群组 206,1030–1031)、 @@ -1641,63 +1656,83 @@ EOF **Interfaces:** - Consumes: `EQW_Sprites`(Task 10) -- Produces: 全局 `EQW_SpriteIndex`,含 `build(spriteTree)` → `{ 键名: 精灵ID }`、`idOf(key)` → `number`(查不到抛错)、`init()`(用 `EQW_Sprites` 建好内部索引) +- Produces: 全局 `EQW_SpriteIndex`,含 + - `build(spriteTree)` → `{ 键名: 精灵ID }` + - `buildGroupMap(spriteTree)` → `{ 键名: 群组ID }` + - `init(spriteTree)`(默认用 `EQW_Sprites` 建好两张内部索引) + - `idOf(key)` → `number`(查不到抛错) + - `groupIdOf(key)` → `number`(查不到抛错) -> 布局配置里 `attach.target` 写的是**键名**而非数字 ID(清单 §6.1:「`target` 一律写键名,ID 回填时只改一处映射表」),而精灵常量是按 View 组织的,故需要这张扁平索引。 +> 布局配置里 `attach.target` 写的是**键名**而非数字 ID(清单 §6.1:「`target` 一律写键名,ID 回填时只改一处映射表」),而精灵常量是按 View → group 组织的,故需要这张扁平索引。 +> `groupIdOf` 是新结构带来的收益:group 容器把精灵与它的群组 ID 绑在一起,于是「某个精灵属于哪个群组」可以机械查出——显隐走群组时(前端红线:显隐由组件的 `showXxx`/`hideXxx` 控制)不必再手工对照。 +> **结构约定**:View 内 `Layer`(含 `Layer1`/`Layer2`…)是 View 级声明、跳过;其余键的值必须是**对象**(group 容器),否则报错。group 容器内 `id` 是群组 ID、跳过;其余键一律是精灵 id,**值必须是数字**,否则报错。 > **重复键必须报错**而非后者覆盖前者——覆盖会让某个界面静默指向另一个界面的精灵。 -> **保留键约定**:View 内键名为 `Layer` / `Group` 或带数字后缀(`Layer1`、`Group2`…)的是 View 级声明,跳过不进索引;其余键一律是精灵 id。 -> **值必须是数字**:View 级声明与精灵条目在结构上同级、只靠键名区分,所以把「值不是数字」当作错误抓出来——否则写错前缀(如 `Grup: 201`)会静默变成一个假精灵。 - [ ] **Step 1: 写失败的测试 `client/tests/test_spriteindex.js`** ```js -// View 组织的精灵常量 → 扁平键名索引 +// View → group → 精灵 的常量树 → 扁平键名索引 const { load, throws } = require('./_load'); const t = require('./_assert')(); load('client/js/01_SubGame/codes/core/SpriteIndex.js'); -// ---- 展平:Layer/Group 声明跳过,精灵进索引 ---- +// ---- 展平:Layer 与 group 的 id 都不进索引,精灵进 ---- const tree = { TopInfoView: { Layer: 101, - Group: 201, - BTN_A: 1001, - BTN_B: 1002 + TopInfo: { id: 201, BTN_A: 1001, BTN_B: 1002 } }, PlayerMarkView: { - Layer: 101, - Group1: 202, - Group2: 203, - P_LEFT_BANKER: 1100, - P_RIGHT_BANKER: 1110 + Layer: 101, + LeftMark: { id: 202, P_LEFT_BANKER: 1100 }, + RightMark: { id: 203, P_RIGHT_BANKER: 1110 } } }; t.eq('展平结果', EQW_SpriteIndex.build(tree), { BTN_A: 1001, BTN_B: 1002, P_LEFT_BANKER: 1100, P_RIGHT_BANKER: 1110 }); -// ---- View 级声明不进索引(含带数字后缀的)---- +// ---- Layer 与 group 的 id 不进索引 ---- const flat = EQW_SpriteIndex.build(tree); t.eq('Layer 不进索引', flat.Layer, undefined); -t.eq('Group 不进索引', flat.Group, undefined); -t.eq('Group1 不进索引', flat.Group1, undefined); -t.eq('Group2 不进索引', flat.Group2, undefined); +t.eq('group 的 id 不进索引', flat.id, undefined); + +// ---- 群组映射:精灵 → 它所属的群组 ID ---- +t.eq('群组映射', EQW_SpriteIndex.buildGroupMap(tree), + { BTN_A: 201, BTN_B: 201, P_LEFT_BANKER: 202, P_RIGHT_BANKER: 203 }); + +// ---- 多个 Layer 的 View(Layer1/Layer2)也正确跳过 ---- +const multiLayer = { + OverlayView: { Layer1: 104, Layer2: 105, Bar: { id: 230, TIP_BG: 1750 } } +}; +t.eq('多 Layer 声明跳过', EQW_SpriteIndex.build(multiLayer), { TIP_BG: 1750 }); // ---- 反面:重复键报错,不静默覆盖 ---- const dupTree = { - ViewA: { Layer: 101, Group: 201, SAME: 1001 }, - ViewB: { Layer: 102, Group: 202, SAME: 1002 } + ViewA: { Layer: 101, G1: { id: 201, SAME: 1001 } }, + ViewB: { Layer: 102, G2: { id: 202, SAME: 1002 } } }; t.eq('重复键报错', throws(() => EQW_SpriteIndex.build(dupTree)), true); -// ---- 反面:精灵值不是数字要报错(防写错保留键前缀变成假精灵)---- -const badTree = { ViewA: { Layer: 101, Group: 201, NESTED: { id: 1001 } } }; -t.eq('值非数字报错', throws(() => EQW_SpriteIndex.build(badTree)), true); +// ---- 反面:精灵值不是数字要报错 ---- +const badValue = { ViewA: { Layer: 101, G1: { id: 201, NESTED: { x: 1 } } } }; +t.eq('精灵值非数字报错', throws(() => EQW_SpriteIndex.build(badValue)), true); -// ---- idOf:查得到 / 查不到抛错 ---- +// ---- 反面:View 下混入标量键(不是 group 容器)要报错 ---- +// 例如把群组 ID 误写成 View 级的 `Group: 201`,而不是包进 group 容器 +const strayScalar = { ViewA: { Layer: 101, Group: 201, G1: { id: 202, BTN: 1001 } } }; +t.eq('View 下混入标量键报错', throws(() => EQW_SpriteIndex.build(strayScalar)), true); + +// ---- 反面:group 容器缺 id 要报错 ---- +const noGroupId = { ViewA: { Layer: 101, G1: { BTN: 1001 } } }; +t.eq('group 缺 id 报错', throws(() => EQW_SpriteIndex.build(noGroupId)), true); + +// ---- idOf / groupIdOf:查得到 / 查不到抛错 ---- EQW_SpriteIndex.init(tree); t.eq('idOf 查到', EQW_SpriteIndex.idOf('BTN_A'), 1001); +t.eq('groupIdOf 查到', EQW_SpriteIndex.groupIdOf('P_RIGHT_BANKER'), 203); t.eq('idOf 查不到抛错', throws(() => EQW_SpriteIndex.idOf('NOT_EXIST')), true); +t.eq('groupIdOf 查不到抛错', throws(() => EQW_SpriteIndex.groupIdOf('NOT_EXIST')), true); // ---- 空树 ---- t.eq('空树', EQW_SpriteIndex.build({}), {}); @@ -1731,47 +1766,80 @@ Expected: `EQW_SpriteIndex is not defined`。 /////////////////////////////////////////////////////////////// ////////// EQW_SpriteIndex: 精灵键名 → ID 扁平索引 ///////////// /////////////////////////////////////////////////////////////// -// 精灵常量以 UI View 为组织单位:Layer / Group 扁平声明在 View 下, -// 精灵只写 id(所属关系在注释里)。而布局配置里 attach.target 写的是 -// 【键名】(清单 §6.1:ID 回填时只改一处映射表),故需把 View 结构 -// 展平成 { 键名: 精灵ID } 供求解器查用。 -// 【显式失败】重复键、值非数字、查不到的键一律抛错——静默覆盖会让某个 -// 界面指向另一个界面的精灵,且极难排查(工程总则 §7)。 +// 精灵常量以 UI View 为组织单位,View 下是一个个 group 容器: +// EQW_Sprites.XxxView = { Layer: 图层, GroupName: { id: 群组, 精灵键: id, ... } } +// 而布局配置里 attach.target 写的是【键名】(清单 §6.1:ID 回填时只改一处 +// 映射表),故需把这棵树展平成 { 键名: 精灵ID } 供求解器查用; +// group 容器把精灵与群组 ID 绑在一起,顺带能给出 { 键名: 群组ID }。 +// 【显式失败】重复键、值非数字、结构不合法、查不到的键一律抛错——静默覆盖 +// 会让某个界面指向另一个界面的精灵,且极难排查(工程总则 §7)。 var EQW_SpriteIndex = EQW_SpriteIndex || { _index: null, + _groupMap: null, - //View 级保留键:Layer / Group,可带数字后缀(Layer1、Group2…) - _isReserved: function (key) { - return (/^Layer\d*$/).test(key) || (/^Group\d*$/).test(key); + //View 级保留键:Layer,可带数字后缀(Layer1、Layer2…) + _isLayerKey: function (key) { + return (/^Layer\d*$/).test(key); }, - //展平:遍历 树 → View → 键 - build: function (spriteTree) { - var index = {}; + //遍历 树 → View → group → 精灵,对每个精灵回调 fn(key, spriteId, groupId) + _walk: function (spriteTree, fn) { for (var viewName in spriteTree) { if (!spriteTree.hasOwnProperty(viewName)) { continue; } var view = spriteTree[viewName]; - for (var key in view) { - if (!view.hasOwnProperty(key)) { continue; } - if (this._isReserved(key)) { continue; } //View 级 Layer/Group 声明 - if (typeof view[key] !== 'number') { - throw new Error('[EQW_SpriteIndex] 精灵 ' + key + ' 的值不是数字(View ' + - viewName + ')——Layer/Group 声明请用保留键名 Layer* / Group*'); + for (var groupName in view) { + if (!view.hasOwnProperty(groupName)) { continue; } + if (this._isLayerKey(groupName)) { continue; } //View 级图层声明 + var group = view[groupName]; + if (!group || typeof group !== 'object') { + throw new Error('[EQW_SpriteIndex] ' + viewName + '.' + groupName + + ' 不是 group 容器(值应为对象)——图层声明请用保留键名 Layer*'); } - if (index.hasOwnProperty(key)) { - throw new Error('[EQW_SpriteIndex] 精灵键名重复: ' + key + - '(View ' + viewName + ' 与更早的定义冲突)'); + if (typeof group.id !== 'number') { + throw new Error('[EQW_SpriteIndex] group 容器 ' + viewName + '.' + groupName + + ' 缺少数字型的 id(群组 ID)'); + } + for (var key in group) { + if (!group.hasOwnProperty(key)) { continue; } + if (key === 'id') { continue; } //群组 ID + if (typeof group[key] !== 'number') { + throw new Error('[EQW_SpriteIndex] 精灵 ' + key + ' 的值不是数字(' + + viewName + '.' + groupName + ')'); + } + fn(key, group[key], group.id, viewName, groupName); } - index[key] = view[key]; } } + }, + + //展平成 { 键名: 精灵ID } + build: function (spriteTree) { + var index = {}; + this._walk(spriteTree, function (key, spriteId, groupId, viewName, groupName) { + if (index.hasOwnProperty(key)) { + throw new Error('[EQW_SpriteIndex] 精灵键名重复: ' + key + + '(' + viewName + '.' + groupName + ' 与更早的定义冲突)'); + } + index[key] = spriteId; + }); return index; }, - //用给定的树(默认 EQW_Sprites)建立内部索引 + //展平成 { 键名: 群组ID },供「显隐走群组」时查精灵属于哪个群组 + buildGroupMap: function (spriteTree) { + var map = {}; + this._walk(spriteTree, function (key, spriteId, groupId) { + map[key] = groupId; + }); + return map; + }, + + //用给定的树(默认 EQW_Sprites)建立两张内部索引 init: function (spriteTree) { - this._index = this.build(spriteTree || EQW_Sprites); + var tree = spriteTree || EQW_Sprites; + this._index = this.build(tree); + this._groupMap = this.buildGroupMap(tree); return this._index; }, @@ -1782,6 +1850,15 @@ var EQW_SpriteIndex = EQW_SpriteIndex || { throw new Error('[EQW_SpriteIndex] 未知精灵键名: ' + key); } return this._index[key]; + }, + + //按键名取它所属的群组 ID + groupIdOf: function (key) { + if (!this._groupMap) { throw new Error('[EQW_SpriteIndex] 尚未 init()'); } + if (!this._groupMap.hasOwnProperty(key)) { + throw new Error('[EQW_SpriteIndex] 未知精灵键名: ' + key); + } + return this._groupMap[key]; } }; ``` diff --git a/docs/superpowers/specs/2026-08-26-二七王前端A-地基与逻辑层-design.md b/docs/superpowers/specs/2026-08-26-二七王前端A-地基与逻辑层-design.md index db8f3aa..84446dc 100644 --- a/docs/superpowers/specs/2026-08-26-二七王前端A-地基与逻辑层-design.md +++ b/docs/superpowers/specs/2026-08-26-二七王前端A-地基与逻辑层-design.md @@ -104,7 +104,7 @@ client/tests/ | `Groups.js` | `EQW_Groups` | 清单 §0.3 群组表 201–250 | | `ImageResources.js` | `EQW_Images` | 清单 §3 图片资源总表,**每条按 ImageResources.template 的要求注明用途 / 帧数 / 每帧含义 / 尺寸** | | `SoundResources.js` | `EQW_Sounds` | 清单 §4——**整节 T-20 未定**,本阶段只建文件骨架与说明注释,不臆造条目(见 §11) | -| `Sprites_*.js` | `EQW_Sprites` | 清单 §5.1–§5.8。**以 UI View 为组织单位**(一个 View 将来对应一个 `BaseComponent`):`Layer` / `Group` 扁平声明在 View 下(用到多个时写 `Layer1`/`Group2`…),精灵只写 id、所属关系在注释里。每个精灵注明类型 / 用途 / 资源键 / 帧说明 / 所属群组 | +| `Sprites_*.js` | `EQW_Sprites` | 清单 §5.1–§5.8。**以 UI View 为组织单位**(一个 View 将来对应一个 `BaseComponent`),View 下是一个个 **group 容器**:`{ Layer: 图层, GroupName: { id: 群组, 精灵键: id, … } }`。用到多个图层时写 `Layer1`/`Layer2`。每个精灵注明类型 / 用途 / 资源键 / 帧说明 | | `LayoutConstants.js` | `EQW_Layout` | 清单 §6.3 `textStyle` 预设、§6.4 `CARD_SIZE` 三档、座位键常量 `SELF/LEFT/RIGHT` | | `Layout_Table.js` | `EQW_Layout` | 清单 §6.5 常驻区 + §6.6 玩家位(含气泡朝向 `arrowRight`) | | `Layout_Cards.js` | `EQW_Layout` | 清单 §6.7 牌区(手牌单排/双排、底牌、埋牌底牌、已出牌 `bySeat`、冲关牌) | @@ -189,20 +189,40 @@ EQW_SeatMap.toSeat(displayKey, mySeat) // → 服务端座位号 ### 5.5 `SpriteIndex` -精灵常量以 UI View 为组织单位(`Layer`/`Group` 扁平声明在 View 下,精灵只写 id),而布局配置里 `attach.target` 写的是**键名**(清单 §6.1:「`target` 一律写键名而非数字 ID」)。故需一张扁平索引: +精灵常量的组织是 **View → group 容器 → 精灵**: ```js -EQW_SpriteIndex.build(spriteTree) // 遍历 树 → View → 键 → { 键名: 精灵ID } -EQW_SpriteIndex.idOf(key) // 查 ID;查不到显式抛错,不返回 undefined +EQW_Sprites.TopInfoView = { + Layer: EQW_Layers.TABLE_STATIC, // 用到多个图层时写 Layer1 / Layer2… + TopInfo: { // group 容器 + id: EQW_Groups.TOP_INFO, + TOP_INFO_BG: 1001, + TOP_CALL_TEXT: 1003 + } +}; ``` -**保留键约定**:View 内键名为 `Layer` / `Group` 或带数字后缀(`Layer1`、`Group2`…)的是 View 级声明,跳过不进索引;其余键一律是精灵 id。 +而布局配置里 `attach.target` 写的是**键名**(清单 §6.1:「`target` 一律写键名而非数字 ID」),故需把这棵树展平: -**显式失败**三处(工程总则 §7): +```js +EQW_SpriteIndex.build(spriteTree) // → { 键名: 精灵ID } +EQW_SpriteIndex.buildGroupMap(spriteTree) // → { 键名: 群组ID } +EQW_SpriteIndex.init(spriteTree) // 默认用 EQW_Sprites 建好两张索引 +EQW_SpriteIndex.idOf(key) // 查精灵 ID;查不到显式抛错 +EQW_SpriteIndex.groupIdOf(key) // 查它所属的群组 ID;查不到显式抛错 +``` -- 键名拼错时 `idOf` 立即报错,而不是把 `undefined` 传给 `SpriteManager` 静默无效; +`groupIdOf` 是这个结构带来的收益:group 容器把精灵与群组 ID 绑在一起,「某个精灵属于哪个群组」于是可以机械查出——显隐走群组时(前端红线:显隐由组件的 `showXxx`/`hideXxx` 控制)不必再手工对照。 + +**结构约定**:View 内 `Layer`(含 `Layer1`/`Layer2`…)是 View 级声明、跳过;其余键的值必须是**对象**(group 容器)。group 容器内 `id` 是群组 ID、跳过;其余键一律是精灵 id。 + +**显式失败**五处(工程总则 §7): + +- `idOf` / `groupIdOf` 键名拼错时立即报错,而不是把 `undefined` 传给 `SpriteManager` 静默无效; - **重复键报错**而非后者覆盖前者——覆盖会让某个界面静默指向另一个界面的精灵。同名键在不同 View 下重复由清单 §5 的键名设计避免(`PLAY_SELF_*` / `PLAY_LEFT_*` / `PLAY_RIGHT_*`); -- **值非数字报错**——View 级声明与精灵条目在结构上同级、只靠键名区分,写错前缀(如 `Grup: 201`)会静默变成一个假精灵,故把「值不是数字」当作错误抓出来。 +- **精灵值非数字报错**; +- **View 下混入标量键报错**(例如把群组 ID 误写成 View 级的 `Group: 201` 而没包进 group 容器); +- **group 容器缺 `id` 报错**。 ---