# VR 票务 UniApp 补充文档

> 创建时间：2026-05-14
> 背景：基于 vr-shopxo-plugin H5 实现（ticket_detail.html）+ 插件后端，为 vr-shopxo-uniapp 移植提供完整的数据结构、后端接口、交互规范
> 依赖：vr-ticket-integration-plan.md（原有 Phase 1-4 规划不变）

---

## 一、核心数据结构

### 1.1 vr_goods_config（v3.0 协议）

商品表 `goods.vr_goods_config` 存储票务配置快照，发布时写入，读取时直接使用不做关联查询：

```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": "测试场馆",
      "address": "北京市朝阳区",
      "location": { "lng": "116.4", "lat": "39.9" }
    },
    "rooms": [
      {
        "id": "room_001",
        "name": "主要展厅",
        "sections": [
          { "char": "A", "name": "VIP区", "color": "#e74c3c" },
          { "char": "B", "name": "看台",  "color": "#3498db" },
          { "char": "C", "name": "普通",  "color": "#2ecc71" }
        ],
        "map": ["AAAAAA", "BBBBBB", "CCCCCC"],
        "seats": {
          "A": { "price": 899, "color": "#e74c3c", "label": "VIP" },
          "B": { "price": 599, "color": "#3498db", "label": "看台" },
          "C": { "price": 299, "color": "#2ecc71", "label": "普通" }
        }
      },
      {
        "id": "room_002",
        "name": "新放映室 2",
        "sections": [
          { "char": "A", "name": "VIP区", "color": "#e74c3c" },
          { "char": "B", "name": "普通",  "color": "#2ecc71" }
        ],
        "map": ["AAAAA", "BBBBB"],
        "seats": {
          "A": { "price": 699, "color": "#e74c3c", "label": "VIP" },
          "B": { "price": 399, "color": "#2ecc71", "label": "普通" }
        }
      }
    ]
  }
}
```

**关键说明**：
- `selected_sections` 以 `room_id` 为 key（因为不同 room 的相同 char 指向不同分区）
- `template_snapshot` 在 Admin 发布时从 `vr_seat_templates.seat_map` 读取并存储，不做实时关联查询
- `rooms[].id` 为 UUID 格式（如 `room_001`），用于前端座位 DOM 的 `data-seat-key` 属性

---

### 1.2 seatSpecMap（座位规格映射）

`seatSpecMap` 是前端选座的核心数据，按 `seat_key` 索引每个座位的完整规格信息：

```json
// seatSpecMap（后端 GetGoodsViewData 动态构建，前端只读）
{
  "room_001_A_1": {
    "spec_base_id": 10001,
    "price": 899.00,
    "inventory": 1,
    "spec": [
      { "type": "$vr-场次",   "value": "15:00-16:59" },
      { "type": "$vr-场馆",   "value": "测试场馆" },
      { "type": "$vr-演播室", "value": "主要展厅" },
      { "type": "$vr-分区",   "value": "测试场馆-主要展厅-A" },
      { "type": "$vr-座位号", "value": "测试场馆-主要展厅-A-A1" }
    ],
    "venueName": "测试场馆",
    "roomId": "room_001",
    "roomName": "主要展厅",
    "section": { "char": "A", "name": "VIP区", "color": "#e74c3c" },
    "rowLabel": "A",
    "colNum": 1
  }
}
```

**构建方式**（后端 `SeatSkuService::buildSeatSpecMap`）：
1. 查询 `GoodsSpecBase`（含 `extends.seat_key`）+ `GoodsSpecValue`（含 `value`）
2. 通过 `GoodsSpecValue.value` 匹配 `GoodsSpecType.value` JSON 中的 `name` 确定维度
3. 遍历 `seat_map.rooms[].map` 提取 `rowLabel`（`chr(65+rowIndex)`）和 `colNum`（从1开始）
4. 合并以上信息输出 `seatSpecMap`

**前端用途**：
- `seatSpecMap[seatKey].price` → 座位价格
- `seatSpecMap[seatKey].inventory` → 是否可售（≤0 = 已售）
- `seatSpecMap[seatKey].spec` → submit 时提交完整 5 维规格数组

---

### 1.3 SPEC_DIMS（5 维规格维度常量）

```php
// PHP 后端（SeatSkuService.php）
const SPEC_DIMS = [
    '$vr-场次',    // 第1维
    '$vr-场馆',    // 第2维
    '$vr-演播室',  // 第3维
    '$vr-分区',    // 第4维
    '$vr-座位号',  // 第5维
];
```

