# VR 票务 spec 选择器 + 多座位选择 — 数据结构与实现说明 > 日期:2026-04-21 > 背景:修正 COUNCIL_PHASE2_ASSESSMENT_CORRECTED.md 中 spec 描述的根本性错误 --- ## 一、核心结论(先说清楚) 原报告里所有关于 spec 的示例都是错的,原因:**spec 是 4 维度联合索引,而不是 1 个维度;前端目前根本拿不到完整的 4 维 spec 映射**。 我们实际上要做的产品形态是: > **一个风格化、带座位图的多维度 spec 规格选择器** > > - 界面同时具备原生 ShopXO spec 选择器的交互(场次/场馆/分区可选择,不在分支下的选项自动隐藏/变灰) > - 又有自己的多座位视图(在座位图上直接点选多个座位) > - 最终在 submit() 时,把选中座位的完整 4 维 spec 数组提交到 Buy 链路 --- ## 二、ShopXO spec 的真实结构 ### 2.1 四维 SPEC_DIMS ShopXO 的每个 GoodsSpecBase 记录,通过 4 个维度的 spec 值联合确定: ```php const SPEC_DIMS = ['$vr-场馆', '$vr-分区', '$vr-座位号', '$vr-场次']; ``` ### 2.2 一个座位的完整 spec 数组(示例) 以商品118的某个座位为例(A排3座,VIP区,VR体验馆-1号厅,场次15:00-16:59): ```json { "spec_base_id": 1001, "price": 380.00, "inventory": 1, "spec": [ { "type": "$vr-场馆", "value": "VR体验馆-1号厅" }, { "type": "$vr-分区", "value": "VR体验馆-1号厅-VIP区" }, { "type": "$vr-座位号", "value": "VR体验馆-1号厅-VIP区-A-1排3座" }, { "type": "$vr-场次", "value": "15:00-16:59" } ] } ``` > ⚠️ **关键**:spec value 不是 `"A_3"` 或 `"roomId_A_3"` 这种短格式, > 而是**完整路径字符串** `"VR体验馆-1号厅-VIP区-A-1排3座"`。 > 这个字符串由 BatchGenerate 第131行构建: > `$val_seat = "{$venueName}-{$roomName}-{$char}-{$rowLabel}{$col}"` ### 2.3 从前端座位元素到 spec_base_id 的正确路径 前端一个座位 DOM 元素,持有以下数据: - `roomId` = "room_001"(来自 template_snapshot.rooms[i].id) - `rowLabel` = "A"(座位行标签) - `colNum` = 3(座位列号) - `char` = "A"(座位类型 char,对应 sections[i].char) 要找到对应的 GoodsSpecBase,需要用以下映射关系: ``` GoodsSpecBase.extends = {"seat_key": "room_001_A_3"} ↓ GetGoodsViewData() 动态构建 spec_base_id_map = {"room_001_A_3": 1001} ↓ 前端 seatKey = room_001 + "_" + "A" + "_" + 3 = "room_001_A_3" ↓ spec_base_id_map["room_001_A_3"] = 1001 ✅ ``` --- ## 三、前端实际能拿到的数据(现状 vs 需要的) ### 3.1 当前 GetGoodsViewData() 返回的数据(不完整) ```php [ 'vr_seat_template' => [ 'seat_map' => [...], // template_snapshot.rooms(座位图原始数据) 'spec_base_id_map' => [...], // ⚠️ 键名格式不对(roomId_row_col),且前端没有对应生成逻辑 ], 'goods_spec_data' => [ ['spec_id' => 2001, 'spec_name' => '15:00-16:59', 'price' => 380], ['spec_id' => 2002, 'spec_name' => '18:00-20:59', 'price' => 480], ] ] ``` **缺失的信息**: 1. ❌ `goods_spec_data` 只有场次维度,缺少场馆/分区/座位号三个维度 2. ❌ `spec_base_id_map` 的 key 格式(`roomId_row_col`)前端无法构造 3. ❌ 前端不知道哪个 spec_id 对应哪个座位 ### 3.2 前端需要的完整数据结构(GetGoodsViewData 应返回) ```php [ 'vr_seat_template' => [ 'venue' => $config['template_snapshot']['venue'], 'rooms' => $config['template_snapshot']['rooms'], // 座位图原始数据 'sessions' => $config['sessions'], // 场次列表 ], // 【修复】重构后的 seatSpecMap:room_row_col → 完整规格信息 // 用途:前端选中座位后,直接查表组装 4 维 spec 数组 'seatSpecMap' => [ 'room_001_A_3' => [ 'spec_base_id' => 1001, 'price' => 380.00, 'inventory' => 1, 'spec' => [ ['type' => '$vr-场馆', 'value' => 'VR体验馆-1号厅'], ['type' => '$vr-分区', 'value' => 'VR体验馆-1号厅-VIP区'], ['type' => '$vr-座位号', 'value' => 'VR体验馆-1号厅-VIP区-A-1排3座'], ['type' => '$vr-场次', 'value' => '15:00-16:59'], ], 'rowLabel' => 'A', 'colNum' => 3, 'roomId' => 'room_001', 'section' => ['char' => 'A', 'name' => 'VIP区', 'color' => '#f06292'], ], 'room_001_B_5' => [ 'spec_base_id' => 1002, 'price' => 180.00, // ... 同上 ], // 每个可购座位一行 ], // 当前商品的全部场次(用户需要先选场次) 'sessions' => [ ['spec_id' => 2001, 'spec_name' => '15:00-16:59', 'price' => 380, 'start' => '15:00', 'end' => '16:59'], ['spec_id' => 2002, 'spec_name' => '18:00-20:59', 'price' => 480, 'start' => '18:00', 'end' => '20:59'], ], ] ``` ### 3.3 seatSpecMap 的生成逻辑(在 GetGoodsViewData 中实现) ```php // GetGoodsViewData() 中新增: // 1. 查询当前商品所有 GoodsSpecBase(含 extends.seat_key) $specs = Db::name('GoodsSpecBase') ->where('goods_id', $goodsId) ->where('inventory', '>', 0) ->select(); // 2. 查询每个 spec_base_id 对应的 4 维 GoodsSpecValue $specValues = Db::name('GoodsSpecValue') ->whereIn('goods_spec_base_id', array_column($specs->toArray(), 'id')) ->select() ->toArray(); // 3. 按 spec_base_id 分组,构建 4 维 spec 数组 $specByBaseId = []; foreach ($specValues as $sv) { $specByBaseId[$sv['goods_spec_base_id']][] = [ 'type' => $sv['type'], 'value' => $sv['value'], ]; } // 4. 构建 seatSpecMap:seat_key → 完整规格 $seatSpecMap = []; foreach ($specs as $spec) { $extends = json_decode($spec['extends'] ?? '{}', true); $seatKey = $extends['seat_key'] ?? ''; if (empty($seatKey)) continue; $seatSpecMap[$seatKey] = [ 'spec_base_id' => intval($spec['id']), 'price' => floatval($spec['price']), 'inventory' => intval($spec['inventory']), 'spec' => $specByBaseId[$spec['id']] ?? [], ]; } ``` --- ## 四、前端 spec 选择器的完整交互 ### 4.1 UI 结构 ``` ┌─────────────────────────────────────────────────┐ │ 场次选择(Tab 或下拉) │ │ [15:00-16:59] [18:00-20:59] │ │ │ │ 选完场次后 → 座位图自动切换到该场次的可用座位 │ │ │ │ 场馆选择(单选) │ │ [VR体验馆-1号厅 ✓] [VR体验馆-2号厅] │ │ │ │ 分区选择(单选 / 多选)灰色表示不在分支内 │ │ [VIP区 ✓] [看台区] [普通区-已售罄] │ │ │ │ ─────────── 座位图(多选)──────────────── │ │ [舞台 - 固定位置] │ │ A排 [■■■■] ← 可选座位(VIP) │ │ B排 [■■--■■] ← 部分已售 │ │ C排 [已灰掉] ← 不在当前分区或已售 │ └─────────────────────────────────────────────────┘ ``` ### 4.2 spec 选择器的联动逻辑 **维度优先级**:`场次 > 场馆 > 演播室 > 分区` 每次用户切换选项时,过滤规则: ``` 场次切换 → 重置场馆/分区/座位 → 用 seatSpecMap 过滤出该场次所有座位 场馆切换 → 重置分区/座位 → 用 seatSpecMap 过滤出该场馆所有座位 分区切换 → 只高亮/过滤座位 → 用 seatSpecMap 过滤出该分区座位(灰色其他) 座位点击 → 选中/取消 → 更新 selectedSeats[] ``` **灰色/隐藏逻辑**(参考原生 ShopXO spec 选择器): ```javascript // 某座位"可亮"的条件:该座位的 spec 数组 包含 当前已选场次 + 当前已选场馆 + (当前已选分区 或 未选分区) // 具体实现在 selectSession() / selectVenue() / selectSection() 中调用 filterSeatMap() function filterSeatMap(sessionSpecId, venueId, sectionChar) { document.querySelectorAll('.vr-seat:not(.space)').forEach(function(el) { var seatKey = el.dataset.rowLabel + '_' + el.dataset.colNum; var seatInfo = seatSpecMap[seatKey]; if (!seatInfo) { el.classList.add('disabled'); return; } var spec = seatInfo.spec; var hasSession = spec.some(s => s.type === '$vr-场次' && s.value === currentSessionSpec); var hasVenue = spec.some(s => s.type === '$vr-场馆' && s.value.includes(currentVenue)); var hasSection = !sectionChar || spec.some(s => s.type === '$vr-分区' && s.value.includes(sectionChar)); var isAvailable = seatInfo.inventory > 0; if (hasSession && hasVenue && hasSection && isAvailable) { el.classList.remove('disabled', 'sold'); } else { el.classList.add(isAvailable ? 'disabled' : 'sold'); } }); } ``` --- ## 五、submit() 时如何组装 spec 数组 用户选了 2 个座位(A排3座 + A排5座,都是VIP区,15:00场次,VR体验馆-1号厅): ```javascript // 选中座位后的 selectedSeats[] 数据结构 [ { seatKey: 'room_001_A_3', price: 380, rowLabel: 'A', colNum: 3, section: {...} }, { seatKey: 'room_001_A_5', price: 380, rowLabel: 'A', colNum: 5, section: {...} }, ] // submit() 构造 goods_data var goodsDataList = selectedSeats.map(function(seat) { var seatInfo = seatSpecMap[seat.seatKey]; // 从注入的 seatSpecMap 查 return { goods_id: self.goodsId, spec: seatInfo.spec, // 4维完整 spec 数组!不是1维! stock: 1, order_base: { extension_data: { attendee: { real_name: '...', phone: '...', id_card: '...' } } } }; }); ``` **生成的 goods_data(简化展示)**: ```json [ { "goods_id": 118, "spec": [ { "type": "$vr-场馆", "value": "VR体验馆-1号厅" }, { "type": "$vr-分区", "value": "VR体验馆-1号厅-VIP区" }, { "type": "$vr-座位号", "value": "VR体验馆-1号厅-VIP区-A-1排3座" }, { "type": "$vr-场次", "value": "15:00-16:59" } ], "stock": 1, "order_base": { "extension_data": { "attendee": { "real_name": "张三", "phone": "13800138000", "id_card": "" } } } }, { "goods_id": 118, "spec": [ { "type": "$vr-场馆", "value": "VR体验馆-1号厅" }, { "type": "$vr-分区", "value": "VR体验馆-1号厅-VIP区" }, { "type": "$vr-座位号", "value": "VR体验馆-1号厅-VIP区-A-1排5座" }, { "type": "$vr-场次", "value": "15:00-16:59" } ], "stock": 1, "order_base": { "extension_data": { "attendee": { "real_name": "李四", ... } } } } ] ``` ShopXO 的 `GoodsSpecificationsHandle` 通过 4 个 type-value 组合在 GoodsSpecValue 表中精确匹配到对应的 GoodsSpecBase,拿到 `inventory=1` 和 `price=380`。 --- ## 六、需要修改的文件清单 | 文件 | 改动 | |------|------| | `SeatSkuService::GetGoodsViewData()` | 新增 `seatSpecMap` 生成逻辑,返回完整 4 维 spec 映射 | | `Goods.php`(票务判断块) | `MyViewAssign` 中加入 `seatSpecMap` 和 `sessions` | | `ticket_detail.html` JS | 新增 `selectSession()` / `selectVenue()` / `selectSection()` 联动逻辑;`filterSeatMap()` 过滤;`submit()` 使用 `seatSpecMap` 组装 spec | | `ticket_detail.html` HTML | 新增场次/场馆/分区选择器的 DOM 结构 | | `ticket_detail.css` | spec 选择器样式(选中态/灰色态/隐藏态) | --- ## 七、修复优先级 | 优先级 | 任务 | 依赖 | |--------|------|------| | P0 | 重构 `GetGoodsViewData()` 返回 `seatSpecMap` | 后端 | | P0 | 前端用 `seatSpecMap` 替代错误的 `specBaseIdMap` | 前端 | | P0 | `submit()` 使用 `seatSpecMap[seatKey].spec` | 前端 | | P1 | 实现场次/场馆/分区选择器 UI + 联动逻辑 | 前端 | | P1 | `filterSeatMap()` 实现灰色/隐藏过滤 | 前端 | | P2 | `loadSoldSeats()` → 使用 `seatSpecMap` + inventory 字段 | 前端 |