# 商品详情扩展字段数据字典与前端使用说明

> 日期：2026-04-21
> 用途：前端 agent（antigravity / cursor）拿到商品详情页时，扩展字段里有哪些可用数据？如何用？

---

## 一、核心数据结构全貌

商品详情页加载时，PHP 后端向模板注入以下变量：

| 模板变量名 | 来源 | 说明 |
|-----------|------|------|
| `$goods` | ShopXO GoodsService | ShopXO 原生商品数据（id/title/price/content/images 等） |
| `$vr_seat_template` | `SeatSkuService::GetGoodsViewData()` | 票务插件扩展数据 |
| `$goods_spec_data` | `SeatSkuService::GetGoodsViewData()` | 场次列表 |

---

## 二、vr_goods_config（goods 表扩展字段）

存储位置：`goods.vr_goods_config`（JSON 字段）

这是商品发布时由管理员配置的数据快照，**前端只能读，不能写**。

### 完整 JSON 示例（商品 118，VR 演唱会）

```json
[
  {
    "version": 3.0,
    "template_id": 4,
    "selected_rooms": ["room_001", "room_002"],
    "selected_sections": {
      "room_001": ["A", "B"],
      "room_002": ["A"]
    },
    "sessions": [
      { "start": "15:00", "end": "16:59" },
      { "start": "18:00", "end": "20:59" }
    ],
    "template_snapshot": {
      "venue": {
        "name": "VR 体验馆",
        "address": "北京市朝阳区建国路88号",
        "location": { "lng": "116.45792", "lat": "39.90745" },
        "images": [
          "/static/attachments/202603/venue_001.jpg",
          "/static/attachments/202603/venue_002.jpg"
        ]
      },
      "rooms": [
        {
          "id": "room_001",
          "name": "1号演播厅",
          "map": [
            "AAAAA_____BBBBB",
            "AAAAA_____BBBBB",
            "AAAAA_____BBBBB",
            "CCCCCCCCCCCCCCC",
            "CCCCCCCCCCCCCCC"
          ],
          "sections": [
            { "char": "A", "name": "VIP区",   "price": 380, "color": "#f06292" },
            { "char": "B", "name": "看台区",   "price": 180, "color": "#4fc3f7" },
            { "char": "C", "name": "普通区",   "price": 80,  "color": "#81c784" }
          ],
          "seats": {
            "A": { "char": "A", "name": "VIP区",   "price": 380, "color": "#f06292" },
            "B": { "char": "B", "name": "看台区",   "price": 180, "color": "#4fc3f7" },
            "C": { "char": "C", "name": "普通区",   "price": 80,  "color": "#81c784" }
          }
        },
        {
          "id": "room_002",
          "name": "2号演播厅（副厅）",
          "map": [
            "DDDDDDD",
            "DDDDDDD",
            "EEEEEEE"
          ],
          "sections": [
            { "char": "D", "name": "互动区",   "price": 280, "color": "#ffb74d" },
            { "char": "E", "name": "站票区",   "price": 50,  "color": "#90a4ae" }
          ],
          "seats": {
            "D": { "char": "D", "name": "互动区",   "price": 280, "color": "#ffb74d" },
            "E": "char": "E", "name": "站票区",   "price": 50,  "color": "#90a4ae" }
          }
        }
      ]
    }
  }
]
```

### 字段说明表

#### 顶层字段

| 字段 | 类型 | 前端可用性 | 说明 |
|------|------|-----------|------|
| `version` | float | ⚪ 不需要 | 协议版本，用于兼容判断 |
| `template_id` | int | ⚪ 不需要 | 关联的座位模板 ID，内部使用 |
| `selected_rooms` | string[] | ✅ 可用 | 用户在后台选中的房间 ID 列表 |
| `selected_sections` | object | ✅ 可用 | key=房间ID，value=该房间选中的分区字符数组 |
| `sessions` | object[] | ✅ 可用（**重要**） | 场次列表，每个场次有 start/end/price |
| `template_snapshot` | object | ✅ 可用（**核心**） | 座位图的完整快照，前端渲染数据来源 |