前端用 `seatSpecMap[seatKey].spec` 数组代替直接访问 `SPEC_DIMS`。

---

### 1.4 座位图字符矩阵

```json
// seat_map.rooms[].map — 字符串数组，每字符对应一列
map: ["AAAAAA", "BBBBBB", "CCCCCC"]
// A = VIP区座位，B = 看台座位，C = 普通座位
// _ 或 - = 过道/空位（不渲染座位）
```

**渲染规则**：
- 字符 = `_` / `-`：渲染空白占位 div（维持对齐）
- 字符 in `sections[].char`：渲染可用座位（带分区颜色）
- 该座位 `inventory ≤ 0` 或在 `soldSeats` 中：渲染灰色已售座位

**座位 DOM `data-seat-key` 格式**：`{roomId}_{rowLabel}_{colNum}`（例如 `room_001_A_1`）

---

### 1.5 goods_spec_data（场次列表）

```json
// 后端 GetGoodsViewData 从 sessions[] + seatSpecMap 构建
[
  { "spec_id": 0, "spec_name": "15:00-16:59", "price": 299, "start": "15:00", "end": "16:59" },
  { "spec_id": 0, "spec_name": "18:00-20:59", "price": 399, "start": "18:00", "end": "20:59" }
]
```

前端用于渲染场次选择器横向滚动卡片。

---

## 二、后端 API 接口

### 2.1 商品详情 API（VR 扩展字段）

**请求**：`POST /api/goods/detail`
```json
{ "id": 118 }
```

**响应**（关键字段）：
```json
{
  "code": 0,
  "data": {
    "id": 118,
    "title": "VR演唱会",
    "images": "[\"https://...jpg\"]",
    "price": "299-899",
    "is_vr_ticket": 1,
    "vr_goods_config": {
      "version": 3.0,
      "template_id": 4,
      "sessions": [...],
      "template_snapshot": {
        "venue": {...},
        "rooms": [...]
      }
    }
  }
}
```

**注意**：`vr_goods_config` 直接嵌入商品详情响应，前端无需额外请求。

---

### 2.2 购物车提交 API（多座位下单）

**请求**：`POST /api/cart/save`
```json
{
  "goods_data": [
    {
      "goods_id": 118,
      "spec_base_id": 10001,
      "stock": 1,
      "extension_data": "{\"attendee\":{\"real_name\":\"张三\",\"phone\":\"13800138000\",\"id_card\":\"...\"},\"seat\":{\"seat_key\":\"room_001_A_1\",\"row\":\"A\",\"col\":1,\"section\":\"VIP区\"}}"
    },
    {
      "goods_id": 118,
      "spec_base_id": 10002,
      "stock": 1,
      "extension_data": "{\"attendee\":{\"real_name\":\"李四\",\"phone\":\"13900139000\",\"id_card\":\"...\"},\"seat\":{\"seat_key\":\"room_001_A_2\",\"row\":\"A\",\"col\":2,\"section\":\"VIP区\"}}"
    }
  ],
  "buy_type": "goods",
  "address_id": "0"
}
```

**说明**：
- 每个座位单独一条 `goods_data` 记录
- `spec_base_id` 从 `seatSpecMap[seatKey].spec_base_id` 获取
- `extension_data` 为 JSON 序列化的观演人 + 座位信息
- 后端 ShopXO BuyService 按 `spec_base_id` 原子扣库存（`FOR UPDATE SKIP LOCKED`）

---

### 2.3 票夹 API

**列表**：`GET /api/plugins/index?pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=list`
```
无参数（依赖 C 端 session）
```

**响应**：
```json
{
  "code": 0,
  "data": {
    "tickets": [
      {
        "id": 482815,
        "goods_id": 118,
        "goods_title": "VR演唱会",
        "seat_info": "主要展厅 A区 1排1座",
        "session_time": "15:00-16:59",
        "venue_name": "测试场馆",
        "real_name": "张三",
        "verify_status": 0,
        "issued_at": "2026-05-01 12:00:00",
        "short_code": "003a2hgmgety"
      }
    ],
    "count": 1
  }
}
```

| verify_status | 含义 |
|---|---|
| 0 | 未核销 |
| 1 | 已核销 |
| 2 | 已退款 |

**票详情**：`GET /api/plugins/index?...&pluginsaction=detail&id={ticketId}`

