# 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 字段 | 前端 |