#### template_snapshot.venue

| 字段 | 前端可用性 | 说明 |
|------|-----------|------|
| `name` | ✅ 可用 | 场馆名称（用于展示） |
| `address` | ✅ 可用 | 场馆地址（用于展示） |
| `location.lng/lat` | ⚠️ 可选 | 经纬度，用于地图展示 |
| `images` | ✅ 可用 | 场馆图片列表（用于顶部 Banner） |

#### template_snapshot.rooms[]（每个房间）

| 字段 | 前端可用性 | 说明 |
|------|-----------|------|
| `id` | ✅ 可用（**重要**） | 房间唯一 ID，用于前端 seatKey 构造 |
| `name` | ✅ 可用 | 房间名称（用于场馆切换选择器） |
| `map` | ✅ 可用（**核心**） | 座位图字符矩阵，用于渲染座位行 |
| `sections[]` | ✅ 可用 | 分区列表（char→name/price/color，用于图例 + 分区切换） |
| `seats` | ✅ 可用 | char→座位属性映射，用于查找座位详情 |

#### template_snapshot.rooms[].map 格式说明

`map` 是一个字符串数组，每行对应座位图的一行：

```json
["AAAAA_____BBBBB", "AAAAA_____BBBBB"]
```

- 字符 `'A'` / `'B'` / `'C'` = 座位（char），通过 `seats[char]` 查到座位属性（分区/价格/颜色）
- 字符 `'_'` = 空位（不渲染座位元素）
- 字符 `'-'` = 空位（不渲染座位元素）
- 其他非字母字符 = 不渲染

**如何从 map 渲染座位**：
```javascript
// map = ["AAAAA_____BBBBB", "AAAAA_____BBBBB"]
map.forEach(function(rowStr, rowIndex) {
    var rowLabel = String.fromCharCode(65 + rowIndex);  // 0→A, 1→B, ...
    var chars = rowStr.split('');  // ['A','A','A','A','A','_',...,'B','B','B','B','B']
    chars.forEach(function(char, colIndex) {
        if (char === '_' || char === '-') return;  // 跳过空位
        var seatInfo = rooms[i].seats[char];  // 查到座位属性
        // colIndex + 1 = colNum（列号，从1开始）
    });
});
```

**注意**：PHP `mb_str_split()` 在某些环境不可用，用 `split('')` 即可（座位字符都是 ASCII）。

---

## 三、GetGoodsViewData() 注入的模板数据

这是后端处理后注入到模板的变量，**前端可以直接使用**。

### 3.1 注入变量总览

```php
// Goods.php 票务判断块
$viewData = \app\plugins\vr_ticket\service\SeatSkuService::GetGoodsViewData($goods_id);
MyViewAssign([
    'vr_seat_template' => $viewData['vr_seat_template'],  // 座位图数据
    'goods_spec_data'  => $viewData['goods_spec_data'],      // 场次列表
    // 【待新增】
    'seatSpecMap'     => $viewData['seatSpecMap'] ?? [],    // 座位→规格映射
]);
```

### 3.2 vr_seat_template（注入后模板中访问 `$vr_seat_template`）

```javascript
// PHP 模板输出（JSON 注入）
var vrSeatTemplate = <?php echo json_encode($vr_seat_template ?? [], JSON_UNESCAPED_UNICODE); ?>;
var seatSpecMap = <?php echo json_encode($seatSpecMap ?? [], JSON_UNESCAPED_UNICODE); ?>;
var goodsSpecData = <?php echo json_encode($goods_spec_data ?? [], JSON_UNESCAPED_UNICODE); ?>;
```

#### vr_seat_template 数据结构

