# 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() // 每个已选座位渲染一个表单块 `
观演人 ${i+1} (${seatLabel})
` ``` --- ## 四、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 原规划 |