22 KiB
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 存储票务配置快照,发布时写入,读取时直接使用不做关联查询:
{
"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 索引每个座位的完整规格信息:
// 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):
- 查询
GoodsSpecBase(含extends.seat_key)+GoodsSpecValue(含value) - 通过
GoodsSpecValue.value匹配GoodsSpecType.valueJSON 中的name确定维度 - 遍历
seat_map.rooms[].map提取rowLabel(chr(65+rowIndex))和colNum(从1开始) - 合并以上信息输出
seatSpecMap
前端用途:
seatSpecMap[seatKey].price→ 座位价格seatSpecMap[seatKey].inventory→ 是否可售(≤0 = 已售)seatSpecMap[seatKey].spec→ submit 时提交完整 5 维规格数组
1.3 SPEC_DIMS(5 维规格维度常量)
// 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 商品详情 API(VR 扩展字段)
请求: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_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)
响应:
{
"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 filterSeats(5 维过滤)
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 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
{ "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 过滤 |
| 座位图渲染 | 待开发 | 字符矩阵渲染,可选/已售状态 |
| 多座位选择 | 待开发 | 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 页面自动变灰 |
九、后端待配合事项
- seatSpecMap 返回:建议在商品详情 API 响应中直接嵌入
seatSpecMap,UniApp 无 GoodsSpecValue 读取权限 - CartSave API:确认
extension_data字段接受 JSON 序列化字符串 - ticket/list API:确认 C 端 session 鉴权方式(JWT 或 cookie)
- 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 原规划 |