```javascript
{
    // === 直接透传 template_snapshot（来源：goods.vr_goods_config）===
    venue: {
        name: "VR 体验馆",
        address: "北京市朝阳区建国路88号",
        location: { lng: "116.45792", lat: "39.90745" },
        images: ["/static/attachments/202603/venue_001.jpg"]
    },
    rooms: [
        {
            id: "room_001",
            name: "1号演播厅",
            map: ["AAAAA_____BBBBB", "AAAAA_____BBBBB", "AAAAA_____BBBBB", "CCCCCCCCCCCCCCC", "CCCCCCCCCCCCCCC"],
            sections: [
                { char: "A", name: "VIP区",  price: 380, color: "#f06292" },
                { char: "B", name: "看台区", price: 180, color: "#4fc3f7" },
                { char: "C", name: "普通区", price: 80,  color: "#81c784" }
            ],
            seats: {
                A: { char: "A", name: "VIP区",  price: 380, color: "#f06292" },
                B: { char: "B", name: "看台区", price: 180, color: "#4fc3f7" },
                C: { char: "C", name: "普通区", price: 80,  color: "#81c784" }
            }
        },
        {
            id: "room_002",
            name: "2号演播厅（副厅）",
            map: ["DDDDDDD", "DDDDDDD", "EEEEEEE"],
            sections: [
                { char: "D", name: "互动区", price: 280, color: "#ffb74d" },
                { char: "E", name: "站票区", price: 50,  color: "#90a4ae" }
            ],
            seats: {
                D: { char: "D", name: "互动区", price: 280, color: "#ffb74d" },
                E: { char: "E", name: "站票区", price: 50,  color: "#90a4ae" }
            }
        }
    ],
    sessions: [
        { start: "15:00", end: "16:59" },
        { start: "18:00", end: "20:59" }
    ],
    // === 来自 goods.vr_goods_config 的原始选择数据 ===
    selectedRooms: ["room_001", "room_002"],
    selectedSections: {
        "room_001": ["A", "B"],
        "room_002": ["A"]
    }
}
```

#### goods_spec_data（场次列表）

```javascript
// 来源：goods.vr_goods_config.sessions + GoodsSpecBase.price
[
    { 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: 280, start: "18:00", end: "20:59" }
]
// ⚠️ 注意：spec_id 是 GoodsSpecBase ID（场次级别，非座位级别）
// 前端不需要直接使用 spec_id，直接使用 sessions 数组即可
```

### 3.3 seatSpecMap（待新增：GetGoodsViewData 返回的核心数据）

**来源**：`GetGoodsViewData()` 查询 GoodsSpecBase + GoodsSpecValue + GoodsSpecBase.extends，动态构建

**用途**：前端选中座位后，查表获取该座位的完整 4 维 spec 数组 + 对应 spec_base_id

```javascript
// key 格式：{roomId}_{rowLabel}_{colNum}
// 例如：room_001_A_3 = room_001 的 A排 第3列

{
    "room_001_A_1": {
        spec_base_id: 10001,
        price: 380,
        inventory: 1,
        rowLabel: "A",
        colNum: 3,
        roomId: "room_001",
        section: { char: "A", name: "VIP区", color: "#f06292" },
        // === 4维 spec 数组（submit() 时直接使用）===
        spec: [
            { type: "$vr-场馆",   value: "VR 体验馆" },
            { type: "$vr-分区",   value: "VR 体验馆-1号演播厅-VIP区" },
            { type: "$vr-座位号", value: "VR 体验馆-1号演播厅-VIP区-A-1排3座" },
            { type: "$vr-场次",   value: "15:00-16:59" }
        ]
    },
    "room_001_A_2": { spec_base_id: 10002, price: 380, inventory: 1, /* ... */ },
    "room_001_A_3": { /* 同上，A排第3座 */ },
    "room_001_B_8": { spec_base_id: 10025, price: 180, inventory: 0, /* 已售 */ },
    "room_002_D_1": { spec_base_id: 20001, price: 280, inventory: 1, /* ... */ },
    // ... 每个可购座位一行
}
```

