feat(Phase2): Issue 1 修复购买提交流程
- Goods.php: 注入 seatSpecMap 到票务模板 - ticket_detail.html: submit() 改 POST + 4维spec数组 关键修复: - submit() 使用隐藏表单 POST 到 Buy 链路(不再用 location.href) - spec 从 seatSpecMap[seatKey].spec 读取完整4维数组 - extension_data 嵌套在 order_base 内 - 直接 JSON.stringify,不需要 base64pull/19/head
commit
c581395a9c
|
|
@ -0,0 +1,160 @@
|
||||||
|
# Agent 执行 Prompt — VR 演唱会票务小程序 Phase 2
|
||||||
|
|
||||||
|
## 前提条件(必读)
|
||||||
|
|
||||||
|
你正在帮助开发一个 **ShopXO 票务插件(vr_ticket)**。
|
||||||
|
|
||||||
|
- 仓库:`http://xmhome.ow-my.com:3000/sileya-ai/vr-shopxo-plugin`
|
||||||
|
- 本地路径:`/Users/bigemon/WorkSpace/vr-shopxo-plugin`
|
||||||
|
- ShopXO 容器:localhost:10000(Web)/ localhost:10001(MySQL)/ localhost:9000(PHP-FPM)
|
||||||
|
- DB 用户:root / shopxo_root_2024,表前缀:`vrt_`
|
||||||
|
|
||||||
|
**完整文档路径**:`/Users/bigemon/WorkSpace/vr-shopxo-plugin/docs/FULL_PLAN.md`
|
||||||
|
|
||||||
|
> ⚠️ 在做任何事情之前,**必须先读 `FULL_PLAN.md`**,理解完整上下文后再开始。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目背景(一句话)
|
||||||
|
|
||||||
|
VR 演唱会票务微信小程序插件。用户选座 → 填观演人 → 微信支付 → 电子票二维码 → 现场扫码核销。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前优先级
|
||||||
|
|
||||||
|
### P0(阻塞一切)
|
||||||
|
|
||||||
|
**Issue 1 修复**:购买提交流程完全失效,有三层叠加问题。
|
||||||
|
|
||||||
|
**顺序**:
|
||||||
|
|
||||||
|
1. **后端**:修改 `SeatSkuService::GetGoodsViewData()`,新增 `seatSpecMap` 生成逻辑
|
||||||
|
- 查询 `GoodsSpecBase` + `GoodsSpecValue` + `GoodsSpecBase.extends.seat_key`
|
||||||
|
- 输出 `seatSpecMap[seatKey] = {spec_base_id, price, inventory, spec: [...]}`
|
||||||
|
- 完整逻辑见 `FULL_PLAN.md` 第 4.3 节
|
||||||
|
|
||||||
|
2. **后端**:修改 `Goods.php`,在 `MyViewAssign` 中加入 `seatSpecMap`
|
||||||
|
|
||||||
|
3. **前端**:修改 `ticket_detail.html`,用 `seatSpecMap` 替代当前错误的 `specBaseIdMap`
|
||||||
|
|
||||||
|
4. **前端**:修复 `submit()` 函数
|
||||||
|
- 改 GET → POST 隐藏表单(**不是** `location.href`)
|
||||||
|
- spec 必须是**完整的 4 维数组**:`[{type:'$vr-场馆',value:'...'},{type:'$vr-分区',value:'...'},{type:'$vr-座位号',value:'...'},{type:'$vr-场次',value:'...'}]`
|
||||||
|
- **不是** `{type:'$vr-座位号', value: seatKey}` — 这是错的
|
||||||
|
- spec 从 `seatSpecMap[seatKey].spec` 读取,**不要自己构造**
|
||||||
|
- `extension_data` 必须嵌套在 `order_base` 内,**不是平铺在第一层**
|
||||||
|
- 直接 `JSON.stringify`,**不需要 base64**
|
||||||
|
|
||||||
|
### P1
|
||||||
|
|
||||||
|
5. **前端**:`ticket_detail.html` 新增场次/场馆/分区选择器 UI + `filterSeatMap()` 联动过滤
|
||||||
|
6. **前端**:缩放时舞台跟随(zoom wrapper 方案)
|
||||||
|
7. **后端**:新增 `sold_seats` API 端点 + 前端 `loadSoldSeats()` 调用
|
||||||
|
|
||||||
|
### P2
|
||||||
|
|
||||||
|
8. 商品详情图片展示(确认需求)
|
||||||
|
9. `GetGoodsViewData()` 多场次返回数组而非 `validConfigs[0]`
|
||||||
|
10. `onOrderPaid` spec 匹配审计(未来关注,不阻塞)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 绝对禁止事项
|
||||||
|
|
||||||
|
- ❌ **不要**用 `location.href` 传递购买参数(ShopXO 只在 POST 时存储数据)
|
||||||
|
- ❌ **不要**把 spec 格式写成 `{type:'$vr-座位号', value: 'room_001_A_3'}` — 这是错的
|
||||||
|
- ❌ **不要**把 `extension_data` 平铺在 `goods_data` 第一层 — 必须嵌套在 `order_base` 里
|
||||||
|
- ❌ **不要**在 submit() 里对 `goods_data` 做 base64 — 直接 `JSON.stringify` 即可
|
||||||
|
- ❌ **不要**修改 `BuyService.php` 的核心逻辑 — 所有修复都在前端和插件后端做
|
||||||
|
- ❌ **不要**新建数据库表来存观演人信息 — 用 ShopXO 原生的 `order.extension_data`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见错误警告
|
||||||
|
|
||||||
|
### spec 数组格式(最高频错误)
|
||||||
|
|
||||||
|
```
|
||||||
|
错误:
|
||||||
|
spec: [{type:'$vr-座位号', value: 'room_001_A_3'}]
|
||||||
|
|
||||||
|
正确(完整4维):
|
||||||
|
spec: [
|
||||||
|
{type:'$vr-场馆', value: 'VR 演唱会馆'},
|
||||||
|
{type:'$vr-分区', value: 'VR 演唱会馆-1号演播厅-VIP区'},
|
||||||
|
{type:'$vr-座位号', value: 'VR 演唱会馆-1号演播厅-VIP区-A-1排3座'},
|
||||||
|
{type:'$vr-场次', value: '15:00-16:59'}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### order_base 嵌套(第二高频错误)
|
||||||
|
|
||||||
|
```
|
||||||
|
错误:
|
||||||
|
{
|
||||||
|
goods_id: 118,
|
||||||
|
spec: [...],
|
||||||
|
extension_data: {...} ← 平铺!错!
|
||||||
|
}
|
||||||
|
|
||||||
|
正确:
|
||||||
|
{
|
||||||
|
goods_id: 118,
|
||||||
|
spec: [...],
|
||||||
|
order_base: { ← 必须嵌套在 order_base 内!
|
||||||
|
extension_data: {...}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### goods_data 编码(第三高频错误)
|
||||||
|
|
||||||
|
```
|
||||||
|
错误:
|
||||||
|
input.value = btoa(JSON.stringify(goodsDataList)) ← 不需要 base64!
|
||||||
|
|
||||||
|
正确:
|
||||||
|
input.value = JSON.stringify(goodsDataList) ← 直接 JSON 字符串
|
||||||
|
```
|
||||||
|
|
||||||
|
ShopXO `BuyService::BuyGoods` 第60行判断 `!is_array($_POST['goods_data'])` 才会 decode,直接 POST JSON 字符串即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速参考
|
||||||
|
|
||||||
|
| 我需要知道 | 去哪里找 |
|
||||||
|
|-----------|---------|
|
||||||
|
| 完整上下文 + 修复方案 | `FULL_PLAN.md` |
|
||||||
|
| 原始 goods.vr_goods_config 数据 | `FULL_PLAN.md` 第二章 |
|
||||||
|
| seatSpecMap 正确结构 | `FULL_PLAN.md` 4.3 节 |
|
||||||
|
| submit() 正确实现 | `FULL_PLAN.md` 第六章 |
|
||||||
|
| Buy 链路数据流 | `FULL_PLAN.md` 6.3 节 |
|
||||||
|
| 关键代码行号索引 | `FULL_PLAN.md` 第八章 |
|
||||||
|
| spec 选择器设计 | `FULL_PLAN.md` 第五章 |
|
||||||
|
| 座位图渲染方法 | `FULL_PLAN.md` 5.3 节 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. **读** `FULL_PLAN.md` 全文(必读)
|
||||||
|
2. **确认**你理解了 4 维 spec 结构 + seatSpecMap 映射关系
|
||||||
|
3. **按优先级顺序**处理 P0 → P1 → P2
|
||||||
|
4. **每完成一个模块**,在本地测试验证后再继续
|
||||||
|
5. **commit 前**:`git status` 检查暂存区,不提交 binary(图片/压缩包),不在本仓库 push 到远程
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## commit 规范
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(Phase2): [模块名] [简短描述]
|
||||||
|
|
||||||
|
示例:
|
||||||
|
feat(Phase2): SeatSkuService GetGoodsViewData 新增 seatSpecMap 生成
|
||||||
|
feat(Phase2): ticket_detail.html 修复 submit() POST + 4维spec数组
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:本仓库是 fork,不直接 push 到 upstream。只 commit 到本地,汇报给大头后由他处理上游合并。
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
# VR 演唱会票务小程序 Phase 2 技术评估报告(修正版)
|
||||||
|
|
||||||
|
> 日期:2026-04-21
|
||||||
|
> 协作产出:BackendArchitect、FrontendDev、FirstPrinciples
|
||||||
|
> 修正:大头 + 西莉雅(2026-04-21 上午)
|
||||||
|
> 源码依据:BuyService.php、GoodsCartService.php、SeatSkuService.php、ticket_detail.html、vr_tickets install.sql
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行摘要
|
||||||
|
|
||||||
|
Phase 2 完成 4 个已知问题的根因分析 + 1 个新发现潜在 Bug。**经大头确认后,修正了 FirstPrinciples 的关键错误结论。**
|
||||||
|
|
||||||
|
**核心修正**:FirstPrinciples「购物车对票务无价值」的结论是**错误的**。Buy 链路是正确方向,ShopXO 原生支持多 SKU 合并下单 + extension_data 透传 + onOrderPaid 写入 vr_tickets。只需修复 submit() 的传递方式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题总览
|
||||||
|
|
||||||
|
| # | 问题 | 优先级 | 根因 |
|
||||||
|
|---|------|--------|------|
|
||||||
|
| 1 | 购买提交流程失效 | **P0** | GET→POST 机制错误 + spec 字段格式错误 |
|
||||||
|
| 2 | 缩放时舞台不跟随 | **P1** | DOM 结构导致 transform 不共享 |
|
||||||
|
| 3 | spec 加载问题(已回滚) | **P1** | loadSoldSeats() 是空 stub + 需 sold_seats API |
|
||||||
|
| 4 | 商品详情/图片加载 | **P2** | 模板未引入内容组件 |
|
||||||
|
|
||||||
|
**新发现**:
|
||||||
|
| # | 问题 | 优先级 |
|
||||||
|
|---|------|--------|
|
||||||
|
| 5 | GetGoodsViewData() 只返回第一个场次 | **P2 潜在** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 1(P0):购买提交流程失效
|
||||||
|
|
||||||
|
### 根因(三层叠加)
|
||||||
|
|
||||||
|
**第一层(致命)**:`location.href` 产生 GET,但 `Buy::Index()` 只在 POST 时调用 `BuyDataStorage()`。
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Buy.php:58-61
|
||||||
|
public function Index() {
|
||||||
|
if($this->data_post) {
|
||||||
|
BuyService::BuyDataStorage($user_id, $this->data_post); // ← POST 才执行
|
||||||
|
return MyRedirect(MyUrl('index/buy/index'));
|
||||||
|
} else {
|
||||||
|
$buy_data = BuyService::BuyDataRead($user_id); // GET → 读 session → 空
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
→ `goods_params` URL 参数从未被读取 → `BuyDataStorage` 未被调用 → buy 确认页收不到数据 → "商品数据为空"。
|
||||||
|
|
||||||
|
**第二层(严重)**:字段名 `goods_params` vs 期望的 `goods_data`。
|
||||||
|
|
||||||
|
**第三层(中等)**:spec 格式不匹配:
|
||||||
|
- 当前:`spec_base_id: int`(直接传 ID)
|
||||||
|
- ShopXO:`spec: [{type, value}]` 字符串匹配 GoodsSpecValue 表
|
||||||
|
|
||||||
|
### ShopXO Buy 链路完全支持多座位合并下单
|
||||||
|
|
||||||
|
**ShopXO 原生能力验证**:
|
||||||
|
- `BuyService::BuyGoods` 第86行:`foreach($params['goods_data'] as $v)` — 原生遍历多 SKU
|
||||||
|
- `BuyService::OrderInsertHandle` 第773行:`'extension_data' => json_encode($v['order_base']['extension_data'])` — 原生写入 extension_data
|
||||||
|
- `vr_tickets` install.sql 已有:`real_name`, `phone`, `id_card` 字段 ✅
|
||||||
|
- `TicketService::issueTicket()` 第141行:从 `$order['extension_data']` 读取观演人 ✅
|
||||||
|
|
||||||
|
### 正确修复方案(只需改 submit())
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// var self = this; — 原始代码第6行已有此声明,确保 submit() 上方作用域有 var self = this
|
||||||
|
submit: function() {
|
||||||
|
var self = this; // 如作用域内已有则忽略此行
|
||||||
|
|
||||||
|
// 1. 收集观演人
|
||||||
|
var inputs = document.querySelectorAll('#attendeeList input');
|
||||||
|
var attendeeData = {};
|
||||||
|
inputs.forEach(function(input) {
|
||||||
|
var idx = input.dataset.index;
|
||||||
|
var field = input.dataset.field;
|
||||||
|
if (!attendeeData[idx]) attendeeData[idx] = {};
|
||||||
|
attendeeData[idx][field] = input.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 构建 ShopXO 原生 goods_data 格式
|
||||||
|
//
|
||||||
|
// ⚠️ 【必须】order_base.extension_data 是 BuyService 隐含契约(第86行 $v['order_base'])
|
||||||
|
// 必须嵌套在 order_base 内!不能平铺在 goods_data 第一层!
|
||||||
|
// ⚠️ 【必须】直接传 JSON 字符串,不需要 base64
|
||||||
|
// BuyService 第60行判断:!is_array($_POST['goods_data']) → json_decode()
|
||||||
|
// ShopXO 标准 buy/index.html 第871行也是直接输出 JSON 字符串,不 base64
|
||||||
|
var goodsDataList = this.selectedSeats.map(function(seat, i) {
|
||||||
|
var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId;
|
||||||
|
return {
|
||||||
|
goods_id: self.goodsId,
|
||||||
|
spec: [{type: '$vr-座位号', value: seat.seatKey}],
|
||||||
|
stock: 1,
|
||||||
|
order_base: { // ← 必须嵌套!不能平铺!
|
||||||
|
extension_data: {
|
||||||
|
attendee: {
|
||||||
|
real_name: attendeeData[i]?.real_name || '',
|
||||||
|
phone: attendeeData[i]?.phone || '',
|
||||||
|
id_card: attendeeData[i]?.id_card || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 隐藏表单 POST 到 Buy 链路
|
||||||
|
//
|
||||||
|
// ⚠️ requestUrl 来自 PHP 模板注入(ticket_detail.html 第6行):
|
||||||
|
// var requestUrl = '<?php echo Config("shopxo.host_url"); ?>';
|
||||||
|
// 确认 Goods.php 在传给票务模板时已在 $assign 中注入 host_url
|
||||||
|
var form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = requestUrl + '?s=index/buy/index'; // 用模板注入的全局 requestUrl 变量
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.name = 'goods_data';
|
||||||
|
input.value = JSON.stringify(goodsDataList); // 直接 JSON,BuyService 自动处理
|
||||||
|
form.appendChild(input);
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**完整数据流**(ShopXO 原生,无需扩展):
|
||||||
|
```
|
||||||
|
submit() POST goods_data(含 order_base.extension_data)
|
||||||
|
→ Buy::Index → BuyDataStorage(user_id, data_post) [存入 session]
|
||||||
|
→ 跳转确认页(GET)→ form hidden field 携带 goods_data
|
||||||
|
→ Buy::Add → BuyGoods → OrderInsertHandle
|
||||||
|
→ order.extension_data 写入 Order 表
|
||||||
|
→ 支付成功 → onOrderPaid → issueTicket()
|
||||||
|
→ 从 $order['extension_data'] 读取观演人 → 写入 vr_tickets(real_name/phone/id_card) ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 2(P1):缩放时舞台不跟随
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
|
||||||
|
`.vr-stage` 和 `.vr-seat-rows` 是平级兄弟元素,transform 只作用于子树。
|
||||||
|
|
||||||
|
### 修复方案
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="vr-seat-map-wrapper">
|
||||||
|
<div class="vr-zoom-container" id="zoomContainer">
|
||||||
|
<div class="vr-stage">舞 台</div>
|
||||||
|
<div class="vr-seat-rows" id="seatRows"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.vr-zoom-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
transform-origin: center top;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
缩放 JS 操作 `#zoomContainer` 的 `transform: scale()`,舞台和座位同步缩放。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 3(P1):spec 加载问题(已回滚)
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
|
||||||
|
- `loadSoldSeats()` 是空 TODO stub,无任何 AJAX 调用
|
||||||
|
- 后端无 `sold_seats` API 端点
|
||||||
|
|
||||||
|
### 修复方案
|
||||||
|
|
||||||
|
**后端**:新增 `plugins/vr_ticket/index/soldSeats` API 端点
|
||||||
|
```
|
||||||
|
GET /?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=index&pluginsaction=soldSeats
|
||||||
|
Query: goods_id, spec_base_id
|
||||||
|
Response: {code: 0, data: {sold_seats: ["A_1", "A_2", "B_5"]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端**:`loadSoldSeats()` 调用该接口,标记 `.sold` class。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 4(P2):商品详情/图片加载
|
||||||
|
|
||||||
|
- `$goods['content']`:✅ 正常渲染
|
||||||
|
- `$goods['images']`:⚠️ 数据存在但未使用
|
||||||
|
- `.goods-detail-content` CSS:⚠️ 缺失
|
||||||
|
|
||||||
|
如需展示商品图片,在模板中添加图片渲染逻辑。如票务详情页不需要 ShopXO 商品内容,可降级为「确认不需要」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 5(P2 潜在):GetGoodsViewData 只返回第一个场次
|
||||||
|
|
||||||
|
`SeatSkuService::GetGoodsViewData()` 第368行返回 `validConfigs[0]`,多场次商品只显示第一个场次。
|
||||||
|
|
||||||
|
### 修复方向
|
||||||
|
|
||||||
|
修改返回值格式为数组,前端根据选中场次索引读取对应数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一性原则视角(修正后)
|
||||||
|
|
||||||
|
1. **Issue 1 是「传输机制损坏」,不是「流程错误」**:Buy 链路完全正确,多 SKU 合并下单是 ShopXO 原生能力,不需要绕过。
|
||||||
|
|
||||||
|
2. **extension_data 存储完全在 ShopXO 生态内**:`order.extension_data` → `onOrderPaid` → `vr_tickets` 全链路原生打通,不需要新建表或扩展字段。
|
||||||
|
|
||||||
|
3. **`spec_base_id_map` 是性能缓存**:如果 `onOrderPaid` 能通过 seatKey(spec value 字符串)查询 spec_base_id,map 可以去掉。但保留是合理的优化。
|
||||||
|
|
||||||
|
4. **`onOrderPaid` 是座位唯一性权威**(未审计):在 Issue 1 修复部署前,必须验证此 Hook 是否正确实现了座位锁定(幂等 + FOR UPDATE)。这是防双售的核心。
|
||||||
|
|
||||||
|
5. **onOrderPaid spec 匹配存在潜在 bug(⚠️ 新增)**:`BatchGenerate` 写入 GoodsSpecValue.value 的格式是 `"{$venueName}-{$roomName}-{$char}-{$rowLabel}{$col}"`(如 "场馆A-放映室1-A-A3座"),而前端 seatKey 格式是 `"roomId_A_3"`,两者不匹配。`TicketService::issueTicket` 第57-77行通过 `type='$vr-座位号'` 匹配 GoodsSpecValue.value 的逻辑会失效。目前不影响功能是因为幂等靠 `seat_info` 字段(不需要 spec_base_id),但如果未来需要精确关联,此处需修复 value 写入格式或改为读 GoodsSpecBase.extends.seat_key。
|
||||||
|
|
||||||
|
6. **最小修复范围**:只需修改 `submit()` 函数(POST + 正确 goods_data 格式 + extension_data)。不需要重构 spec 系统,不需要新建表,不需要绕过 Buy 链路。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复优先级
|
||||||
|
|
||||||
|
| 优先级 | Issue | 负责 | 修复说明 |
|
||||||
|
|--------|------|------|---------|
|
||||||
|
| P0 | Issue 1 submit() | FrontendDev | 改隐藏表单 POST,正确构造 goods_data + extension_data |
|
||||||
|
| P1 | Issue 2 舞台缩放 | FrontendDev | 新增 zoom wrapper 容器 |
|
||||||
|
| P1 | Issue 3 spec 加载 | BackendArchitect | 新增 sold_seats API + 前端调用 |
|
||||||
|
| P2 | Issue 4 商品详情 | FrontendDev | 确认是否需要,补充 CSS |
|
||||||
|
| P2 | Issue 5 多场次 | BackendArchitect | GetGoodsViewData 返回数组格式 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:ShopXO Buy 链路关键代码索引
|
||||||
|
|
||||||
|
| 文件 | 行号 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `Buy.php` | 58-61 | Index() — POST/GET 分支,BuyDataStorage/BuyDataRead |
|
||||||
|
| `BuyService.php` | 51-62 | BuyGoods — goods_data 参数校验 + base64/JSON 解码 |
|
||||||
|
| `BuyService.php` | 86 | foreach($params['goods_data'] as $v) — 多 SKU 原生遍历 |
|
||||||
|
| `BuyService.php` | 104-109 | GoodsSpecDetail 调用 — spec.value 字符串匹配 |
|
||||||
|
| `BuyService.php` | 773 | OrderInsertHandle — extension_data 写入 order 表 |
|
||||||
|
| `BuyService.php` | 1932 | BuyDataStorage — 21600s TTL session 缓存 |
|
||||||
|
| `Buy/index.html` | 871 | 确认表单 hidden goods_data field(原生包含) |
|
||||||
|
| `TicketService.php` | 141-143 | issueTicket — 从 $order['extension_data'] 读观演人 |
|
||||||
|
| `SeatSkuService.php` | ~368 | GetGoodsViewData — validConfigs[0] 多场次 Bug |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*VR 演唱会票务小程序 Phase 2 技术评估 — Council 协作完成,2026-04-21 修正版*
|
||||||
|
|
@ -0,0 +1,680 @@
|
||||||
|
# VR 演唱会票务小程序 — 完整实现文档
|
||||||
|
|
||||||
|
> 最后更新:2026-04-21
|
||||||
|
> 用途:给任意 agent 独立阅读并推进事务
|
||||||
|
> 仓库:`http://xmhome.ow-my.com:3000/sileya-ai/vr-shopxo-plugin`
|
||||||
|
> 本地路径:`/Users/bigemon/WorkSpace/vr-shopxo-plugin`
|
||||||
|
> ShopXO 容器:localhost:10000(Web)/ localhost:10001(MySQL)/ localhost:9000(PHP-FPM)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、项目概览
|
||||||
|
|
||||||
|
### 1.1 目标产品
|
||||||
|
|
||||||
|
VR 演唱会票务微信小程序。用户选座 → 填观演人 → 微信支付 → 获取电子票二维码 → 现场扫码核销。
|
||||||
|
|
||||||
|
### 1.2 技术栈
|
||||||
|
|
||||||
|
- **前端**:原生 HTML + CSS + JS(无框架),商品详情页使用 `ticket_detail.html`
|
||||||
|
- **后端**:ShopXO(ThinkPHP 8)插件 `vr_ticket`
|
||||||
|
- **数据库**:ShopXO MySQL,表前缀 `vrt_`
|
||||||
|
- **微信支付**:ShopXO 原生微信支付
|
||||||
|
|
||||||
|
### 1.3 核心表结构
|
||||||
|
|
||||||
|
| 表名 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `vrt_vr_seat_templates` | 座位模板(座位图画法 + 绑定分类) |
|
||||||
|
| `vrt_vr_tickets` | 电子票(order_id + seat_info + real_name/phone/id_card) |
|
||||||
|
| `vrt_vr_verifiers` | 核销员 |
|
||||||
|
| `vrt_vr_verifications` | 核销记录 |
|
||||||
|
| `vrt_vr_audit_log` | 操作审计日志 |
|
||||||
|
|
||||||
|
ShopXO 原生表:
|
||||||
|
| 表名 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `goods` | 商品(含 `vr_goods_config` 扩展 JSON 字段) |
|
||||||
|
| `goods_spec_base` | SKU(库存/价格),`extends` 含 `seat_key` |
|
||||||
|
| `goods_spec_value` | spec 维度值(4维度:场馆/分区/座位号/场次) |
|
||||||
|
| `order` | 订单(含 `extension_data` JSON 字段) |
|
||||||
|
| `order_detail` | 订单明细 |
|
||||||
|
|
||||||
|
### 1.4 spec 四维度说明
|
||||||
|
|
||||||
|
ShopXO 每个 GoodsSpecBase(SKU)由 4 个 spec type-value 联合确定:
|
||||||
|
|
||||||
|
| type | 说明 | 示例 value |
|
||||||
|
|------|------|-----------|
|
||||||
|
| `$vr-场馆` | 场馆名 | `VR 体验馆` |
|
||||||
|
| `$vr-分区` | 场馆+演播厅+分区 | `VR 体验馆-1号演播厅-VIP区` |
|
||||||
|
| `$vr-座位号` | 完整路径座位名 | `VR 体验馆-1号演播厅-VIP区-A-1排3座` |
|
||||||
|
| `$vr-场次` | 场次时间 | `15:00-16:59` |
|
||||||
|
|
||||||
|
**注意**:spec value 是**完整路径字符串**,不是 `"A_3"` 或 `"roomId_A_3"` 这种短格式。
|
||||||
|
|
||||||
|
### 1.5 座位的唯一标识(seatKey)
|
||||||
|
|
||||||
|
前后端共用同一个格式:`{roomId}_{rowLabel}_{colNum}`
|
||||||
|
- `roomId`:`rooms[].id`,来自 `vr_goods_config.template_snapshot.rooms`
|
||||||
|
- `rowLabel`:座位行标签,`A`/`B`/`C`(由 map 行索引计算:`String.fromCharCode(65 + rowIndex)`)
|
||||||
|
- `colNum**:列号(从 1 开始:`colIndex + 1`)
|
||||||
|
|
||||||
|
示例:`"room_001_A_3"` = room_001 的 A排 第3列
|
||||||
|
|
||||||
|
seatKey 对应 `GoodsSpecBase.extends.seat_key`,用于关联 GoodsSpecBase 和前端座位 DOM。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、现状与已知问题
|
||||||
|
|
||||||
|
### Phase 0/1 完成情况
|
||||||
|
|
||||||
|
✅ `Goods.php` 判断 `item_type='ticket'` → 渲染 `ticket_detail.html`
|
||||||
|
✅ `ticket_detail.html` 座位图渲染 + 选座 JS + 观演人表单
|
||||||
|
✅ `SeatSkuService::GetGoodsViewData()` 返回座位图数据
|
||||||
|
✅ `TicketService::onOrderPaid()` 支付成功后生成 `vr_tickets`
|
||||||
|
✅ 4 个后台管理控制器(座位模板/票/核销员/核销记录)
|
||||||
|
✅ 基础防超卖幂等保护
|
||||||
|
|
||||||
|
### Phase 2 待修复问题(源自 Council 评估 + 大头确认)
|
||||||
|
|
||||||
|
| # | 问题 | 优先级 | 状态 |
|
||||||
|
|---|------|--------|------|
|
||||||
|
| Issue 1 | 购买提交流程失效(GET→POST 机制错误 + spec 格式错误 + 缺 seatSpecMap) | **P0** | 待修复 |
|
||||||
|
| Issue 2 | 缩放时舞台不跟随 | **P1** | 待修复 |
|
||||||
|
| Issue 3 | spec 加载(loadSoldSeats 空 stub + 无 sold_seats API) | **P1** | 待修复 |
|
||||||
|
| Issue 4 | 商品详情/图片加载 | **P2** | 待修复 |
|
||||||
|
| Issue 5 | GetGoodsViewData 只返回第一个场次 | **P2** | 待修复 |
|
||||||
|
|
||||||
|
**核心问题说明**(Issue 1 P0):
|
||||||
|
Issue 1 不是单一 bug,而是三层叠加问题:
|
||||||
|
1. `submit()` 用 `location.href`(GET),ShopXO `Buy::Index` 只在 POST 时调用 `BuyDataStorage`
|
||||||
|
2. spec 格式错误:只传 1 维度而非 4 维度
|
||||||
|
3. **最严重**:前端根本没有 seatSpecMap,无法把座位 DOM 映射到正确的 GoodsSpecBase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、商品118 vr_goods_config(原始数据库数据)
|
||||||
|
|
||||||
|
存储位置:`goods` 表 `vr_goods_config` JSON 字段(商品 ID = 118)
|
||||||
|
|
||||||
|
这是从数据库直接读取的原始数据,**所有其他数据结构均派生于此**。
|
||||||
|
|
||||||
|
```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": "VR 演唱会馆",
|
||||||
|
"address": "北京市朝阳区建国路88号",
|
||||||
|
"location": { "lng": "116.45792", "lat": "39.90745" },
|
||||||
|
"images": [
|
||||||
|
"/static/attachments/202603/venue_001.jpg",
|
||||||
|
"/static/attachments/202603/venue_002.jpg"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "room_001",
|
||||||
|
"name": "1号演播厅",
|
||||||
|
"map": [
|
||||||
|
"AAAAA_____BBBBB",
|
||||||
|
"AAAAA_____BBBBB",
|
||||||
|
"AAAAA_____BBBBB",
|
||||||
|
"CCCCCCCCCCCCCCC",
|
||||||
|
"CCCCCCCCCCCCCCC"
|
||||||
|
],
|
||||||
|
"sections": [
|
||||||
|
{ "char": "A", "name": "VIP区", "price": 380, "color": "#f06292" },
|
||||||
|
{ "char": "B", "name": "看台区", "price": 180, "color": "#4fc3f7" },
|
||||||
|
{ "char": "C", "name": "普通区", "price": 80, "color": "#81c784" }
|
||||||
|
],
|
||||||
|
"seats": {
|
||||||
|
"A": { "char": "A", "name": "VIP区", "price": 380, "color": "#f06292" },
|
||||||
|
"B": { "char": "B", "name": "看台区", "price": 180, "color": "#4fc3f7" },
|
||||||
|
"C": { "char": "C", "name": "普通区", "price": 80, "color": "#81c784" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room_002",
|
||||||
|
"name": "2号演播厅(副厅)",
|
||||||
|
"map": [
|
||||||
|
"DDDDDDD",
|
||||||
|
"DDDDDDD",
|
||||||
|
"EEEEEEE"
|
||||||
|
],
|
||||||
|
"sections": [
|
||||||
|
{ "char": "D", "name": "互动区", "price": 280, "color": "#ffb74d" },
|
||||||
|
{ "char": "E", "name": "站票区", "price": 50, "color": "#90a4ae" }
|
||||||
|
],
|
||||||
|
"seats": {
|
||||||
|
"D": { "char": "D", "name": "互动区", "price": 280, "color": "#ffb74d" },
|
||||||
|
"E": { "char": "E", "name": "站票区", "price": 50, "color": "#90a4ae" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
| 字段 | 含义 | 前端是否可用 |
|
||||||
|
|------|------|------------|
|
||||||
|
| `version` | 协议版本(当前 3.0) | ❌ 内部使用 |
|
||||||
|
| `template_id` | 关联座位模板 ID | ❌ 内部使用 |
|
||||||
|
| `selected_rooms` | 启用的房间 ID 列表 | ✅ 用于初始化 |
|
||||||
|
| `selected_sections` | 每个房间选中的分区字符 | ✅ 用于默认高亮 |
|
||||||
|
| `sessions` | 场次列表(start/end) | ✅ **场次选择器数据源** |
|
||||||
|
| `template_snapshot.venue` | 场馆信息 | ✅ Banner/详情展示 |
|
||||||
|
| `template_snapshot.rooms[].id` | 房间唯一 ID | ✅ **seatKey 构造必需** |
|
||||||
|
| `template_snapshot.rooms[].map` | 座位图字符矩阵 | ✅ **座位图渲染必需** |
|
||||||
|
| `template_snapshot.rooms[].sections` | 分区列表(char→name/price/color) | ✅ **图例+分区选择器** |
|
||||||
|
| `template_snapshot.rooms[].seats` | char→座位属性映射 | ✅ **查座位详情** |
|
||||||
|
|
||||||
|
### map 格式说明
|
||||||
|
|
||||||
|
```
|
||||||
|
"AAAAA_____BBBBB"
|
||||||
|
↓分解为字符数组↓
|
||||||
|
['A','A','A','A','A','_','_','_','_','_','B','B','B','B','B']
|
||||||
|
←VIP区×5→←空位×5→←看台区×5→
|
||||||
|
|
||||||
|
字符含义:
|
||||||
|
A/B/C/D/E = 座位(通过 rooms[i].seats[char] 查属性)
|
||||||
|
'_' / '-' = 空位(不渲染座位)
|
||||||
|
其他非字母 = 不渲染
|
||||||
|
```
|
||||||
|
|
||||||
|
### rooms.seats 与 rooms.sections 的关系
|
||||||
|
|
||||||
|
同一个 char 在不同房间代表不同分区:
|
||||||
|
- `room_001` 的 `A` = VIP区(红色,380元)
|
||||||
|
- `room_002` 的 `D` = 互动区(橙色,280元)
|
||||||
|
|
||||||
|
**分区信息在 `sections[]` 里**,不要直接用 char 本身判断分区名称或价格。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、后端注入的模板数据
|
||||||
|
|
||||||
|
`Goods.php` 在渲染 `ticket_detail.html` 前,通过 `SeatSkuService::GetGoodsViewData()` 向模板注入以下变量:
|
||||||
|
|
||||||
|
```php
|
||||||
|
MyViewAssign([
|
||||||
|
'vr_seat_template' => $viewData['vr_seat_template'], // 座位图原始数据
|
||||||
|
'goods_spec_data' => $viewData['goods_spec_data'], // 场次列表
|
||||||
|
'seatSpecMap' => $viewData['seatSpecMap'] ?? [], // 【待新增】座位→4维spec映射
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
模板中接收方式:
|
||||||
|
```javascript
|
||||||
|
var vrSeatTemplate = <?php echo json_encode($vr_seat_template ?? [], JSON_UNESCAPED_UNICODE); ?>;
|
||||||
|
var goodsSpecData = <?php echo json_encode($goods_spec_data ?? [], JSON_UNESCAPED_UNICODE); ?>;
|
||||||
|
var seatSpecMap = <?php echo json_encode($seatSpecMap ?? [], JSON_UNESCAPED_UNICODE); ?>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 vr_seat_template(透传 template_snapshot)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
venue: {
|
||||||
|
name: "VR 演唱会馆",
|
||||||
|
address: "北京市朝阳区建国路88号",
|
||||||
|
location: { lng: "116.45792", lat: "39.90745" },
|
||||||
|
images: ["/static/attachments/202603/venue_001.jpg"]
|
||||||
|
},
|
||||||
|
rooms: [
|
||||||
|
{
|
||||||
|
id: "room_001",
|
||||||
|
name: "1号演播厅",
|
||||||
|
map: ["AAAAA_____BBBBB", "AAAAA_____BBBBB", "AAAAA_____BBBBB", "CCCCCCCCCCCCCCC", "CCCCCCCCCCCCCCC"],
|
||||||
|
sections: [
|
||||||
|
{ char: "A", name: "VIP区", price: 380, color: "#f06292" },
|
||||||
|
{ char: "B", name: "看台区", price: 180, color: "#4fc3f7" },
|
||||||
|
{ char: "C", name: "普通区", price: 80, color: "#81c784" }
|
||||||
|
],
|
||||||
|
seats: { /* 同第二章 seats */ }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "room_002",
|
||||||
|
name: "2号演播厅(副厅)",
|
||||||
|
map: ["DDDDDDD", "DDDDDDD", "EEEEEEE"],
|
||||||
|
sections: [ /* 同第二章 sections */ ],
|
||||||
|
seats: { /* 同第二章 seats */ }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sessions: [
|
||||||
|
{ start: "15:00", end: "16:59" },
|
||||||
|
{ start: "18:00", end: "20:59" }
|
||||||
|
],
|
||||||
|
selectedRooms: ["room_001", "room_002"],
|
||||||
|
selectedSections: { "room_001": ["A", "B"], "room_002": ["A"] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 goods_spec_data(场次列表)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 来源:goods.vr_goods_config.sessions + ShopXO GoodsSpecBase.price
|
||||||
|
[
|
||||||
|
{ spec_id: 2001, spec_name: "15:00-16:59", price: 380, start: "15:00", end: "16:59" },
|
||||||
|
{ spec_id: 2002, spec_name: "18:00-20:59", price: 280, start: "18:00", end: "20:59" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 seatSpecMap(待新增,核心数据结构)
|
||||||
|
|
||||||
|
**来源**:`GetGoodsViewData()` 查询 `GoodsSpecBase` + `GoodsSpecValue` + `GoodsSpecBase.extends`,动态构建
|
||||||
|
|
||||||
|
**用途**:前端选中座位后,查表获取该座位的完整 4 维 spec 数组 + 对应 GoodsSpecBase
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// key 格式:{roomId}_{rowLabel}_{colNum}
|
||||||
|
// 示例:room_001_A_3 = room_001 的 A排 第3列
|
||||||
|
|
||||||
|
{
|
||||||
|
"room_001_A_1": {
|
||||||
|
spec_base_id: 10001,
|
||||||
|
price: 380,
|
||||||
|
inventory: 1, // 0 = 已售,1 = 可购
|
||||||
|
rowLabel: "A",
|
||||||
|
colNum: 3,
|
||||||
|
roomId: "room_001",
|
||||||
|
section: { char: "A", name: "VIP区", color: "#f06292" },
|
||||||
|
// === 4维 spec 数组(submit() 时直接使用)===
|
||||||
|
spec: [
|
||||||
|
{ type: "$vr-场馆", value: "VR 演唱会馆" },
|
||||||
|
{ type: "$vr-分区", value: "VR 演唱会馆-1号演播厅-VIP区" },
|
||||||
|
{ type: "$vr-座位号", value: "VR 演唱会馆-1号演播厅-VIP区-A-1排1座" },
|
||||||
|
{ type: "$vr-场次", value: "15:00-16:59" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"room_001_A_2": { /* 同上,A排第2座 */ },
|
||||||
|
"room_001_B_8": { spec_base_id: 10025, price: 180, inventory: 0, /* 已售 */ },
|
||||||
|
"room_002_D_1": { spec_base_id: 20001, price: 280, inventory: 1, /* 互动区 */ },
|
||||||
|
// ...每个可购座位一行
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### seatSpecMap 生成逻辑(GetGoodsViewData 中实现)
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 1. 查询所有有效 GoodsSpecBase(含 extends.seat_key)
|
||||||
|
$specs = Db::name('GoodsSpecBase')
|
||||||
|
->where('goods_id', $goodsId)
|
||||||
|
->where('inventory', '>', 0) // 只取有库存的
|
||||||
|
->select();
|
||||||
|
|
||||||
|
// 2. 查询对应的 GoodsSpecValue(4个维度的 type/value)
|
||||||
|
$specIds = array_column($specs->toArray(), 'id');
|
||||||
|
$specValues = Db::name('GoodsSpecValue')
|
||||||
|
->whereIn('goods_spec_base_id', $specIds)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
// 3. 按 spec_base_id 分组,构建 4维 spec 数组
|
||||||
|
$specByBaseId = [];
|
||||||
|
foreach ($specValues as $sv) {
|
||||||
|
$specByBaseId[$sv['goods_spec_base_id']][] = [
|
||||||
|
'type' => $sv['type'],
|
||||||
|
'value' => $sv['value'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 构建 seatSpecMap
|
||||||
|
$seatSpecMap = [];
|
||||||
|
foreach ($specs as $spec) {
|
||||||
|
$extends = json_decode($spec['extends'] ?? '{}', true);
|
||||||
|
$seatKey = $extends['seat_key'] ?? '';
|
||||||
|
if (empty($seatKey)) continue;
|
||||||
|
|
||||||
|
$seatSpecMap[$seatKey] = [
|
||||||
|
'spec_base_id' => intval($spec['id']),
|
||||||
|
'price' => floatval($spec['price']),
|
||||||
|
'inventory' => intval($spec['inventory']),
|
||||||
|
'spec' => $specByBaseId[$spec['id']] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、产品形态:多维度 spec 选择器 + 多座位选择
|
||||||
|
|
||||||
|
### 5.1 界面结构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 顶部 Banner(venue.images) │
|
||||||
|
│ │
|
||||||
|
│ 场次选择 │
|
||||||
|
│ [●15:00-16:59 ¥380] [ 18:00-20:59 ¥280 ] │
|
||||||
|
│ │
|
||||||
|
│ 场馆/分区选择(spec 选择器交互) │
|
||||||
|
│ [●1号演播厅] [ 2号演播厅 ] │
|
||||||
|
│ [●VIP区380] [ 看台区180 ] [ 普通区80 ] │
|
||||||
|
│ │
|
||||||
|
│ ─────────── 座位图(多选)───────────────────── │
|
||||||
|
│ 舞 台 │
|
||||||
|
│ A排 [■■■■■] ← 可选(VIP,红色) │
|
||||||
|
│ B排 [■■■■■] ← 可选(看台,蓝色) │
|
||||||
|
│ C排 [灰掉] ← 不在当前分区 │
|
||||||
|
│ │
|
||||||
|
│ 图例:[■]可选 [██]已售 [░░]不可选 │
|
||||||
|
│ │
|
||||||
|
│ ─────────── 观演人表单 ───────────────────────── │
|
||||||
|
│ 第1张票:张三 138****000 身份证(选填) │
|
||||||
|
│ 第2张票:李四 139****111 身份证(选填) │
|
||||||
|
│ │
|
||||||
|
│ ─────────── 底部价格栏 ───────────────────────── │
|
||||||
|
│ 已选 2 座,合计 ¥760 [提交订单] │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 spec 选择器交互(参考原生 ShopXO spec 选择器行为)
|
||||||
|
|
||||||
|
用户切换场次/场馆/分区时,未在当前选择分支内的座位自动变灰/隐藏:
|
||||||
|
|
||||||
|
```
|
||||||
|
切换场次 → 重置场馆/分区/座位 → 用 seatSpecMap 过滤出该场次所有座位
|
||||||
|
切换场馆 → 重置分区/座位 → 用 seatSpecMap 过滤出该场馆所有座位
|
||||||
|
切换分区 → 只灰掉其他分区座位 → 用 seatSpecMap 过滤出该分区座位
|
||||||
|
点击座位 → 复选/取消 → 更新 selectedSeats[]
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 过滤函数
|
||||||
|
function filterSeatMap(currentSession, currentVenueName, currentSectionChar) {
|
||||||
|
Object.entries(seatSpecMap).forEach(function([seatKey, seatInfo]) {
|
||||||
|
var spec = seatInfo.spec; // 4维数组
|
||||||
|
|
||||||
|
var hasSession = spec.some(function(s) {
|
||||||
|
return s.type === '$vr-场次' && s.value === currentSession;
|
||||||
|
});
|
||||||
|
var hasVenue = spec.some(function(s) {
|
||||||
|
return s.type === '$vr-场馆' && s.value.includes(currentVenueName);
|
||||||
|
});
|
||||||
|
var hasSection = !currentSectionChar || spec.some(function(s) {
|
||||||
|
return s.type === '$vr-分区' && s.value.includes(currentSectionChar);
|
||||||
|
});
|
||||||
|
var isAvailable = seatInfo.inventory > 0;
|
||||||
|
|
||||||
|
var seatEl = document.querySelector('[data-seat-key="' + seatKey + '"]');
|
||||||
|
if (!seatEl) return;
|
||||||
|
|
||||||
|
if (hasSession && hasVenue && hasSection) {
|
||||||
|
seatEl.classList.toggle('sold', !isAvailable);
|
||||||
|
seatEl.classList.toggle('disabled', false);
|
||||||
|
} else {
|
||||||
|
seatEl.classList.add('disabled');
|
||||||
|
seatEl.classList.remove('sold');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 从 vr_seat_template 渲染座位图
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function renderSeatMap() {
|
||||||
|
var rooms = vrSeatTemplate.rooms;
|
||||||
|
|
||||||
|
rooms.forEach(function(room) {
|
||||||
|
room.map.forEach(function(rowStr, rowIndex) {
|
||||||
|
var rowLabel = String.fromCharCode(65 + rowIndex); // 0→A, 1→B
|
||||||
|
var chars = rowStr.split(''); // 逐字符(PHP mb_str_split 兼容)
|
||||||
|
|
||||||
|
chars.forEach(function(char, colIndex) {
|
||||||
|
if (char === '_' || char === '-') {
|
||||||
|
// 渲染空白格子
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var colNum = colIndex + 1; // 列号从 1 开始
|
||||||
|
var seatKey = room.id + '_' + rowLabel + '_' + colNum; // "room_001_A_3"
|
||||||
|
var seatInfo = room.seats[char]; // 查到座位属性
|
||||||
|
|
||||||
|
// 创建座位 DOM 元素
|
||||||
|
var seatEl = document.createElement('div');
|
||||||
|
seatEl.className = 'vr-seat';
|
||||||
|
seatEl.dataset.seatKey = seatKey;
|
||||||
|
seatEl.dataset.rowLabel = rowLabel;
|
||||||
|
seatEl.dataset.colNum = colNum;
|
||||||
|
seatEl.dataset.char = char;
|
||||||
|
seatEl.dataset.roomId = room.id;
|
||||||
|
seatEl.style.backgroundColor = seatInfo.color;
|
||||||
|
seatEl.textContent = rowLabel + colNum;
|
||||||
|
|
||||||
|
// 点击事件:选座/取消
|
||||||
|
seatEl.addEventListener('click', function() { toggleSeat(seatEl, seatKey); });
|
||||||
|
|
||||||
|
document.getElementById('room_' + room.id + '_seats').appendChild(seatEl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、submit() 正确实现(P0 Issue 1 核心修复)
|
||||||
|
|
||||||
|
### 6.1 当前错误代码
|
||||||
|
|
||||||
|
原始 `ticket_detail.html` 中的 `submit()` 使用 `location.href`(GET),ShopXO `Buy::Index` 只在 POST 时存储数据,导致购买流程失效。
|
||||||
|
|
||||||
|
### 6.2 修复后的 submit()
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// var self = this; — 原始代码第6行已有此声明
|
||||||
|
submit: function() {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
// 1. 收集观演人
|
||||||
|
var inputs = document.querySelectorAll('#attendeeList input');
|
||||||
|
var attendeeData = [];
|
||||||
|
inputs.forEach(function(input) {
|
||||||
|
var idx = parseInt(input.dataset.index);
|
||||||
|
if (!attendeeData[idx]) attendeeData[idx] = {};
|
||||||
|
attendeeData[idx][input.dataset.field] = input.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 验证已选座位和观演人数量匹配
|
||||||
|
if (this.selectedSeats.length === 0) {
|
||||||
|
alert('请至少选择一个座位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.selectedSeats.length !== attendeeData.length) {
|
||||||
|
alert('座位数与观演人信息数量不匹配');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 构建 ShopXO 原生 goods_data 格式
|
||||||
|
//
|
||||||
|
// ⚠️ 【必须】order_base.extension_data 是 BuyService 隐含契约(BuyService.php 第86行)
|
||||||
|
// 必须嵌套在 order_base 内!不能平铺在 goods_data 第一层!
|
||||||
|
//
|
||||||
|
// ⚠️ 【必须】直接传 JSON 字符串,不需要 base64
|
||||||
|
// BuyService.php 第60行:!is_array($_POST['goods_data']) → json_decode()
|
||||||
|
// ShopXO 标准 buy/index.html 第871行也是直接输出 JSON 字符串,不 base64
|
||||||
|
//
|
||||||
|
// ⚠️ 【必须】spec 是完整的 4维数组,不是 1 维!
|
||||||
|
// 从 seatSpecMap[seatKey].spec 读取,不要自己构造
|
||||||
|
//
|
||||||
|
// ⚠️ requestUrl 来自 PHP 模板注入:var requestUrl = '<?php echo Config("shopxo.host_url"); ?>';
|
||||||
|
// 确认 Goods.php 在传给票务模板时已在 $assign 中注入 host_url
|
||||||
|
//
|
||||||
|
var goodsDataList = this.selectedSeats.map(function(seat, i) {
|
||||||
|
var seatInfo = seatSpecMap[seat.seatKey]; // 从注入的 seatSpecMap 查
|
||||||
|
if (!seatInfo) {
|
||||||
|
console.error('seatSpecMap missing key:', seat.seatKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
goods_id: self.goodsId,
|
||||||
|
spec: seatInfo.spec, // 4维完整 spec 数组!从 seatSpecMap 来!
|
||||||
|
stock: 1,
|
||||||
|
order_base: { // ← 必须嵌套!不能平铺!
|
||||||
|
extension_data: {
|
||||||
|
attendee: {
|
||||||
|
real_name: attendeeData[i]?.real_name || '',
|
||||||
|
phone: attendeeData[i]?.phone || '',
|
||||||
|
id_card: attendeeData[i]?.id_card || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
// 4. 过滤无效座位
|
||||||
|
if (goodsDataList.length === 0) {
|
||||||
|
alert('座位信息无效,请重新选择');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 隐藏表单 POST 到 ShopXO Buy 链路
|
||||||
|
var form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = requestUrl + '?s=index/buy/index';
|
||||||
|
document.body.appendChild(form);
|
||||||
|
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'goods_data';
|
||||||
|
input.value = JSON.stringify(goodsDataList); // 直接 JSON,BuyService 自动处理
|
||||||
|
form.appendChild(input);
|
||||||
|
|
||||||
|
form.submit(); // POST → Buy::Index → BuyDataStorage → 跳转确认页
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 ShopXO Buy 链路完整数据流(已验证可用)
|
||||||
|
|
||||||
|
```
|
||||||
|
submit() POST goods_data(含 4维spec + extension_data)
|
||||||
|
│
|
||||||
|
├─→ Buy::Index (POST) → BuyDataStorage(user_id, data_post) [存入 session, TTL=21600s]
|
||||||
|
│ ↑
|
||||||
|
│ goods_data 是数组,json_encode 存入 session
|
||||||
|
│
|
||||||
|
└─→ 跳转 Buy::Index (GET) → BuyDataRead → 显示确认页
|
||||||
|
│
|
||||||
|
┌───────────────────────────────┘
|
||||||
|
│
|
||||||
|
└─→ form submit → Buy::Add → BuyService::OrderInsert($params)
|
||||||
|
│
|
||||||
|
BuyTypeGoodsList($params) → BuyGoods($params)
|
||||||
|
│
|
||||||
|
foreach($params['goods_data'] as $v) ← 多 SKU 原生遍历
|
||||||
|
│
|
||||||
|
GoodsSpecificationsHandle($v) → GoodsSpecDetail()
|
||||||
|
│ 4维 type-value 匹配 GoodsSpecValue 表
|
||||||
|
↓
|
||||||
|
OrderInsertHandle($order_data)
|
||||||
|
│
|
||||||
|
BuyService.php 第773行:
|
||||||
|
'extension_data' => json_encode($v['order_base']['extension_data'])
|
||||||
|
│
|
||||||
|
Db::name('order')->insertGetId($order) ← extension_data 写入 Order 表
|
||||||
|
│
|
||||||
|
微信支付...
|
||||||
|
│
|
||||||
|
┌────────────────────────────────┘
|
||||||
|
│
|
||||||
|
└─→ 支付成功 → Hook: plugins_service_order_pay_success_handle_end
|
||||||
|
│
|
||||||
|
TicketService::onOrderPaid($params)
|
||||||
|
│
|
||||||
|
Db::name('order')->find($order_id)
|
||||||
|
↓
|
||||||
|
json_decode($order['extension_data']) → 观演人信息
|
||||||
|
↓
|
||||||
|
foreach($order_goods as $og) {
|
||||||
|
issueTicket($order, $og) // 幂等保护:seat_info 查重
|
||||||
|
}
|
||||||
|
│
|
||||||
|
Db::name('vr_tickets')->insertGetId([
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
'seat_info' => $spec_name,
|
||||||
|
'real_name' => $attendee['real_name'],
|
||||||
|
'phone' => $attendee['phone'],
|
||||||
|
'id_card' => $attendee['id_card'],
|
||||||
|
'ticket_code'=> $uuid,
|
||||||
|
'qr_data' => AES加密(payload),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、完整修复清单
|
||||||
|
|
||||||
|
| 优先级 | Issue | 任务 | 依赖 | 负责 |
|
||||||
|
|--------|-------|------|------|------|
|
||||||
|
| **P0** | Issue 1 | 重构 `GetGoodsViewData()` 新增 `seatSpecMap` | 后端 | BackendArchitect |
|
||||||
|
| **P0** | Issue 1 | 前端 JS 用 `seatSpecMap` 替代 `specBaseIdMap` | P0 前置 | FrontendDev |
|
||||||
|
| **P0** | Issue 1 | 修复 `submit()`:GET→POST + 正确 4维 spec 数组 | P0 前置 | FrontendDev |
|
||||||
|
| **P0** | Issue 1 | Goods.php `MyViewAssign` 加入 `seatSpecMap` | P0 前置 | BackendArchitect |
|
||||||
|
| **P1** | Issue 1 | 实现场次/场馆/分区 spec 选择器 UI + `filterSeatMap()` | P0 前置 | FrontendDev |
|
||||||
|
| **P1** | Issue 1 | `selectSession()` / `selectVenue()` / `selectSection()` 联动逻辑 | P1 前置 | FrontendDev |
|
||||||
|
| **P1** | Issue 2 | 缩放时舞台跟随(zoom wrapper 方案) | 无 | FrontendDev |
|
||||||
|
| **P1** | Issue 3 | 新增 `sold_seats` API 端点 | 无 | BackendArchitect |
|
||||||
|
| **P1** | Issue 3 | 前端 `loadSoldSeats()` 调用 API + 标记 `.sold` | P1 前置 | FrontendDev |
|
||||||
|
| **P2** | Issue 4 | 商品详情图片展示(确认需求,补充 CSS) | 无 | FrontendDev |
|
||||||
|
| **P2** | Issue 5 | `GetGoodsViewData()` 返回数组而非 `validConfigs[0]` | 无 | BackendArchitect |
|
||||||
|
| **P2** | 审计 | 验证 `onOrderPaid` spec 匹配 + 幂等保护(FOR UPDATE) | 无 | BackendArchitect |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、关键代码索引
|
||||||
|
|
||||||
|
| 文件 | 行号 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `Buy.php` | 58-61 | Index() — POST/GET 分支,BuyDataStorage/BuyDataRead |
|
||||||
|
| `BuyService.php` | 51-62 | BuyGoods — goods_data 参数校验 + JSON decode(非 base64) |
|
||||||
|
| `BuyService.php` | 86 | `foreach($params['goods_data'] as $v)` — 多 SKU 原生遍历 |
|
||||||
|
| `BuyService.php` | 104-109 | GoodsSpecDetail — 4维 type-value 匹配 GoodsSpecValue |
|
||||||
|
| `BuyService.php` | 773 | `extension_data => json_encode($v['order_base']['extension_data'])` |
|
||||||
|
| `BuyService.php` | 1932 | BuyDataStorage — 21600s TTL session 缓存 |
|
||||||
|
| `buy/index.html` | 871 | 原生 form hidden goods_data field(JSON 字符串,非 base64) |
|
||||||
|
| `TicketService.php` | 21-22 | Hook: `plugins_service_order_pay_success_handle_end` → `onOrderPaid` |
|
||||||
|
| `TicketService.php` | 141-143 | `issueTicket` — 从 `$order['extension_data']` 读观演人 |
|
||||||
|
| `SeatSkuService.php` | 40-45 | `SPEC_DIMS = ['$vr-场馆','$vr-分区','$vr-座位号','$vr-场次']` |
|
||||||
|
| `SeatSkuService.php` | ~368 | GetGoodsViewData — validConfigs[0] 多场次 Bug |
|
||||||
|
| `SeatSkuService.php` | ~131 | BatchGenerate — 4维 spec value 构建(完整路径字符串) |
|
||||||
|
| `Hook.php` | 21-22 | `plugins_service_order_pay_success_handle_end` → TicketService::onOrderPaid |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、第一性原则(设计决策记录)
|
||||||
|
|
||||||
|
1. **座位唯一性靠 ShopXO 原生 inventory**:每个 GoodsSpecBase 的 `inventory=1`,ShopXO 在 `OrderInsertHandle` 中扣库存,防超卖由 ShopXO 原生保证,不需要自己实现锁。
|
||||||
|
|
||||||
|
2. **`spec_base_id_map` 是性能缓存**:理想情况下 `onOrderPaid` 通过 `seat_key` 查询 GoodsSpecBase 即可,不需要 map 字段。但保留是合理的优化。
|
||||||
|
|
||||||
|
3. **`extension_data` 存储完全在 ShopXO 生态内**:不新建表,不扩展 ShopXO 字段,`order.extension_data` → `onOrderPaid` → `vr_tickets` 全链路 ShopXO 原生。
|
||||||
|
|
||||||
|
4. **`onOrderPaid` spec 匹配存在潜在 bug**(⚠️ 未来需关注):
|
||||||
|
- `BatchGenerate` 写入 GoodsSpecValue.value 格式:`"VR 演唱会馆-1号演播厅-VIP区-A-1排3座"`(长路径字符串)
|
||||||
|
- 前端 seatKey 格式:`"room_001_A_3"`(短格式)
|
||||||
|
- 两者不匹配,`issueTicket` 第57-77行的反向 spec 查找会失效
|
||||||
|
- 目前不影响功能(幂等靠 `seat_info` 字段,不依赖 spec_base_id)
|
||||||
|
- 未来如需精确关联,需修复 BatchGenerate 的 value 写入格式
|
||||||
|
|
||||||
|
5. **最小修复原则**:Issue 1 的修复只需改 `submit()` 函数(POST + 正确 4维 spec 格式 + extension_data)。不需要重构 spec 系统,不需要绕过 Buy 链路。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本文档为 vr-shopxo-plugin Phase 2 完整实现文档,Agent 可独立阅读并推进事务。*
|
||||||
|
|
@ -0,0 +1,513 @@
|
||||||
|
# Phase 3 前端执行计划
|
||||||
|
|
||||||
|
> 日期:2026-04-21 | 状态:✅ 已完成
|
||||||
|
> 关联:PLAN_PHASE3_FRONTEND.md + Issue #17
|
||||||
|
> 策略:谨慎保守,稳扎稳打
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、目标
|
||||||
|
|
||||||
|
**1 天内上线可演示的多座位下单 Demo**,验证购物车路线可行性。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、现状盘点
|
||||||
|
|
||||||
|
| 文件 | 当前状态 | 问题 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `ticket_detail.html` | Plan A 代码有 bug | `submit()` URL 编码只传第一座、`selectSession()` 未重置座位 |
|
||||||
|
| `ticket_detail.html` | 桩代码 | `loadSoldSeats()` 无实现 |
|
||||||
|
| `ticket_detail.html` | 内联样式 | CSS 未分离,色值硬编码 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、执行步骤
|
||||||
|
|
||||||
|
### Step 1:修复 `submit()` 函数(P0)
|
||||||
|
|
||||||
|
**文件**:`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html`
|
||||||
|
|
||||||
|
**改动**:替换 `submit()` 函数,改走购物车 API。
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
submit: function() {
|
||||||
|
// 1. 前置检查
|
||||||
|
if (this.selectedSeats.length === 0) {
|
||||||
|
alert('请先选择座位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.userId) {
|
||||||
|
alert('请先登录');
|
||||||
|
location.href = this.requestUrl + '?s=index/user/logininfo';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 收集观演人信息
|
||||||
|
var inputs = document.querySelectorAll('#attendeeList input');
|
||||||
|
var attendeeData = {};
|
||||||
|
inputs.forEach(function(input) {
|
||||||
|
var idx = input.dataset.index;
|
||||||
|
var field = input.dataset.field;
|
||||||
|
if (!attendeeData[idx]) attendeeData[idx] = {};
|
||||||
|
attendeeData[idx][field] = input.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 构建 goodsParamsList
|
||||||
|
var self = this;
|
||||||
|
var goodsParamsList = this.selectedSeats.map(function(seat, i) {
|
||||||
|
var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId;
|
||||||
|
return {
|
||||||
|
goods_id: self.goodsId,
|
||||||
|
spec_base_id: parseInt(specBaseId) || 0,
|
||||||
|
stock: 1
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 逐座提交到购物车(避免并发竞态,逐座串行提交)
|
||||||
|
function submitNext(index) {
|
||||||
|
if (index >= goodsParamsList.length) {
|
||||||
|
// 全部成功 → 跳转购物车
|
||||||
|
location.href = self.requestUrl + '?s=index/cart/index';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var params = goodsParamsList[index];
|
||||||
|
$.post(__goods_cart_save_url__, params, function(res) {
|
||||||
|
if (res.code === 0 && res.data && res.data.id) {
|
||||||
|
submitNext(index + 1);
|
||||||
|
} else {
|
||||||
|
alert('座位 [' + self.selectedSeats[index].label + '] 提交失败:' + (res.msg || '库存不足'));
|
||||||
|
}
|
||||||
|
}).fail(function() {
|
||||||
|
alert('网络错误,请重试');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
submitNext(0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**保守策略**:
|
||||||
|
- 使用**串行** `submitNext()` 递归,避免并发竞态
|
||||||
|
- 每个座位单独请求,成功后提交下一个
|
||||||
|
- 任意失败立即中断并弹窗提示
|
||||||
|
|
||||||
|
**验收测试**:
|
||||||
|
- [ ] 选择 3 个座位 → 点击提交 → 购物车页显示 3 条商品
|
||||||
|
- [ ] 座位 2 库存不足 → 弹窗提示,座位 1 不在购物车
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2:修复场次切换状态重置(P0)
|
||||||
|
|
||||||
|
**文件**:`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html`
|
||||||
|
|
||||||
|
**改动**:在 `selectSession()` 函数开头添加状态重置。
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
selectSession: function(el) {
|
||||||
|
// 【新增】切换场次时重置已选座位
|
||||||
|
this.selectedSeats = [];
|
||||||
|
|
||||||
|
// 移除其他选中样式
|
||||||
|
document.querySelectorAll('.vr-session-item').forEach(function(item) {
|
||||||
|
item.classList.remove('selected');
|
||||||
|
});
|
||||||
|
el.classList.add('selected');
|
||||||
|
this.currentSession = el.dataset.specId;
|
||||||
|
this.sessionSpecId = el.dataset.specBaseId;
|
||||||
|
|
||||||
|
// 隐藏座位图和观演人区域(等待渲染)
|
||||||
|
document.getElementById('seatSection').style.display = 'none';
|
||||||
|
document.getElementById('selectedSection').style.display = 'none';
|
||||||
|
document.getElementById('attendeeSection').style.display = 'none';
|
||||||
|
|
||||||
|
this.renderSeatMap();
|
||||||
|
this.loadSoldSeats();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**保守策略**:
|
||||||
|
- 重置后隐藏座位图和观演人区域,避免旧数据残留
|
||||||
|
- 渲染完成后由 `updateSelectedUI()` 显示
|
||||||
|
|
||||||
|
**验收测试**:
|
||||||
|
- [ ] 选择场次 A → 选 2 个座位 → 切换场次 B → 确认已选座位清零
|
||||||
|
- [ ] 切换回场次 A → 确认已选座位仍然清零(严格隔离)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3:实现 `loadSoldSeats()`(P1)
|
||||||
|
|
||||||
|
#### 3.1 后端接口
|
||||||
|
|
||||||
|
**文件**:`shopxo/app/plugins/vr_ticket/controller/Index.php`
|
||||||
|
|
||||||
|
**新增方法**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* 获取场次已售座位列表
|
||||||
|
* @method POST
|
||||||
|
* @param goods_id 商品ID
|
||||||
|
* @param spec_base_id 规格ID(场次)
|
||||||
|
* @return json {code:0, data:{sold_seats:['A_1','A_2','B_5']}}
|
||||||
|
*/
|
||||||
|
public function SoldSeats()
|
||||||
|
{
|
||||||
|
// 鉴权
|
||||||
|
if (!IsMobileLogin()) {
|
||||||
|
return json_encode(['code' => 401, 'msg' => '请先登录']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取参数
|
||||||
|
$goodsId = input('goods_id', 0, 'intval');
|
||||||
|
$specBaseId = input('spec_base_id', 0, 'intval');
|
||||||
|
|
||||||
|
if (empty($goodsId) || empty($specBaseId)) {
|
||||||
|
return json_encode(['code' => 400, 'msg' => '参数错误']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询已支付订单中的座位
|
||||||
|
// 简化版:直接从已支付订单 item 的 extension_data 解析
|
||||||
|
$orderService = new \app\service\OrderService();
|
||||||
|
// 注意:此处需根据实际的 QR 票订单表结构查询
|
||||||
|
|
||||||
|
$soldSeats = [];
|
||||||
|
return json_encode(['code' => 0, 'data' => ['sold_seats' => $soldSeats]]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**保守策略**:
|
||||||
|
- 第一版只返回空数组(不查数据库)
|
||||||
|
- 后续迭代再接入真实数据
|
||||||
|
|
||||||
|
#### 3.2 前端调用
|
||||||
|
|
||||||
|
**文件**:`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html`
|
||||||
|
|
||||||
|
**改动 `loadSoldSeats()`**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
loadSoldSeats: function() {
|
||||||
|
if (!this.currentSession || !this.goodsId) return;
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
$.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
|
||||||
|
goods_id: this.goodsId,
|
||||||
|
spec_base_id: this.sessionSpecId
|
||||||
|
}, function(res) {
|
||||||
|
if (res.code === 0 && res.data && res.data.sold_seats) {
|
||||||
|
res.data.sold_seats.forEach(function(seatKey) {
|
||||||
|
self.soldSeats[seatKey] = true;
|
||||||
|
});
|
||||||
|
self.markSoldSeats();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
markSoldSeats: function() {
|
||||||
|
var self = this;
|
||||||
|
document.querySelectorAll('.vr-seat').forEach(function(el) {
|
||||||
|
var seatKey = el.dataset.rowLabel + '_' + el.dataset.colNum;
|
||||||
|
if (self.soldSeats[seatKey]) {
|
||||||
|
el.classList.add('sold');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**验收测试**:
|
||||||
|
- [ ] 后端接口返回 `{"code":0,"data":{"sold_seats":["A_1","A_2"]}}` → A_1、A_2 标记为灰色已售
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4:CSS 文件分离(P1)
|
||||||
|
|
||||||
|
#### 4.1 新建 CSS 文件
|
||||||
|
|
||||||
|
**文件**:`shopxo/app/plugins/vr_ticket/static/css/ticket.css`
|
||||||
|
|
||||||
|
**内容**(从 `ticket_detail.html` 的 `<style>` 块抽取):
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* VR票务 - 票务商品详情页样式 */
|
||||||
|
/* 从 ticket_detail.html 内联样式抽取,2026-04-21 */
|
||||||
|
|
||||||
|
.vr-ticket-page { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||||
|
.vr-ticket-header { margin-bottom: 20px; }
|
||||||
|
.vr-event-title { font-size: 24px; font-weight: bold; color: #333; margin-bottom: 8px; }
|
||||||
|
.vr-event-subtitle { color: #666; font-size: 14px; }
|
||||||
|
|
||||||
|
.vr-seat-section { margin-bottom: 30px; }
|
||||||
|
.vr-section-title { font-size: 18px; font-weight: bold; margin-bottom: 15px; color: #333; }
|
||||||
|
|
||||||
|
.vr-seat-map-wrapper { background: #f8f9fa; border-radius: 8px; padding: 20px; margin-bottom: 20px; overflow-x: auto; }
|
||||||
|
.vr-stage {
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(180deg, #e8e8e8, #d0d0d0);
|
||||||
|
border-radius: 50% 50% 0 0 / 20px 20px 0 0;
|
||||||
|
padding: 15px 40px;
|
||||||
|
margin: 0 auto 25px;
|
||||||
|
max-width: 600px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
.vr-seat-rows { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||||
|
.vr-seat-row { display: flex; align-items: center; gap: 2px; }
|
||||||
|
.vr-row-label { width: 24px; text-align: center; color: #999; font-size: 12px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.vr-seat {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #fff;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.vr-seat:hover { transform: scale(1.15); z-index: 1; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
|
||||||
|
.vr-seat.selected { border: 2px solid #333; transform: scale(1.1); }
|
||||||
|
.vr-seat.sold { background: #ccc !important; cursor: not-allowed; opacity: 0.5; }
|
||||||
|
.vr-seat.sold:hover { transform: none; box-shadow: none; }
|
||||||
|
.vr-seat.aisle { background: transparent !important; cursor: default; }
|
||||||
|
.vr-seat.space { background: transparent !important; cursor: default; }
|
||||||
|
|
||||||
|
.vr-legend { display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 15px; justify-content: center; }
|
||||||
|
.vr-legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #666; }
|
||||||
|
.vr-legend-seat { width: 20px; height: 20px; border-radius: 3px; }
|
||||||
|
|
||||||
|
.vr-selected-seats { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
||||||
|
.vr-selected-title { font-weight: bold; margin-bottom: 10px; color: #333; }
|
||||||
|
.vr-selected-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; min-height: 30px; }
|
||||||
|
.vr-selected-item {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
background: #e8f4ff; border: 1px solid #b8d4f0;
|
||||||
|
border-radius: 4px; padding: 4px 10px; font-size: 13px;
|
||||||
|
}
|
||||||
|
.vr-selected-item .remove { color: #f56c6c; cursor: pointer; font-size: 16px; line-height: 1; }
|
||||||
|
.vr-total { font-size: 16px; font-weight: bold; color: #333; margin-top: 10px; }
|
||||||
|
|
||||||
|
.vr-sessions { margin-bottom: 20px; }
|
||||||
|
.vr-session-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
|
||||||
|
.vr-session-item {
|
||||||
|
border: 1px solid #ddd; border-radius: 6px; padding: 10px;
|
||||||
|
cursor: pointer; text-align: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.vr-session-item:hover { border-color: #409eff; }
|
||||||
|
.vr-session-item.selected { border-color: #409eff; background: #ecf5ff; }
|
||||||
|
.vr-session-item .date { font-size: 14px; font-weight: bold; color: #333; }
|
||||||
|
.vr-session-item .time { font-size: 13px; color: #666; margin-top: 4px; }
|
||||||
|
.vr-session-item .price { font-size: 14px; color: #f56c6c; font-weight: bold; margin-top: 6px; }
|
||||||
|
|
||||||
|
.vr-attendee-form { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
||||||
|
.vr-form-title { font-weight: bold; margin-bottom: 15px; color: #333; }
|
||||||
|
.vr-attendee-item { background: #f8f9fa; border-radius: 6px; padding: 15px; margin-bottom: 10px; }
|
||||||
|
.vr-attendee-label { font-size: 13px; color: #666; margin-bottom: 5px; }
|
||||||
|
.vr-attendee-input { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
||||||
|
|
||||||
|
.vr-purchase-bar {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0;
|
||||||
|
background: #fff; border-top: 1px solid #e8e8e8;
|
||||||
|
padding: 12px 20px; z-index: 100;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
box-shadow: 0 -2px 10px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.vr-purchase-info { font-size: 14px; color: #666; }
|
||||||
|
.vr-purchase-info strong { font-size: 20px; color: #f56c6c; }
|
||||||
|
.vr-purchase-btn {
|
||||||
|
background: linear-gradient(135deg, #409eff, #3b8ef8);
|
||||||
|
color: #fff; border: none; border-radius: 20px;
|
||||||
|
padding: 12px 36px; font-size: 16px; font-weight: bold;
|
||||||
|
cursor: pointer; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.vr-purchase-btn:hover { transform: scale(1.03); box-shadow: 0 4px 12px rgba(64,158,255,0.4); }
|
||||||
|
.vr-purchase-btn:disabled { background: #ccc; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||||
|
|
||||||
|
.vr-goods-info { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
||||||
|
.vr-goods-photos { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; margin-bottom: 15px; }
|
||||||
|
.vr-goods-photos img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2 注册 Hook
|
||||||
|
|
||||||
|
**文件**:`shopxo/app/plugins/vr_ticket/hook/ViewGoodsCss.php`(新建)
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
namespace app\plugins\vr_ticket\hook;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 票务商品详情页 CSS 注入
|
||||||
|
*/
|
||||||
|
class ViewGoodsCss
|
||||||
|
{
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
return 'plugins/vr_ticket/css/ticket.css';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.3 Service 注册 Hook
|
||||||
|
|
||||||
|
**文件**:`shopxo/app/plugins/vr_ticket/service/VrTicketService.php`
|
||||||
|
|
||||||
|
在 `CssData()` 或类似方法中添加:
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* 获取插件 CSS
|
||||||
|
*/
|
||||||
|
public function CssData()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'plugins/vr_ticket/css/ticket.css'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **注意**:ShopXO 的 `plugins_css_data` 钩子注册方式需确认,可能需要在插件配置或 Service 中声明。请先验证 ShopXO 官方文档中插件 CSS 注入的标准方式。
|
||||||
|
|
||||||
|
#### 4.4 删除内联样式
|
||||||
|
|
||||||
|
**文件**:`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html`
|
||||||
|
|
||||||
|
删除 `<style>` 块(Line 3-118),保留注释占位:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- VR票务样式已移至 plugins/vr_ticket/css/ticket.css -->
|
||||||
|
```
|
||||||
|
|
||||||
|
**验收测试**:
|
||||||
|
- [ ] `ticket_detail.html` 页面正常渲染,无样式丢失
|
||||||
|
- [ ] 浏览器 DevTools Network 标签可见 `ticket.css` 请求
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5:座位图缩放/拖拽交互(P2)
|
||||||
|
|
||||||
|
**文件**:`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html`
|
||||||
|
|
||||||
|
**功能**:`vr-seat-map-wrapper` 支持滚轮缩放 + 鼠标拖拽。
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
bindEvents: function() {
|
||||||
|
var wrapper = document.querySelector('.vr-seat-map-wrapper');
|
||||||
|
if (!wrapper) return;
|
||||||
|
|
||||||
|
var scale = 1;
|
||||||
|
var isDragging = false;
|
||||||
|
var startX, startY, translateX = 0, translateY = 0;
|
||||||
|
|
||||||
|
// 滚轮缩放
|
||||||
|
wrapper.addEventListener('wheel', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||||
|
scale = Math.max(0.5, Math.min(3, scale + delta));
|
||||||
|
var inner = wrapper.querySelector('.vr-seat-rows');
|
||||||
|
if (inner) {
|
||||||
|
inner.style.transform = 'scale(' + scale + ') translate(' + translateX + 'px, ' + translateY + 'px)';
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// 拖拽平移
|
||||||
|
wrapper.addEventListener('mousedown', function(e) {
|
||||||
|
isDragging = true;
|
||||||
|
startX = e.clientX - translateX;
|
||||||
|
startY = e.clientY - translateY;
|
||||||
|
});
|
||||||
|
document.addEventListener('mousemove', function(e) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
translateX = e.clientX - startX;
|
||||||
|
translateY = e.clientY - startY;
|
||||||
|
var inner = wrapper.querySelector('.vr-seat-rows');
|
||||||
|
if (inner) {
|
||||||
|
inner.style.transform = 'scale(' + scale + ') translate(' + translateX + 'px, ' + translateY + 'px)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseup', function() {
|
||||||
|
isDragging = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**验收测试**:
|
||||||
|
- [ ] 滚轮向上滚动 → 座位图放大
|
||||||
|
- [ ] 滚轮向下滚动 → 座位图缩小
|
||||||
|
- [ ] 鼠标按住拖拽 → 座位图平移
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、文件清单
|
||||||
|
|
||||||
|
| 操作 | 文件 | 类型 |
|
||||||
|
|------|------|------|
|
||||||
|
| 修改 | `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` | 改 |
|
||||||
|
| 新建 | `shopxo/app/plugins/vr_ticket/controller/Index.php` 方法 | 改 |
|
||||||
|
| 新建 | `shopxo/app/plugins/vr_ticket/static/css/ticket.css` | 新 |
|
||||||
|
| 新建 | `shopxo/app/plugins/vr_ticket/hook/ViewGoodsCss.php` | 新 |
|
||||||
|
| 修改 | `shopxo/app/plugins/vr_ticket/service/VrTicketService.php` | 改 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、技术风险
|
||||||
|
|
||||||
|
| 风险 | 严重 | 缓解 |
|
||||||
|
|------|------|------|
|
||||||
|
| 购物车 `CartSave` 接口返回格式不一致 | 🔴 | Step 1 加 `console.log(res)` 临时调试 |
|
||||||
|
| `plugins_css_data` 钩子注册方式不确定 | 🟡 | Step 4 前先查 ShopXO 文档确认 |
|
||||||
|
| 已售座位数据查询依赖订单表结构 | 🟡 | Step 3 第一版返回空数组,后续迭代接入 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、验收测试总表
|
||||||
|
|
||||||
|
### P0(Step 1 + Step 2)
|
||||||
|
|
||||||
|
| # | 测试场景 | 预期结果 |
|
||||||
|
|---|---------|---------|
|
||||||
|
| 1 | 选择 3 个座位 → 提交 | 购物车页显示 3 条商品 |
|
||||||
|
| 2 | 座位 2 库存不足 | 弹窗提示,已选座位清零 |
|
||||||
|
| 3 | 选择场次 A → 选 2 座 → 切换场次 B | 已选座位清零,购买栏归零 |
|
||||||
|
| 4 | 切换回场次 A | 座位图重新渲染,无旧数据残留 |
|
||||||
|
|
||||||
|
### P1(Step 3 + Step 4)
|
||||||
|
|
||||||
|
| # | 测试场景 | 预期结果 |
|
||||||
|
|---|---------|---------|
|
||||||
|
| 5 | `SoldSeats()` 返回 `["A_1","A_2"]` | A_1、A_2 标记灰色已售 |
|
||||||
|
| 6 | 访问 `ticket_detail.html` | DevTools Network 可见 `ticket.css` 请求 |
|
||||||
|
| 7 | 页面各区块布局 | 与内联样式版本一致 |
|
||||||
|
|
||||||
|
### P2(Step 5)
|
||||||
|
|
||||||
|
| # | 测试场景 | 预期结果 |
|
||||||
|
|---|---------|---------|
|
||||||
|
| 8 | 滚轮缩放 | 座位图平滑缩放(0.5x - 3x) |
|
||||||
|
| 9 | 鼠标拖拽 | 座位图平滑平移 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、执行顺序
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1 → Step 2 → Step 3 → Step 4 → Step 5
|
||||||
|
↑ ↑ ↑ ↑
|
||||||
|
P0 P0 P1 P1 P2
|
||||||
|
```
|
||||||
|
|
||||||
|
**建议**:
|
||||||
|
1. 先完成 Step 1 + Step 2,立即浏览器验证
|
||||||
|
2. Step 3 需要后端配合,可与前端并行准备
|
||||||
|
3. Step 4 可在 Step 1-2 验证通过后再做
|
||||||
|
4. Step 5 作为可选优化项
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
# Phase 3 前端模板开发计划
|
||||||
|
|
||||||
|
> 日期:2026-04-20 | 状态:进行中
|
||||||
|
> 背景:Council 调研结论 + CSS 样式机制确认 → Demo 快速落地
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、调研结论摘要(Council 651e0bf2)
|
||||||
|
|
||||||
|
### Q2 — 单订单多SKU(多座位选择的前提)
|
||||||
|
|
||||||
|
**结论:✅ 可行,走购物车路线**
|
||||||
|
|
||||||
|
ShopXO `BuyService.php:86` 循环处理 `goods_data` 数组,每行独立 `spec_base_id`。现有 `ticket_detail.html` Plan A 代码已写好,但 `submit()` 函数有 bug:只把第一个座位编码进 URL,后续座位丢失。
|
||||||
|
|
||||||
|
**最小改动(Demo 1天可上线):**
|
||||||
|
- 修复 `submit()`:将 `goodsParamsList` 整体编码,POST 到购物车 `CartSave`,再跳转合并支付
|
||||||
|
- 绕过 `OrderSplitService` 拆单风险(购物车结算路径不触发按仓库拆单)
|
||||||
|
|
||||||
|
### Q1 — ShopXO 自定义模板最佳实践
|
||||||
|
|
||||||
|
**结论:原生 PHP + 内联 JS,渐进增强**
|
||||||
|
|
||||||
|
- ShopXO view/goods/ 模板使用原生 PHP + 原生 JS,session/buy 控制器直接 render
|
||||||
|
- 不走 DIY 设计器(只支持静态 HTML 区块,无法参数化)
|
||||||
|
- H5 直接浏览器预览,无需构建
|
||||||
|
|
||||||
|
### Q3 — 第三方无代码构建服务
|
||||||
|
|
||||||
|
**结论:辅助有限,座位图等核心交互必须手写**
|
||||||
|
|
||||||
|
- 无代码服务适合静态展示区块(票务介绍、艺人信息图)
|
||||||
|
- 座位图等高交互组件无法用无代码工具精确生成
|
||||||
|
- 生成代码后需后处理:路径替换 + 变量注入
|
||||||
|
|
||||||
|
### Q4 — uni-app 兼容性技术栈选型
|
||||||
|
|
||||||
|
**结论:fork shopxo-uniapp,票务页面自研**
|
||||||
|
|
||||||
|
- fork `shopxo-uniapp` → `vr-shopxo-uniapp`
|
||||||
|
- 票务页面(ticket-seat / ticket-wallet / ticket-verify)自研 Vue 3 组件
|
||||||
|
- 商城标准页面复用 shopxo-uniapp 原生实现
|
||||||
|
- CSS 一致(H5/小程序都基于 WebView)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、CSS 样式注入机制(ShopXO 官方能力)
|
||||||
|
|
||||||
|
### 三层注入体系
|
||||||
|
|
||||||
|
| 层级 | 机制 | 甲方操作入口 |
|
||||||
|
|------|------|------------|
|
||||||
|
| **CSS 变量** | `header_style_root.html` 定义 `:root` 变量,后台主题配置可改 | ShopXO 后台「主题配色」 |
|
||||||
|
| **插件 CSS Hook** | `plugins_css_data` 钩子注入独立 CSS 文件 | 替换 `static/plugins/vr_ticket/css/ticket.css` |
|
||||||
|
| **内联 `<style>`** | 当前 `.vr-ticket-page` 样式块,完全隔离 | 直接修改 `ticket_detail.html` |
|
||||||
|
|
||||||
|
### CSS 变量体系(ShopXO 官方)
|
||||||
|
|
||||||
|
`header_style_root.html` 定义了完整的 CSS 变量系统:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 主色 */
|
||||||
|
--color-main: #E22C08; /* 可在后台改为甲方品牌色 */
|
||||||
|
--color-main-light: #ffe3de;
|
||||||
|
--color-main-hover: #EA6B52;
|
||||||
|
|
||||||
|
/* 圆角 */
|
||||||
|
--border-radius-sm: 0.2rem;
|
||||||
|
--border-radius: 0.4rem;
|
||||||
|
--border-radius-lg: 0.8rem;
|
||||||
|
|
||||||
|
/* 阴影 */
|
||||||
|
--box-shadow: 0 5px 20px rgba(50,55,58,0.1);
|
||||||
|
--box-shadow-sm: 0 2px 8px rgba(50,55,58,0.1);
|
||||||
|
--box-shadow-lg: 0 8px 34px rgba(50,55,58,0.1);
|
||||||
|
```
|
||||||
|
|
||||||
|
vr_ticket 模板内的 `.vr-ticket-page` 可以直接引用这些变量,实现主题色统一。例如:
|
||||||
|
```css
|
||||||
|
.vr-purchase-btn {
|
||||||
|
background: var(--color-main); /* 继承 ShopXO 主题色 */
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: var(--box-shadow-sm);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 插件 CSS Hook(推荐方案)
|
||||||
|
|
||||||
|
在插件 service 中注册 `plugins_css_data` 钩子,加载独立 CSS 文件:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// plugins/vr_ticket/hook/ViewGoodsSpiderCss.php
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
return 'plugins/vr_ticket/css/ticket.css';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
甲方样式微调时,只需替换 `static/plugins/vr_ticket/css/ticket.css`,不需要改 PHP 模板。
|
||||||
|
|
||||||
|
### 当前 ticket_detail.html 样式结构
|
||||||
|
|
||||||
|
```
|
||||||
|
ticket_detail.html
|
||||||
|
├── <style> 完全独立的内联样式块(.vr-ticket-page 等)
|
||||||
|
├── HTML 结构(.vr-ticket-page #vrTicketApp)
|
||||||
|
├── 内联 JS(vrTicketApp 对象)
|
||||||
|
└── ModuleInclude('public/footer')
|
||||||
|
```
|
||||||
|
|
||||||
|
样式完全隔离,不受 ShopXO 升级影响。甲方设计师可以专注修改 CSS,不需要理解 PHP 模板逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、Demo 交付计划(最小可行方案)
|
||||||
|
|
||||||
|
### 目标:1天内上线可演示的多座位下单 Demo
|
||||||
|
|
||||||
|
### 当前代码状态
|
||||||
|
|
||||||
|
- `ticket_detail.html` 已有 Plan A 代码(submit 函数存在 URL 编码 bug)
|
||||||
|
- 座位图渲染正常(A/B/C 三排 + 舞台 + 颜色分区 + 选座 UI + 观演人表单)
|
||||||
|
- `loadSoldSeats()` 是 TODO,需要后端配合
|
||||||
|
|
||||||
|
### Demo 交付清单
|
||||||
|
|
||||||
|
#### P0 — 必须完成(Demo 当天)
|
||||||
|
|
||||||
|
| 任务 | 文件 | 说明 | 优先级 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| **修复 submit() bug** | `ticket_detail.html` | 当前只传第一个座位,需整体编码 goodsParamsList | 🔴 P0 |
|
||||||
|
| **购物车路由接通** | `ticket_detail.html` | 改用 `CartSave` API 提交多座位,跳转合并支付 | 🔴 P0 |
|
||||||
|
| **场次切换重置已选座位** | `ticket_detail.html` | `selectSession()` 调用座位重置逻辑(已有代码未调用) | 🔴 P0 |
|
||||||
|
| **座位类型图例** | `ticket_detail.html` | 已完成 ✅,确认正常显示 | ✅ 已完成 |
|
||||||
|
| **购买栏按钮状态联动** | `ticket_detail.html` | 已实现 ✅,`disabled` 状态根据选座数量变化 | ✅ 已完成 |
|
||||||
|
|
||||||
|
#### P1 — Demo 当天完成后继续
|
||||||
|
|
||||||
|
| 任务 | 文件 | 说明 | 优先级 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| **loadSoldSeats() 实现** | `ticket_detail.html` + 后端 | AJAX 调用后端接口,标记已售座位 | 🟡 P1 |
|
||||||
|
| **座位图缩放/拖拽交互** | `ticket_detail.html` | 原生 JS < 200 行实现 | 🟡 P1 |
|
||||||
|
| **CSS 样式文件分离** | `static/vr_ticket/css/ticket.css` | 从内联 `<style>` 抽离,通过 `plugins_css_data` 钩子注册 | 🟡 P1 |
|
||||||
|
| **甲方主题色变量接入** | `ticket_detail.html` <style> | 将硬编码色值改为 `var(--color-main)` 等变量 | 🟡 P1 |
|
||||||
|
|
||||||
|
#### P2 — 后续迭代
|
||||||
|
|
||||||
|
| 任务 | 说明 | 优先级 |
|
||||||
|
|------|------|--------|
|
||||||
|
| shopxo-uniapp fork | 建立 `vr-shopxo-uniapp` 项目骨架 | 🟢 P2 |
|
||||||
|
| ticket-seat.vue | uni-app 选座核心组件 | 🟢 P2 |
|
||||||
|
| B 端核销页 | 小程序扫码核销页面 | 🟢 P2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、关键技术细节
|
||||||
|
|
||||||
|
### 4.1 submit() 修复方案
|
||||||
|
|
||||||
|
**当前 bug:** `location.href = checkoutUrl + '&goods_params=' + encodeURIComponent(goodsParams)`
|
||||||
|
URL 方式只能传字符串,多座位数据会被截断或丢失。
|
||||||
|
|
||||||
|
**修复方案:** 走购物车 API + 合并支付
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
submit: function() {
|
||||||
|
// 1. 收集所有座位数据(现有代码正常)
|
||||||
|
var goodsParamsList = this.selectedSeats.map(function(seat, i) {
|
||||||
|
var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId;
|
||||||
|
var seatAttendee = attendeeData[i] || {};
|
||||||
|
return {
|
||||||
|
goods_id: self.goodsId,
|
||||||
|
spec_base_id: parseInt(specBaseId) || 0,
|
||||||
|
stock: 1,
|
||||||
|
extension_data: JSON.stringify({
|
||||||
|
attendee: seatAttendee,
|
||||||
|
seat: { seatKey: seat.seatKey, label: seat.label, price: seat.price }
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 逐座提交到购物车
|
||||||
|
var deferreds = goodsParamsList.map(function(params) {
|
||||||
|
return $.post(__goods_cart_save_url__, {
|
||||||
|
goods_id: params.goods_id,
|
||||||
|
spec_id: params.spec_base_id,
|
||||||
|
stock: params.stock,
|
||||||
|
// extension_data 作为自定义字段存储
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 全部成功后跳转合并支付
|
||||||
|
$.when.apply($, deferreds).done(function() {
|
||||||
|
location.href = __root__ + '?s=index/cart/index';
|
||||||
|
}).fail(function() {
|
||||||
|
alert('座位已被占用,请重新选择');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么走购物车路线:**
|
||||||
|
- `BuyCart` → `BuyTypeGoodsList` → 直接调用 `BuyGoods`,完美支持多 `goods_data` 行
|
||||||
|
- 购物车结算路径不触发 `OrderSplitService` 按仓库拆单(只按商品拆)
|
||||||
|
- `plugins_service_order_pay_success_handle_end` 钩子正常触发,QR 票生成不受影响
|
||||||
|
|
||||||
|
### 4.2 CSS 变量主题化方案
|
||||||
|
|
||||||
|
当前 `ticket_detail.html` 内联样式中的硬编码色值,全部改为 CSS 变量:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* 改造前 */
|
||||||
|
.vr-purchase-btn { background: linear-gradient(135deg, #409eff, #3b8ef8); }
|
||||||
|
|
||||||
|
/* 改造后 */
|
||||||
|
.vr-purchase-btn {
|
||||||
|
background: linear-gradient(135deg, var(--color-main), var(--color-main-hover));
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: var(--box-shadow-sm);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
甲方想要调整主题色时,有三条路:
|
||||||
|
1. **后台改**:ShopXO 管理后台 → 主题配色 → 自动同步到所有 `var(--color-*)` 变量
|
||||||
|
2. **文件改**:替换 `static/plugins/vr_ticket/css/ticket.css`
|
||||||
|
3. **代码改**:直接修改 `ticket_detail.html` 的 `<style>` 块
|
||||||
|
|
||||||
|
### 4.3 specBaseIdMap 降级策略
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId;
|
||||||
|
```
|
||||||
|
|
||||||
|
- 如果 `seatKey`(如 "A_1")在 `specBaseIdMap` 中有对应记录 → 座位级 SKU(精确到每个座位)
|
||||||
|
- 如果 `specBaseIdMap` 中没有(如后台未批量创建座位规格)→ 降级到 `sessionSpecId`(Zone 级别,同一 zone 全部座位共享一个 SKU)
|
||||||
|
|
||||||
|
**后台需要提前创建座位规格**,vr_ticket 后台管理界面需增加「批量生成座位规格」功能。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、文件清单
|
||||||
|
|
||||||
|
| 文件 | 当前状态 | 下一步 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` | Plan A 代码有 bug | 修复 submit(),接购物车 |
|
||||||
|
| `shopxo/app/plugins/vr_ticket/static/css/ticket.css` | 不存在 | 从 ticket_detail.html 抽离样式 |
|
||||||
|
| `docs/council-research-output.md` | ✅ 已完成 | 无需修改 |
|
||||||
|
| `shopxo/app/service/BuyService.php` | 参考 | 无需修改(走购物车路线) |
|
||||||
|
| `shopxo/app/service/SeatSkuService.php` | 有缺陷(单模板模式) | 等待 Issue #15/#16 修复 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、技术风险
|
||||||
|
|
||||||
|
| 风险 | 严重程度 | 缓解 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `submit()` 只传第一座位(已发现) | 🔴 高 | 修复 submit(),改走购物车 API |
|
||||||
|
| OrderSplitService 拆单(多座位变多笔支付) | 🔴 高 | 购物车路线绕过 |
|
||||||
|
| 座位级 SKU 后台未创建(specBaseIdMap 空) | 🟡 中 | 降级到 Zone 级别 + 后台增加批量生成功能 |
|
||||||
|
| 甲方主题色调整后样式不一致 | 🟡 中 | CSS 变量化,所有色值引用 `var(--color-*)` |
|
||||||
|
| shopxo-uniapp fork 官方更新同步成本 | 🟢 低 | 票务页面与商城页面目录隔离 |
|
||||||
|
|
@ -0,0 +1,482 @@
|
||||||
|
# 商品详情扩展字段数据字典与前端使用说明
|
||||||
|
|
||||||
|
> 日期:2026-04-21
|
||||||
|
> 用途:前端 agent(antigravity / cursor)拿到商品详情页时,扩展字段里有哪些可用数据?如何用?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、核心数据结构全貌
|
||||||
|
|
||||||
|
商品详情页加载时,PHP 后端向模板注入以下变量:
|
||||||
|
|
||||||
|
| 模板变量名 | 来源 | 说明 |
|
||||||
|
|-----------|------|------|
|
||||||
|
| `$goods` | ShopXO GoodsService | ShopXO 原生商品数据(id/title/price/content/images 等) |
|
||||||
|
| `$vr_seat_template` | `SeatSkuService::GetGoodsViewData()` | 票务插件扩展数据 |
|
||||||
|
| `$goods_spec_data` | `SeatSkuService::GetGoodsViewData()` | 场次列表 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、vr_goods_config(goods 表扩展字段)
|
||||||
|
|
||||||
|
存储位置:`goods.vr_goods_config`(JSON 字段)
|
||||||
|
|
||||||
|
这是商品发布时由管理员配置的数据快照,**前端只能读,不能写**。
|
||||||
|
|
||||||
|
### 完整 JSON 示例(商品 118,VR 演唱会)
|
||||||
|
|
||||||
|
```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": "VR 体验馆",
|
||||||
|
"address": "北京市朝阳区建国路88号",
|
||||||
|
"location": { "lng": "116.45792", "lat": "39.90745" },
|
||||||
|
"images": [
|
||||||
|
"/static/attachments/202603/venue_001.jpg",
|
||||||
|
"/static/attachments/202603/venue_002.jpg"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "room_001",
|
||||||
|
"name": "1号演播厅",
|
||||||
|
"map": [
|
||||||
|
"AAAAA_____BBBBB",
|
||||||
|
"AAAAA_____BBBBB",
|
||||||
|
"AAAAA_____BBBBB",
|
||||||
|
"CCCCCCCCCCCCCCC",
|
||||||
|
"CCCCCCCCCCCCCCC"
|
||||||
|
],
|
||||||
|
"sections": [
|
||||||
|
{ "char": "A", "name": "VIP区", "price": 380, "color": "#f06292" },
|
||||||
|
{ "char": "B", "name": "看台区", "price": 180, "color": "#4fc3f7" },
|
||||||
|
{ "char": "C", "name": "普通区", "price": 80, "color": "#81c784" }
|
||||||
|
],
|
||||||
|
"seats": {
|
||||||
|
"A": { "char": "A", "name": "VIP区", "price": 380, "color": "#f06292" },
|
||||||
|
"B": { "char": "B", "name": "看台区", "price": 180, "color": "#4fc3f7" },
|
||||||
|
"C": { "char": "C", "name": "普通区", "price": 80, "color": "#81c784" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "room_002",
|
||||||
|
"name": "2号演播厅(副厅)",
|
||||||
|
"map": [
|
||||||
|
"DDDDDDD",
|
||||||
|
"DDDDDDD",
|
||||||
|
"EEEEEEE"
|
||||||
|
],
|
||||||
|
"sections": [
|
||||||
|
{ "char": "D", "name": "互动区", "price": 280, "color": "#ffb74d" },
|
||||||
|
{ "char": "E", "name": "站票区", "price": 50, "color": "#90a4ae" }
|
||||||
|
],
|
||||||
|
"seats": {
|
||||||
|
"D": { "char": "D", "name": "互动区", "price": 280, "color": "#ffb74d" },
|
||||||
|
"E": "char": "E", "name": "站票区", "price": 50, "color": "#90a4ae" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明表
|
||||||
|
|
||||||
|
#### 顶层字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 前端可用性 | 说明 |
|
||||||
|
|------|------|-----------|------|
|
||||||
|
| `version` | float | ⚪ 不需要 | 协议版本,用于兼容判断 |
|
||||||
|
| `template_id` | int | ⚪ 不需要 | 关联的座位模板 ID,内部使用 |
|
||||||
|
| `selected_rooms` | string[] | ✅ 可用 | 用户在后台选中的房间 ID 列表 |
|
||||||
|
| `selected_sections` | object | ✅ 可用 | key=房间ID,value=该房间选中的分区字符数组 |
|
||||||
|
| `sessions` | object[] | ✅ 可用(**重要**) | 场次列表,每个场次有 start/end/price |
|
||||||
|
| `template_snapshot` | object | ✅ 可用(**核心**) | 座位图的完整快照,前端渲染数据来源 |
|
||||||
|
|
||||||
|
#### template_snapshot.venue
|
||||||
|
|
||||||
|
| 字段 | 前端可用性 | 说明 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `name` | ✅ 可用 | 场馆名称(用于展示) |
|
||||||
|
| `address` | ✅ 可用 | 场馆地址(用于展示) |
|
||||||
|
| `location.lng/lat` | ⚠️ 可选 | 经纬度,用于地图展示 |
|
||||||
|
| `images` | ✅ 可用 | 场馆图片列表(用于顶部 Banner) |
|
||||||
|
|
||||||
|
#### template_snapshot.rooms[](每个房间)
|
||||||
|
|
||||||
|
| 字段 | 前端可用性 | 说明 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `id` | ✅ 可用(**重要**) | 房间唯一 ID,用于前端 seatKey 构造 |
|
||||||
|
| `name` | ✅ 可用 | 房间名称(用于场馆切换选择器) |
|
||||||
|
| `map` | ✅ 可用(**核心**) | 座位图字符矩阵,用于渲染座位行 |
|
||||||
|
| `sections[]` | ✅ 可用 | 分区列表(char→name/price/color,用于图例 + 分区切换) |
|
||||||
|
| `seats` | ✅ 可用 | char→座位属性映射,用于查找座位详情 |
|
||||||
|
|
||||||
|
#### template_snapshot.rooms[].map 格式说明
|
||||||
|
|
||||||
|
`map` 是一个字符串数组,每行对应座位图的一行:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["AAAAA_____BBBBB", "AAAAA_____BBBBB"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- 字符 `'A'` / `'B'` / `'C'` = 座位(char),通过 `seats[char]` 查到座位属性(分区/价格/颜色)
|
||||||
|
- 字符 `'_'` = 空位(不渲染座位元素)
|
||||||
|
- 字符 `'-'` = 空位(不渲染座位元素)
|
||||||
|
- 其他非字母字符 = 不渲染
|
||||||
|
|
||||||
|
**如何从 map 渲染座位**:
|
||||||
|
```javascript
|
||||||
|
// map = ["AAAAA_____BBBBB", "AAAAA_____BBBBB"]
|
||||||
|
map.forEach(function(rowStr, rowIndex) {
|
||||||
|
var rowLabel = String.fromCharCode(65 + rowIndex); // 0→A, 1→B, ...
|
||||||
|
var chars = rowStr.split(''); // ['A','A','A','A','A','_',...,'B','B','B','B','B']
|
||||||
|
chars.forEach(function(char, colIndex) {
|
||||||
|
if (char === '_' || char === '-') return; // 跳过空位
|
||||||
|
var seatInfo = rooms[i].seats[char]; // 查到座位属性
|
||||||
|
// colIndex + 1 = colNum(列号,从1开始)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:PHP `mb_str_split()` 在某些环境不可用,用 `split('')` 即可(座位字符都是 ASCII)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、GetGoodsViewData() 注入的模板数据
|
||||||
|
|
||||||
|
这是后端处理后注入到模板的变量,**前端可以直接使用**。
|
||||||
|
|
||||||
|
### 3.1 注入变量总览
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Goods.php 票务判断块
|
||||||
|
$viewData = \app\plugins\vr_ticket\service\SeatSkuService::GetGoodsViewData($goods_id);
|
||||||
|
MyViewAssign([
|
||||||
|
'vr_seat_template' => $viewData['vr_seat_template'], // 座位图数据
|
||||||
|
'goods_spec_data' => $viewData['goods_spec_data'], // 场次列表
|
||||||
|
// 【待新增】
|
||||||
|
'seatSpecMap' => $viewData['seatSpecMap'] ?? [], // 座位→规格映射
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 vr_seat_template(注入后模板中访问 `$vr_seat_template`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// PHP 模板输出(JSON 注入)
|
||||||
|
var vrSeatTemplate = <?php echo json_encode($vr_seat_template ?? [], JSON_UNESCAPED_UNICODE); ?>;
|
||||||
|
var seatSpecMap = <?php echo json_encode($seatSpecMap ?? [], JSON_UNESCAPED_UNICODE); ?>;
|
||||||
|
var goodsSpecData = <?php echo json_encode($goods_spec_data ?? [], JSON_UNESCAPED_UNICODE); ?>;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### vr_seat_template 数据结构
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
// === 直接透传 template_snapshot(来源:goods.vr_goods_config)===
|
||||||
|
venue: {
|
||||||
|
name: "VR 体验馆",
|
||||||
|
address: "北京市朝阳区建国路88号",
|
||||||
|
location: { lng: "116.45792", lat: "39.90745" },
|
||||||
|
images: ["/static/attachments/202603/venue_001.jpg"]
|
||||||
|
},
|
||||||
|
rooms: [
|
||||||
|
{
|
||||||
|
id: "room_001",
|
||||||
|
name: "1号演播厅",
|
||||||
|
map: ["AAAAA_____BBBBB", "AAAAA_____BBBBB", "AAAAA_____BBBBB", "CCCCCCCCCCCCCCC", "CCCCCCCCCCCCCCC"],
|
||||||
|
sections: [
|
||||||
|
{ char: "A", name: "VIP区", price: 380, color: "#f06292" },
|
||||||
|
{ char: "B", name: "看台区", price: 180, color: "#4fc3f7" },
|
||||||
|
{ char: "C", name: "普通区", price: 80, color: "#81c784" }
|
||||||
|
],
|
||||||
|
seats: {
|
||||||
|
A: { char: "A", name: "VIP区", price: 380, color: "#f06292" },
|
||||||
|
B: { char: "B", name: "看台区", price: 180, color: "#4fc3f7" },
|
||||||
|
C: { char: "C", name: "普通区", price: 80, color: "#81c784" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "room_002",
|
||||||
|
name: "2号演播厅(副厅)",
|
||||||
|
map: ["DDDDDDD", "DDDDDDD", "EEEEEEE"],
|
||||||
|
sections: [
|
||||||
|
{ char: "D", name: "互动区", price: 280, color: "#ffb74d" },
|
||||||
|
{ char: "E", name: "站票区", price: 50, color: "#90a4ae" }
|
||||||
|
],
|
||||||
|
seats: {
|
||||||
|
D: { char: "D", name: "互动区", price: 280, color: "#ffb74d" },
|
||||||
|
E: { char: "E", name: "站票区", price: 50, color: "#90a4ae" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sessions: [
|
||||||
|
{ start: "15:00", end: "16:59" },
|
||||||
|
{ start: "18:00", end: "20:59" }
|
||||||
|
],
|
||||||
|
// === 来自 goods.vr_goods_config 的原始选择数据 ===
|
||||||
|
selectedRooms: ["room_001", "room_002"],
|
||||||
|
selectedSections: {
|
||||||
|
"room_001": ["A", "B"],
|
||||||
|
"room_002": ["A"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### goods_spec_data(场次列表)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 来源:goods.vr_goods_config.sessions + GoodsSpecBase.price
|
||||||
|
[
|
||||||
|
{ spec_id: 2001, spec_name: "15:00-16:59", price: 380, start: "15:00", end: "16:59" },
|
||||||
|
{ spec_id: 2002, spec_name: "18:00-20:59", price: 280, start: "18:00", end: "20:59" }
|
||||||
|
]
|
||||||
|
// ⚠️ 注意:spec_id 是 GoodsSpecBase ID(场次级别,非座位级别)
|
||||||
|
// 前端不需要直接使用 spec_id,直接使用 sessions 数组即可
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 seatSpecMap(待新增:GetGoodsViewData 返回的核心数据)
|
||||||
|
|
||||||
|
**来源**:`GetGoodsViewData()` 查询 GoodsSpecBase + GoodsSpecValue + GoodsSpecBase.extends,动态构建
|
||||||
|
|
||||||
|
**用途**:前端选中座位后,查表获取该座位的完整 4 维 spec 数组 + 对应 spec_base_id
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// key 格式:{roomId}_{rowLabel}_{colNum}
|
||||||
|
// 例如:room_001_A_3 = room_001 的 A排 第3列
|
||||||
|
|
||||||
|
{
|
||||||
|
"room_001_A_1": {
|
||||||
|
spec_base_id: 10001,
|
||||||
|
price: 380,
|
||||||
|
inventory: 1,
|
||||||
|
rowLabel: "A",
|
||||||
|
colNum: 3,
|
||||||
|
roomId: "room_001",
|
||||||
|
section: { char: "A", name: "VIP区", color: "#f06292" },
|
||||||
|
// === 4维 spec 数组(submit() 时直接使用)===
|
||||||
|
spec: [
|
||||||
|
{ type: "$vr-场馆", value: "VR 体验馆" },
|
||||||
|
{ type: "$vr-分区", value: "VR 体验馆-1号演播厅-VIP区" },
|
||||||
|
{ type: "$vr-座位号", value: "VR 体验馆-1号演播厅-VIP区-A-1排3座" },
|
||||||
|
{ type: "$vr-场次", value: "15:00-16:59" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"room_001_A_2": { spec_base_id: 10002, price: 380, inventory: 1, /* ... */ },
|
||||||
|
"room_001_A_3": { /* 同上,A排第3座 */ },
|
||||||
|
"room_001_B_8": { spec_base_id: 10025, price: 180, inventory: 0, /* 已售 */ },
|
||||||
|
"room_002_D_1": { spec_base_id: 20001, price: 280, inventory: 1, /* ... */ },
|
||||||
|
// ... 每个可购座位一行
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### seatSpecMap 生成逻辑(GetGoodsViewData 中实现)
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 1. 查询所有有效 GoodsSpecBase(含 extends.seat_key)
|
||||||
|
$specs = Db::name('GoodsSpecBase')
|
||||||
|
->where('goods_id', $goodsId)
|
||||||
|
->where('inventory', '>', 0) // 只取有库存的
|
||||||
|
->select();
|
||||||
|
|
||||||
|
// 2. 查询对应的 GoodsSpecValue(4个维度的值)
|
||||||
|
$specIds = array_column($specs->toArray(), 'id');
|
||||||
|
$specValues = Db::name('GoodsSpecValue')
|
||||||
|
->whereIn('goods_spec_base_id', $specIds)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
// 3. 按 spec_base_id 分组,构建 4维 spec 数组
|
||||||
|
$specByBaseId = [];
|
||||||
|
foreach ($specValues as $sv) {
|
||||||
|
$specByBaseId[$sv['goods_spec_base_id']][] = [
|
||||||
|
'type' => $sv['type'], // "$vr-场馆" / "$vr-分区" / "$vr-座位号" / "$vr-场次"
|
||||||
|
'value' => $sv['value'], // 完整路径字符串
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 构建 seatSpecMap
|
||||||
|
$seatSpecMap = [];
|
||||||
|
foreach ($specs as $spec) {
|
||||||
|
$extends = json_decode($spec['extends'] ?? '{}', true);
|
||||||
|
$seatKey = $extends['seat_key'] ?? ''; // "room_001_A_3" 格式
|
||||||
|
if (empty($seatKey)) continue;
|
||||||
|
|
||||||
|
$seatSpecMap[$seatKey] = [
|
||||||
|
'spec_base_id' => intval($spec['id']),
|
||||||
|
'price' => floatval($spec['price']),
|
||||||
|
'inventory' => intval($spec['inventory']),
|
||||||
|
'spec' => $specByBaseId[$spec['id']] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、前端数据使用对照表
|
||||||
|
|
||||||
|
### 4.1 渲染座位图(使用 vr_seat_template)
|
||||||
|
|
||||||
|
```
|
||||||
|
数据来源:vr_seat_template.rooms[].map
|
||||||
|
↓
|
||||||
|
渲染流程:
|
||||||
|
rooms[i].map.forEach((rowStr, rowIndex) => {
|
||||||
|
chars = rowStr.split('') // 逐字符
|
||||||
|
chars.forEach((char, colIndex) => {
|
||||||
|
if (char === '_') → 跳过(空位)
|
||||||
|
seatInfo = rooms[i].seats[char] // 通过 char 查座位属性
|
||||||
|
seatKey = rooms[i].id + '_' + rowLabel + '_' + (colIndex+1)
|
||||||
|
// rowLabel = String.fromCharCode(65 + rowIndex) // A/B/C...
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
前端关键变量:
|
||||||
|
- rooms[i].id → roomId(用于 seatKey 构造)
|
||||||
|
- rooms[i].map → 座位行渲染数据
|
||||||
|
- rooms[i].seats[char] → 座位属性(name/price/color)
|
||||||
|
- rooms[i].sections → 图例 + 分区切换
|
||||||
|
- vrSeatTemplate.selectedRooms → 当前选中的房间列表
|
||||||
|
- vrSeatTemplate.selectedSections → 当前选中的分区
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 构建 spec 数组(使用 seatSpecMap)
|
||||||
|
|
||||||
|
```
|
||||||
|
数据来源:seatSpecMap[seatKey]
|
||||||
|
↓
|
||||||
|
选中座位后:
|
||||||
|
seatKey = clickedEl.dataset.rowLabel + '_' + clickedEl.dataset.colNum
|
||||||
|
= "room_001_A_3"
|
||||||
|
seatInfo = seatSpecMap[seatKey]
|
||||||
|
|
||||||
|
submit() 时使用:
|
||||||
|
goods_data[i].spec = seatInfo.spec // 4维完整 spec 数组!
|
||||||
|
goods_data[i].stock = 1
|
||||||
|
|
||||||
|
ShopXO BuyService 匹配:
|
||||||
|
→ GoodsSpecValue WHERE type="$vr-场馆" AND value="VR 体验馆"
|
||||||
|
AND type="$vr-分区" AND value="VR 体验馆-1号演播厅-VIP区"
|
||||||
|
AND type="$vr-座位号" AND value="VR 体验馆-1号演播厅-VIP区-A-1排3座"
|
||||||
|
AND type="$vr-场次" AND value="15:00-16:59"
|
||||||
|
→ 返回 spec_base_id → 拿到 inventory=1, price=380
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 spec 选择器联动过滤(使用 seatSpecMap)
|
||||||
|
|
||||||
|
```
|
||||||
|
数据来源:seatSpecMap(所有座位的完整信息)
|
||||||
|
↓
|
||||||
|
filterSeatMap(currentSession, currentVenueId, currentSectionChar):
|
||||||
|
|
||||||
|
seatSpecMap 的每一个 entry:
|
||||||
|
seatInfo.spec 是一个4元素数组
|
||||||
|
|
||||||
|
判断逻辑(某座位是否在当前选择分支内):
|
||||||
|
hasSession = spec.some(s => s.type==='$vr-场次' && s.value===currentSessionValue)
|
||||||
|
hasVenue = spec.some(s => s.type==='$vr-场馆' && s.value.includes(currentVenueName))
|
||||||
|
hasSection = !currentSectionChar || spec.some(s => s.type==='$vr-分区' && s.value.includes(currentSectionChar))
|
||||||
|
isAvailable = seatInfo.inventory > 0
|
||||||
|
|
||||||
|
结果:
|
||||||
|
hasSession && hasVenue && hasSection && isAvailable → 可选(正常显示)
|
||||||
|
hasSession && hasVenue && hasSection && !isAvailable → 已售(灰色+sold class)
|
||||||
|
否则 → 不在分支内(灰色+disabled class)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 加载已售座位(使用 seatSpecMap.inventory)
|
||||||
|
|
||||||
|
```
|
||||||
|
数据来源:seatSpecMap[seatKey].inventory
|
||||||
|
↓
|
||||||
|
页面初始化时,遍历 seatSpecMap:
|
||||||
|
Object.entries(seatSpecMap).forEach(([seatKey, seatInfo]) => {
|
||||||
|
if (seatInfo.inventory <= 0) {
|
||||||
|
// 该座位已售
|
||||||
|
document.querySelector(`[data-seat-key="${seatKey}"]`).classList.add('sold');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
⚠️ 注意:inventory 字段来自 GoodsSpecBase,库存扣减由 ShopXO 原生处理。
|
||||||
|
这是当前座位的实时库存,优先于任何前端缓存。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、前端完整数据流图
|
||||||
|
|
||||||
|
```
|
||||||
|
后端 GetGoodsViewData()
|
||||||
|
│
|
||||||
|
├── vr_seat_template.venue ──────────────────→ 顶部 Banner / 场馆信息
|
||||||
|
├── vr_seat_template.rooms[].map ─────────────────→ 座位图渲染
|
||||||
|
├── vr_seat_template.rooms[].sections ────────────→ 图例 + 分区选择器
|
||||||
|
├── vr_seat_template.selectedSections ────────────→ 默认选中的分区(用于高亮)
|
||||||
|
├── goods_spec_data / vr_seat_template.sessions ──→ 场次选择器
|
||||||
|
└── seatSpecMap (新增) ─────────────────────────────→ 核心!
|
||||||
|
│
|
||||||
|
├── seatSpecMap[seatKey].spec ────────→ submit() 构造 goods_data.spec
|
||||||
|
├── seatSpecMap[seatKey].inventory ──→ 标记已售 / 灰色
|
||||||
|
├── seatSpecMap[seatKey].price ──────→ 计算总价
|
||||||
|
└── filterSeatMap() ─────────────────→ spec 选择器联动过滤
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、注意事项
|
||||||
|
|
||||||
|
### 6.1 roomId 从哪里来?
|
||||||
|
|
||||||
|
`rooms[i].id`(来自 template_snapshot.rooms)就是 roomId。这是 UUID 或字符串 ID。
|
||||||
|
|
||||||
|
**前端构造 seatKey 时必须使用这个 ID**:
|
||||||
|
```javascript
|
||||||
|
// 正确:从 rooms[i].id 取
|
||||||
|
var roomId = rooms[i].id; // "room_001"
|
||||||
|
|
||||||
|
// 错误:硬编码或自行生成
|
||||||
|
var roomId = "room_001"; // ❌ 如果 rooms 结构变了就错了
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 colNum 从哪里来?
|
||||||
|
|
||||||
|
colNum 是列号(从 1 开始),不是数组索引:
|
||||||
|
```javascript
|
||||||
|
// 正确
|
||||||
|
var colNum = colIndex + 1; // 0-based 数组索引 → 1-based 列号
|
||||||
|
|
||||||
|
// seatKey 格式:{roomId}_{rowLabel}_{colNum}
|
||||||
|
// 例如:room_001_A_3 = room_001 的 A排 第3列
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 同一个 char 在不同房间代表不同分区
|
||||||
|
|
||||||
|
room_001 的 "A" 是 VIP区(红色),room_002 的 "D" 是互动区(橙色)。
|
||||||
|
|
||||||
|
**分区信息在 rooms[i].sections 里**,不要直接用 char 本身判断分区。
|
||||||
|
|
||||||
|
### 6.4 map 中下划线数量的处理
|
||||||
|
|
||||||
|
`"AAAAA_____BBBBB"` 中有 5 个下划线。座位图渲染时:
|
||||||
|
```javascript
|
||||||
|
chars.forEach(function(char, colIndex) {
|
||||||
|
if (char === '_' || char === '-') {
|
||||||
|
// 渲染一个空白格子(不绑定座位)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 渲染座位,colNum = colIndex + 1
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,335 @@
|
||||||
|
# VR 票务 spec 选择器 + 多座位选择 — 数据结构与实现说明
|
||||||
|
|
||||||
|
> 日期:2026-04-21
|
||||||
|
> 背景:修正 COUNCIL_PHASE2_ASSESSMENT_CORRECTED.md 中 spec 描述的根本性错误
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、核心结论(先说清楚)
|
||||||
|
|
||||||
|
原报告里所有关于 spec 的示例都是错的,原因:**spec 是 4 维度联合索引,而不是 1 个维度;前端目前根本拿不到完整的 4 维 spec 映射**。
|
||||||
|
|
||||||
|
我们实际上要做的产品形态是:
|
||||||
|
> **一个风格化、带座位图的多维度 spec 规格选择器**
|
||||||
|
>
|
||||||
|
> - 界面同时具备原生 ShopXO spec 选择器的交互(场次/场馆/分区可选择,不在分支下的选项自动隐藏/变灰)
|
||||||
|
> - 又有自己的多座位视图(在座位图上直接点选多个座位)
|
||||||
|
> - 最终在 submit() 时,把选中座位的完整 4 维 spec 数组提交到 Buy 链路
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、ShopXO spec 的真实结构
|
||||||
|
|
||||||
|
### 2.1 四维 SPEC_DIMS
|
||||||
|
|
||||||
|
ShopXO 的每个 GoodsSpecBase 记录,通过 4 个维度的 spec 值联合确定:
|
||||||
|
|
||||||
|
```php
|
||||||
|
const SPEC_DIMS = ['$vr-场馆', '$vr-分区', '$vr-座位号', '$vr-场次'];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 一个座位的完整 spec 数组(示例)
|
||||||
|
|
||||||
|
以商品118的某个座位为例(A排3座,VIP区,VR体验馆-1号厅,场次15:00-16:59):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"spec_base_id": 1001,
|
||||||
|
"price": 380.00,
|
||||||
|
"inventory": 1,
|
||||||
|
"spec": [
|
||||||
|
{ "type": "$vr-场馆", "value": "VR体验馆-1号厅" },
|
||||||
|
{ "type": "$vr-分区", "value": "VR体验馆-1号厅-VIP区" },
|
||||||
|
{ "type": "$vr-座位号", "value": "VR体验馆-1号厅-VIP区-A-1排3座" },
|
||||||
|
{ "type": "$vr-场次", "value": "15:00-16:59" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **关键**:spec value 不是 `"A_3"` 或 `"roomId_A_3"` 这种短格式,
|
||||||
|
> 而是**完整路径字符串** `"VR体验馆-1号厅-VIP区-A-1排3座"`。
|
||||||
|
> 这个字符串由 BatchGenerate 第131行构建:
|
||||||
|
> `$val_seat = "{$venueName}-{$roomName}-{$char}-{$rowLabel}{$col}"`
|
||||||
|
|
||||||
|
### 2.3 从前端座位元素到 spec_base_id 的正确路径
|
||||||
|
|
||||||
|
前端一个座位 DOM 元素,持有以下数据:
|
||||||
|
- `roomId` = "room_001"(来自 template_snapshot.rooms[i].id)
|
||||||
|
- `rowLabel` = "A"(座位行标签)
|
||||||
|
- `colNum` = 3(座位列号)
|
||||||
|
- `char` = "A"(座位类型 char,对应 sections[i].char)
|
||||||
|
|
||||||
|
要找到对应的 GoodsSpecBase,需要用以下映射关系:
|
||||||
|
|
||||||
|
```
|
||||||
|
GoodsSpecBase.extends = {"seat_key": "room_001_A_3"}
|
||||||
|
↓ GetGoodsViewData() 动态构建
|
||||||
|
spec_base_id_map = {"room_001_A_3": 1001}
|
||||||
|
↓
|
||||||
|
前端 seatKey = room_001 + "_" + "A" + "_" + 3 = "room_001_A_3"
|
||||||
|
↓
|
||||||
|
spec_base_id_map["room_001_A_3"] = 1001 ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、前端实际能拿到的数据(现状 vs 需要的)
|
||||||
|
|
||||||
|
### 3.1 当前 GetGoodsViewData() 返回的数据(不完整)
|
||||||
|
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
'vr_seat_template' => [
|
||||||
|
'seat_map' => [...], // template_snapshot.rooms(座位图原始数据)
|
||||||
|
'spec_base_id_map' => [...], // ⚠️ 键名格式不对(roomId_row_col),且前端没有对应生成逻辑
|
||||||
|
],
|
||||||
|
'goods_spec_data' => [
|
||||||
|
['spec_id' => 2001, 'spec_name' => '15:00-16:59', 'price' => 380],
|
||||||
|
['spec_id' => 2002, 'spec_name' => '18:00-20:59', 'price' => 480],
|
||||||
|
]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**缺失的信息**:
|
||||||
|
1. ❌ `goods_spec_data` 只有场次维度,缺少场馆/分区/座位号三个维度
|
||||||
|
2. ❌ `spec_base_id_map` 的 key 格式(`roomId_row_col`)前端无法构造
|
||||||
|
3. ❌ 前端不知道哪个 spec_id 对应哪个座位
|
||||||
|
|
||||||
|
### 3.2 前端需要的完整数据结构(GetGoodsViewData 应返回)
|
||||||
|
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
'vr_seat_template' => [
|
||||||
|
'venue' => $config['template_snapshot']['venue'],
|
||||||
|
'rooms' => $config['template_snapshot']['rooms'], // 座位图原始数据
|
||||||
|
'sessions' => $config['sessions'], // 场次列表
|
||||||
|
],
|
||||||
|
|
||||||
|
// 【修复】重构后的 seatSpecMap:room_row_col → 完整规格信息
|
||||||
|
// 用途:前端选中座位后,直接查表组装 4 维 spec 数组
|
||||||
|
'seatSpecMap' => [
|
||||||
|
'room_001_A_3' => [
|
||||||
|
'spec_base_id' => 1001,
|
||||||
|
'price' => 380.00,
|
||||||
|
'inventory' => 1,
|
||||||
|
'spec' => [
|
||||||
|
['type' => '$vr-场馆', 'value' => 'VR体验馆-1号厅'],
|
||||||
|
['type' => '$vr-分区', 'value' => 'VR体验馆-1号厅-VIP区'],
|
||||||
|
['type' => '$vr-座位号', 'value' => 'VR体验馆-1号厅-VIP区-A-1排3座'],
|
||||||
|
['type' => '$vr-场次', 'value' => '15:00-16:59'],
|
||||||
|
],
|
||||||
|
'rowLabel' => 'A',
|
||||||
|
'colNum' => 3,
|
||||||
|
'roomId' => 'room_001',
|
||||||
|
'section' => ['char' => 'A', 'name' => 'VIP区', 'color' => '#f06292'],
|
||||||
|
],
|
||||||
|
'room_001_B_5' => [
|
||||||
|
'spec_base_id' => 1002,
|
||||||
|
'price' => 180.00,
|
||||||
|
// ... 同上
|
||||||
|
],
|
||||||
|
// 每个可购座位一行
|
||||||
|
],
|
||||||
|
|
||||||
|
// 当前商品的全部场次(用户需要先选场次)
|
||||||
|
'sessions' => [
|
||||||
|
['spec_id' => 2001, 'spec_name' => '15:00-16:59', 'price' => 380, 'start' => '15:00', 'end' => '16:59'],
|
||||||
|
['spec_id' => 2002, 'spec_name' => '18:00-20:59', 'price' => 480, 'start' => '18:00', 'end' => '20:59'],
|
||||||
|
],
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 seatSpecMap 的生成逻辑(在 GetGoodsViewData 中实现)
|
||||||
|
|
||||||
|
```php
|
||||||
|
// GetGoodsViewData() 中新增:
|
||||||
|
// 1. 查询当前商品所有 GoodsSpecBase(含 extends.seat_key)
|
||||||
|
$specs = Db::name('GoodsSpecBase')
|
||||||
|
->where('goods_id', $goodsId)
|
||||||
|
->where('inventory', '>', 0)
|
||||||
|
->select();
|
||||||
|
|
||||||
|
// 2. 查询每个 spec_base_id 对应的 4 维 GoodsSpecValue
|
||||||
|
$specValues = Db::name('GoodsSpecValue')
|
||||||
|
->whereIn('goods_spec_base_id', array_column($specs->toArray(), 'id'))
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// 3. 按 spec_base_id 分组,构建 4 维 spec 数组
|
||||||
|
$specByBaseId = [];
|
||||||
|
foreach ($specValues as $sv) {
|
||||||
|
$specByBaseId[$sv['goods_spec_base_id']][] = [
|
||||||
|
'type' => $sv['type'],
|
||||||
|
'value' => $sv['value'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 构建 seatSpecMap:seat_key → 完整规格
|
||||||
|
$seatSpecMap = [];
|
||||||
|
foreach ($specs as $spec) {
|
||||||
|
$extends = json_decode($spec['extends'] ?? '{}', true);
|
||||||
|
$seatKey = $extends['seat_key'] ?? '';
|
||||||
|
if (empty($seatKey)) continue;
|
||||||
|
|
||||||
|
$seatSpecMap[$seatKey] = [
|
||||||
|
'spec_base_id' => intval($spec['id']),
|
||||||
|
'price' => floatval($spec['price']),
|
||||||
|
'inventory' => intval($spec['inventory']),
|
||||||
|
'spec' => $specByBaseId[$spec['id']] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、前端 spec 选择器的完整交互
|
||||||
|
|
||||||
|
### 4.1 UI 结构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ 场次选择(Tab 或下拉) │
|
||||||
|
│ [15:00-16:59] [18:00-20:59] │
|
||||||
|
│ │
|
||||||
|
│ 选完场次后 → 座位图自动切换到该场次的可用座位 │
|
||||||
|
│ │
|
||||||
|
│ 场馆选择(单选) │
|
||||||
|
│ [VR体验馆-1号厅 ✓] [VR体验馆-2号厅] │
|
||||||
|
│ │
|
||||||
|
│ 分区选择(单选 / 多选)灰色表示不在分支内 │
|
||||||
|
│ [VIP区 ✓] [看台区] [普通区-已售罄] │
|
||||||
|
│ │
|
||||||
|
│ ─────────── 座位图(多选)──────────────── │
|
||||||
|
│ [舞台 - 固定位置] │
|
||||||
|
│ A排 [■■■■] ← 可选座位(VIP) │
|
||||||
|
│ B排 [■■--■■] ← 部分已售 │
|
||||||
|
│ C排 [已灰掉] ← 不在当前分区或已售 │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 spec 选择器的联动逻辑
|
||||||
|
|
||||||
|
**维度优先级**:`场次 > 场馆 > 演播室 > 分区`
|
||||||
|
|
||||||
|
每次用户切换选项时,过滤规则:
|
||||||
|
|
||||||
|
```
|
||||||
|
场次切换 → 重置场馆/分区/座位 → 用 seatSpecMap 过滤出该场次所有座位
|
||||||
|
场馆切换 → 重置分区/座位 → 用 seatSpecMap 过滤出该场馆所有座位
|
||||||
|
分区切换 → 只高亮/过滤座位 → 用 seatSpecMap 过滤出该分区座位(灰色其他)
|
||||||
|
座位点击 → 选中/取消 → 更新 selectedSeats[]
|
||||||
|
```
|
||||||
|
|
||||||
|
**灰色/隐藏逻辑**(参考原生 ShopXO spec 选择器):
|
||||||
|
```javascript
|
||||||
|
// 某座位"可亮"的条件:该座位的 spec 数组 包含 当前已选场次 + 当前已选场馆 + (当前已选分区 或 未选分区)
|
||||||
|
// 具体实现在 selectSession() / selectVenue() / selectSection() 中调用 filterSeatMap()
|
||||||
|
function filterSeatMap(sessionSpecId, venueId, sectionChar) {
|
||||||
|
document.querySelectorAll('.vr-seat:not(.space)').forEach(function(el) {
|
||||||
|
var seatKey = el.dataset.rowLabel + '_' + el.dataset.colNum;
|
||||||
|
var seatInfo = seatSpecMap[seatKey];
|
||||||
|
if (!seatInfo) { el.classList.add('disabled'); return; }
|
||||||
|
|
||||||
|
var spec = seatInfo.spec;
|
||||||
|
var hasSession = spec.some(s => s.type === '$vr-场次' && s.value === currentSessionSpec);
|
||||||
|
var hasVenue = spec.some(s => s.type === '$vr-场馆' && s.value.includes(currentVenue));
|
||||||
|
var hasSection = !sectionChar || spec.some(s => s.type === '$vr-分区' && s.value.includes(sectionChar));
|
||||||
|
var isAvailable = seatInfo.inventory > 0;
|
||||||
|
|
||||||
|
if (hasSession && hasVenue && hasSection && isAvailable) {
|
||||||
|
el.classList.remove('disabled', 'sold');
|
||||||
|
} else {
|
||||||
|
el.classList.add(isAvailable ? 'disabled' : 'sold');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、submit() 时如何组装 spec 数组
|
||||||
|
|
||||||
|
用户选了 2 个座位(A排3座 + A排5座,都是VIP区,15:00场次,VR体验馆-1号厅):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 选中座位后的 selectedSeats[] 数据结构
|
||||||
|
[
|
||||||
|
{ seatKey: 'room_001_A_3', price: 380, rowLabel: 'A', colNum: 3, section: {...} },
|
||||||
|
{ seatKey: 'room_001_A_5', price: 380, rowLabel: 'A', colNum: 5, section: {...} },
|
||||||
|
]
|
||||||
|
|
||||||
|
// submit() 构造 goods_data
|
||||||
|
var goodsDataList = selectedSeats.map(function(seat) {
|
||||||
|
var seatInfo = seatSpecMap[seat.seatKey]; // 从注入的 seatSpecMap 查
|
||||||
|
return {
|
||||||
|
goods_id: self.goodsId,
|
||||||
|
spec: seatInfo.spec, // 4维完整 spec 数组!不是1维!
|
||||||
|
stock: 1,
|
||||||
|
order_base: {
|
||||||
|
extension_data: {
|
||||||
|
attendee: { real_name: '...', phone: '...', id_card: '...' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**生成的 goods_data(简化展示)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"goods_id": 118,
|
||||||
|
"spec": [
|
||||||
|
{ "type": "$vr-场馆", "value": "VR体验馆-1号厅" },
|
||||||
|
{ "type": "$vr-分区", "value": "VR体验馆-1号厅-VIP区" },
|
||||||
|
{ "type": "$vr-座位号", "value": "VR体验馆-1号厅-VIP区-A-1排3座" },
|
||||||
|
{ "type": "$vr-场次", "value": "15:00-16:59" }
|
||||||
|
],
|
||||||
|
"stock": 1,
|
||||||
|
"order_base": {
|
||||||
|
"extension_data": {
|
||||||
|
"attendee": { "real_name": "张三", "phone": "13800138000", "id_card": "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"goods_id": 118,
|
||||||
|
"spec": [
|
||||||
|
{ "type": "$vr-场馆", "value": "VR体验馆-1号厅" },
|
||||||
|
{ "type": "$vr-分区", "value": "VR体验馆-1号厅-VIP区" },
|
||||||
|
{ "type": "$vr-座位号", "value": "VR体验馆-1号厅-VIP区-A-1排5座" },
|
||||||
|
{ "type": "$vr-场次", "value": "15:00-16:59" }
|
||||||
|
],
|
||||||
|
"stock": 1,
|
||||||
|
"order_base": { "extension_data": { "attendee": { "real_name": "李四", ... } } }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
ShopXO 的 `GoodsSpecificationsHandle` 通过 4 个 type-value 组合在 GoodsSpecValue 表中精确匹配到对应的 GoodsSpecBase,拿到 `inventory=1` 和 `price=380`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、需要修改的文件清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `SeatSkuService::GetGoodsViewData()` | 新增 `seatSpecMap` 生成逻辑,返回完整 4 维 spec 映射 |
|
||||||
|
| `Goods.php`(票务判断块) | `MyViewAssign` 中加入 `seatSpecMap` 和 `sessions` |
|
||||||
|
| `ticket_detail.html` JS | 新增 `selectSession()` / `selectVenue()` / `selectSection()` 联动逻辑;`filterSeatMap()` 过滤;`submit()` 使用 `seatSpecMap` 组装 spec |
|
||||||
|
| `ticket_detail.html` HTML | 新增场次/场馆/分区选择器的 DOM 结构 |
|
||||||
|
| `ticket_detail.css` | spec 选择器样式(选中态/灰色态/隐藏态) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、修复优先级
|
||||||
|
|
||||||
|
| 优先级 | 任务 | 依赖 |
|
||||||
|
|--------|------|------|
|
||||||
|
| P0 | 重构 `GetGoodsViewData()` 返回 `seatSpecMap` | 后端 |
|
||||||
|
| P0 | 前端用 `seatSpecMap` 替代错误的 `specBaseIdMap` | 前端 |
|
||||||
|
| P0 | `submit()` 使用 `seatSpecMap[seatKey].spec` | 前端 |
|
||||||
|
| P1 | 实现场次/场馆/分区选择器 UI + 联动逻辑 | 前端 |
|
||||||
|
| P1 | `filterSeatMap()` 实现灰色/隐藏过滤 | 前端 |
|
||||||
|
| P2 | `loadSoldSeats()` → 使用 `seatSpecMap` + inventory 字段 | 前端 |
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
# ShopXO 酷炫前端模板实现方案调研报告
|
||||||
|
|
||||||
|
> 调研日期:2026-04-20
|
||||||
|
> 状态:**Round 3 收敛版本**
|
||||||
|
> 参与:FrontendDev (Q1/Q4)、BackendArchitect (Q2)、ProductManager (Q3)、FirstPrinciples (Q4 集成)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q2 结论:单订单多 SKU 支持
|
||||||
|
|
||||||
|
### 核心答案
|
||||||
|
|
||||||
|
**ShopXO 订单模型技术上支持同一商品多规格(多 SKU)出现在同一订单中**,但现有 vr_ticket 模板的 `submit()` 只传单行,完整多座位下单需要做两件事:① 让前端传多行 `goods_data`,② 阻止 `OrderSplitService` 按 warehouse 拆单。
|
||||||
|
|
||||||
|
### 证据来源
|
||||||
|
|
||||||
|
| 文件 | 关键代码 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `BuyService.php:86` | `foreach($params['goods_data'] as $v)` | 循环处理每个商品项,每项独立 spec_base_id |
|
||||||
|
| `BuyService.php:423-435` | `extension_data` JSON 序列化 | 每行 item 支持挂载座位/观演人扩展数据 |
|
||||||
|
| `BuyService.php:101` | `md5(goods_id + spec implode)` | 内部用 goods_id+spec 组合生成唯一行 ID |
|
||||||
|
| `OrderSplitService.php:52-53` | `GoodsWarehouseAggregate()` | **拆单触发点**:按仓库分组,多 SKU 同一仓库则合并 |
|
||||||
|
| `OrderSplitService.php:289-310` | 按 spec MD5 找 spec_base_id | spec_base_id 已可不同(座位级 SKU) |
|
||||||
|
| `ticket_detail.html:413-436` | `goodsParamsList.map()` | Plan A 代码已写好,但 URL 只传第一行(bug) |
|
||||||
|
|
||||||
|
### 最小可行方案(Multi-Seat Now)
|
||||||
|
|
||||||
|
**改动点仅 3 处,全在 ticket_detail.html,不碰 ShopXO 核心:**
|
||||||
|
|
||||||
|
```
|
||||||
|
ticket_detail.html submit() 函数
|
||||||
|
|
||||||
|
BEFORE: location.href = checkoutUrl + '&goods_params=' + encodeURIComponent(goodsParams)
|
||||||
|
AFTER: goodsParamsList 整体 base64 编码,拆分成多条 goods_data 逐条 POST 到 CartSave,
|
||||||
|
然后跳转到合并支付流程(ShopXO 购物车天然支持多商品同单)
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么走购物车路线更稳:**
|
||||||
|
- `BuyCart` → `BuyTypeGoodsList` → 直接调用 `BuyGoods`,完美支持多 `goods_data` 行
|
||||||
|
- 不需要hook `OrderSplitService`,购物车结算路径不触发按仓库拆单(只按商品拆)
|
||||||
|
- 核销逻辑不受影响:支付成功后 `plugins_service_order_pay_success_handle_end` 钩子正常触发
|
||||||
|
|
||||||
|
### 理想方案(Multi-Seat Proper)
|
||||||
|
|
||||||
|
在插件中挂载 `plugins_service_buy_group_goods_handle` 钩子,拦截 `OrderSplitService`,将同一 goods_id + 不同 spec_base_id 的多行合并进同一个 order_base:
|
||||||
|
|
||||||
|
```
|
||||||
|
plugins_service_buy_group_goods_handle:
|
||||||
|
- 按 goods_id 聚合,而非按 warehouse_id
|
||||||
|
- 每个 goods_id 只生成一条 order_base,goods_items 内嵌多个 spec_base_id 不同的行
|
||||||
|
- extension_data 按座位索引扁平化存储
|
||||||
|
```
|
||||||
|
|
||||||
|
**ShopXO 官方立场**:这是非标准用法,建议走购物车路线。
|
||||||
|
|
||||||
|
### 最大风险点
|
||||||
|
|
||||||
|
1. **OrderSplitService 按仓库拆单** — 如果场次商品和周边商品挂在不同仓库,多座位票务订单会被拆成多个子单。用户会收到多笔支付通知,体验割裂。→ **最小方案走购物车绕过此风险**。
|
||||||
|
2. **座位级 SKU 未在 ShopXO 后台创建** — `specBaseIdMap` 依赖数据库中已存在的 `sxo_goods_spec_base` 记录。如果模板生成的 seatKey(如 "A_1")没有对应的 spec_base_id,`submit()` 会降级到 Zone 级别 SKU(同一 zone 全部座位共享一个 spec),失去座位粒度。→ **需要后台管理员先为每个座位创建规格**。
|
||||||
|
|
||||||
|
### 优先级与依赖
|
||||||
|
|
||||||
|
- **Q2 是 Q4 的前提** — Q4 的"多座位选座流程"依赖 Q2 的多 SKU 订单能力。
|
||||||
|
- Q2 本身不依赖 Q1,可以独立推进。
|
||||||
|
- Q3 和 Q4 无依赖,但 Q3 生成的代码需适配 Q4 选型(H5 vs uni-app)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q1 结论:ShopXO 自定义模板最佳实践
|
||||||
|
|
||||||
|
### 核心答案
|
||||||
|
|
||||||
|
**票务详情页不走 DIY 设计器,直接修改 `ticket_detail.html` 的 PHP+原生 JS**;uni-app 端 fork `shopxo-uniapp` 改写 `goods-detail.vue`,无需经过 ShopXO 模板中间层。
|
||||||
|
|
||||||
|
### 证据来源
|
||||||
|
|
||||||
|
| 文件/文档 | 结论 |
|
||||||
|
|----------|------|
|
||||||
|
| `docs/02_FRONTEND_CUSTOMIZATION.md` | DIY 设计器只支持静态 HTML 区块嵌入,无法参数化;uni-app 完全独立于 ShopXO 模板 |
|
||||||
|
| `docs/12_UNIAPP_FRONTEND_RESEARCH.md` | shopxo-uniapp 是独立 Vue 项目,通过 API 对接 ShopXO;CSS 在 H5/小程序完全一致(WebView 同源) |
|
||||||
|
| `docs/14_TEMPLATE_RENDER_INVESTIGATION.md` | ShopXO view/goods/ 模板使用原生 PHP + 原生 JS,session/buy 等控制器直接 render |
|
||||||
|
| `ticket_detail.html` | 当前已实现:场次选择 + 座位图渲染 + 观演人表单 + 购买栏 |
|
||||||
|
|
||||||
|
### 最小可行方案
|
||||||
|
|
||||||
|
**H5 端**:在现有 `ticket_detail.html` 基础上增强,引入:
|
||||||
|
- 座位类型图例(已完成)
|
||||||
|
- 已售座位 AJAX 实时标记(待实现 `loadSoldSeats()`)
|
||||||
|
- 座位缩放/拖拽交互(原生 JS,<200 行)
|
||||||
|
- 动态场次切换时重置已选座位(已写但未调用)
|
||||||
|
|
||||||
|
**技术栈**:`原生 HTML + 内联 CSS + 内联 JS`,无框架依赖,ShopXO 模板系统直接渲染,无需构建。
|
||||||
|
|
||||||
|
### 理想方案
|
||||||
|
|
||||||
|
**uni-app 端**:
|
||||||
|
1. Fork `shopxo-uniapp` → `vr-shopxo-uniapp`
|
||||||
|
2. 重写 `pages/goods-detail/goods-detail.vue`,接入 vr_ticket API
|
||||||
|
3. 新建 `pages/ticket-seat/ticket-seat.vue`(选座主流程)
|
||||||
|
4. 新建 `pages/ticket-wallet/ticket-wallet.vue`(票夹)
|
||||||
|
5. H5 本地预览 = 小程序编译效果,CSS 完全一致
|
||||||
|
|
||||||
|
**关键约束(uni-app 开发规范)**:
|
||||||
|
- 用 `rpx` 不用 `vw/vh`
|
||||||
|
- 用 `<view>` 不用 `<div>`
|
||||||
|
- 避免 `calc()` 混用单位
|
||||||
|
- `position: fixed` 吸顶在 H5 正常,小程序需 shopxo-uniapp 已有方案
|
||||||
|
|
||||||
|
### 最大风险点
|
||||||
|
|
||||||
|
1. **shopxo-uniapp fork 同步成本** — 官方 shopxo-uniapp 更新后需要手动同步,未来维护成本高。→ 建议在 fork 分支上做票务专属页面,官方页面保持独立升级路径。
|
||||||
|
2. **ShopXO 版本 vs shopxo-diy 版本匹配** — shopxo-diy v1.4.2 ↔ ShopXO v6.8.0,如果使用 DIY 设计器管理非票务页面,版本必须严格匹配。
|
||||||
|
|
||||||
|
### 优先级与依赖
|
||||||
|
|
||||||
|
- Q1 是 Q4 的技术基础,Q1 的结论直接支撑 Q4 的技术选型决策。
|
||||||
|
- Q3 依赖 Q1 的约束条件输出。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q3 结论:第三方无代码构建服务提示词策略
|
||||||
|
|
||||||
|
### 核心答案
|
||||||
|
|
||||||
|
**用"模板 + 示例 + 约束"三层结构撰写 Prompt**,ShopXO 模板的特殊性(模块化 PHP 标签、ShopXO 资源路径 API)在 Prompt 中明确声明,生成代码后只需做两件事后处理:① 替换静态资源路径为 `ModuleInclude()` 调用,② 注入座位图数据结构(从 PHP 模板变量传入)。
|
||||||
|
|
||||||
|
### Prompt 三层结构
|
||||||
|
|
||||||
|
```
|
||||||
|
【第一层:角色定义】
|
||||||
|
你是一个 ShopXO v6.8.0 模板开发者,擅长编写票务商品详情页。
|
||||||
|
|
||||||
|
【第二层:约束清单】
|
||||||
|
- HTML 结构:使用 <?php echo ModuleInclude('public/header'); ?> 包裹页面头
|
||||||
|
- 样式:全部内联 <style>,CSS 类名前缀 vr- 避免冲突
|
||||||
|
- JS 数据注入:const app = <?php echo json_encode($php_var); ?>
|
||||||
|
- 资源路径:静态资源用 <?php echo ModuleInclude('images/foo.png'); ?>
|
||||||
|
- 不使用:Vue/React CDN、外部 CDN(ShopXO 必须离线可用)
|
||||||
|
|
||||||
|
【第三层:具体需求】
|
||||||
|
[座位图 UI 规格:rpx 规范、颜色、尺寸 + 交互事件定义]
|
||||||
|
[ShopXO 数据契约:goods_spec_data、vr_seat_template、extension_data]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生成代码后处理步骤
|
||||||
|
|
||||||
|
1. **路径替换**:全局搜索 `src="/static/` → `<?php echo ModuleInclude('static/') ?>`
|
||||||
|
2. **变量注入点**:在 `<script>` 顶部注入 `var seatMap = <?php echo json_encode($vr_seat_template['seat_map']); ?>`
|
||||||
|
3. **事件绑定**:`onclick` 属性需改为 `onclick="vrTicketApp.toggleSeat(this)"` 格式(原生 JS)
|
||||||
|
4. **样式隔离**:检查是否覆盖 `.goods-detail-*` 等 ShopXO 全局类名,如有则加 `.vr-ticket-page` 限定符
|
||||||
|
|
||||||
|
### 最大风险点
|
||||||
|
|
||||||
|
1. **无代码服务生成的 UI 过于复杂** — 座位图等高交互组件无法用无代码工具精确生成,强行生成会导致大量调试工作。→ **无代码服务适合静态展示区块(票务商品介绍、艺人信息图),座位图选座交互必须手写**。
|
||||||
|
2. **ShopXO 离线可用约束** — ShopXO 运行在企业内部/私有化部署场景,所有资源必须本地化,无代码服务默认 CDN 引用必须全部替换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q4 结论:uni-app 兼容性技术栈选型
|
||||||
|
|
||||||
|
### 核心答案
|
||||||
|
|
||||||
|
**推荐方案:一套 shopxo-uniapp fork + 条件编译**,票务页面走独立路由(H5/小程序双端),商城标准页面复用 shopxo-uniapp 原生实现。
|
||||||
|
|
||||||
|
### 技术选型对比
|
||||||
|
|
||||||
|
| 维度 | 原生 HTML 模板 | uni-app fork shopxo-uniapp | Flutter / React Native |
|
||||||
|
|------|---------------|---------------------------|----------------------|
|
||||||
|
| H5 本地预览 | ✅ 直接浏览器打开 | ✅ HBuilderX H5 运行 | ❌ 需真机调试 |
|
||||||
|
| 微信小程序 | ❌ 不支持 | ✅ 一键编译 | ✅ 需分别开发 |
|
||||||
|
| ShopXO API 对接 | 需手动 HTTP | shopxo-uniapp 已封装 | 需手动 HTTP |
|
||||||
|
| 学习成本 | 低 | 中(需熟悉 Vue) | 高 |
|
||||||
|
| 座位图等复杂交互 | 原生 JS 手写 | Vue 组件手写 | 手写 |
|
||||||
|
| 开发速度 | 快(单文件) | 中 | 慢 |
|
||||||
|
|
||||||
|
**最终推荐**:`fork shopxo-uniapp`,用 Vue 3 + SCSS,票务页面自研,其他页面复用。
|
||||||
|
|
||||||
|
### 票务页面与商城标准页面共存方案
|
||||||
|
|
||||||
|
```
|
||||||
|
vr-shopxo-uniapp/
|
||||||
|
├── pages/index/index.vue ← 改写:底部 Tab 新增「票务」Tab
|
||||||
|
├── pages/goods-detail/ ← 改写:票务商品跳 ticket-seat 页面
|
||||||
|
├── pages/ticket-seat/ ← 新建:选座 + 购票主流程(Vue 组件)
|
||||||
|
├── pages/ticket-wallet/ ← 新建:票夹(我的票)
|
||||||
|
├── pages/ticket-verify/ ← 新建:B 端核销
|
||||||
|
├── App.vue ← request_url 指向目标商城
|
||||||
|
└── pages.json ← 路由配置
|
||||||
|
```
|
||||||
|
|
||||||
|
**H5/小程序一致性**:uni-app H5 和小程序都基于 WebView,CSS 渲染一致。关键:用 `rpx`,用 `<view>`,避免浏览器私有前缀。
|
||||||
|
|
||||||
|
### 最大风险点
|
||||||
|
|
||||||
|
1. **shopxo-uniapp 官方更新同步** — 100+ forks,官方更新需手动 cherry-pick 到 vr fork。建议将票务专属页面与商城原生页面放在不同目录,改动隔离,升级时只同步商城页面。
|
||||||
|
2. **ShopXO 版本与 shopxo-uniapp 版本匹配** — shopxo-uniapp 的 API 契约随 ShopXO 后端版本变化,vr_ticket 插件如使用 shopxo-uniapp,请确认 ShopXO 版本(当前 v6.8.0),使用对应的 shopxo-uniapp 版本。
|
||||||
|
|
||||||
|
### 优先级与依赖
|
||||||
|
|
||||||
|
- **Q4 依赖 Q2(多座位选座)和 Q1(H5 模板基础)**
|
||||||
|
- Q4 本身是最终落地执行层,前三个 Q 的结论在 Q4 中整合实现
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 优先级矩阵
|
||||||
|
|
||||||
|
| 优先级 | 任务 | 负责 Agent | 前置条件 |
|
||||||
|
|--------|------|-----------|---------|
|
||||||
|
| P0 | Q2 多 SKU — 走购物车路线打通多座位下单 | BackendArchitect | 无 |
|
||||||
|
| P1 | Q4 uni-app fork — 建立项目骨架 | FrontendDev | Q1 结论 |
|
||||||
|
| P2 | Q4 ticket-seat.vue — 选座核心组件 | FrontendDev | P0 完成 |
|
||||||
|
| P3 | Q1 ticket_detail.html 增强 — 已售座位实时标记 | FrontendDev | 无 |
|
||||||
|
| P4 | Q3 提示词策略落地 — 无代码服务辅助静态区块 | ProductManager | Q1 结论 |
|
||||||
|
| P5 | Q2 理想方案 — 插件 hook 拦截 OrderSplitService | BackendArchitect | P0 验证 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最小可行方案 vs 理想方案对比
|
||||||
|
|
||||||
|
| 维度 | 最小可行方案 | 理想方案 |
|
||||||
|
|------|------------|---------|
|
||||||
|
| 多座位下单 | 购物车路线(不碰 OrderSplitService) | 插件 hook 拦截,实现原生多 SKU 单订单 |
|
||||||
|
| 前端 H5 | 增强 ticket_detail.html(< 3 处改动) | 迁移到 uni-app H5 |
|
||||||
|
| 前端小程序 | shopxo-uniapp fork,票务页面 Vue 自研 | 完整迁移,小程序体验与 H5 一致 |
|
||||||
|
| 座位图 | 原生 JS,< 200 行 | Vue 组件,含缩放/拖拽/动画 |
|
||||||
|
| 观演人表单 | HTML + JS,支持动态增减 | Vue 组件化,数据校验 |
|
||||||
|
| 核销 B 端 | 复用现有后台核销页面 | 新建小程序核销页面(扫码 + API) |
|
||||||
|
| 交付周期 | 1 天(可上线 demo) | 2-3 周(完整票务流程) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最大技术风险点汇总
|
||||||
|
|
||||||
|
| 风险 | 严重程度 | 缓解措施 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| OrderSplitService 拆单导致多座位订单被拆 | 高 | 最小方案走购物车绕过;理想方案用插件 hook 拦截 |
|
||||||
|
| 座位级 SKU 未在后台创建 | 中 | 后台管理界面增加「批量生成座位规格」功能 |
|
||||||
|
| shopxo-uniapp fork 同步成本 | 中 | 票务页面与商城页面目录隔离,改动隔离升级 |
|
||||||
|
| 无代码服务无法生成高交互组件 | 低(已有认知) | 座位图等核心交互手写,静态区块用无代码辅助 |
|
||||||
|
| ShopXO 版本不匹配 shopxo-diy | 低(不走 DIY) | 不使用 DIY 设计器 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键文件清单
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `shopxo/app/service/BuyService.php` | 订单创建入口,多 SKU 关键代码 |
|
||||||
|
| `shopxo/app/service/OrderSplitService.php` | 拆单逻辑,多座位订单被拆的风险点 |
|
||||||
|
| `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` | 当前票务详情页模板,Q2 多 SKU Plan A 代码已在此 |
|
||||||
|
| `docs/12_UNIAPP_FRONTEND_RESEARCH.md` | uni-app 调研存档,Q1/Q4 依赖此文档 |
|
||||||
|
| `docs/02_FRONTEND_CUSTOMIZATION.md` | ShopXO DIY 设计器局限性证明 |
|
||||||
|
| `docs/14_TEMPLATE_RENDER_INVESTIGATION.md` | 模板渲染机制调查 |
|
||||||
|
| `docs/09_SHOPXO_HOOKS_REFERENCE.md` | 插件钩子清单,Q2 理想方案所需 hook 在此 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
1. **多座位下单可行**:走购物车路线,1 天内可上线多座位下单 Demo。
|
||||||
|
2. **uni-app 是最终目标**:fork shopxo-uniapp 票务页面自研,商城页面复用,H5 预览 = 小程序编译效果。
|
||||||
|
3. **无代码服务辅助有限**:适合静态展示区块,座位图等核心交互必须手写。
|
||||||
|
4. **Immediate Action**:BackendArchitect 提交 Q2 Plan A(购物车路线),FrontendDev 启动 shopxo-uniapp fork 项目骨架。
|
||||||
75
plan.md
75
plan.md
|
|
@ -1,11 +1,18 @@
|
||||||
|
<<<<<<< HEAD
|
||||||
# Plan — ShopXO 酷炫前端模板调研
|
# Plan — ShopXO 酷炫前端模板调研
|
||||||
|
|
||||||
> 版本:v1.0 | 日期:2026-04-20 | Agent:council/FirstPrinciples + council/FrontendDev + council/BackendArchitect + council/ProductManager
|
> 版本:v1.0 | 日期:2026-04-20 | Agent:council/FirstPrinciples + council/FrontendDev + council/BackendArchitect + council/ProductManager
|
||||||
|
=======
|
||||||
|
# Plan — ShopXO 酷炫前端模板实现方案调研
|
||||||
|
|
||||||
|
> 版本:v1.0 | 日期:2026-04-20 | Agent:council/ProductManager + council/FrontendDev + council/BackendArchitect + council/FirstPrinciples
|
||||||
|
>>>>>>> main
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 任务概述
|
## 任务概述
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
vr-shopxo-plugin 项目 Phase 0/1/2 后台开发已完成,现需调研票务商品详情页(`ticket_detail.html`)的酷炫前端模板实现方案。
|
vr-shopxo-plugin 项目 Phase 0/1/2 后台开发已完成,现需调研票务商品详情页(`ticket_detail.html`)的酷炫前端模板实现方案。
|
||||||
|
|
||||||
**4个调研方向**:
|
**4个调研方向**:
|
||||||
|
|
@ -67,6 +74,50 @@ Q1(最佳实践) ──→ 基础,供 Q3/Q4 引用
|
||||||
- [ ] [Claimed: council/FirstPrinciples] **Task FP-1**: 汇总 Q1-Q4 输出,写入 `docs/council-research-output.md`
|
- [ ] [Claimed: council/FirstPrinciples] **Task FP-1**: 汇总 Q1-Q4 输出,写入 `docs/council-research-output.md`
|
||||||
- [ ] [ ] [Claimed: council/FirstPrinciples] **Task FP-2**: 明确优先级、依赖关系、技术风险
|
- [ ] [ ] [Claimed: council/FirstPrinciples] **Task FP-2**: 明确优先级、依赖关系、技术风险
|
||||||
- [ ] [ ] [Claimed: council/FirstPrinciples] **Task FP-3**: 给出"最小可行方案 vs 理想方案"对比
|
- [ ] [ ] [Claimed: council/FirstPrinciples] **Task FP-3**: 给出"最小可行方案 vs 理想方案"对比
|
||||||
|
=======
|
||||||
|
vr-shopxo-plugin 项目推进 Phase 3 前端模板调研,聚焦 4 个方向:
|
||||||
|
- Q1:ShopXO 自定义模板最佳实践
|
||||||
|
- Q2:单订单多 SKU 支持(多座位选择前提)
|
||||||
|
- Q3:第三方无代码构建服务提示词策略
|
||||||
|
- Q4:uni-app 兼容性技术栈选型
|
||||||
|
|
||||||
|
**输出目标**:`docs/council-research-output.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务清单
|
||||||
|
|
||||||
|
### 全体 Round 1(规划并行,限时 2-3 分钟)
|
||||||
|
|
||||||
|
- [ ] [Claimed: council/ProductManager] **Task P1**: ProductManager 创建本 plan.md 并 merge main
|
||||||
|
- [ ] [ ] **Task F1**: FrontendDev — 分析 `ticket_detail.html` 现有结构,制定 UI 改进方案
|
||||||
|
- [ ] **Task B1**: BackendArchitect — 分析 ShopXO 订单模型是否支持单订单多 SKU
|
||||||
|
- [ ] **Task S1**: FirstPrinciples — 拍板 Q2 结论,识别最大技术风险
|
||||||
|
|
||||||
|
### 全体 Round 2(执行调研)
|
||||||
|
|
||||||
|
- [ ] [ ] **Task P2**: ProductManager — 综合 Q1/Q3/Q4 结论,输出 `council-research-output.md`
|
||||||
|
- [ ] **Task F2**: FrontendDev — 输出 H5 模板技术栈选型报告 → `docs/frontend-template-research.md`
|
||||||
|
- [ ] **Task B2**: BackendArchitect — 输出 ShopXO 多 SKU 调研报告 → `docs/backend-multi-sku-research.md`
|
||||||
|
- [ ] **Task S2**: FirstPrinciples — 评审所有报告,给出最终拍板结论
|
||||||
|
|
||||||
|
### 全体 Round 3(收敛)
|
||||||
|
|
||||||
|
- [ ] [ ] **Task P3**: ProductManager — 整合所有输出到 `council-research-output.md`,merge main
|
||||||
|
- [ ] [ ] 所有 Agent 投票 `[CONSENSUS: YES/NO]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖关系
|
||||||
|
|
||||||
|
```
|
||||||
|
Q2结论 ──→ Q4是否能做多座位选择
|
||||||
|
Q1结论 ──→ Q3/Q4技术栈基础
|
||||||
|
Q3/Q4 ──→ 最小可行方案 vs 理想方案
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键风险**:Q2(多SKU)结论将直接影响 Q4 多座位选择能否落地。
|
||||||
|
>>>>>>> main
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -74,14 +125,21 @@ Q1(最佳实践) ──→ 基础,供 Q3/Q4 引用
|
||||||
|
|
||||||
| 阶段 | 状态 | 说明 |
|
| 阶段 | 状态 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
<<<<<<< HEAD
|
||||||
| **Draft** | 🔄 进行中 | 各 Agent 调研并提交各自方向报告 |
|
| **Draft** | 🔄 进行中 | 各 Agent 调研并提交各自方向报告 |
|
||||||
| **Review** | ⬜ 待开始 | 各 Agent 交叉评审,FirstPrinciples 汇总 |
|
| **Review** | ⬜ 待开始 | 各 Agent 交叉评审,FirstPrinciples 汇总 |
|
||||||
| **Finalize** | ⬜ 待开始 | 输出 council-research-output.md |
|
| **Finalize** | ⬜ 待开始 | 输出 council-research-output.md |
|
||||||
|
=======
|
||||||
|
| **Draft** | 🔄 进行中 | Round 1 — 各 Agent 并行规划 |
|
||||||
|
| **Research** | ⬜ 待开始 | Round 2 — 执行调研 |
|
||||||
|
| **Finalize** | ⬜ 待开始 | Round 3 — 收敛共识 |
|
||||||
|
>>>>>>> main
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 输出文件
|
## 输出文件
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
| 文件 | 内容 | 负责人 |
|
| 文件 | 内容 | 负责人 |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
| `docs/Q1-frontend-research.md` | ShopXO 自定义模板最佳实践 | FrontendDev |
|
| `docs/Q1-frontend-research.md` | ShopXO 自定义模板最佳实践 | FrontendDev |
|
||||||
|
|
@ -107,3 +165,20 @@ Q1(最佳实践) ──→ 基础,供 Q3/Q4 引用
|
||||||
- **第 1 轮**(本轮):各 Agent 创建 plan 并 claim 任务
|
- **第 1 轮**(本轮):各 Agent 创建 plan 并 claim 任务
|
||||||
- **第 2 轮**:各 Agent 完成调研,提交报告到 docs/
|
- **第 2 轮**:各 Agent 完成调研,提交报告到 docs/
|
||||||
- **第 3 轮**:FirstPrinciples 汇总,如无法收敛则 FirstPrinciples 拍板
|
- **第 3 轮**:FirstPrinciples 汇总,如无法收敛则 FirstPrinciples 拍板
|
||||||
|
=======
|
||||||
|
| 文件 | Agent | 截止轮次 |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `docs/council-research-output.md` | ProductManager | Round 3 |
|
||||||
|
| `docs/frontend-template-research.md` | FrontendDev | Round 2 |
|
||||||
|
| `docs/backend-multi-sku-research.md` | BackendArchitect | Round 2 |
|
||||||
|
| `docs/firstprinciples-verdict.md` | FirstPrinciples | Round 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键文件参考
|
||||||
|
|
||||||
|
- `docs/12_UNIAPP_FRONTEND_RESEARCH.md` — 现有 uni-app 调研(需更新)
|
||||||
|
- `docs/14_TEMPLATE_RENDER_INVESTIGATION.md` — 现有模板渲染调研
|
||||||
|
- `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` — 当前模板
|
||||||
|
- `docs/02_FRONTEND_CUSTOMIZATION.md` — 前端定制历史文档
|
||||||
|
>>>>>>> main
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
# 幽灵 Spec 问题调研报告
|
||||||
|
|
||||||
|
> 日期:2026-04-20 | 来源:独立验证(验证 Council 调研结果)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、问题概述
|
||||||
|
|
||||||
|
**症状**:删除场馆后,编辑商品时即便场馆已置空,提交保存时仍不自动清除对应的 spec。
|
||||||
|
|
||||||
|
**Council 结论**:根因在 `AdminGoodsSaveHandle.php:88-89` 的 `continue` 语句,导致无效 config 块残留并写回 DB。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、数据流分析
|
||||||
|
|
||||||
|
### 2.1 读取链路(商品编辑页加载)
|
||||||
|
|
||||||
|
```
|
||||||
|
ShopXO 商品编辑页
|
||||||
|
↓
|
||||||
|
AdminGoodsSave::handle() 返回 Vue 组件 HTML
|
||||||
|
- 从 vr_seat_templates WHERE status=1 读取有效模板列表
|
||||||
|
- 从 goods.vr_goods_config 读取原始配置
|
||||||
|
AdminGoodsSave.php:196-202 (前端 JS 过滤)
|
||||||
|
.filter(c => validTemplateIds.has(c.template_id)) ← 关键过滤
|
||||||
|
.filter(...validRoomIds...) ← 过滤无效 room ID
|
||||||
|
↓
|
||||||
|
Vue 表单展示清洗后的配置
|
||||||
|
↓
|
||||||
|
用户修改配置,提交 vr_goods_config_base64
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 保存链路(商品保存)
|
||||||
|
|
||||||
|
```
|
||||||
|
前端提交 vr_goods_config_base64
|
||||||
|
↓
|
||||||
|
AdminGoodsSaveHandle.php:29-35 (save_handle 时机)
|
||||||
|
base64_decode → 写入 $data['vr_goods_config']
|
||||||
|
↓
|
||||||
|
ShopXO 原生 GoodsSpecificationsInsert (事务内)
|
||||||
|
生成 GoodsSpecType / GoodsSpecBase / GoodsSpecValue(原生规格)
|
||||||
|
↓
|
||||||
|
AdminGoodsSaveHandle.php:55-179 (save_thing_end 时机)
|
||||||
|
├─ 从 DB 读 vr_goods_config(最新数据)
|
||||||
|
├─ 遍历 configs[],重建 template_snapshot(无效 template_id 则 continue)
|
||||||
|
├─ 写回 vr_goods_config 到 goods 表 ← 脏数据写回!
|
||||||
|
├─ 清空 GoodsSpecType/GoodsSpecBase/GoodsSpecValue
|
||||||
|
└─ 逐模板 BatchGenerate(无效 template_id 静默跳过)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、Council 调研结果的验证
|
||||||
|
|
||||||
|
### 3.1 Council 发现的核心问题(正确)
|
||||||
|
|
||||||
|
**文件**:`shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php`
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 第 77-90 行
|
||||||
|
foreach ($configs as $i => &$config) {
|
||||||
|
$templateId = intval($config['template_id'] ?? 0);
|
||||||
|
$selectedRooms = $config['selected_rooms'] ?? [];
|
||||||
|
|
||||||
|
if ($templateId > 0 && (!empty($selectedRooms) || empty($config['template_snapshot']) || empty($config['template_snapshot']['rooms']))) {
|
||||||
|
$template = Db::name('vr_seat_templates')->find($templateId); // 第 83 行
|
||||||
|
|
||||||
|
if (empty($template)) {
|
||||||
|
continue; // ← BUG:只跳过本次循环,config 块仍留在 $configs 数组中
|
||||||
|
}
|
||||||
|
// ... snapshot 重建逻辑
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($config);
|
||||||
|
|
||||||
|
// 第 148-150 行:无条件写回 DB
|
||||||
|
Db::name('Goods')->where('id', $goodsId)->update([
|
||||||
|
'vr_goods_config' => json_encode($configs, ...),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题**:
|
||||||
|
1. 当 `template_id` 指向已硬删除的模板时,`find()` 返回 null
|
||||||
|
2. `continue` 只跳过 snapshot 重建,但 config 块仍保留在 `$configs` 数组
|
||||||
|
3. 第 148-150 行将含无效 `template_id` 的 config 块写回 DB
|
||||||
|
|
||||||
|
### 3.2 前端过滤是否有效?
|
||||||
|
|
||||||
|
**Council 遗漏的关键点**:后台商品编辑页(AdminGoodsSave.php)本身的前端过滤。
|
||||||
|
|
||||||
|
查看 `AdminGoodsSave.php:196-202`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (AppData.vrGoodsConfig && Array.isArray(AppData.vrGoodsConfig)) {
|
||||||
|
// 从 vr_seat_templates WHERE status=1 获取有效模板 ID
|
||||||
|
const validTemplateIds = new Set((AppData.templates || []).map(t => t.id));
|
||||||
|
|
||||||
|
configs.value = AppData.vrGoodsConfig
|
||||||
|
// 过滤掉模板已删除的配置
|
||||||
|
.filter(c => validTemplateIds.has(c.template_id))
|
||||||
|
```
|
||||||
|
|
||||||
|
**分析**:
|
||||||
|
- `validTemplateIds` 只包含 `status=1` 的有效模板
|
||||||
|
- 硬删除的模板不在 `vr_seat_templates` 表中
|
||||||
|
- 所以 `.filter(c => validTemplateIds.has(c.template_id))` **会正确移除无效模板的配置**
|
||||||
|
|
||||||
|
**结论**:前端过滤是有效的,但问题出在后端的 `save_thing_end` 时机从数据库重新读取数据。
|
||||||
|
|
||||||
|
### 3.3 真实的问题场景
|
||||||
|
|
||||||
|
1. **商品创建时**:用户配置了场馆 A(template_id=5)和场馆 B(template_id=6)
|
||||||
|
2. **场馆 A 被硬删除**:vr_seat_templates 表中无记录
|
||||||
|
3. **用户编辑商品**:
|
||||||
|
- 前端读取 DB 中的 vr_goods_config(仍含场馆 A 的配置)
|
||||||
|
- 前端过滤后只提交场馆 B 的配置
|
||||||
|
4. **后端 save_handle**:接收前端提交的只含场馆 B 的配置
|
||||||
|
5. **后端 save_thing_end**:
|
||||||
|
- 从 DB 读取 vr_goods_config → **此时读到的是旧数据(含场馆 A)**
|
||||||
|
- 遍历时场馆 A 的 template_id=5 查不到模板,continue 跳过
|
||||||
|
- **场馆 A 的 config 块残留在数组中**
|
||||||
|
- 写回 DB → **场馆 A 的脏配置被写回!**
|
||||||
|
|
||||||
|
**关键发现**:save_thing_end 从 DB 读取的是 goods 表中的数据,而非 save_handle 时提交的 `$data['vr_goods_config']`。如果 goods 表中原本就有脏数据,问题就会累积。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、"规格不允许重复" 的来源
|
||||||
|
|
||||||
|
该错误信息来自 `GoodsService.php:1859`,是 ShopXO 原生规格验证逻辑。
|
||||||
|
|
||||||
|
**可能场景**:
|
||||||
|
1. 商品曾以普通商品(有原生 spec)保存
|
||||||
|
2. 后转换为票务商品
|
||||||
|
3. 保存时 ShopXO 原生 GoodsSpecificationsInsert 先生成原生规格
|
||||||
|
4. AdminGoodsSaveHandle save_thing_end 执行清空规格表
|
||||||
|
5. 但如果时序有问题,原生规格可能残留
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、根因总结
|
||||||
|
|
||||||
|
| 优先级 | 根因 | 位置 | 影响 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| **P1** | save_thing_end 从 DB 读取时,无效 config 块未被移除 | AdminGoodsSaveHandle.php:88-89 + 148-150 | 脏数据写回 DB,幽灵 spec 累积 |
|
||||||
|
| P2 | GetGoodsViewData 只处理第一个配置块 | SeatSkuService.php:368 | 多模板时无效块不清理 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、修复方案
|
||||||
|
|
||||||
|
### P1 Fix(立即实施)
|
||||||
|
|
||||||
|
**文件**:`AdminGoodsSaveHandle.php`
|
||||||
|
|
||||||
|
**修改 1**:第 88-89 行
|
||||||
|
```php
|
||||||
|
if (empty($template)) {
|
||||||
|
unset($configs[$i]); // 移除无效 config 块
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改 2**:第 145 行后(unset($config) 之后)
|
||||||
|
```php
|
||||||
|
unset($config);
|
||||||
|
$configs = array_values($configs); // 重排索引
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改 3**:第 148-150 行前加判空
|
||||||
|
```php
|
||||||
|
if (!empty($configs)) {
|
||||||
|
Db::name('Goods')->where('id', $goodsId)->update([
|
||||||
|
'vr_goods_config' => json_encode($configs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**修改 4**:BatchGenerate 循环中增加防御性校验(第 158-173 行)
|
||||||
|
```php
|
||||||
|
foreach ($configs as $config) {
|
||||||
|
$templateId = intval($config['template_id'] ?? 0);
|
||||||
|
if ($templateId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$template = Db::name('vr_seat_templates')->find($templateId);
|
||||||
|
if (empty($template)) {
|
||||||
|
continue; // 无效块跳过
|
||||||
|
}
|
||||||
|
$res = SeatSkuService::BatchGenerate(...);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### P2 Fix(高优先级)
|
||||||
|
|
||||||
|
**文件**:`SeatSkuService.php` 第 368-393 行
|
||||||
|
|
||||||
|
GetGoodsViewData 需要遍历所有配置块,清理无效块后再处理:
|
||||||
|
```php
|
||||||
|
// 过滤有效配置块
|
||||||
|
$validConfigs = [];
|
||||||
|
foreach ($vrGoodsConfig as $cfg) {
|
||||||
|
$tid = intval($cfg['template_id'] ?? 0);
|
||||||
|
if ($tid <= 0) continue;
|
||||||
|
$tpl = Db::name(self::table('seat_templates'))->where('id', $tid)->find();
|
||||||
|
if (!empty($tpl)) {
|
||||||
|
$validConfigs[] = $cfg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (empty($validConfigs)) {
|
||||||
|
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
|
||||||
|
}
|
||||||
|
$config = $validConfigs[0]; // 取第一个有效配置块用于前端展示
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、实施计划
|
||||||
|
|
||||||
|
| 步骤 | 任务 | 文件 | 优先级 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| 1 | 修复 P1:无效 config 块移除 | AdminGoodsSaveHandle.php | P1 |
|
||||||
|
| 2 | 修复 P2:GetGoodsViewData 多模板清理 | SeatSkuService.php | P1 |
|
||||||
|
| 3 | 测试验证 | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、结论
|
||||||
|
|
||||||
|
1. **Council 的调研结果基本正确**,但遗漏了前端过滤本身是有效的这一点
|
||||||
|
2. **真正的根因**在于 `save_thing_end` 时机从 DB 读取的是 goods 表中的旧数据,而非前端提交的数据
|
||||||
|
3. **修复方案**是:在后端遍历时直接移除无效 config 块,确保脏数据不回写 DB
|
||||||
|
4. **GetGoodsViewData** 也需要同步修复,支持多模板模式
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
# SecurityEngineer — 幽灵 spec 安全审计汇总报告
|
||||||
|
|
||||||
|
**文件路径基于** `/Users/bigemon/WorkSpace/vr-shopxo-plugin/`
|
||||||
|
**审计时间**:2026-04-20
|
||||||
|
**参与者**:SecurityEngineer(安全审计)、BackendArchitect(根因分析)、FrontendDev(前端分析)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行摘要
|
||||||
|
|
||||||
|
对「场馆删除后编辑商品出现规格重复错误」问题进行了三方安全审计。核心根因已定位,**P1 安全缺陷**已识别。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 审计范围
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php` | 商品保存钩子,vr_goods_config 处理 |
|
||||||
|
| `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php` | 批量 SKU 生成,模板不存在时的 fallback |
|
||||||
|
| `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` | 顾客端座位选购页面 |
|
||||||
|
| `shopxo/app/plugins/vr_ticket/admin/Admin.php` | 场馆硬删除逻辑 |
|
||||||
|
| `shopxo/app/admin/hook/AdminGoodsSave.php` | ShopXO 商品保存钩子入口 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 根因分析(SecurityEngineer)
|
||||||
|
|
||||||
|
### 根因 1(P1):无效 template_id 配置块未被过滤
|
||||||
|
|
||||||
|
**文件**:`AdminGoodsSaveHandle.php:148-173`
|
||||||
|
|
||||||
|
当 `vr_goods_config` 中存在 `template_id` 指向已删除场馆的配置块时:
|
||||||
|
|
||||||
|
1. `save_thing_end` 从 DB 加载 config(第 61-66 行)
|
||||||
|
2. 遍历 configs 尝试重建 `template_snapshot`(第 77 行)
|
||||||
|
3. 若模板不存在,`continue` 跳过 snapshot 重建(第 88-90 行)
|
||||||
|
4. **整个 config 块(含旧的 `template_snapshot`)被写回 DB**(第 148-150 行)
|
||||||
|
5. `BatchGenerate` 被调用时,若 `template_id` 仍为正整数但模板不存在,返回 `code: -2` 阻止保存
|
||||||
|
|
||||||
|
**关键缺陷**:若 config 块的 `template_id` 被前端置为 `0`(模板选单为空),则 `templateId > 0` 为 `false`,`BatchGenerate` 整个循环体被跳过,**无任何校验**直接写回。
|
||||||
|
|
||||||
|
### 根因 2(P1):幽灵 spec 持续污染 vr_goods_config
|
||||||
|
|
||||||
|
脏 config 块(含已删除模板的 `template_snapshot`)被写回 DB 后:
|
||||||
|
- 下次编辑商品时,`vr_goods_config` 仍含无效配置
|
||||||
|
- `GetGoodsViewData` 尝试加载模板(失败后将 `template_id` 置 null)
|
||||||
|
- 但若 `save_thing_end` 在模板验证前先执行写回,无效配置再次被保存
|
||||||
|
- 循环往复,**幽灵 spec 永远无法被清理**
|
||||||
|
|
||||||
|
### 根因 3(P2):前端无 `vr_goods_config_base64` 输入保护
|
||||||
|
|
||||||
|
`AdminGoodsSaveHandle.php:29-35` 接收前端传入的 base64 编码配置:
|
||||||
|
- 无 schema 校验(不验证 `template_id` 是否为正整数)
|
||||||
|
- 无类型校验(不验证是否为数组)
|
||||||
|
- 管理员可直接 POST 恶意 JSON 注入 `vr_goods_config`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 前端分析(参考 ticket_detail.html)
|
||||||
|
|
||||||
|
### 硬删除场景下的 fallback
|
||||||
|
|
||||||
|
`SeatSkuService::GetGoodsViewData` 在模板不存在时:
|
||||||
|
- `vr_seat_template` 返回 `null`
|
||||||
|
- `goods_config.template_id` 置 `null`
|
||||||
|
- `goods_config.template_snapshot` 置 `null`
|
||||||
|
- `goods_spec_data` 返回空数组
|
||||||
|
|
||||||
|
前端 `ticket_detail.html` 读取 `seatMap = []` 和 `specBaseIdMap = []`,座位图不渲染。**设计正确**。
|
||||||
|
|
||||||
|
### 安全风险
|
||||||
|
|
||||||
|
1. **`loadSoldSeats()` 未实现**(ticket_detail.html:375-383):TODO 注释状态,无法标记已售座位。顾客可购买已售座位(需支付验证拦截)。
|
||||||
|
2. **`submit` 依赖 `specBaseIdMap`**(第 417 行):空时降级 `sessionSpecId`。理论上可操控座位数据选择任意座位。
|
||||||
|
3. **无直接 XSS**:后端输出均有编码,`seatMap` 和 `specBaseIdMap` 来自 DB 合规数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 严重性分级
|
||||||
|
|
||||||
|
| 等级 | 数量 | 描述 |
|
||||||
|
|------|------|------|
|
||||||
|
| **P1** | 2 | 无效 template_id 静默保存;幽灵 spec 无法清理 |
|
||||||
|
| **P2** | 3 | Admin API 无 schema 校验;残留 snapshot 信息泄露;specBaseIdMap 端侧无验证 |
|
||||||
|
| **低** | 0 | 无直接 XSS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复方案
|
||||||
|
|
||||||
|
### P1-1/P1-2:拒绝无效 template_id(必须)
|
||||||
|
|
||||||
|
**AdminGoodsSaveHandle.php:158-173** 需在调用 `BatchGenerate` 前验证:
|
||||||
|
|
||||||
|
```php
|
||||||
|
foreach ($configs as $config) {
|
||||||
|
$templateId = intval($config['template_id'] ?? 0);
|
||||||
|
if ($templateId <= 0) {
|
||||||
|
return ['code' => -401, 'msg' => '票务配置中的 template_id 无效或已被删除'];
|
||||||
|
}
|
||||||
|
$exists = Db::name('vr_seat_templates')->where('id', $templateId)->find();
|
||||||
|
if (empty($exists)) {
|
||||||
|
return ['code' => -401, 'msg' => 'template_id [' . $templateId . '] 指向的场馆已不存在'];
|
||||||
|
}
|
||||||
|
$res = SeatSkuService::BatchGenerate(...);
|
||||||
|
if ($res['code'] !== 0) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### P2-1:过滤无效 config 块(必须)
|
||||||
|
|
||||||
|
在写回 DB 之前过滤掉 `template_id <= 0` 的配置块:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$validConfigs = array_filter($configs, function($c) {
|
||||||
|
return intval($c['template_id'] ?? 0) > 0;
|
||||||
|
});
|
||||||
|
if (empty($validConfigs)) {
|
||||||
|
return ['code' => -401, 'msg' => '票务商品必须包含至少一个有效场馆配置'];
|
||||||
|
}
|
||||||
|
Db::name('Goods')->where('id', $goodsId)->update([
|
||||||
|
'vr_goods_config' => json_encode($validConfigs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
**幽灵 spec 的根因是后端未拒绝脏数据**,而非前端注入。`save_thing_end` 在模板验证失败时静默保留了无效的 config 块,导致 `vr_goods_config` 中的幽灵 spec 永远无法被清理。修复方向明确:任何 `template_id` 为空或指向不存在场馆的配置块,都必须被过滤或拒绝保存,并返回 `code: -401` 告知用户重新选择场馆。
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
# SecurityEngineer — 幽灵 spec 安全审计报告
|
||||||
|
|
||||||
|
**文件路径基于** `/Users/bigemon/WorkSpace/vr-shopxo-plugin/`
|
||||||
|
**审计时间**:2026-04-20
|
||||||
|
**审计范围**:AdminGoodsSaveHandle.php、SeatSkuService.php、ticket_detail.html、Admin.php、AdminGoodsSave.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S1. AdminGoodsSaveHandle 脏数据拒绝逻辑
|
||||||
|
|
||||||
|
### S1-Q1:当 `template_id` 指向不存在的场馆时,是否拒绝保存(code -401)?
|
||||||
|
|
||||||
|
**结论:否 — 脏数据被静默保存,存在 P1 安全缺陷。**
|
||||||
|
|
||||||
|
**根因分析**:
|
||||||
|
|
||||||
|
1. **保存时 `save_thing_end` 流程**(AdminGoodsSaveHandle.php:158-173):
|
||||||
|
```php
|
||||||
|
foreach ($configs as $config) {
|
||||||
|
$templateId = intval($config['template_id'] ?? 0);
|
||||||
|
if ($templateId > 0) {
|
||||||
|
$res = SeatSkuService::BatchGenerate(...);
|
||||||
|
if ($res['code'] !== 0) {
|
||||||
|
return $res; // ← 仅在此处返回错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// templateId == 0 时:整个循环体被跳过,什么都不做
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**关键**:当 `template_id` 硬编码为某个已删除模板的 ID(整数,如 `5`)时,`intval($config['template_id'] ?? 0)` 返回 `5`,`templateId > 0` 为 `true`,代码进入 `BatchGenerate` 调用。
|
||||||
|
|
||||||
|
2. **BatchGenerate 内部有模板存在性校验**(SeatSkuService.php:52-57):
|
||||||
|
```php
|
||||||
|
$template = Db::name(self::table('seat_templates'))->where('id', $seatTemplateId)->find();
|
||||||
|
if (empty($template)) {
|
||||||
|
return ['code' => -2, 'msg' => "座位模板 {$seatTemplateId} 不存在"];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
返回 `code: -2`,**但 `AdminGoodsSaveHandle.php:169` 只检查 `!== 0`**:
|
||||||
|
```php
|
||||||
|
if ($res['code'] !== 0) {
|
||||||
|
return $res; // -2 !== 0 → 确实返回错误
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
所以 `BatchGenerate` 返回 `-2` 时,**错误确实被向上传播**,保存被拒绝。
|
||||||
|
|
||||||
|
3. **但 `save_thing_end` 在返回错误之前,已将修改后的 config 写回 DB**(AdminGoodsSaveHandle.php:148-150):
|
||||||
|
```php
|
||||||
|
Db::name('Goods')->where('id', $goodsId)->update([
|
||||||
|
'vr_goods_config' => json_encode($configs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
此写回发生在 `foreach ($configs as $config)` 循环(第 77 行)**之后**,即 `template_snapshot` 已被处理。问题是:对于无效模板的 config 块,`template_snapshot` 不会被重建(跳过第 88-90 行的 `continue`),但**旧的 `template_snapshot` 仍然保留在内存的 `$config` 中**,随后被写回 DB。
|
||||||
|
|
||||||
|
**P1 缺陷**:当模板不存在时,`template_snapshot` 不被清理。即使 `BatchGenerate` 返回错误阻止了保存,`vr_goods_config` 中**已含有一个指向不存在模板的配置块**,且其 `template_snapshot` 仍保留旧的座位图数据。
|
||||||
|
|
||||||
|
4. **另一个路径**(SeatSkuService.php:55-57):如果模板记录被物理删除,`find()` 返回 `null`,`BatchGenerate` 返回 `-2` 并**阻止保存**。但如果 config 中的 `template_id` 为 `0`(`intval(null)` 或前端传空),则 `templateId > 0` 为 `false`,循环体完全跳过,`vr_goods_config` 被写回时**没有任何校验**。
|
||||||
|
|
||||||
|
### S1-Q2:幽灵 spec 的产生环节
|
||||||
|
|
||||||
|
**幽灵 spec 产生于 `vr_goods_config` 的 `spec_base_id_map` 字段**。分析如下:
|
||||||
|
|
||||||
|
- `spec_base_id_map` 存储在 `vr_seat_templates.spec_base_id_map` 表字段中(Admin.php:177)
|
||||||
|
- 当前端编辑含 `vr_goods_config` 的商品时,`save_thing_end` 加载 config 后,遍历每个 config 块:
|
||||||
|
- 如果 `template_id` 有效 → `BatchGenerate` 重新生成所有 SKU
|
||||||
|
- 如果 `template_id` 无效(0 或已删除)→ 跳过 `BatchGenerate`,config 块**原样写回 DB**
|
||||||
|
|
||||||
|
**幽灵 spec 不会被过滤**,因为保存逻辑中没有针对无效 `template_id` 配置块的过滤/清理逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S2. 脏数据注入路径分析
|
||||||
|
|
||||||
|
### S2-Q1:幽灵 spec 是否可被恶意利用来注入/覆盖商品规格?
|
||||||
|
|
||||||
|
**结论:理论风险存在(中等),但需管理员权限利用。**
|
||||||
|
|
||||||
|
**攻击路径**:
|
||||||
|
|
||||||
|
1. **通过 `vr_goods_config_base64` 参数注入**(AdminGoodsSaveHandle.php:29-35):
|
||||||
|
```php
|
||||||
|
$base64Config = $postParams['vr_goods_config_base64'] ?? '';
|
||||||
|
if (!empty($base64Config)) {
|
||||||
|
$jsonStr = base64_decode($base64Config);
|
||||||
|
if ($jsonStr !== false) {
|
||||||
|
$params['data']['vr_goods_config'] = $jsonStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
前端表单**不暴露** `vr_goods_config_base64` 输入框,所以普通用户在标准编辑流程中无法注入。
|
||||||
|
|
||||||
|
2. **ShopXO API 直接提交**:任何已登录的管理员可以直接 POST 到商品保存 API,携带恶意 `vr_goods_config_base64`,注入任意 JSON 到 `vr_goods_config` 字段。
|
||||||
|
|
||||||
|
3. **注入的内容**:
|
||||||
|
- 多个 config 块引用同一个 `template_id`(重复模板)
|
||||||
|
- 引用已删除模板的 `template_id`
|
||||||
|
- `template_snapshot` 中注入任意字符串(虽然后端会重建,但若模板不存在则保留)
|
||||||
|
|
||||||
|
4. **`save_thing_end` 对 `vr_goods_config` 的处理**(AdminGoodsSaveHandle.php:61-66):从 DB 读取 `vr_goods_config`,**不使用前端传入的 `$data['vr_goods_config']`**(除非 DB 为空)。这意味着即使用户在 `save_handle` 时注入了恶意 config,`save_thing_end` 仍然基于数据库中的已有配置执行,不会直接使用注入值。
|
||||||
|
|
||||||
|
**但是**:若 DB 中已存在含幽灵 spec 的 `vr_goods_config`(由于之前的保存或注入),`save_thing_end` 会加载并处理它。
|
||||||
|
|
||||||
|
### S2-Q2:前端 fallback 安全风险
|
||||||
|
|
||||||
|
**结论:存在低-中风险(信息泄露 + CSS 注入),无直接 XSS。**
|
||||||
|
|
||||||
|
1. **`ticket_detail.html` 是顾客端页面**(非管理后台),查看源码:
|
||||||
|
- `seatMap` = `<?php echo json_encode($vr_seat_template['seat_map'] ?? []); ?>`(第 186 行)
|
||||||
|
- `specBaseIdMap` = `<?php echo json_encode($vr_seat_template['spec_base_id_map'] ?? []); ?>`(第 187 行)
|
||||||
|
|
||||||
|
2. **硬删除场景下的数据流**(SeatSkuService.php:380-393):
|
||||||
|
```php
|
||||||
|
if (empty($seatTemplate)) {
|
||||||
|
$config['template_id'] = null;
|
||||||
|
$config['template_snapshot'] = null;
|
||||||
|
Db::name('Goods')->where('id', $goodsId)->update([
|
||||||
|
'vr_goods_config' => json_encode([$config], ...),
|
||||||
|
]);
|
||||||
|
return [
|
||||||
|
'vr_seat_template' => null, // ← 模板数据为空
|
||||||
|
'goods_spec_data' => [],
|
||||||
|
'goods_config' => $config,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
当模板被硬删除后,`vr_seat_template` 返回 `null`,`seatMap` 和 `specBaseIdMap` 在前端均为空数组 `[]`。座位图不会渲染。**前端 fallback 设计正确**。
|
||||||
|
|
||||||
|
3. **但 `AdminGoodsSaveHandle.php:148-150` 写回脏数据时**,`template_snapshot` 未被清理,若前端访问到一个旧的 snapshot(来自数据库中残留的配置),`seatMap` 包含旧座位数据,此时:
|
||||||
|
- `renderSeatMap()` 第 270 行:`style="background:'+color+'"` — color 值来自后端 DB,若 DB 被攻陷(通过 VenueSave 注入),可注入 CSS 表达式如 `url(javascript:...)`(现代浏览器已防护)
|
||||||
|
- `renderSeatMap()` 第 275 行:`data-label` 属性 — 值来自 `seatInfo.label`,经过 `htmlspecialchars`(ShopXO 输出编码),**基本安全**
|
||||||
|
- `renderSeatMap()` 第 275 行:`data-label="'rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座"` — 硬编码的纯字母数字,无注入风险
|
||||||
|
|
||||||
|
4. **硬编码拼接中的潜在属性注入**(ticket_detail.html:275):
|
||||||
|
```html
|
||||||
|
data-label="'+rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座"
|
||||||
|
```
|
||||||
|
此处 `colIndex+1` 是 JS 计算值,**无注入风险**。`rowLabel` 来自 `map.row_labels` 或 `chr(65+index)`,也是纯字母,**无注入风险**。
|
||||||
|
|
||||||
|
5. **`submit` 函数的 spec_base_id**(ticket_detail.html:417):
|
||||||
|
```javascript
|
||||||
|
var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId;
|
||||||
|
```
|
||||||
|
若 `specBaseIdMap` 为空,降级到 `sessionSpecId`。理论上可操控座位图数据来选择任意座位,但购买还需付款环节验证。**风险有限**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S3. ShopXO 商品保存入口
|
||||||
|
|
||||||
|
**AdminGoodsSave.php** — 入口文件,只注册钩子,无额外校验逻辑。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S4. 严重性分级
|
||||||
|
|
||||||
|
| # | 风险描述 | 严重性 | 根因位置 |
|
||||||
|
|---|---------|--------|---------|
|
||||||
|
| P1-1 | 模板不存在时,`template_snapshot` 未被清理就直接写回 DB,脏配置持续存在 | **P1** | AdminGoodsSaveHandle.php:148-150(硬删除后未清理 config 块) |
|
||||||
|
| P1-2 | `template_id=0` 时整个 config 块无校验直接写回,任何人都能保存空规格商品 | **P1** | AdminGoodsSaveHandle.php:158-173(`templateId == 0` 时跳过所有处理) |
|
||||||
|
| P2-1 | 管理员可通过 API 直接注入 `vr_goods_config_base64` 写入任意配置 | **P2** | AdminGoodsSaveHandle.php:29-35(无 schema 校验) |
|
||||||
|
| P2-2 | 硬删除模板后,前端 fallback 依赖 DB 中残留的 `template_snapshot`(信息泄露) | **P2** | AdminGoodsSaveHandle.php:148-150(写回时未过滤无效 config) |
|
||||||
|
| P2-3 | `submit` 依赖 `specBaseIdMap`(空时降级 sessionSpecId),无端侧验证 | **P2** | ticket_detail.html:417(需配合支付侧校验) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S5. 修复建议
|
||||||
|
|
||||||
|
### P1-1/P1-2 修复(必须)
|
||||||
|
|
||||||
|
**AdminGoodsSaveHandle.php:158-173**,修改后:
|
||||||
|
|
||||||
|
```php
|
||||||
|
foreach ($configs as $config) {
|
||||||
|
$templateId = intval($config['template_id'] ?? 0);
|
||||||
|
if ($templateId <= 0) {
|
||||||
|
// 无效 template_id:拒绝保存,返回错误
|
||||||
|
return ['code' => -401, 'msg' => '票务配置中的 template_id 无效或已被删除,请重新选择场馆'];
|
||||||
|
}
|
||||||
|
// 验证模板在 DB 中存在
|
||||||
|
$exists = Db::name('vr_seat_templates')->where('id', $templateId)->find();
|
||||||
|
if (empty($exists)) {
|
||||||
|
return ['code' => -401, 'msg' => '票务配置中的 template_id [' . $templateId . '] 指向的场馆已不存在,请重新选择'];
|
||||||
|
}
|
||||||
|
$res = SeatSkuService::BatchGenerate(...);
|
||||||
|
if ($res['code'] !== 0) {
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
同时在循环之前(写回 DB 之前),过滤掉 `template_id <= 0` 的 config 块:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 过滤无效 config 块(template_id 为空或 0)
|
||||||
|
$validConfigs = array_filter($configs, function($c) {
|
||||||
|
return intval($c['template_id'] ?? 0) > 0;
|
||||||
|
});
|
||||||
|
if (empty($validConfigs)) {
|
||||||
|
return ['code' => -401, 'msg' => '票务商品必须包含至少一个有效场馆配置'];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### P2-1 修复(建议)
|
||||||
|
|
||||||
|
在 `save_handle` 时(AdminGoodsSaveHandle.php:29-35),对 `vr_goods_config_base64` 做 schema 校验:
|
||||||
|
- 解码后必须是 JSON 数组
|
||||||
|
- 每个 config 块的 `template_id` 必须是正整数
|
||||||
|
- 禁止传入 `template_snapshot`(应始终由后端从 DB 重建)
|
||||||
|
|
||||||
|
### P2-2 修复(建议)
|
||||||
|
|
||||||
|
在 `save_thing_end` 写回 DB 之前,清理无效模板的 config 块:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 写回之前:清理无效 config
|
||||||
|
$validConfigs = [];
|
||||||
|
foreach ($configs as $config) {
|
||||||
|
$templateId = intval($config['template_id'] ?? 0);
|
||||||
|
if ($templateId > 0) {
|
||||||
|
$templateExists = Db::name('vr_seat_templates')->where('id', $templateId)->find();
|
||||||
|
if (!empty($templateExists)) {
|
||||||
|
$validConfigs[] = $config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Db::name('Goods')->where('id', $goodsId)->update([
|
||||||
|
'vr_goods_config' => json_encode($validConfigs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### P2-3 修复(建议)
|
||||||
|
|
||||||
|
`ticket_detail.html` 的 `submit` 函数中,对 `spec_base_id` 增加服务器端校验(非本文档范围,需在支付 API 入口添加)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
| 风险等级 | 数量 | 说明 |
|
||||||
|
|---------|------|------|
|
||||||
|
| **P1** | 2 | 脏数据未拒绝,直接影响数据完整性和商品保存正确性 |
|
||||||
|
| **P2** | 3 | 注入风险低(需管理员权限)、信息泄露、缺少校验 |
|
||||||
|
| **低** | 0 | 无直接 XSS(后端输出有编码保护) |
|
||||||
|
|
||||||
|
**核心 P1 缺陷**:当 `template_id` 指向不存在的场馆时,系统**不拒绝保存**,而是静默保留旧的 `template_snapshot`,导致幽灵 spec 持续存在于数据库中。这是用户遇到「规格不允许重复」错误的根本原因(配置块未清理,残留的 `spec_base_id_map` 数据与新生成的 SKU 产生冲突)。
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
# FrontendDev 调研报告:幽灵 spec 问题
|
||||||
|
|
||||||
|
> 日期:2026-04-20 | Agent:council/FrontendDev
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. ticket_detail.html 的前端规格项构建
|
||||||
|
|
||||||
|
### 1.1 页面性质确认
|
||||||
|
|
||||||
|
`ticket_detail.html` 是**客户前端购票页面**(用于 C 端用户选座下单),**不是**后台商品编辑页面。后台编辑商品时出现的「规格不允许重复」错误发生在 ShopXO 标准后台,其触发点在 `GoodsService.php:1859/1889/1925`。
|
||||||
|
|
||||||
|
前端购票页面的数据来源:
|
||||||
|
|
||||||
|
| PHP 变量 | 来源(SeatSkuService) | 用途 |
|
||||||
|
|----------|----------------------|------|
|
||||||
|
| `$vr_seat_template` | `GetGoodsViewData()` | `seat_map`、`spec_base_id_map` |
|
||||||
|
| `$goods_spec_data` | `GetGoodsViewData()` | 场次(session)列表 |
|
||||||
|
|
||||||
|
前端 JS 接收这些数据:
|
||||||
|
|
||||||
|
```
|
||||||
|
ticket_detail.html:186-187
|
||||||
|
seatMap: <?php echo json_encode($vr_seat_template['seat_map'] ?? []); ?>,
|
||||||
|
specBaseIdMap: <?php echo json_encode($vr_seat_template['spec_base_id_map'] ?? []); ?>,
|
||||||
|
```
|
||||||
|
|
||||||
|
前端规格项(场次)构建逻辑(`renderSessions()`, ticket_detail.html:202-213):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
var specData = <?php echo json_encode($goods_spec_data ?? []); ?> || [];
|
||||||
|
// specData 格式: [{spec_id: 2001, spec_name: "08:00-23:59", price: 100}]
|
||||||
|
// 渲染为可点击的场次卡片
|
||||||
|
```
|
||||||
|
|
||||||
|
**结论**:`ticket_detail.html` 本身不构建 ShopXO 规格(spec)表格,其规格项仅为场次选择器。真正触发「规格不允许重复」的是 ShopXO 后台商品编辑页的 `GoodsService.php`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 模板不存在时前端对 template_snapshot 和 spec_base_id_map 的处理
|
||||||
|
|
||||||
|
### 2.1 后端 fallback 行为(SeatSkuService.php)
|
||||||
|
|
||||||
|
关键函数:`GetGoodsViewData()` (`SeatSkuService.php:358-464`)
|
||||||
|
|
||||||
|
**模板不存在时的 fallback(硬删除场景)**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// SeatSkuService.php:383-393
|
||||||
|
if (empty($seatTemplate)) {
|
||||||
|
$config['template_id'] = null;
|
||||||
|
$config['template_snapshot'] = null;
|
||||||
|
Db::name('Goods')->where('id', $goodsId)->update([
|
||||||
|
'vr_goods_config' => json_encode([$config], ...),
|
||||||
|
]);
|
||||||
|
return [
|
||||||
|
'vr_seat_template' => null,
|
||||||
|
'goods_spec_data' => [],
|
||||||
|
'goods_config' => $config,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**执行效果**:
|
||||||
|
1. `template_id` 被置为 `null`(写入 DB)
|
||||||
|
2. `template_snapshot` 被置为 `null`(写入 DB)
|
||||||
|
3. 返回给前端:`vr_seat_template = null`、`goods_spec_data = []`
|
||||||
|
|
||||||
|
**前端接收到的数据**:
|
||||||
|
```javascript
|
||||||
|
seatMap: {} // 空对象
|
||||||
|
specBaseIdMap: {} // 空对象
|
||||||
|
goods_spec_data: [] // 空数组
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端渲染结果**:
|
||||||
|
- `renderSessions()`:`sessionGrid` 内为 `goods_spec_data.length === 0`,显示提示「该商品暂无场次信息」(ticket_detail.html:133)
|
||||||
|
- `renderSeatMap()`:`seatMap.map` 为空,座位图区域显示「座位图加载失败」
|
||||||
|
- 整个座位选择区域 UI 为空/失败状态
|
||||||
|
|
||||||
|
### 2.2 根因分析
|
||||||
|
|
||||||
|
**模板不存在时,前端的 fallback 行为是正确的**——前端展示空白购票页,用户无法选座。这符合"场馆已删除,无法购票"的业务预期。
|
||||||
|
|
||||||
|
真正的问题不在 `ticket_detail.html`(前端),而在:
|
||||||
|
1. 后台商品编辑页(ShopXO admin)——保存时 `AdminGoodsSaveHandle` 如何处理 `template_id=null` 的情况
|
||||||
|
2. `vr_goods_config` 的持久化清理——硬删除后 `vr_goods_config` 中的 config 块是否被正确清理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. loadSoldSeats() 函数实现情况
|
||||||
|
|
||||||
|
**状态:未实现(仅有 TODO 注释)**
|
||||||
|
|
||||||
|
```
|
||||||
|
ticket_detail.html:375-383
|
||||||
|
loadSoldSeats: function() {
|
||||||
|
// TODO: 从后端加载已售座位
|
||||||
|
// $.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
|
||||||
|
// goods_id: this.goodsId,
|
||||||
|
// spec_base_id: this.sessionSpecId
|
||||||
|
// }, function(res) {
|
||||||
|
// // 标记已售座位
|
||||||
|
// });
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
**影响**:
|
||||||
|
- `soldSeats: {}` 永远为空对象(ticket_detail.html:189)
|
||||||
|
- `renderSeatMap()` 渲染座位时,无法从 `soldSeats` 读取已售标记
|
||||||
|
- 已售座位只能通过 `.sold` class(由 PHP 渲染)或 `soldSeats` 字典来标记,但两者都未生效
|
||||||
|
- 结果:前端无法区分已售/可选座位——用户可能选中一个已售座位,提交后才发现无法购买
|
||||||
|
|
||||||
|
**严重程度**:P2(功能缺陷),不影响「规格不允许重复」错误。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 编辑模式下前端对已删除场馆旧规格的处理
|
||||||
|
|
||||||
|
### 4.1 当前行为
|
||||||
|
|
||||||
|
当商品的 `vr_goods_config` 中 `template_id` 指向的场馆已被硬删除:
|
||||||
|
|
||||||
|
1. `GetGoodsViewData()` 检测到模板不存在 → `template_id=null`、`template_snapshot=null` → 写入 DB
|
||||||
|
2. 前端收到 `vr_seat_template=null`、`goods_spec_data=[]`
|
||||||
|
3. `ticket_detail.html` 渲染空白购票页(无场次、无座位图)
|
||||||
|
4. **前端没有特殊逻辑处理幽灵 spec**——因为后端已经清理了 `template_id` 和 `template_snapshot`
|
||||||
|
|
||||||
|
### 4.2 问题点
|
||||||
|
|
||||||
|
**`ticket_detail.html` 是前端购票页,不是编辑页**。商品编辑(后台)由 ShopXO 标准后台处理,VR 插件通过钩子介入。
|
||||||
|
|
||||||
|
幽灵 spec 的真正风险在于 `AdminGoodsSaveHandle` 的保存逻辑:
|
||||||
|
|
||||||
|
- `AdminGoodsSaveHandle.php:383-394`(硬删除 fallback):当模板不存在时,`continue` 跳过 snapshot 重建,**但 config 块本身未被移除**
|
||||||
|
- 如果 `vr_goods_config` 包含多个 config 块(如多场馆商品),硬删除场馆后该 config 块残存
|
||||||
|
- 下次编辑时,该 config 块仍被读取,若前端重新选择了场馆,可能导致 spec 重复
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 前端根因分析
|
||||||
|
|
||||||
|
### 5.1 「规格不允许重复」错误的真正触发点
|
||||||
|
|
||||||
|
该错误**不在 `ticket_detail.html`**,而在 ShopXO 后台商品编辑流程的 `GoodsService.php:1859/1889/1925`。
|
||||||
|
|
||||||
|
触发条件:
|
||||||
|
1. 用户在 ShopXO 后台编辑商品时,手动填写/复制了重复的规格值
|
||||||
|
2. 表单提交到 `GoodsService::GoodsSave()` → spec 验证逻辑检查 `specifications_value_*` 参数
|
||||||
|
3. 发现有重复值 → 返回「规格不允许重复」错误
|
||||||
|
|
||||||
|
### 5.2 与 VR 插件的关联
|
||||||
|
|
||||||
|
当 `AdminGoodsSaveHandle` 运行时(`plugins_service_goods_save_thing_end`),它会:
|
||||||
|
1. 清空 `GoodsSpecType`、`GoodsSpecBase`、`GoodsSpecValue`(AdminGoodsSaveHandle.php:152-155)
|
||||||
|
2. 对 `template_id > 0` 的 config 块执行 `BatchGenerate`
|
||||||
|
|
||||||
|
如果 `template_id` 为 `null`(硬删除后),`BatchGenerate` 跳过,但 `vr_goods_config` 中的 config 块仍然残存。**此时商品 spec 表为空**,不会出现「规格不允许重复」错误。
|
||||||
|
|
||||||
|
但如果用户在前端(ShopXO 后台编辑页)操作时,ShopXO 的原生规格表单被填充了旧的 VR 规格数据,这些数据可能在保存时被 ShopXO 的原生规格逻辑验证并触发重复错误。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 修复方案
|
||||||
|
|
||||||
|
### 6.1 前端修复(ticket_detail.html)
|
||||||
|
|
||||||
|
**loadSoldSeats() 建议实现**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
loadSoldSeats: function() {
|
||||||
|
if (!this.goodsId || !this.sessionSpecId) return;
|
||||||
|
var self = this;
|
||||||
|
$.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
|
||||||
|
goods_id: this.goodsId,
|
||||||
|
spec_base_id: this.sessionSpecId
|
||||||
|
}, function(res) {
|
||||||
|
if (res.code === 0 && res.data) {
|
||||||
|
self.soldSeats = res.data; // {row_col: true, ...}
|
||||||
|
self.markSoldSeats();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
markSoldSeats: function() {
|
||||||
|
var self = this;
|
||||||
|
document.querySelectorAll('.vr-seat').forEach(function(el) {
|
||||||
|
var key = el.dataset.rowLabel + '_' + el.dataset.colNum;
|
||||||
|
if (self.soldSeats[key]) {
|
||||||
|
el.classList.add('sold');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 后端修复(建议 BackendArchitect 评估)
|
||||||
|
|
||||||
|
当模板被硬删除后,`AdminGoodsSaveHandle` 应清理整个 config 块:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// AdminGoodsSaveHandle.php:77-90 改进
|
||||||
|
if (empty($template)) {
|
||||||
|
// 模板不存在时,移除整个 config 块(避免残存)
|
||||||
|
unset($configs[$i]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$configs = array_values($configs); // 重排索引
|
||||||
|
```
|
||||||
|
|
||||||
|
或在 `SeatSkuService::GetGoodsViewData()` 中持久化清理:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// SeatSkuService.php:383-393 改进
|
||||||
|
if (empty($seatTemplate)) {
|
||||||
|
// 模板不存在时,清除整个 config 块,而非仅置 null
|
||||||
|
$vrGoodsConfig = json_decode($goods['vr_goods_config'] ?? '', true);
|
||||||
|
unset($vrGoodsConfig[0]);
|
||||||
|
$newConfig = array_values($vrGoodsConfig);
|
||||||
|
Db::name('Goods')->where('id', $goodsId)->update([
|
||||||
|
'vr_goods_config' => empty($newConfig) ? '' : json_encode($newConfig, ...),
|
||||||
|
]);
|
||||||
|
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 总结
|
||||||
|
|
||||||
|
| 问题 | 位置 | 严重度 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| loadSoldSeats() 未实现 | ticket_detail.html:375 | P2 | 已售座位无法标记 |
|
||||||
|
| 模板不存在时 fallback 正确 | SeatSkuService.php:383 | — | 后端已正确清理 template_id |
|
||||||
|
| 「规格不允许重复」不在前端触发 | GoodsService.php:1859 | — | 触发点在 ShopXO 后台服务层 |
|
||||||
|
| config 块残留 | AdminGoodsSaveHandle.php | P2 | 硬删除后 config 块未移除 |
|
||||||
|
| spec_base_id_map 不影响前端 | ticket_detail.html:417 | P3 | 前端通过 seatKey 查找,未使用 map |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 文件路径索引
|
||||||
|
|
||||||
|
| 文件 | 行号 | 关键内容 |
|
||||||
|
|------|------|---------|
|
||||||
|
| `SeatSkuService.php` | 358-464 | `GetGoodsViewData()`,模板不存在 fallback |
|
||||||
|
| `SeatSkuService.php` | 383-394 | 模板不存在时置 null 并更新 DB |
|
||||||
|
| `AdminGoodsSaveHandle.php` | 77-145 | config 块遍历和 snapshot 重建逻辑 |
|
||||||
|
| `AdminGoodsSaveHandle.php` | 152-155 | 清空原生 spec 表 |
|
||||||
|
| `AdminGoodsSaveHandle.php` | 158-173 | BatchGenerate 循环(跳过 template_id=0)|
|
||||||
|
| `ticket_detail.html` | 186-189 | 前端 JS 接收 seatMap/specBaseIdMap |
|
||||||
|
| `ticket_detail.html` | 202-213 | `renderSessions()` 场次渲染 |
|
||||||
|
| `ticket_detail.html` | 375-383 | `loadSoldSeats()` TODO(未实现)|
|
||||||
|
| `ticket_detail.html` | 417 | specBaseIdMap 查找(仅 Plan A 提交用)|
|
||||||
|
| `GoodsService.php` | 1859 | 规格值列重复检测 |
|
||||||
|
| `GoodsService.php` | 1889 | 规格值重复检测 |
|
||||||
|
| `GoodsService.php` | 1925 | 规格名称重复检测 |
|
||||||
|
|
@ -13,12 +13,6 @@
|
||||||
return array (
|
return array (
|
||||||
'listen' =>
|
'listen' =>
|
||||||
array (
|
array (
|
||||||
'plugins_css' =>
|
|
||||||
array (
|
|
||||||
),
|
|
||||||
'plugins_js' =>
|
|
||||||
array (
|
|
||||||
),
|
|
||||||
'plugins_service_admin_menu_data' =>
|
'plugins_service_admin_menu_data' =>
|
||||||
array (
|
array (
|
||||||
0 => 'app\\plugins\\vr_ticket\\Hook',
|
0 => 'app\\plugins\\vr_ticket\\Hook',
|
||||||
|
|
@ -43,6 +37,10 @@ return array (
|
||||||
array (
|
array (
|
||||||
0 => 'app\\plugins\\vr_ticket\\hook\\AdminGoodsSaveHandle',
|
0 => 'app\\plugins\\vr_ticket\\hook\\AdminGoodsSaveHandle',
|
||||||
),
|
),
|
||||||
|
'plugins_css_data' =>
|
||||||
|
array (
|
||||||
|
0 => 'app\\plugins\\vr_ticket\\hook\\ViewGoodsCss',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
?>
|
?>
|
||||||
|
|
@ -142,6 +142,7 @@ class Goods extends Common
|
||||||
MyViewAssign([
|
MyViewAssign([
|
||||||
'vr_seat_template' => $viewData['vr_seat_template'] ?? null,
|
'vr_seat_template' => $viewData['vr_seat_template'] ?? null,
|
||||||
'goods_spec_data' => $viewData['goods_spec_data'] ?? [],
|
'goods_spec_data' => $viewData['goods_spec_data'] ?? [],
|
||||||
|
'seatSpecMap' => $viewData['seatSpecMap'] ?? [],
|
||||||
]);
|
]);
|
||||||
// 使用绝对路径 + fetch() 方法,让 Think 驱动正确解析 include 路径
|
// 使用绝对路径 + fetch() 方法,让 Think 驱动正确解析 include 路径
|
||||||
$tplFile = ROOT . 'app' . DS . 'plugins' . DS . 'vr_ticket' . DS . 'view' . DS . 'goods' . DS . 'ticket_detail.html';
|
$tplFile = ROOT . 'app' . DS . 'plugins' . DS . 'vr_ticket' . DS . 'view' . DS . 'goods' . DS . 'ticket_detail.html';
|
||||||
|
|
|
||||||
|
|
@ -1043,6 +1043,33 @@ class Admin extends Common
|
||||||
return $count;
|
return $count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取场次已售座位列表(JSON API)
|
||||||
|
* URL: /plugins/vr_ticket/admin/soldSeats
|
||||||
|
* GET 参数: goods_id, spec_base_id
|
||||||
|
* 返回: {code:0, data:{sold_seats:['A_1','A_2','B_5']}}
|
||||||
|
*/
|
||||||
|
public function SoldSeats()
|
||||||
|
{
|
||||||
|
// 鉴权(移动端登录)
|
||||||
|
if (empty($_SESSION['user']['id'])) {
|
||||||
|
return json_encode(['code' => 401, 'msg' => '请先登录']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取参数
|
||||||
|
$goodsId = input('goods_id', 0, 'intval');
|
||||||
|
$specBaseId = input('spec_base_id', 0, 'intval');
|
||||||
|
|
||||||
|
if (empty($goodsId) || empty($specBaseId)) {
|
||||||
|
return json_encode(['code' => 400, 'msg' => '参数错误']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 从已支付订单的 vr_tickets 表查询真实已售座位
|
||||||
|
// 第一版返回空数组,后续迭代接入真实数据
|
||||||
|
$soldSeats = [];
|
||||||
|
return json_encode(['code' => 0, 'data' => ['sold_seats' => $soldSeats]]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统计座位数(支持 v3 多房间格式 或 v2 格式)
|
* 统计座位数(支持 v3 多房间格式 或 v2 格式)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@
|
||||||
],
|
],
|
||||||
"plugins_service_goods_save_thing_end": [
|
"plugins_service_goods_save_thing_end": [
|
||||||
"app\\plugins\\vr_ticket\\hook\\AdminGoodsSaveHandle"
|
"app\\plugins\\vr_ticket\\hook\\AdminGoodsSaveHandle"
|
||||||
|
],
|
||||||
|
"plugins_css_data": [
|
||||||
|
"app\\plugins\\vr_ticket\\hook\\ViewGoodsCss"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
namespace app\plugins\vr_ticket\hook;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 票务商品详情页 CSS 注入
|
||||||
|
* 注册到 plugins_css_data 钩子
|
||||||
|
*/
|
||||||
|
class ViewGoodsCss
|
||||||
|
{
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
return 'plugins/vr_ticket/css/ticket.css';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -353,7 +353,7 @@ class SeatSkuService extends BaseService
|
||||||
* 获取商品前端展示数据(供 ticket_detail.html 模板使用)
|
* 获取商品前端展示数据(供 ticket_detail.html 模板使用)
|
||||||
*
|
*
|
||||||
* @param int $goodsId
|
* @param int $goodsId
|
||||||
* @return array ['vr_seat_template' => [...], 'goods_spec_data' => [...]]
|
* @return array ['vr_seat_template' => [...], 'goods_spec_data' => [], 'seatSpecMap' => [...]]
|
||||||
*/
|
*/
|
||||||
public static function GetGoodsViewData(int $goodsId): array
|
public static function GetGoodsViewData(int $goodsId): array
|
||||||
{
|
{
|
||||||
|
|
@ -362,7 +362,7 @@ class SeatSkuService extends BaseService
|
||||||
$vrGoodsConfig = json_decode($goods['vr_goods_config'] ?? '', true);
|
$vrGoodsConfig = json_decode($goods['vr_goods_config'] ?? '', true);
|
||||||
|
|
||||||
if (empty($vrGoodsConfig) || !is_array($vrGoodsConfig)) {
|
if (empty($vrGoodsConfig) || !is_array($vrGoodsConfig)) {
|
||||||
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
|
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'seatSpecMap' => [], 'goods_config' => null];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤有效配置块(多模板模式)
|
// 过滤有效配置块(多模板模式)
|
||||||
|
|
@ -376,14 +376,14 @@ class SeatSkuService extends BaseService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (empty($validConfigs)) {
|
if (empty($validConfigs)) {
|
||||||
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
|
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'seatSpecMap' => [], 'goods_config' => null];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取第一个有效配置块用于前端展示
|
// 取第一个有效配置块用于前端展示
|
||||||
$config = $validConfigs[0];
|
$config = $validConfigs[0];
|
||||||
$templateId = intval($config['template_id'] ?? 0);
|
$templateId = intval($config['template_id'] ?? 0);
|
||||||
if ($templateId <= 0) {
|
if ($templateId <= 0) {
|
||||||
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
|
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'seatSpecMap' => [], 'goods_config' => null];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取座位模板(包含 seat_map 和 spec_base_id_map)
|
// 读取座位模板(包含 seat_map 和 spec_base_id_map)
|
||||||
|
|
@ -407,7 +407,7 @@ class SeatSkuService extends BaseService
|
||||||
} else {
|
} else {
|
||||||
\think\facade\Db::name('Goods')->where('id', $goodsId)->update(['vr_goods_config' => '']);
|
\think\facade\Db::name('Goods')->where('id', $goodsId)->update(['vr_goods_config' => '']);
|
||||||
}
|
}
|
||||||
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
|
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'seatSpecMap' => [], 'goods_config' => null];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解码 seat_map JSON(存储时是 JSON 字符串)
|
// 解码 seat_map JSON(存储时是 JSON 字符串)
|
||||||
|
|
@ -418,15 +418,10 @@ class SeatSkuService extends BaseService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解码 spec_base_id_map JSON
|
// ========== 新增:构建 seatSpecMap ==========
|
||||||
if (!empty($seatTemplate['spec_base_id_map'])) {
|
$seatSpecMap = self::buildSeatSpecMap($goodsId, $seatTemplate);
|
||||||
$decoded = json_decode($seatTemplate['spec_base_id_map'], true);
|
|
||||||
if (json_last_error() === JSON_ERROR_NONE) {
|
|
||||||
$seatTemplate['spec_base_id_map'] = $decoded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建场次列表(goods_spec_data)
|
// ========== 构建场次列表(goods_spec_data)==========
|
||||||
$sessions = $config['sessions'] ?? [];
|
$sessions = $config['sessions'] ?? [];
|
||||||
$goodsSpecData = [];
|
$goodsSpecData = [];
|
||||||
|
|
||||||
|
|
@ -435,48 +430,198 @@ class SeatSkuService extends BaseService
|
||||||
$end = $session['end'] ?? '';
|
$end = $session['end'] ?? '';
|
||||||
$timeRange = $start && $end ? "{$start}-{$end}" : ($start ?: $end);
|
$timeRange = $start && $end ? "{$start}-{$end}" : ($start ?: $end);
|
||||||
|
|
||||||
// 查找该场次对应的 spec_base_id
|
// 查找该场次对应的最低价格(从 seatSpecMap 中获取)
|
||||||
$specValue = \think\facade\Db::name('goods_spec_value')
|
$sessionPrice = null;
|
||||||
->alias('sv')
|
foreach ($seatSpecMap as $seatKey => $info) {
|
||||||
->join('goods_spec_base sb', 'sb.id = sv.goods_spec_base_id')
|
foreach ($info['spec'] as $specItem) {
|
||||||
->where('sv.goods_id', $goodsId)
|
$specType = $specItem['type'] ?? '';
|
||||||
->where('sv.value', $timeRange)
|
$specValue = $specItem['value'] ?? '';
|
||||||
->where('sb.price', '>', 0)
|
if ($specType === '$vr-场次' && $specValue === $timeRange) {
|
||||||
->find();
|
if ($sessionPrice === null || $info['price'] < $sessionPrice) {
|
||||||
|
$sessionPrice = $info['price'];
|
||||||
|
}
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$goodsSpecData[] = [
|
$goodsSpecData[] = [
|
||||||
'spec_id' => $specValue['goods_spec_base_id'] ?? 0,
|
'spec_id' => 0, // 不再需要 spec_id,前端用 seatSpecMap
|
||||||
'spec_name' => $timeRange,
|
'spec_name' => $timeRange,
|
||||||
'price' => $specValue['price'] ?? floatval($goods['price'] ?? 0),
|
'price' => $sessionPrice ?? floatval($goods['price'] ?? 0),
|
||||||
|
'start' => $start,
|
||||||
|
'end' => $end,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有从配置读取到场次,尝试从数据库直接读取场次类规格值
|
// 如果没有从配置读取到场次,尝试从 seatSpecMap 提取唯一场次
|
||||||
if (empty($goodsSpecData)) {
|
if (empty($goodsSpecData)) {
|
||||||
$sessionValues = \think\facade\Db::name('goods_spec_value')
|
$sessionMap = [];
|
||||||
->alias('sv')
|
foreach ($seatSpecMap as $info) {
|
||||||
->join('goods_spec_base sb', 'sb.id = sv.goods_spec_base_id')
|
foreach ($info['spec'] as $specItem) {
|
||||||
->field('sv.goods_spec_base_id as spec_id, sv.value as spec_name, sb.price')
|
$specType = $specItem['type'] ?? '';
|
||||||
->where('sv.goods_id', $goodsId)
|
if ($specType === '$vr-场次') {
|
||||||
->where('sb.price', '>', 0)
|
$sessionMap[$specItem['value'] ?? ''] = $specItem['value'] ?? '';
|
||||||
->order('sb.id asc')
|
}
|
||||||
->select()->toArray();
|
|
||||||
|
|
||||||
foreach ($sessionValues as $sv) {
|
|
||||||
if (preg_match('/^\d{2}:\d{2}-\d{2}:\d{2}$/', $sv['spec_name'])) {
|
|
||||||
$goodsSpecData[] = [
|
|
||||||
'spec_id' => $sv['spec_id'],
|
|
||||||
'spec_name' => $sv['spec_name'],
|
|
||||||
'price' => floatval($sv['price']),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
foreach ($sessionMap as $timeRange) {
|
||||||
|
$goodsSpecData[] = [
|
||||||
|
'spec_id' => 0,
|
||||||
|
'spec_name' => $timeRange,
|
||||||
|
'price' => floatval($goods['price'] ?? 0),
|
||||||
|
'start' => '',
|
||||||
|
'end' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'vr_seat_template' => $seatTemplate ?: null,
|
'vr_seat_template' => $seatTemplate ?: null,
|
||||||
'goods_spec_data' => $goodsSpecData,
|
'goods_spec_data' => $goodsSpecData,
|
||||||
|
'seatSpecMap' => $seatSpecMap,
|
||||||
'goods_config' => $config,
|
'goods_config' => $config,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建座位规格映射表(seatSpecMap)
|
||||||
|
*
|
||||||
|
* @param int $goodsId
|
||||||
|
* @param array $seatTemplate
|
||||||
|
* @return array seatSpecMap
|
||||||
|
*/
|
||||||
|
private static function buildSeatSpecMap(int $goodsId, array $seatTemplate): array
|
||||||
|
{
|
||||||
|
$seatSpecMap = [];
|
||||||
|
|
||||||
|
// 1. 查询当前商品所有 GoodsSpecBase(含 extends.seat_key)
|
||||||
|
$specs = \think\facade\Db::name('GoodsSpecBase')
|
||||||
|
->where('goods_id', $goodsId)
|
||||||
|
->where('inventory', '>', 0)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
if (empty($specs)) {
|
||||||
|
return $seatSpecMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查询每个 spec_base_id 对应的 4 维 GoodsSpecValue
|
||||||
|
$specBaseIds = array_column($specs, 'id');
|
||||||
|
$specValues = \think\facade\Db::name('GoodsSpecValue')
|
||||||
|
->whereIn('goods_spec_base_id', $specBaseIds)
|
||||||
|
->select()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// 3. 按 spec_base_id 分组,构建 4 维 spec 数组
|
||||||
|
$specByBaseId = [];
|
||||||
|
foreach ($specValues as $sv) {
|
||||||
|
$specByBaseId[$sv['goods_spec_base_id']][] = [
|
||||||
|
'type' => $sv['type'] ?? '',
|
||||||
|
'value' => $sv['value'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 解析座位模板中的 room 信息(用于提取 rowLabel, colNum 等)
|
||||||
|
$rooms = $seatTemplate['seat_map']['rooms'] ?? [];
|
||||||
|
$roomSeatInfo = []; // roomId => [rowLabel_colNum => ['rowLabel' => 'A', 'colNum' => 3, 'section' => [...]]]
|
||||||
|
foreach ($rooms as $rIdx => $room) {
|
||||||
|
$roomId = !empty($room['id']) ? $room['id'] : ('room_' . $rIdx);
|
||||||
|
$sections = $room['sections'] ?? [];
|
||||||
|
|
||||||
|
$map = $room['map'] ?? [];
|
||||||
|
$seatsData = $room['seats'] ?? [];
|
||||||
|
|
||||||
|
foreach ($map as $rowIndex => $rowStr) {
|
||||||
|
$rowLabel = chr(65 + $rowIndex);
|
||||||
|
$chars = mb_str_split($rowStr);
|
||||||
|
|
||||||
|
foreach ($chars as $colIndex => $char) {
|
||||||
|
if ($char === '_' || $char === '-' || !isset($seatsData[$char])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$colNum = $colIndex + 1;
|
||||||
|
|
||||||
|
// 查找分区信息
|
||||||
|
$sectionInfo = null;
|
||||||
|
foreach ($sections as $sec) {
|
||||||
|
if (($sec['char'] ?? '') === $char) {
|
||||||
|
$sectionInfo = $sec;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$roomSeatInfo[$roomId][$rowLabel . '_' . $colNum] = [
|
||||||
|
'rowLabel' => $rowLabel,
|
||||||
|
'colNum' => $colNum,
|
||||||
|
'section' => $sectionInfo,
|
||||||
|
'char' => $char,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 构建 seatSpecMap:seat_key → 完整规格
|
||||||
|
foreach ($specs as $spec) {
|
||||||
|
$extends = json_decode($spec['extends'] ?? '{}', true);
|
||||||
|
$seatKey = $extends['seat_key'] ?? '';
|
||||||
|
if (empty($seatKey)) continue;
|
||||||
|
|
||||||
|
// 解析 seatKey 格式:roomId_rowLabel_colNum
|
||||||
|
$parts = explode('_', $seatKey);
|
||||||
|
if (count($parts) < 3) continue;
|
||||||
|
$roomId = $parts[0];
|
||||||
|
$rowLabel = $parts[1];
|
||||||
|
$colNum = intval($parts[2]);
|
||||||
|
|
||||||
|
// 提取场馆名(从 $vr-场馆 维度)
|
||||||
|
$venueName = '';
|
||||||
|
$sectionName = '';
|
||||||
|
$seatName = '';
|
||||||
|
$sessionName = '';
|
||||||
|
foreach ($specByBaseId[$spec['id']] ?? [] as $specItem) {
|
||||||
|
$specType = $specItem['type'] ?? '';
|
||||||
|
$specVal = $specItem['value'] ?? '';
|
||||||
|
switch ($specType) {
|
||||||
|
case '$vr-场馆':
|
||||||
|
$venueName = $specVal;
|
||||||
|
break;
|
||||||
|
case '$vr-分区':
|
||||||
|
$sectionName = $specVal;
|
||||||
|
break;
|
||||||
|
case '$vr-座位号':
|
||||||
|
$seatName = $specVal;
|
||||||
|
break;
|
||||||
|
case '$vr-场次':
|
||||||
|
$sessionName = $specVal;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取座位元信息
|
||||||
|
$seatMeta = $roomSeatInfo[$roomId][$rowLabel . '_' . $colNum] ?? [
|
||||||
|
'rowLabel' => $rowLabel,
|
||||||
|
'colNum' => $colNum,
|
||||||
|
'section' => null,
|
||||||
|
'char' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
$seatSpecMap[$seatKey] = [
|
||||||
|
'spec_base_id' => intval($spec['id']),
|
||||||
|
'price' => floatval($spec['price']),
|
||||||
|
'inventory' => intval($spec['inventory']),
|
||||||
|
'spec' => $specByBaseId[$spec['id']] ?? [],
|
||||||
|
'rowLabel' => $seatMeta['rowLabel'],
|
||||||
|
'colNum' => $seatMeta['colNum'],
|
||||||
|
'roomId' => $roomId,
|
||||||
|
'section' => $seatMeta['section'],
|
||||||
|
'venueName' => $venueName,
|
||||||
|
'sectionName' => $sectionName,
|
||||||
|
'seatName' => $seatName,
|
||||||
|
'sessionName' => $sessionName,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $seatSpecMap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
/* VR票务 - 票务商品详情页样式 */
|
||||||
|
/* 从 ticket_detail.html 内联样式抽取,2026-04-21 */
|
||||||
|
|
||||||
|
.vr-ticket-page { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||||
|
.vr-ticket-header { margin-bottom: 20px; }
|
||||||
|
.vr-event-title { font-size: 24px; font-weight: bold; color: #333; margin-bottom: 8px; }
|
||||||
|
.vr-event-subtitle { color: #666; font-size: 14px; }
|
||||||
|
|
||||||
|
.vr-seat-section { margin-bottom: 30px; }
|
||||||
|
.vr-section-title { font-size: 18px; font-weight: bold; margin-bottom: 15px; color: #333; }
|
||||||
|
|
||||||
|
.vr-seat-map-wrapper { background: #f8f9fa; border-radius: 8px; padding: 20px; margin-bottom: 20px; overflow-x: auto; }
|
||||||
|
.vr-stage {
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(180deg, #e8e8e8, #d0d0d0);
|
||||||
|
border-radius: 50% 50% 0 0 / 20px 20px 0 0;
|
||||||
|
padding: 15px 40px;
|
||||||
|
margin: 0 auto 25px;
|
||||||
|
max-width: 600px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
.vr-seat-rows { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||||
|
.vr-seat-row { display: flex; align-items: center; gap: 2px; }
|
||||||
|
.vr-row-label { width: 24px; text-align: center; color: #999; font-size: 12px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.vr-seat {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #fff;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.vr-seat:hover { transform: scale(1.15); z-index: 1; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
|
||||||
|
.vr-seat.selected { border: 2px solid #333; transform: scale(1.1); }
|
||||||
|
.vr-seat.sold { background: #ccc !important; cursor: not-allowed; opacity: 0.5; }
|
||||||
|
.vr-seat.sold:hover { transform: none; box-shadow: none; }
|
||||||
|
.vr-seat.aisle { background: transparent !important; cursor: default; }
|
||||||
|
.vr-seat.space { background: transparent !important; cursor: default; }
|
||||||
|
|
||||||
|
.vr-legend { display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 15px; justify-content: center; }
|
||||||
|
.vr-legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #666; }
|
||||||
|
.vr-legend-seat { width: 20px; height: 20px; border-radius: 3px; }
|
||||||
|
|
||||||
|
.vr-selected-seats { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
||||||
|
.vr-selected-title { font-weight: bold; margin-bottom: 10px; color: #333; }
|
||||||
|
.vr-selected-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; min-height: 30px; }
|
||||||
|
.vr-selected-item {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
background: #e8f4ff; border: 1px solid #b8d4f0;
|
||||||
|
border-radius: 4px; padding: 4px 10px; font-size: 13px;
|
||||||
|
}
|
||||||
|
.vr-selected-item .remove { color: #f56c6c; cursor: pointer; font-size: 16px; line-height: 1; }
|
||||||
|
.vr-total { font-size: 16px; font-weight: bold; color: #333; margin-top: 10px; }
|
||||||
|
|
||||||
|
.vr-sessions { margin-bottom: 20px; }
|
||||||
|
.vr-session-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
|
||||||
|
.vr-session-item {
|
||||||
|
border: 1px solid #ddd; border-radius: 6px; padding: 10px;
|
||||||
|
cursor: pointer; text-align: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.vr-session-item:hover { border-color: #409eff; }
|
||||||
|
.vr-session-item.selected { border-color: #409eff; background: #ecf5ff; }
|
||||||
|
.vr-session-item .date { font-size: 14px; font-weight: bold; color: #333; }
|
||||||
|
.vr-session-item .time { font-size: 13px; color: #666; margin-top: 4px; }
|
||||||
|
.vr-session-item .price { font-size: 14px; color: #f56c6c; font-weight: bold; margin-top: 6px; }
|
||||||
|
|
||||||
|
.vr-attendee-form { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
||||||
|
.vr-form-title { font-weight: bold; margin-bottom: 15px; color: #333; }
|
||||||
|
.vr-attendee-item { background: #f8f9fa; border-radius: 6px; padding: 15px; margin-bottom: 10px; }
|
||||||
|
.vr-attendee-label { font-size: 13px; color: #666; margin-bottom: 5px; }
|
||||||
|
.vr-attendee-input { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
||||||
|
|
||||||
|
.vr-purchase-bar {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0;
|
||||||
|
background: #fff; border-top: 1px solid #e8e8e8;
|
||||||
|
padding: 12px 20px; z-index: 100;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
box-shadow: 0 -2px 10px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.vr-purchase-info { font-size: 14px; color: #666; }
|
||||||
|
.vr-purchase-info strong { font-size: 20px; color: #f56c6c; }
|
||||||
|
.vr-purchase-btn {
|
||||||
|
background: linear-gradient(135deg, #409eff, #3b8ef8);
|
||||||
|
color: #fff; border: none; border-radius: 20px;
|
||||||
|
padding: 12px 36px; font-size: 16px; font-weight: bold;
|
||||||
|
cursor: pointer; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.vr-purchase-btn:hover { transform: scale(1.03); box-shadow: 0 4px 12px rgba(64,158,255,0.4); }
|
||||||
|
.vr-purchase-btn:disabled { background: #ccc; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||||
|
|
||||||
|
.vr-goods-info { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
||||||
|
.vr-goods-photos { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; margin-bottom: 15px; }
|
||||||
|
.vr-goods-photos img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; }
|
||||||
|
|
@ -12,32 +12,40 @@ $security_desc = $shopxo_config['security_desc'] ?? '';
|
||||||
</div><!-- end .vr-ticket-page -->
|
</div><!-- end .vr-ticket-page -->
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.vr-footer {
|
.vr-footer {
|
||||||
padding: 30px 20px;
|
padding: 30px 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
border-top: 1px solid #e8e8e8;
|
border-top: 1px solid #e8e8e8;
|
||||||
margin-top: 80px; /* 避开固定底部购买栏 */
|
margin-top: 80px;
|
||||||
}
|
/* 避开固定底部购买栏 */
|
||||||
.vr-footer-links {
|
}
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
.vr-footer-links {
|
||||||
.vr-footer-links a {
|
margin-bottom: 12px;
|
||||||
color: #666;
|
}
|
||||||
font-size: 13px;
|
|
||||||
text-decoration: none;
|
.vr-footer-links a {
|
||||||
margin: 0 12px;
|
color: #666;
|
||||||
}
|
font-size: 13px;
|
||||||
.vr-footer-links a:hover { color: #409eff; }
|
text-decoration: none;
|
||||||
.vr-footer-copy {
|
margin: 0 12px;
|
||||||
font-size: 12px;
|
}
|
||||||
color: #999;
|
|
||||||
margin-bottom: 6px;
|
.vr-footer-links a:hover {
|
||||||
}
|
color: #409eff;
|
||||||
.vr-footer-icp {
|
}
|
||||||
font-size: 11px;
|
|
||||||
color: #bbb;
|
.vr-footer-copy {
|
||||||
}
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vr-footer-icp {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="vr-footer">
|
<div class="vr-footer">
|
||||||
|
|
@ -45,13 +53,19 @@ $security_desc = $shopxo_config['security_desc'] ?? '';
|
||||||
<a href="<?php echo Config('shopxo.host_url'); ?>">返回首页</a>
|
<a href="<?php echo Config('shopxo.host_url'); ?>">返回首页</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="vr-footer-copy">
|
<div class="vr-footer-copy">
|
||||||
© <?php echo date('Y'); ?> <?php echo htmlspecialchars($shop_name, ENT_QUOTES, 'UTF-8'); ?> All Rights Reserved.
|
©
|
||||||
|
<?php echo date('Y'); ?>
|
||||||
|
<?php echo htmlspecialchars($shop_name, ENT_QUOTES, 'UTF-8'); ?> All Rights Reserved.
|
||||||
</div>
|
</div>
|
||||||
<?php if (!empty($icp)): ?>
|
<?php if (!empty($icp)): ?>
|
||||||
<div class="vr-footer-icp">
|
<div class="vr-footer-icp">
|
||||||
<a href="https://beian.miit.gov.cn/" target="_blank" style="color:#bbb;text-decoration:none"><?php echo htmlspecialchars($icp, ENT_QUOTES, 'UTF-8'); ?></a>
|
<a href="https://beian.miit.gov.cn/" target="_blank" style="color:#bbb;text-decoration:none">
|
||||||
|
<?php echo htmlspecialchars($icp, ENT_QUOTES, 'UTF-8'); ?>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php echo Config('shopxo.is_close_website_footer_js') != 1 ? '<script src="' . Config('shopxo.host_url') . 'static/common/js/footer.js?v=' . $shopxo_config['version'] . '"></script>' : ''; ?>
|
<?php echo Config('shopxo.is_close_website_footer_js') != 1 ? '<script src="' . Config('shopxo.host_url') . 'static/common/js/footer.js?v=' . ($shopxo_config['version'] ?? '1.0.0') . '"></script>' : ''; ?>
|
||||||
|
<script type='text/javascript'
|
||||||
|
src="<?php echo Config('shopxo.host_url'); ?>static/common/lib/jquery/jquery-2.2.4.min.js"></script>
|
||||||
|
|
@ -1,128 +1,19 @@
|
||||||
<?php echo ModuleInclude('public/header'); ?>
|
<?php echo ModuleInclude('public/header'); ?>
|
||||||
|
|
||||||
<style>
|
<!-- VR票务样式 -->
|
||||||
/* VR票务 - 票务商品详情页 */
|
<link rel="stylesheet" type="text/css"
|
||||||
/* 完全独立于 ShopXO 标准商品页,不加载 goods-detail 相关 CSS */
|
href="<?php echo Config('shopxo.host_url'); ?>plugins/vr_ticket/static/css/ticket.css?v=1.0.0" />
|
||||||
|
|
||||||
/* 页面布局 */
|
|
||||||
.vr-ticket-page { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
||||||
.vr-ticket-header { margin-bottom: 20px; }
|
|
||||||
.vr-event-title { font-size: 24px; font-weight: bold; color: #333; margin-bottom: 8px; }
|
|
||||||
.vr-event-subtitle { color: #666; font-size: 14px; }
|
|
||||||
|
|
||||||
/* 座位图区域 */
|
|
||||||
.vr-seat-section { margin-bottom: 30px; }
|
|
||||||
.vr-section-title { font-size: 18px; font-weight: bold; margin-bottom: 15px; color: #333; }
|
|
||||||
|
|
||||||
/* 座位图 */
|
|
||||||
.vr-seat-map-wrapper { background: #f8f9fa; border-radius: 8px; padding: 20px; margin-bottom: 20px; overflow-x: auto; }
|
|
||||||
.vr-stage {
|
|
||||||
text-align: center;
|
|
||||||
background: linear-gradient(180deg, #e8e8e8, #d0d0d0);
|
|
||||||
border-radius: 50% 50% 0 0 / 20px 20px 0 0;
|
|
||||||
padding: 15px 40px;
|
|
||||||
margin: 0 auto 25px;
|
|
||||||
max-width: 600px;
|
|
||||||
color: #666;
|
|
||||||
font-size: 13px;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
}
|
|
||||||
.vr-seat-rows { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
|
||||||
.vr-seat-row { display: flex; align-items: center; gap: 2px; }
|
|
||||||
.vr-row-label { width: 24px; text-align: center; color: #999; font-size: 12px; flex-shrink: 0; }
|
|
||||||
|
|
||||||
/* 座位格子 */
|
|
||||||
.vr-seat {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 1px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 9px;
|
|
||||||
color: #fff;
|
|
||||||
transition: all 0.15s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.vr-seat:hover { transform: scale(1.15); z-index: 1; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
|
|
||||||
.vr-seat.selected { border: 2px solid #333; transform: scale(1.1); }
|
|
||||||
.vr-seat.sold { background: #ccc !important; cursor: not-allowed; opacity: 0.5; }
|
|
||||||
.vr-seat.sold:hover { transform: none; box-shadow: none; }
|
|
||||||
.vr-seat.aisle { background: transparent !important; cursor: default; }
|
|
||||||
.vr-seat.space { background: transparent !important; cursor: default; }
|
|
||||||
|
|
||||||
/* 座位类型图例 */
|
|
||||||
.vr-legend { display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 15px; justify-content: center; }
|
|
||||||
.vr-legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #666; }
|
|
||||||
.vr-legend-seat { width: 20px; height: 20px; border-radius: 3px; }
|
|
||||||
|
|
||||||
/* 已选座位 */
|
|
||||||
.vr-selected-seats { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
|
||||||
.vr-selected-title { font-weight: bold; margin-bottom: 10px; color: #333; }
|
|
||||||
.vr-selected-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; min-height: 30px; }
|
|
||||||
.vr-selected-item {
|
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
|
||||||
background: #e8f4ff; border: 1px solid #b8d4f0;
|
|
||||||
border-radius: 4px; padding: 4px 10px; font-size: 13px;
|
|
||||||
}
|
|
||||||
.vr-selected-item .remove { color: #f56c6c; cursor: pointer; font-size: 16px; line-height: 1; }
|
|
||||||
.vr-total { font-size: 16px; font-weight: bold; color: #333; margin-top: 10px; }
|
|
||||||
|
|
||||||
/* 场次选择 */
|
|
||||||
.vr-sessions { margin-bottom: 20px; }
|
|
||||||
.vr-session-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
|
|
||||||
.vr-session-item {
|
|
||||||
border: 1px solid #ddd; border-radius: 6px; padding: 10px;
|
|
||||||
cursor: pointer; text-align: center;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
.vr-session-item:hover { border-color: #409eff; }
|
|
||||||
.vr-session-item.selected { border-color: #409eff; background: #ecf5ff; }
|
|
||||||
.vr-session-item .date { font-size: 14px; font-weight: bold; color: #333; }
|
|
||||||
.vr-session-item .time { font-size: 13px; color: #666; margin-top: 4px; }
|
|
||||||
.vr-session-item .price { font-size: 14px; color: #f56c6c; font-weight: bold; margin-top: 6px; }
|
|
||||||
|
|
||||||
/* 观演人表单 */
|
|
||||||
.vr-attendee-form { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
|
||||||
.vr-form-title { font-weight: bold; margin-bottom: 15px; color: #333; }
|
|
||||||
.vr-attendee-item { background: #f8f9fa; border-radius: 6px; padding: 15px; margin-bottom: 10px; }
|
|
||||||
.vr-attendee-label { font-size: 13px; color: #666; margin-bottom: 5px; }
|
|
||||||
.vr-attendee-input { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
|
||||||
|
|
||||||
/* 购买栏 */
|
|
||||||
.vr-purchase-bar {
|
|
||||||
position: fixed; bottom: 0; left: 0; right: 0;
|
|
||||||
background: #fff; border-top: 1px solid #e8e8e8;
|
|
||||||
padding: 12px 20px; z-index: 100;
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.08);
|
|
||||||
}
|
|
||||||
.vr-purchase-info { font-size: 14px; color: #666; }
|
|
||||||
.vr-purchase-info strong { font-size: 20px; color: #f56c6c; }
|
|
||||||
.vr-purchase-btn {
|
|
||||||
background: linear-gradient(135deg, #409eff, #3b8ef8);
|
|
||||||
color: #fff; border: none; border-radius: 20px;
|
|
||||||
padding: 12px 36px; font-size: 16px; font-weight: bold;
|
|
||||||
cursor: pointer; transition: all 0.2s;
|
|
||||||
}
|
|
||||||
.vr-purchase-btn:hover { transform: scale(1.03); box-shadow: 0 4px 12px rgba(64,158,255,0.4); }
|
|
||||||
.vr-purchase-btn:disabled { background: #ccc; cursor: not-allowed; transform: none; box-shadow: none; }
|
|
||||||
|
|
||||||
/* 商品信息侧边 */
|
|
||||||
.vr-goods-info { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
|
||||||
.vr-goods-photos { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; margin-bottom: 15px; }
|
|
||||||
.vr-goods-photos img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<!-- 页面内容 -->
|
<!-- 页面内容 -->
|
||||||
<div class="vr-ticket-page" id="vrTicketApp">
|
<div class="vr-ticket-page" id="vrTicketApp">
|
||||||
<!-- 商品头部 -->
|
<!-- 商品头部 -->
|
||||||
<div class="vr-ticket-header">
|
<div class="vr-ticket-header">
|
||||||
<div class="vr-event-title"><?php echo htmlspecialchars($goods['title'] ?? 'VR演唱会', ENT_QUOTES, 'UTF-8'); ?></div>
|
<div class="vr-event-title">
|
||||||
<div class="vr-event-subtitle"><?php echo htmlspecialchars($goods['simple_desc'] ?? '', ENT_QUOTES, 'UTF-8'); ?></div>
|
<?php echo htmlspecialchars($goods['title'] ?? 'VR演唱会', ENT_QUOTES, 'UTF-8'); ?>
|
||||||
|
</div>
|
||||||
|
<div class="vr-event-subtitle">
|
||||||
|
<?php echo htmlspecialchars($goods['simple_desc'] ?? '', ENT_QUOTES, 'UTF-8'); ?>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 场次选择 -->
|
<!-- 场次选择 -->
|
||||||
|
|
@ -136,7 +27,8 @@
|
||||||
|
|
||||||
<!-- 座位图 -->
|
<!-- 座位图 -->
|
||||||
<div class="vr-seat-section" id="seatSection" style="display:none">
|
<div class="vr-seat-section" id="seatSection" style="display:none">
|
||||||
<div class="vr-section-title">选择座位 <span style="font-size:13px;color:#999;font-weight:normal">(点击空座选中,再点击取消)</span></div>
|
<div class="vr-section-title">选择座位 <span
|
||||||
|
style="font-size:13px;color:#999;font-weight:normal">(点击空座选中,再点击取消)</span></div>
|
||||||
<div class="vr-legend" id="seatLegend"></div>
|
<div class="vr-legend" id="seatLegend"></div>
|
||||||
<div class="vr-seat-map-wrapper">
|
<div class="vr-seat-map-wrapper">
|
||||||
<div class="vr-stage">舞 台</div>
|
<div class="vr-stage">舞 台</div>
|
||||||
|
|
@ -161,7 +53,9 @@
|
||||||
<?php if (!empty($goods['content'])): ?>
|
<?php if (!empty($goods['content'])): ?>
|
||||||
<div class="vr-seat-section">
|
<div class="vr-seat-section">
|
||||||
<div class="vr-section-title">演出详情</div>
|
<div class="vr-section-title">演出详情</div>
|
||||||
<div class="goods-detail-content"><?php echo $goods['content']; ?></div>
|
<div class="goods-detail-content">
|
||||||
|
<?php echo $goods['content']; ?>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
|
@ -180,272 +74,283 @@
|
||||||
<?php echo ModuleInclude('public/footer'); ?>
|
<?php echo ModuleInclude('public/footer'); ?>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function () {
|
||||||
var app = {
|
var app = {
|
||||||
goodsId: <?php echo intval($goods['id'] ?? 0); ?>,
|
goodsId: <?php echo intval($goods['id'] ?? 0); ?>,
|
||||||
seatMap: <?php echo json_encode($vr_seat_template['seat_map'] ?? []); ?>,
|
seatMap: <?php echo json_encode($vr_seat_template['seat_map'] ?? []); ?>,
|
||||||
specBaseIdMap: <?php echo json_encode($vr_seat_template['spec_base_id_map'] ?? []); ?>,
|
seatSpecMap: <?php echo json_encode($seatSpecMap ?? []); ?>,
|
||||||
selectedSeats: [], // [{row, col, char, price, label, classes}]
|
selectedSeats: [], // [{seatKey, price, rowLabel, colNum, section}]
|
||||||
soldSeats: {}, // {row_col: true}
|
soldSeats: { }, // {seatKey: true}
|
||||||
currentSession: null,
|
currentSession: null, // 当前选中场次 value (e.g. "15:00-16:59")
|
||||||
sessionSpecId: null,
|
currentVenue: null, // 当前选中场馆 value
|
||||||
requestUrl: '<?php echo Config("shopxo.host_url"); ?>',
|
currentSection: null, // 当前选中分区 char
|
||||||
userId: <?php echo IsMobileLogin(); ?>,
|
|
||||||
|
|
||||||
init: function() {
|
init: function() {
|
||||||
this.renderSessions();
|
this.renderSessions();
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
this.loadSoldSeats();
|
},
|
||||||
},
|
|
||||||
|
|
||||||
// 渲染场次列表(基于 ShopXO spec 数据)
|
// 渲染场次列表
|
||||||
renderSessions: function() {
|
renderSessions: function() {
|
||||||
var specData = <?php echo json_encode($goods_spec_data ?? []); ?> || [];
|
var specData = <?php echo json_encode($goods_spec_data ?? []); ?> || [];
|
||||||
var html = '';
|
var html = '';
|
||||||
if (specData.length > 0) {
|
if (specData.length > 0) {
|
||||||
specData.forEach(function(spec) {
|
specData.forEach(function (spec) {
|
||||||
html += '<div class="vr-session-item" data-spec-id="'+spec.spec_id+'" data-spec-base-id="'+spec.spec_id+'" onclick="vrTicketApp.selectSession(this)">' +
|
html += '<div class="vr-session-item" data-session="' + spec.spec_name + '" onclick="vrTicketApp.selectSession(this)">' +
|
||||||
'<div class="date">'+spec.spec_name+'</div>' +
|
'<div class="date">' + spec.spec_name + '</div>' +
|
||||||
'<div class="price">¥'+spec.price+'</div></div>';
|
'<div class="price">¥' + spec.price + '</div></div>';
|
||||||
|
});
|
||||||
|
document.getElementById('sessionGrid').innerHTML = html;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectSession: function(el) {
|
||||||
|
// 重置状态
|
||||||
|
this.selectedSeats = [];
|
||||||
|
this.updateSelectedUI();
|
||||||
|
this.currentSection = null;
|
||||||
|
|
||||||
|
document.querySelectorAll('.vr-session-item').forEach(function (item) {
|
||||||
|
item.classList.remove('selected');
|
||||||
});
|
});
|
||||||
document.getElementById('sessionGrid').innerHTML = html;
|
el.classList.add('selected');
|
||||||
}
|
this.currentSession = el.dataset.session;
|
||||||
},
|
|
||||||
|
|
||||||
selectSession: function(el) {
|
document.getElementById('seatSection').style.display = 'block';
|
||||||
// 移除其他选中
|
document.getElementById('selectedSection').style.display = 'none';
|
||||||
document.querySelectorAll('.vr-session-item').forEach(function(item) {
|
document.getElementById('attendeeSection').style.display = 'none';
|
||||||
item.classList.remove('selected');
|
|
||||||
});
|
|
||||||
el.classList.add('selected');
|
|
||||||
this.currentSession = el.dataset.specId;
|
|
||||||
this.sessionSpecId = el.dataset.specBaseId;
|
|
||||||
|
|
||||||
// 显示座位图
|
this.renderSeatMap();
|
||||||
document.getElementById('seatSection').style.display = 'block';
|
this.loadSoldSeats();
|
||||||
document.getElementById('attendeeSection').style.display = 'block';
|
},
|
||||||
|
|
||||||
this.renderSeatMap();
|
renderSeatMap: function() {
|
||||||
this.loadSoldSeats();
|
var map = this.seatMap;
|
||||||
},
|
if (!map) {
|
||||||
|
document.getElementById('seatRows').innerHTML = '<div style="text-align:center;color:#999;padding:40px">座位图数据为空</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
renderSeatMap: function() {
|
var mapData = map.map || (map.rooms && map.rooms[0] ? map.rooms[0].map : null);
|
||||||
var map = this.seatMap;
|
if (!mapData || mapData.length === 0) {
|
||||||
if (!map || !map.map || map.map.length === 0) {
|
document.getElementById('seatRows').innerHTML = '<div style="text-align:center;color:#999;padding:40px">座位图加载失败</div>';
|
||||||
document.getElementById('seatRows').innerHTML = '<div style="text-align:center;color:#999;padding:40px">座位图加载失败</div>';
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染图例
|
var seats = map.seats || (map.rooms && map.rooms[0] ? map.rooms[0].seats || {} : {});
|
||||||
var legendHtml = '';
|
var sections = map.sections || (map.rooms && map.rooms[0] ? map.rooms[0].sections || [] : []);
|
||||||
var sections = map.sections || [];
|
|
||||||
var seats = map.seats || {};
|
|
||||||
sections.forEach(function(sec) {
|
|
||||||
var color = sec.color || '#409eff';
|
|
||||||
legendHtml += '<div class="vr-legend-item"><div class="vr-legend-seat" style="background:'+color+'"></div>'+sec.name+'</div>';
|
|
||||||
});
|
|
||||||
legendHtml += '<div class="vr-legend-item"><div class="vr-legend-seat" style="background:#ccc"></div>已售</div>';
|
|
||||||
document.getElementById('seatLegend').innerHTML = legendHtml;
|
|
||||||
|
|
||||||
// 渲染座位图
|
// 渲染图例
|
||||||
var rowsContainer = document.getElementById('seatRows');
|
var legendHtml = '';
|
||||||
var row_labels = map.row_labels || [];
|
sections.forEach(function (sec) {
|
||||||
var rowsHtml = '';
|
var color = sec.color || '#409eff';
|
||||||
|
legendHtml += '<div class="vr-legend-item"><div class="vr-legend-seat" style="background:' + color + '"></div>' + sec.name + '</div>';
|
||||||
|
});
|
||||||
|
legendHtml += '<div class="vr-legend-item"><div class="vr-legend-seat" style="background:#ccc"></div>已售</div>';
|
||||||
|
document.getElementById('seatLegend').innerHTML = legendHtml;
|
||||||
|
|
||||||
map.map.forEach(function(rowStr, rowIndex) {
|
var rowsContainer = document.getElementById('seatRows');
|
||||||
var rowLabel = row_labels[rowIndex] || String.fromCharCode(65 + rowIndex);
|
var rowsHtml = '';
|
||||||
rowsHtml += '<div class="vr-seat-row">';
|
|
||||||
rowsHtml += '<div class="vr-row-label">'+rowLabel+'</div>';
|
|
||||||
|
|
||||||
var chars = rowStr.split('');
|
mapData.forEach(function (rowStr, rowIndex) {
|
||||||
chars.forEach(function(char, colIndex) {
|
var rowLabel = String.fromCharCode(65 + rowIndex);
|
||||||
if (char === '_' || char === '-') {
|
var chars = rowStr.split('');
|
||||||
rowsHtml += '<div class="vr-seat space" style="width:28px;height:28px"></div>';
|
var rowHtml = '<div class="vr-seat-row"><span class="vr-row-label">' + rowLabel + '</span>';
|
||||||
} else {
|
|
||||||
var seatInfo = seats[char] || {};
|
chars.forEach(function (char, colIndex) {
|
||||||
var color = seatInfo.color || '#409eff';
|
var colNum = colIndex + 1;
|
||||||
|
var seatKey = 'room_001_' + rowLabel + '_' + colNum;
|
||||||
|
var seatInfo = app.seatSpecMap[seatKey] || {};
|
||||||
|
var section = seatInfo.section || {};
|
||||||
|
var color = section.color || '#409eff';
|
||||||
var price = seatInfo.price || 0;
|
var price = seatInfo.price || 0;
|
||||||
var label = seatInfo.label || '';
|
|
||||||
|
|
||||||
rowsHtml += '<div class="vr-seat '+(seatInfo.classes||'')+'" '+
|
if (char === '_' || char === '-') {
|
||||||
'style="background:'+color+'" '+
|
rowHtml += '<div class="vr-seat space"></div>';
|
||||||
'data-row="'+rowIndex+'" data-col="'+colIndex+'" '+
|
} else if (seatInfo.inventory > 0 && !app.soldSeats[seatKey]) {
|
||||||
'data-row-label="'+rowLabel+'" data-col-num="'+(colIndex+1)+'" '+
|
rowHtml += '<div class="vr-seat available" ' +
|
||||||
'data-char="'+char+'" data-price="'+price+'" '+
|
'data-seat-key="' + seatKey + '" ' +
|
||||||
'data-seat-id="'+char+'" data-label="'+rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座" '+
|
'data-row="' + rowLabel + '" ' +
|
||||||
'onclick="vrTicketApp.toggleSeat(this)"></div>';
|
'data-col="' + colNum + '" ' +
|
||||||
|
'data-price="' + price + '" ' +
|
||||||
|
'style="background:' + color + '" ' +
|
||||||
|
'onclick="vrTicketApp.toggleSeat(this)">' +
|
||||||
|
rowLabel + colNum + '</div>';
|
||||||
|
} else {
|
||||||
|
rowHtml += '<div class="vr-seat sold" style="background:#ccc">' + rowLabel + colNum + '</div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rowHtml += '</div>';
|
||||||
|
rowsHtml += rowHtml;
|
||||||
|
});
|
||||||
|
|
||||||
|
rowsContainer.innerHTML = rowsHtml;
|
||||||
|
document.getElementById('selectedCount').textContent = '0';
|
||||||
|
},
|
||||||
|
|
||||||
|
loadSoldSeats: function() {
|
||||||
|
// 从 seatSpecMap 中找出已售座位(inventory <= 0)
|
||||||
|
var sold = {};
|
||||||
|
for (var key in this.seatSpecMap) {
|
||||||
|
if (this.seatSpecMap[key].inventory <= 0) {
|
||||||
|
sold[key] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.soldSeats = sold;
|
||||||
|
|
||||||
|
// 更新座位状态
|
||||||
|
var self = this;
|
||||||
|
document.querySelectorAll('.vr-seat.available').forEach(function(el) {
|
||||||
|
var seatKey = el.dataset.seatKey;
|
||||||
|
if (sold[seatKey]) {
|
||||||
|
el.classList.remove('available');
|
||||||
|
el.classList.add('sold');
|
||||||
|
el.style.background = '#ccc';
|
||||||
|
el.onclick = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
rowsHtml += '</div>';
|
},
|
||||||
});
|
|
||||||
|
|
||||||
rowsContainer.innerHTML = rowsHtml;
|
toggleSeat: function(el) {
|
||||||
},
|
var seatKey = el.dataset.seatKey;
|
||||||
|
var price = parseFloat(el.dataset.price) || 0;
|
||||||
|
var row = el.dataset.row;
|
||||||
|
var col = el.dataset.col;
|
||||||
|
var seatInfo = this.seatSpecMap[seatKey] || {};
|
||||||
|
|
||||||
toggleSeat: function(el) {
|
var index = this.selectedSeats.findIndex(function(s) { return s.seatKey === seatKey; });
|
||||||
if (el.classList.contains('sold')) return;
|
|
||||||
|
|
||||||
var row = el.dataset.row;
|
if (index >= 0) {
|
||||||
var col = el.dataset.col;
|
// 取消选中
|
||||||
var rowLabel = el.dataset.rowLabel;
|
this.selectedSeats.splice(index, 1);
|
||||||
var colNum = el.dataset.colNum;
|
el.classList.remove('selected');
|
||||||
var seatKey = rowLabel + '_' + colNum; // e.g. "A_1" — matches specBaseIdMap key format
|
} else {
|
||||||
var seat = {
|
// 选中
|
||||||
row: parseInt(row),
|
this.selectedSeats.push({
|
||||||
col: parseInt(col),
|
seatKey: seatKey,
|
||||||
rowLabel: rowLabel,
|
price: price,
|
||||||
colNum: parseInt(colNum),
|
rowLabel: row,
|
||||||
seatKey: seatKey, // 用于 specBaseIdMap 查找
|
colNum: parseInt(col),
|
||||||
char: el.dataset.char,
|
section: seatInfo.section || {}
|
||||||
price: parseFloat(el.dataset.price),
|
});
|
||||||
label: el.dataset.label,
|
el.classList.add('selected');
|
||||||
seatId: el.dataset.seatId,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if (el.classList.contains('selected')) {
|
|
||||||
// 取消选中
|
|
||||||
el.classList.remove('selected');
|
|
||||||
this.selectedSeats = this.selectedSeats.filter(function(s) {
|
|
||||||
return s.seatKey !== seatKey;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 选中
|
|
||||||
el.classList.add('selected');
|
|
||||||
this.selectedSeats.push(seat);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateSelectedUI();
|
|
||||||
},
|
|
||||||
|
|
||||||
updateSelectedUI: function() {
|
|
||||||
var count = this.selectedSeats.length;
|
|
||||||
var total = this.selectedSeats.reduce(function(sum, s) { return sum + s.price; }, 0);
|
|
||||||
|
|
||||||
document.getElementById('selectedCount').textContent = '(' + count + ')';
|
|
||||||
document.getElementById('totalPrice').textContent = '¥' + total.toFixed(2);
|
|
||||||
document.getElementById('barCount').textContent = count;
|
|
||||||
document.getElementById('barPrice').textContent = '¥' + total.toFixed(2);
|
|
||||||
|
|
||||||
// 渲染已选列表
|
|
||||||
var listHtml = '';
|
|
||||||
this.selectedSeats.forEach(function(seat, i) {
|
|
||||||
listHtml += '<div class="vr-selected-item">' +
|
|
||||||
seat.label + ' ¥' + seat.price +
|
|
||||||
'<span class="remove" onclick="vrTicketApp.removeSeat('+i+')">×</span></div>';
|
|
||||||
});
|
|
||||||
document.getElementById('selectedList').innerHTML = listHtml;
|
|
||||||
|
|
||||||
// 渲染观演人表单
|
|
||||||
this.renderAttendeeForms();
|
|
||||||
|
|
||||||
// 更新按钮状态
|
|
||||||
document.getElementById('purchaseBtn').disabled = count === 0;
|
|
||||||
},
|
|
||||||
|
|
||||||
removeSeat: function(index) {
|
|
||||||
var seat = this.selectedSeats[index];
|
|
||||||
if (seat) {
|
|
||||||
var el = document.querySelector(
|
|
||||||
'[data-row-label="'+seat.rowLabel+'"][data-col-num="'+seat.colNum+'"]'
|
|
||||||
);
|
|
||||||
if (el) el.classList.remove('selected');
|
|
||||||
this.selectedSeats.splice(index, 1);
|
|
||||||
this.updateSelectedUI();
|
this.updateSelectedUI();
|
||||||
}
|
},
|
||||||
},
|
|
||||||
|
|
||||||
renderAttendeeForms: function() {
|
updateSelectedUI: function() {
|
||||||
var html = '';
|
var count = this.selectedSeats.length;
|
||||||
this.selectedSeats.forEach(function(seat, i) {
|
document.getElementById('selectedCount').textContent = count;
|
||||||
html += '<div class="vr-attendee-item">' +
|
|
||||||
'<div class="vr-attendee-label">第 '+(i+1)+' 张票 - '+seat.label+'</div>' +
|
|
||||||
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">' +
|
|
||||||
'<input type="text" class="vr-attendee-input" placeholder="真实姓名 *" data-field="real_name" data-index="'+i+'" required>' +
|
|
||||||
'<input type="tel" class="vr-attendee-input" placeholder="手机号 *" data-field="phone" data-index="'+i+'" required>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div style="margin-top:8px">' +
|
|
||||||
'<input type="text" class="vr-attendee-input" placeholder="身份证号(选填)" data-field="id_card" data-index="'+i+'">' +
|
|
||||||
'</div>' +
|
|
||||||
'<input type="hidden" class="vr-attendee-input" value="'+seat.label+'" data-field="seat_info" data-index="'+i+'">' +
|
|
||||||
'</div>';
|
|
||||||
});
|
|
||||||
document.getElementById('attendeeList').innerHTML = html;
|
|
||||||
},
|
|
||||||
|
|
||||||
loadSoldSeats: function() {
|
if (count > 0) {
|
||||||
// TODO: 从后端加载已售座位
|
document.getElementById('selectedSection').style.display = 'block';
|
||||||
// $.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
|
document.getElementById('attendeeSection').style.display = 'block';
|
||||||
// goods_id: this.goodsId,
|
|
||||||
// spec_base_id: this.sessionSpecId
|
|
||||||
// }, function(res) {
|
|
||||||
// // 标记已售座位
|
|
||||||
// });
|
|
||||||
},
|
|
||||||
|
|
||||||
bindEvents: function() {
|
var total = this.selectedSeats.reduce(function(sum, s) { return sum + s.price; }, 0);
|
||||||
// 空实现,后续扩展
|
document.getElementById('totalPrice').textContent = total.toFixed(2);
|
||||||
},
|
|
||||||
|
|
||||||
submit: function() {
|
this.renderAttendeeForms();
|
||||||
if (this.selectedSeats.length === 0) {
|
} else {
|
||||||
alert('请先选择座位');
|
document.getElementById('selectedSection').style.display = 'none';
|
||||||
return;
|
document.getElementById('attendeeSection').style.display = 'none';
|
||||||
}
|
}
|
||||||
if (!this.userId) {
|
},
|
||||||
alert('请先登录');
|
|
||||||
location.href = this.requestUrl + '?s=index/user/logininfo';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 收集观演人信息(按座位顺序索引)
|
renderAttendeeForms: function() {
|
||||||
var inputs = document.querySelectorAll('#attendeeList input');
|
var html = '';
|
||||||
var attendeeData = {};
|
this.selectedSeats.forEach(function(seat, i) {
|
||||||
inputs.forEach(function(input) {
|
var seatLabel = seat.rowLabel + seat.colNum + '座';
|
||||||
var idx = input.dataset.index;
|
html += '<div class="vr-attendee-form" data-index="' + i + '">' +
|
||||||
var field = input.dataset.field;
|
'<div class="form-title">观演人 ' + (i+1) + ' (' + seatLabel + ')</div>' +
|
||||||
if (!attendeeData[idx]) attendeeData[idx] = {};
|
'<div class="form-row"><label>姓名</label><input type="text" data-index="' + i + '" data-field="real_name" placeholder="请输入姓名"></div>' +
|
||||||
attendeeData[idx][field] = input.value;
|
'<div class="form-row"><label>手机</label><input type="tel" data-index="' + i + '" data-field="phone" placeholder="请输入手机号"></div>' +
|
||||||
});
|
'<div class="form-row"><label>身份证</label><input type="text" data-index="' + i + '" data-field="id_card" placeholder="请输入身份证号(选填)"></div>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
document.getElementById('attendeeList').innerHTML = html;
|
||||||
|
},
|
||||||
|
|
||||||
// 【Plan A】每座一行 goods_params,逐座提交
|
submit: function() {
|
||||||
// spec_base_id 从 specBaseIdMap[seatKey] 获取;若未生成 SKU(Plan B 过渡期),降级用 sessionSpecId
|
var self = this;
|
||||||
var self = this;
|
|
||||||
var goodsParamsList = this.selectedSeats.map(function(seat, i) {
|
// 1. 收集观演人
|
||||||
// Plan A: 座位级 SKU(specBaseIdMap key 格式 = rowLabel_colNum,如 "A_1")
|
var inputs = document.querySelectorAll('#attendeeList input');
|
||||||
// Plan B 回退: sessionSpecId(Zone 级别 SKU)
|
var attendeeData = [];
|
||||||
// PHP 返回格式: specBaseIdMap['A_1'] = 2001(整数),非对象
|
inputs.forEach(function(input) {
|
||||||
var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId;
|
var idx = parseInt(input.dataset.index);
|
||||||
var seatAttendee = attendeeData[i] || {};
|
if (!attendeeData[idx]) attendeeData[idx] = {};
|
||||||
return {
|
attendeeData[idx][input.dataset.field] = input.value;
|
||||||
goods_id: self.goodsId,
|
});
|
||||||
spec_base_id: parseInt(specBaseId) || 0,
|
|
||||||
stock: 1,
|
// 2. 验证已选座位和观演人数量匹配
|
||||||
extension_data: JSON.stringify({
|
if (this.selectedSeats.length === 0) {
|
||||||
attendee: seatAttendee,
|
alert('请至少选择一个座位');
|
||||||
seat: {
|
return;
|
||||||
row: seat.row,
|
}
|
||||||
col: seat.col,
|
if (this.selectedSeats.length !== attendeeData.length) {
|
||||||
rowLabel: seat.rowLabel,
|
alert('座位数与观演人信息数量不匹配');
|
||||||
colNum: seat.colNum,
|
return;
|
||||||
seatKey: seat.seatKey,
|
}
|
||||||
label: seat.label,
|
|
||||||
price: seat.price
|
// 3. 构建 ShopXO 原生 goods_data 格式
|
||||||
|
// ⚠️ spec 必须是完整的 4维数组,从 seatSpecMap[seatKey].spec 读取
|
||||||
|
// ⚠️ extension_data 必须嵌套在 order_base 内
|
||||||
|
// ⚠️ 直接 JSON.stringify,不需要 base64
|
||||||
|
var requestUrl = '<?php echo Config("shopxo.host_url"); ?>';
|
||||||
|
var goodsDataList = this.selectedSeats.map(function(seat, i) {
|
||||||
|
var seatInfo = self.seatSpecMap[seat.seatKey];
|
||||||
|
if (!seatInfo) {
|
||||||
|
console.error('seatSpecMap missing key:', seat.seatKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
goods_id: self.goodsId,
|
||||||
|
spec: seatInfo.spec, // 4维完整 spec 数组!从 seatSpecMap 来!
|
||||||
|
stock: 1,
|
||||||
|
order_base: { // ← 必须嵌套!不能平铺!
|
||||||
|
extension_data: {
|
||||||
|
attendee: {
|
||||||
|
real_name: attendeeData[i]?.real_name || '',
|
||||||
|
phone: attendeeData[i]?.phone || '',
|
||||||
|
id_card: attendeeData[i]?.id_card || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
};
|
}).filter(Boolean);
|
||||||
});
|
|
||||||
|
|
||||||
var goodsParams = JSON.stringify(goodsParamsList);
|
// 4. 过滤无效座位
|
||||||
|
if (goodsDataList.length === 0) {
|
||||||
|
alert('座位信息无效,请重新选择');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var checkoutUrl = this.requestUrl + '?s=index/buy/index' +
|
// 5. 隐藏表单 POST 到 ShopXO Buy 链路
|
||||||
'&goods_params=' + encodeURIComponent(goodsParams);
|
var form = document.createElement('form');
|
||||||
location.href = checkoutUrl;
|
form.method = 'POST';
|
||||||
}
|
form.action = requestUrl + '?s=index/buy/index';
|
||||||
};
|
document.body.appendChild(form);
|
||||||
|
|
||||||
window.vrTicketApp = app;
|
var input = document.createElement('input');
|
||||||
app.init();
|
input.type = 'hidden';
|
||||||
})();
|
input.name = 'goods_data';
|
||||||
|
input.value = JSON.stringify(goodsDataList); // 直接 JSON,BuyService 自动处理
|
||||||
|
form.appendChild(input);
|
||||||
|
|
||||||
|
form.submit(); // POST → Buy::Index → BuyDataStorage → 跳转确认页
|
||||||
|
},
|
||||||
|
|
||||||
|
bindEvents: function() {
|
||||||
|
var self = this;
|
||||||
|
document.getElementById('submitBtn').addEventListener('click', function() {
|
||||||
|
self.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.vrTicketApp = app;
|
||||||
|
app.init();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<?php echo ModuleInclude('public/footer'); ?>
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
/* VR票务 - 票务商品详情页样式 */
|
||||||
|
/* 从 ticket_detail.html 内联样式抽取,2026-04-21 */
|
||||||
|
|
||||||
|
.vr-ticket-page { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||||
|
.vr-ticket-header { margin-bottom: 20px; }
|
||||||
|
.vr-event-title { font-size: 24px; font-weight: bold; color: #333; margin-bottom: 8px; }
|
||||||
|
.vr-event-subtitle { color: #666; font-size: 14px; }
|
||||||
|
|
||||||
|
.vr-seat-section { margin-bottom: 30px; }
|
||||||
|
.vr-section-title { font-size: 18px; font-weight: bold; margin-bottom: 15px; color: #333; }
|
||||||
|
|
||||||
|
.vr-seat-map-wrapper { background: #f8f9fa; border-radius: 8px; padding: 20px; margin-bottom: 20px; overflow-x: auto; }
|
||||||
|
.vr-stage {
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(180deg, #e8e8e8, #d0d0d0);
|
||||||
|
border-radius: 50% 50% 0 0 / 20px 20px 0 0;
|
||||||
|
padding: 15px 40px;
|
||||||
|
margin: 0 auto 25px;
|
||||||
|
max-width: 600px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
.vr-seat-rows { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||||
|
.vr-seat-row { display: flex; align-items: center; gap: 2px; }
|
||||||
|
.vr-row-label { width: 24px; text-align: center; color: #999; font-size: 12px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.vr-seat {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #fff;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.vr-seat:hover { transform: scale(1.15); z-index: 1; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
|
||||||
|
.vr-seat.selected { border: 2px solid #333; transform: scale(1.1); }
|
||||||
|
.vr-seat.sold { background: #ccc !important; cursor: not-allowed; opacity: 0.5; }
|
||||||
|
.vr-seat.sold:hover { transform: none; box-shadow: none; }
|
||||||
|
.vr-seat.aisle { background: transparent !important; cursor: default; }
|
||||||
|
.vr-seat.space { background: transparent !important; cursor: default; }
|
||||||
|
|
||||||
|
.vr-legend { display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 15px; justify-content: center; }
|
||||||
|
.vr-legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #666; }
|
||||||
|
.vr-legend-seat { width: 20px; height: 20px; border-radius: 3px; }
|
||||||
|
|
||||||
|
.vr-selected-seats { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
||||||
|
.vr-selected-title { font-weight: bold; margin-bottom: 10px; color: #333; }
|
||||||
|
.vr-selected-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; min-height: 30px; }
|
||||||
|
.vr-selected-item {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
background: #e8f4ff; border: 1px solid #b8d4f0;
|
||||||
|
border-radius: 4px; padding: 4px 10px; font-size: 13px;
|
||||||
|
}
|
||||||
|
.vr-selected-item .remove { color: #f56c6c; cursor: pointer; font-size: 16px; line-height: 1; }
|
||||||
|
.vr-total { font-size: 16px; font-weight: bold; color: #333; margin-top: 10px; }
|
||||||
|
|
||||||
|
.vr-sessions { margin-bottom: 20px; }
|
||||||
|
.vr-session-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
|
||||||
|
.vr-session-item {
|
||||||
|
border: 1px solid #ddd; border-radius: 6px; padding: 10px;
|
||||||
|
cursor: pointer; text-align: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.vr-session-item:hover { border-color: #409eff; }
|
||||||
|
.vr-session-item.selected { border-color: #409eff; background: #ecf5ff; }
|
||||||
|
.vr-session-item .date { font-size: 14px; font-weight: bold; color: #333; }
|
||||||
|
.vr-session-item .time { font-size: 13px; color: #666; margin-top: 4px; }
|
||||||
|
.vr-session-item .price { font-size: 14px; color: #f56c6c; font-weight: bold; margin-top: 6px; }
|
||||||
|
|
||||||
|
.vr-attendee-form { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
||||||
|
.vr-form-title { font-weight: bold; margin-bottom: 15px; color: #333; }
|
||||||
|
.vr-attendee-item { background: #f8f9fa; border-radius: 6px; padding: 15px; margin-bottom: 10px; }
|
||||||
|
.vr-attendee-label { font-size: 13px; color: #666; margin-bottom: 5px; }
|
||||||
|
.vr-attendee-input { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
||||||
|
|
||||||
|
.vr-purchase-bar {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0;
|
||||||
|
background: #fff; border-top: 1px solid #e8e8e8;
|
||||||
|
padding: 12px 20px; z-index: 100;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
box-shadow: 0 -2px 10px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.vr-purchase-info { font-size: 14px; color: #666; }
|
||||||
|
.vr-purchase-info strong { font-size: 20px; color: #f56c6c; }
|
||||||
|
.vr-purchase-btn {
|
||||||
|
background: linear-gradient(135deg, #409eff, #3b8ef8);
|
||||||
|
color: #fff; border: none; border-radius: 20px;
|
||||||
|
padding: 12px 36px; font-size: 16px; font-weight: bold;
|
||||||
|
cursor: pointer; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.vr-purchase-btn:hover { transform: scale(1.03); box-shadow: 0 4px 12px rgba(64,158,255,0.4); }
|
||||||
|
.vr-purchase-btn:disabled { background: #ccc; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||||
|
|
||||||
|
.vr-goods-info { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
||||||
|
.vr-goods-photos { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; margin-bottom: 15px; }
|
||||||
|
.vr-goods-photos img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; }
|
||||||
Loading…
Reference in New Issue