```json
{
  "code": 0,
  "data": {
    "ticket": {
      "short_code": "003a2hgmgety",
      "qr_data": "eyJpZCI6NDgyODE1LCJnIjoxMTh9...",
      "qr_expires_at": 1745291400,
      "qr_expires_in": 1800,
      "verify_status": 0,
      "phone": "138****8000"
    }
  }
}
```

**QR payload（签名前）**：
```json
{ "id": 482815, "g": 118, "iat": 1745286000, "exp": 1745287800 }
// iat = 签发时间戳，exp = 过期时间戳（签发后30分钟）
// sig = HMAC-SHA256( payload_json, per-goods_secret )
```

---

## 三、交互规范（从 ticket_detail.html 移植）

### 3.1 选择器级联流程

```
用户选择场次
  → 重置：场馆/演播室/分区/座位图（全部清空+隐藏）
  → 更新 spec options 可用性（场次售罄检查）

用户选择场馆
  → 重置：演播室/分区/座位图（全部清空+隐藏）
  → 更新 spec options 可用性（场馆售罄检查）

用户选择演播室
  → 重置：分区/座位图（清空+隐藏分区）
  → 过滤分区选项（只显示属于该演播室的分区）
  → 动态加载该演播室的座位图（匹配 rooms[].name === currentRoom）
  → 更新 spec options 可用性（演播室售罄检查）

用户选择分区
  → 显示座位图（之前已加载好）
  → filterSeats()：只高亮符合当前 5 维选择的座位
  → 其他座位 opacity:0.3 不可点击

用户点击座位
  → toggleSeat：加入/移出 selectedSeats[]
  → 更新已选座位 UI + 底部总价
  → 显示观演人表单（每座一个）
```

### 3.2 座位图渲染逻辑

```javascript
// renderSeatMap() — 渲染座位矩阵
mapData.forEach((rowStr, rowIndex) => {
    const rowLabel = String.fromCharCode(65 + rowIndex);  // 0→A, 1→B...
    const chars = rowStr.split('');
    chars.forEach((char, colIndex) => {
        const colNum = colIndex + 1;
        const seatKey = `${roomId}_${rowLabel}_${colNum}`;
        const seatInfo = seatSpecMap[seatKey] || {};
        const section = seatInfo.section || {};
        const color = section.color || '#EA4C89';
        const price = seatInfo.price || 0;

        if (char === '_' || char === '-') {
            // 过道空白
        } else if (seatInfo.inventory > 0 && !soldSeats[seatKey]) {
            // 可用座位（可点击）
        } else {
            // 已售座位（灰色）
        }
    });
});
```

### 3.3 filterSeats（5 维过滤）

```javascript
filterSeats: function() {
    // 当前 5 维全部匹配 + inventory > 0 → 高亮可用
    // 否则 → opacity:0.3, pointerEvents:none
    document.querySelectorAll('.vr-seat.available').forEach(function(el) {
        const seatKey = el.dataset.seatKey;
        const seatInfo = seatSpecMap[seatKey] || {};
        let match = { session: true, venue: true, room: true, section: true };

        if (self.currentSession) {
            match.session = seatInfo.spec.some(s => s.type === '$vr-场次' && s.value === self.currentSession);
        }
        if (self.currentVenue) {
            match.venue = seatInfo.spec.some(s => s.type === '$vr-场馆' && s.value === self.currentVenue);
        }
        if (self.currentRoom) {
            match.room = seatInfo.spec.some(s => s.type === '$vr-演播室' && s.value === self.currentRoom);
        }
        if (self.currentSection) {
            match.section = seatInfo.spec.some(s => s.type === '$vr-分区' && s.value === self.currentSection);
        }

        const available = match.session && match.venue && match.room && match.section && seatInfo.inventory > 0;
        el.style.opacity = available ? '1' : '0.3';
        el.style.pointerEvents = available ? 'auto' : 'none';
    });
}
```

### 3.4 售罄级联灰化（从底向上）

```javascript
// updateSpecOptionsAvailability()
// 层级 1: 分区 — 有无可用座位 → 售罄变灰 + "(售罄)"标签
// 层级 2: 演播室 — 所有分区都售罄 → 演播室变灰
// 层级 3: 场馆 — 所有演播室都售罄 → 场馆变灰
// 层级 4: 场次 — 所有场馆都售罄 → 场次变灰
//
// 遍历 seatSpecMap 统计各层级可用座位数（只统计当前场次）
// 灰化时: opacity:0.4 + pointerEvents:none + 添加"(售罄)"span
// 恢复时: 移除灰化样式 + 移除"(售罄)"span
```