#### seatSpecMap 生成逻辑（GetGoodsViewData 中实现）

```php
// 1. 查询所有有效 GoodsSpecBase（含 extends.seat_key）
$specs = Db::name('GoodsSpecBase')
    ->where('goods_id', $goodsId)
    ->where('inventory', '>', 0)  // 只取有库存的
    ->select();

// 2. 查询对应的 GoodsSpecValue（4个维度的值）
$specIds = array_column($specs->toArray(), 'id');
$specValues = Db::name('GoodsSpecValue')
    ->whereIn('goods_spec_base_id', $specIds)
    ->select();

// 3. 按 spec_base_id 分组，构建 4维 spec 数组
$specByBaseId = [];
foreach ($specValues as $sv) {
    $specByBaseId[$sv['goods_spec_base_id']][] = [
        'type'  => $sv['type'],   // "$vr-场馆" / "$vr-分区" / "$vr-座位号" / "$vr-场次"
        'value' => $sv['value'],  // 完整路径字符串
    ];
}

// 4. 构建 seatSpecMap
$seatSpecMap = [];
foreach ($specs as $spec) {
    $extends = json_decode($spec['extends'] ?? '{}', true);
    $seatKey  = $extends['seat_key'] ?? '';  // "room_001_A_3" 格式
    if (empty($seatKey)) continue;

    $seatSpecMap[$seatKey] = [
        'spec_base_id' => intval($spec['id']),
        'price'        => floatval($spec['price']),
        'inventory'    => intval($spec['inventory']),
        'spec'         => $specByBaseId[$spec['id']] ?? [],
    ];
}
```

---

## 四、前端数据使用对照表

### 4.1 渲染座位图（使用 vr_seat_template）

```
数据来源：vr_seat_template.rooms[].map
    ↓
渲染流程：
    rooms[i].map.forEach((rowStr, rowIndex) => {
        chars = rowStr.split('')  // 逐字符
        chars.forEach((char, colIndex) => {
            if (char === '_') → 跳过（空位）
            seatInfo = rooms[i].seats[char]  // 通过 char 查座位属性
            seatKey  = rooms[i].id + '_' + rowLabel + '_' + (colIndex+1)
            // rowLabel = String.fromCharCode(65 + rowIndex)  // A/B/C...
        });
    });

前端关键变量：
    - rooms[i].id         → roomId（用于 seatKey 构造）
    - rooms[i].map        → 座位行渲染数据
    - rooms[i].seats[char] → 座位属性（name/price/color）
    - rooms[i].sections   → 图例 + 分区切换
    - vrSeatTemplate.selectedRooms    → 当前选中的房间列表
    - vrSeatTemplate.selectedSections → 当前选中的分区
```

### 4.2 构建 spec 数组（使用 seatSpecMap）

```
数据来源：seatSpecMap[seatKey]
    ↓
选中座位后：
    seatKey  = clickedEl.dataset.rowLabel + '_' + clickedEl.dataset.colNum
            = "room_001_A_3"
    seatInfo = seatSpecMap[seatKey]

submit() 时使用：
    goods_data[i].spec = seatInfo.spec  // 4维完整 spec 数组！
    goods_data[i].stock = 1

ShopXO BuyService 匹配：
    → GoodsSpecValue WHERE type="$vr-场馆"   AND value="VR 体验馆"
                               AND type="$vr-分区"   AND value="VR 体验馆-1号演播厅-VIP区"
                               AND type="$vr-座位号" AND value="VR 体验馆-1号演播厅-VIP区-A-1排3座"
                               AND type="$vr-场次"   AND value="15:00-16:59"
    → 返回 spec_base_id → 拿到 inventory=1, price=380
```

### 4.3 spec 选择器联动过滤（使用 seatSpecMap）

