# 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() // 每个已选座位渲染一个表单块 `