vr-shopxo-plugin/docs/SPEC_SELECTOR_DESIGN.md

336 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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'], // 场次列表
],
// 【修复】重构后的 seatSpecMaproom_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. 构建 seatSpecMapseat_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 字段 | 前端 |