vr-shopxo-uniapp/docs/vr-ticket-uniapp-supplement.md

22 KiB
Raw Blame History

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_configv3.0 协议)

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

{
  "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_sectionsroom_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 索引每个座位的完整规格信息:

// 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 提取 rowLabelchr(65+rowIndex))和 colNum从1开始
  4. 合并以上信息输出 seatSpecMap

前端用途

  • seatSpecMap[seatKey].price → 座位价格
  • seatSpecMap[seatKey].inventory → 是否可售≤0 = 已售)
  • seatSpecMap[seatKey].spec → submit 时提交完整 5 维规格数组

1.3 SPEC_DIMS5 维规格维度常量)

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

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


1.4 座位图字符矩阵

// 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场次列表

// 后端 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 商品详情 APIVR 扩展字段)

请求POST /api/goods/detail

{ "id": 118 }

响应(关键字段):

{
  "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

{
  "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_idseatSpecMap[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

响应

{
  "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}

{
  "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签名前

{ "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 座位图渲染逻辑

// 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 filterSeats5 维过滤)

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 售罄级联灰化(从底向上)

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

3.5 已选座位 UI

// 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 观演人表单

// 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 请求封装

// 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 解析流程

// 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 票数据结构

// QR payloadBase64 编码)
{
  "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

{ "ticket_code": "uuid格式-xxx-xxx" }

短码自动路由:后端 verifyByShortCode() 先尝试 UUID 格式,失败则解析短码。

响应

{ "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 过滤
座位图渲染 待开发 字符矩阵渲染,可选/已售状态
多座位选择 待开发 toggleSeatselectedSeats[]
售罄级联灰化 待开发 从底向上 4 层灰化 + "(售罄)"标签
座位过滤 filterSeats 待开发 按当前 5 维规格过滤
观演人表单 待开发 每座一个表单块(姓名/手机/身份证)

Phase 3 — 购买与支付

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

Phase 4 — 票夹与核销

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

九、后端待配合事项

  1. seatSpecMap 返回:建议在商品详情 API 响应中直接嵌入 seatSpecMapUniApp 无 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 原规划