### 3.5 已选座位 UI

```javascript
// selectedSeats[] 数组，每选一座 push 一个对象
selectedSeats = [
  {
    seatKey: 'room_001_A_1',
    price: 899,
    rowLabel: 'A',
    colNum: 1,
    section: { char: 'A', name: 'VIP区', color: '#e74c3c' }
  }
];

// updateSelectedUI()
// 合计总价 = selectedSeats.reduce((sum, s) => sum + s.price, 0)
// 每座显示一个观演人表单（姓名/手机/身份证）
// 底部购票按钮 disabled = selectedSeats.length === 0
```

### 3.6 观演人表单

```javascript
// renderAttendeeForms()
// 每个已选座位渲染一个表单块
`
<div class="vr-attendee-block" data-index="${i}">
  <div class="vr-attendee-title">观演人 ${i+1} (${seatLabel})</div>
  <div class="vr-form-row">
    <label>姓名</label>
    <input type="text" data-index="${i}" data-field="real_name" placeholder="请输入姓名" />
  </div>
  <div class="vr-form-row">
    <label>手机</label>
    <input type="tel" data-index="${i}" data-field="phone" placeholder="请输入手机号" />
  </div>
  <div class="vr-form-row">
    <label>身份证</label>
    <input type="text" data-index="${i}" data-field="id_card" placeholder="选填" />
  </div>
</div>
`
```

---

## 四、UniApp 移植注意事项

### 4.1 页面路由

```
pages/goods-vr-ticket/goods-vr-ticket.vue  — VR 票务详情页（已创建）
pages/goods-vr-ticket/components/
  ├── ticket-header/       — 顶部海报+收藏（已完成）
  ├── venue-card/          — 场馆卡片（已完成）
  └── ticket-popup/        — 购票弹窗（已完成，待接入数据）
  ├── vr-session-select/   — 场次选择器（待实现）
  ├── vr-booking-block/     — 规格级联选择（场→馆→室→分区）（待实现）
  ├── vr-seat-selector/    — 座位选择器全屏遮罩（待实现）
  └── vr-attendee-form/    — 观演人表单（待实现）
```

### 4.2 关键适配点

| H5 实现 | UniApp 适配 |
|---------|------------|
| `document.querySelectorAll()` | `uni.createSelectorQuery()` |
| `classList.add/remove` | Vue data driven: `:class="{ 'sold-out': isSoldOut }"` |
| `onclick="fn(this)"` | `@click="fn(item)"` — Vue 事件传参 |
| `style="display:none"` | `v-show` 或 `:style="{ display: isVisible ? 'block' : 'none' }"` |
| `scrollIntoView` | `uni.pageScrollTo({ selector })` 或 `scroll-view` |
| `CryptoJS.enc.Base64` | `btoa(unescape(encodeURIComponent(str)))` |
| `sessionStorage` | `uni.setStorageSync()` / `uni.getStorageSync()` |
| `window.location.href` | `uni.redirectTo()` / `uni.reLaunch()` |
| `$.ajax` | `uni.request()` |
| `_` 和 `-` 占位符 | UniApp 中 `_` 和 `-` 同样适用 |

### 4.3 API 请求封装

```javascript
// main.js 或 common/request.js 中封装
const request = (options) => {
  const app = getApp();
  return new Promise((resolve, reject) => {
    uni.request({
      url: app.globalData.get_request_url(options.action, options.controller || 'goods'),
      method: options.method || 'POST',
      data: options.data,
      success: (res) => {
        if (res.data.code == 0) {
          resolve(res.data);
        } else if (res.data.code == -400) {
          // 未登录，跳转登录页
          uni.redirectTo({ url: '/pages/login/login' });
        } else {
          uni.showToast({ title: res.data.msg || '请求失败', icon: 'none' });
          reject(res.data);
        }
      },
      fail: reject
    });
  });
};
```

### 4.4 vr_goods_config 解析流程

