diff --git a/docs/vr-ticket-uniapp-supplement.md b/docs/vr-ticket-uniapp-supplement.md new file mode 100644 index 00000000..34eb8895 --- /dev/null +++ b/docs/vr-ticket-uniapp-supplement.md @@ -0,0 +1,737 @@ +# 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() +// 每个已选座位渲染一个表单块 +` +