```
数据来源：seatSpecMap（所有座位的完整信息）
    ↓
filterSeatMap(currentSession, currentVenueId, currentSectionChar):

    seatSpecMap 的每一个 entry：
        seatInfo.spec 是一个4元素数组

    判断逻辑（某座位是否在当前选择分支内）：
        hasSession = spec.some(s => s.type==='$vr-场次' && s.value===currentSessionValue)
        hasVenue   = spec.some(s => s.type==='$vr-场馆' && s.value.includes(currentVenueName))
        hasSection = !currentSectionChar || spec.some(s => s.type==='$vr-分区' && s.value.includes(currentSectionChar))
        isAvailable = seatInfo.inventory > 0

    结果：
        hasSession && hasVenue && hasSection && isAvailable → 可选（正常显示）
        hasSession && hasVenue && hasSection && !isAvailable → 已售（灰色+sold class）
        否则 → 不在分支内（灰色+disabled class）
```

### 4.4 加载已售座位（使用 seatSpecMap.inventory）

```
数据来源：seatSpecMap[seatKey].inventory
    ↓
页面初始化时，遍历 seatSpecMap：
    Object.entries(seatSpecMap).forEach(([seatKey, seatInfo]) => {
        if (seatInfo.inventory <= 0) {
            // 该座位已售
            document.querySelector(`[data-seat-key="${seatKey}"]`).classList.add('sold');
        }
    });

⚠️ 注意：inventory 字段来自 GoodsSpecBase，库存扣减由 ShopXO 原生处理。
   这是当前座位的实时库存，优先于任何前端缓存。
```

---

## 五、前端完整数据流图

```
后端 GetGoodsViewData()
        │
        ├── vr_seat_template.venue      ──────────────────→ 顶部 Banner / 场馆信息
        ├── vr_seat_template.rooms[].map ─────────────────→ 座位图渲染
        ├── vr_seat_template.rooms[].sections ────────────→ 图例 + 分区选择器
        ├── vr_seat_template.selectedSections ────────────→ 默认选中的分区（用于高亮）
        ├── goods_spec_data / vr_seat_template.sessions ──→ 场次选择器
        └── seatSpecMap (新增) ─────────────────────────────→ 核心！
                   │
                   ├── seatSpecMap[seatKey].spec ────────→ submit() 构造 goods_data.spec
                   ├── seatSpecMap[seatKey].inventory ──→ 标记已售 / 灰色
                   ├── seatSpecMap[seatKey].price ──────→ 计算总价
                   └── filterSeatMap() ─────────────────→ spec 选择器联动过滤
```

---

## 六、注意事项

### 6.1 roomId 从哪里来？

`rooms[i].id`（来自 template_snapshot.rooms）就是 roomId。这是 UUID 或字符串 ID。

**前端构造 seatKey 时必须使用这个 ID**：
```javascript
// 正确：从 rooms[i].id 取
var roomId = rooms[i].id;  // "room_001"

// 错误：硬编码或自行生成
var roomId = "room_001";   // ❌ 如果 rooms 结构变了就错了
```

### 6.2 colNum 从哪里来？

colNum 是列号（从 1 开始），不是数组索引：
```javascript
// 正确
var colNum = colIndex + 1;  // 0-based 数组索引 → 1-based 列号

// seatKey 格式：{roomId}_{rowLabel}_{colNum}
// 例如：room_001_A_3 = room_001 的 A排 第3列
```

### 6.3 同一个 char 在不同房间代表不同分区

room_001 的 "A" 是 VIP区（红色），room_002 的 "D" 是互动区（橙色）。

**分区信息在 rooms[i].sections 里**，不要直接用 char 本身判断分区。

### 6.4 map 中下划线数量的处理

`"AAAAA_____BBBBB"` 中有 5 个下划线。座位图渲染时：
```javascript
chars.forEach(function(char, colIndex) {
    if (char === '_' || char === '-') {
        // 渲染一个空白格子（不绑定座位）
        return;
    }
    // 渲染座位，colNum = colIndex + 1
});
```