```javascript
// goods-vr-ticket.vue onLoad()
onLoad(params) {
  const app = getApp();
  const goodsId = params.id;

  // 1. 尝试从全局缓存获取（goods-detail 已缓存）
  var goods = app.globalData.goods_data_cache_handle(goodsId);
  if (goods != null) {
    this.handleGoodsData(goods);
    return;
  }

  // 2. 请求 API
  request({ action: 'detail', controller: 'goods', data: { id: goodsId } })
    .then(res => {
      this.handleGoodsData(res.data.goods);
    });
}

handleGoodsData(goods) {
  this.goodsData = goods;

  // 解析 vr_goods_config
  var vrConfig = goods.vr_goods_config;
  if (typeof vrConfig === 'string') {
    vrConfig = JSON.parse(vrConfig);
  }
  this.vrConfig = vrConfig;

  // 提取数据
  this.venues = vrConfig.template_snapshot.venues;
  this.sessions = vrConfig.sessions;
  this.seatMap = vrConfig.template_snapshot;

  // 构建 seatSpecMap（由后端 GetGoodsViewData 返回，前端直接使用）
  // TODO: 需要后端提供 seatSpecMap API 或嵌入 goods 响应中
  this.buildSeatSpecMap();
}
```

### 4.5 待确认：seatSpecMap 获取方式

**现状**：`seatSpecMap` 在 H5 版由 PHP `SeatSkuService::GetGoodsViewData()` 注入模板变量，前端直接用。

**UniApp 方案 A（推荐）**：后端在商品详情 API 中直接返回 `seatSpecMap`（嵌入 `goods.vr_goods_config` 或单独字段）

**UniApp 方案 B**：前端按 `seatSpecMap` 相同逻辑用 JS 重建（不推荐：GoodsSpecValue 无法从前端获取）

**UniApp 方案 C**：新增 `GET /api/plugins/vr_ticket/seatmap?goods_id=xxx` 接口

---

## 五、购买链路完整流程

```
用户选择座位（多选）
    ↓
填写观演人信息（每座一个表单）
    ↓
点击"立即购票"
    ↓
前端校验：座位数 === 观演人数
    ↓
POST /api/cart/save
  goods_data[] = selectedSeats.map(seat => ({
    goods_id: goodsId,
    spec_base_id: seatSpecMap[seat.seatKey].spec_base_id,
    stock: 1,
    extension_data: JSON.stringify({
      attendee: { real_name, phone, id_card },
      seat: { seat_key, row, col, section }
    })
  }))
  buy_type: 'goods'
  address_id: '0'
    ↓
后端 CartSave → BuyService::BuyGoods
  FOR UPDATE SKIP LOCKED 原子扣库存
    ↓
库存不足 → 返回错误，提示用户重选
库存足够 → 写入订单
    ↓
ShopXO 微信支付（Native/H5/小程序）
    ↓
支付成功 → Hook: plugins_service_order_pay_success_handle_end
  → TicketService::onOrderPaid()
    → 解析 extension_data 中的 5 维 spec
    → issueTicket() 生成短码 + QR 签名
    → 写入 vr_tickets 表
    ↓
用户可在票夹页查看电子票（QR码 + 短码）
```

---

## 六、票夹与 QR 票规范

### 6.1 QR 票数据结构

```json
// QR payload（Base64 编码）
{
  "id": 482815,
  "g": 118,
  "iat": 1745286000,
  "exp": 1745287800
}

// qr_data 存储格式（vr_tickets.qr_data）
"{short_code}|{base64(payload)}"
// 例: "003a2hgmgety|eyJpZCI6NDgyODE1LCJnIjoxMTh9..."

// 本地验证（前端）
const payload = JSON.parse(atob(qr_data.split('|')[1]));
if (payload.exp < Date.now() / 1000) {
  // 已过期，显示倒计时刷新提示
}
```

### 6.2 短码格式

```
4位 goods_id(base36) + HMAC-XOR 混淆的 ticket_id
总计约 12 位，可读性好，无需搜索
```

### 6.3 票夹页面结构

```
pages/ticket-wallet/ticket-wallet.vue   — 票夹列表（待实现）
  ├── ticket-card 组件（复用）
  │     ├── 座位信息（场次/场馆/座位号）
  │     ├── QR码（大图，可放大）
  │     ├── 短码（便于手动输入核销）
  │     └── 状态标签（已核销/未核销/已退款）
  └── Realtime 订阅 orders 表状态变更
```

---

## 七、B 端扫码核销（UniApp Admin）

### 7.1 核销页面

```
pages/admin-verify/admin-verify.vue   — 扫码核销（待实现）
  ├── 摄像头扫码（uni.scanCode）
  ├── 短码/UUID 手动输入
  └── 核销结果展示
```

### 7.2 核销 API

**UUID 核销**：`POST /api/plugins/index?...&pluginsaction=verify`
```json
{ "ticket_code": "uuid格式-xxx-xxx" }
```

**短码自动路由**：后端 `verifyByShortCode()` 先尝试 UUID 格式，失败则解析短码。

**响应**：
```json
{ "code": 0, "msg": "核销成功", "data": { "seat_info": "主要展厅 A区 1排1座", "real_name": "张三", "goods_name": "VR演唱会" }}
```
| code | 含义 |
|------|------|
| 0 | 核销成功 |
| -1 | 票不存在 |
| -2 | 已核销 |
| -3 | 已退款 |
| -999 | 系统异常 |

---

## 八、UniApp 待开发清单

### Phase 2 — 规格选择与选座（核心）

| 任务 | 状态 | 说明 |
|------|------|------|
| 接入 vr_goods_config 数据 | 待开发 | 解析场次/场馆/演播室/分区数据 |
| 场次选择器 | 待开发 | 横向滚动卡片，售罄灰化 |
| 场馆选择器 | 待开发 | 级联重置，售罄灰化 |
| 演播室选择器 | 待开发 | 动态过滤分区，显示座位图 |
| 分区选择器 | 待开发 | 演播室激活后显示，按 room 过滤 |
| 座位图渲染 | 待开发 | 字符矩阵渲染，可选/已售状态 |
| 多座位选择 | 待开发 | toggleSeat，selectedSeats[] |
| 售罄级联灰化 | 待开发 | 从底向上 4 层灰化 + "(售罄)"标签 |
| 座位过滤 filterSeats | 待开发 | 按当前 5 维规格过滤 |
| 观演人表单 | 待开发 | 每座一个表单块（姓名/手机/身份证） |

### Phase 3 — 购买与支付

| 任务 | 状态 | 说明 |
|------|------|------|
| 确认购票提交 | 待开发 | CartSave API，多座位 goods_data |
| 支付成功跳转 | 待开发 | 等待 ShopXO 支付回调 |
| 订单状态同步 | 待开发 | Realtime 订阅 orders 表 |

### Phase 4 — 票夹与核销

| 任务 | 状态 | 说明 |
|------|------|------|
| 票夹列表页 | 待开发 | Ticket.list API |
| 票详情页（QR+短码） | 待开发 | Ticket.detail API，QR 缓存逻辑 |
| B 端核销页 | 待开发 | 扫码 + 手动输入，verify API |
| Realtime 状态更新 | 待开发 | 核销后 QR 页面自动变灰 |

---

## 九、后端待配合事项

1. **seatSpecMap 返回**：建议在商品详情 API 响应中直接嵌入 `seatSpecMap`，UniApp 无 GoodsSpecValue 读取权限
2. **CartSave API**：确认 `extension_data` 字段接受 JSON 序列化字符串
3. **ticket/list API**：确认 C 端 session 鉴权方式（JWT 或 cookie）
4. **QR 刷新机制**：前端 15 分钟阈值，前端先查 localStorage 缓存

---

## 十、参考文件索引

| 源文件 | 说明 |
|--------|------|
| `vr-shopxo-plugin/.../view/goods/ticket_detail.html` | H5 实现完整参考（JS 逻辑 100% 可移植） |
| `vr-shopxo-plugin/.../service/SeatSkuService.php` | 后端 SKU 构建 + buildSeatSpecMap |
| `vr-shopxo-plugin/docs/VR_GOODS_CONFIG_SPEC.md` | v3.0 vr_goods_config 协议 |
| `vr-shopxo-plugin/docs/PLAN_5DIM_REFACTOR.md` | 5 维 SPEC_DIMS 详细说明 |
| `vr-shopxo-plugin/docs/PHASE_4_PLAN.md` | QR/短码/票夹/核销完整规范 |
| `vr-shopxo-plugin/docs/PHASE_4_API.md` | 后端 API 完整文档 |
| `vr-shopxo-plugin/docs/SPEC_SELECTOR_DESIGN.md` | 选择器交互规范 |
| `vr-shopxo-plugin/docs/SPEC_SELECTOR_DATA_DICTIONARY.md` | 模板变量数据字典 |
| `vr-shopxo-uniapp/docs/vr-ticket-integration-plan.md` | UniApp Phase 1-4 原规划 |
