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,不需要 base64
pull/19/head
Council 2026-04-21 11:41:59 +08:00
commit c581395a9c
23 changed files with 4639 additions and 433 deletions

160
docs/AGENT_PROMPT.md Normal file
View File

@ -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:10000Web/ localhost:10001MySQL/ localhost:9000PHP-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 到本地,汇报给大头后由他处理上游合并。

View File

@ -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 1P0购买提交流程失效
### 根因(三层叠加)
**第一层(致命)**`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); // 直接 JSONBuyService 自动处理
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 2P1缩放时舞台不跟随
### 根因
`.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 3P1spec 加载问题(已回滚)
### 根因
- `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 4P2商品详情/图片加载
- `$goods['content']`:✅ 正常渲染
- `$goods['images']`:⚠️ 数据存在但未使用
- `.goods-detail-content` CSS 缺失
如需展示商品图片,在模板中添加图片渲染逻辑。如票务详情页不需要 ShopXO 商品内容,可降级为「确认不需要」。
---
## Issue 5P2 潜在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` 能通过 seatKeyspec value 字符串)查询 spec_base_idmap 可以去掉。但保留是合理的优化。
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 修正版*

680
docs/FULL_PLAN.md Normal file
View File

@ -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:10000Web/ localhost:10001MySQL/ localhost:9000PHP-FPM
---
## 一、项目概览
### 1.1 目标产品
VR 演唱会票务微信小程序。用户选座 → 填观演人 → 微信支付 → 获取电子票二维码 → 现场扫码核销。
### 1.2 技术栈
- **前端**:原生 HTML + CSS + JS无框架商品详情页使用 `ticket_detail.html`
- **后端**ShopXOThinkPHP 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 每个 GoodsSpecBaseSKU由 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`GETShopXO `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. 查询对应的 GoodsSpecValue4个维度的 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 界面结构
```
┌─────────────────────────────────────────────────────┐
│ 顶部 Bannervenue.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`GETShopXO `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); // 直接 JSONBuyService 自动处理
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 fieldJSON 字符串,非 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 可独立阅读并推进事务。*

View File

@ -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 4CSS 文件分离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 第一版返回空数组,后续迭代接入 |
---
## 六、验收测试总表
### P0Step 1 + Step 2
| # | 测试场景 | 预期结果 |
|---|---------|---------|
| 1 | 选择 3 个座位 → 提交 | 购物车页显示 3 条商品 |
| 2 | 座位 2 库存不足 | 弹窗提示,已选座位清零 |
| 3 | 选择场次 A → 选 2 座 → 切换场次 B | 已选座位清零,购买栏归零 |
| 4 | 切换回场次 A | 座位图重新渲染,无旧数据残留 |
### P1Step 3 + Step 4
| # | 测试场景 | 预期结果 |
|---|---------|---------|
| 5 | `SoldSeats()` 返回 `["A_1","A_2"]` | A_1、A_2 标记灰色已售 |
| 6 | 访问 `ticket_detail.html` | DevTools Network 可见 `ticket.css` 请求 |
| 7 | 页面各区块布局 | 与内联样式版本一致 |
### P2Step 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 作为可选优化项

View File

@ -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 + 原生 JSsession/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
├── 内联 JSvrTicketApp 对象)
└── 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 官方更新同步成本 | 🟢 低 | 票务页面与商城页面目录隔离 |

View File

@ -0,0 +1,482 @@
# 商品详情扩展字段数据字典与前端使用说明
> 日期2026-04-21
> 用途:前端 agentantigravity / cursor拿到商品详情页时扩展字段里有哪些可用数据如何用
---
## 一、核心数据结构全貌
商品详情页加载时PHP 后端向模板注入以下变量:
| 模板变量名 | 来源 | 说明 |
|-----------|------|------|
| `$goods` | ShopXO GoodsService | ShopXO 原生商品数据id/title/price/content/images 等) |
| `$vr_seat_template` | `SeatSkuService::GetGoodsViewData()` | 票务插件扩展数据 |
| `$goods_spec_data` | `SeatSkuService::GetGoodsViewData()` | 场次列表 |
---
## 二、vr_goods_configgoods 表扩展字段)
存储位置:`goods.vr_goods_config`JSON 字段)
这是商品发布时由管理员配置的数据快照,**前端只能读,不能写**。
### 完整 JSON 示例(商品 118VR 演唱会)
```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=房间IDvalue=该房间选中的分区字符数组 |
| `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. 查询对应的 GoodsSpecValue4个维度的值
$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
});
```

View File

@ -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'], // 场次列表
],
// 【修复】重构后的 seatSpecMaproom_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. 构建 seatSpecMapseat_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 字段 | 前端 |

View File

@ -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_basegoods_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 对接 ShopXOCSS 在 H5/小程序完全一致WebView 同源) |
| `docs/14_TEMPLATE_RENDER_INVESTIGATION.md` | ShopXO view/goods/ 模板使用原生 PHP + 原生 JSsession/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、外部 CDNShopXO 必须离线可用)
【第三层:具体需求】
[座位图 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 和小程序都基于 WebViewCSS 渲染一致。关键:用 `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多座位选座和 Q1H5 模板基础)**
- 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
View File

@ -1,11 +1,18 @@
<<<<<<< HEAD
# Plan — ShopXO 酷炫前端模板调研
> 版本v1.0 | 日期2026-04-20 | Agentcouncil/FirstPrinciples + council/FrontendDev + council/BackendArchitect + council/ProductManager
=======
# Plan — ShopXO 酷炫前端模板实现方案调研
> 版本v1.0 | 日期2026-04-20 | Agentcouncil/ProductManager + council/FrontendDev + council/BackendArchitect + council/FirstPrinciples
>>>>>>> main
---
## 任务概述
<<<<<<< HEAD
vr-shopxo-plugin 项目 Phase 0/1/2 后台开发已完成,现需调研票务商品详情页(`ticket_detail.html`)的酷炫前端模板实现方案。
**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-2**: 明确优先级、依赖关系、技术风险
- [ ] [ ] [Claimed: council/FirstPrinciples] **Task FP-3**: 给出"最小可行方案 vs 理想方案"对比
=======
vr-shopxo-plugin 项目推进 Phase 3 前端模板调研,聚焦 4 个方向:
- Q1ShopXO 自定义模板最佳实践
- Q2单订单多 SKU 支持(多座位选择前提)
- Q3第三方无代码构建服务提示词策略
- Q4uni-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 调研并提交各自方向报告 |
| **Review** | ⬜ 待开始 | 各 Agent 交叉评审FirstPrinciples 汇总 |
| **Finalize** | ⬜ 待开始 | 输出 council-research-output.md |
=======
| **Draft** | 🔄 进行中 | Round 1 — 各 Agent 并行规划 |
| **Research** | ⬜ 待开始 | Round 2 — 执行调研 |
| **Finalize** | ⬜ 待开始 | Round 3 — 收敛共识 |
>>>>>>> main
---
## 输出文件
<<<<<<< HEAD
| 文件 | 内容 | 负责人 |
|------|------|--------|
| `docs/Q1-frontend-research.md` | ShopXO 自定义模板最佳实践 | FrontendDev |
@ -107,3 +165,20 @@ Q1最佳实践 ──→ 基础,供 Q3/Q4 引用
- **第 1 轮**(本轮):各 Agent 创建 plan 并 claim 任务
- **第 2 轮**:各 Agent 完成调研,提交报告到 docs/
- **第 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

View File

@ -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. **商品创建时**:用户配置了场馆 Atemplate_id=5和场馆 Btemplate_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 | 修复 P2GetGoodsViewData 多模板清理 | SeatSkuService.php | P1 |
| 3 | 测试验证 | — | — |
---
## 八、结论
1. **Council 的调研结果基本正确**,但遗漏了前端过滤本身是有效的这一点
2. **真正的根因**在于 `save_thing_end` 时机从 DB 读取的是 goods 表中的旧数据,而非前端提交的数据
3. **修复方案**是:在后端遍历时直接移除无效 config 块,确保脏数据不回写 DB
4. **GetGoodsViewData** 也需要同步修复,支持多模板模式

View File

@ -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
### 根因 1P1无效 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` 整个循环体被跳过,**无任何校验**直接写回。
### 根因 2P1幽灵 spec 持续污染 vr_goods_config
脏 config 块(含已删除模板的 `template_snapshot`)被写回 DB 后:
- 下次编辑商品时,`vr_goods_config` 仍含无效配置
- `GetGoodsViewData` 尝试加载模板(失败后将 `template_id` 置 null
- 但若 `save_thing_end` 在模板验证前先执行写回,无效配置再次被保存
- 循环往复,**幽灵 spec 永远无法被清理**
### 根因 3P2前端无 `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-383TODO 注释状态,无法标记已售座位。顾客可购买已售座位(需支付验证拦截)。
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` 告知用户重新选择场馆。

View File

@ -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 产生冲突)。

View File

@ -0,0 +1,254 @@
# FrontendDev 调研报告:幽灵 spec 问题
> 日期2026-04-20 | Agentcouncil/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 | 规格名称重复检测 |

View File

@ -13,12 +13,6 @@
return array (
'listen' =>
array (
'plugins_css' =>
array (
),
'plugins_js' =>
array (
),
'plugins_service_admin_menu_data' =>
array (
0 => 'app\\plugins\\vr_ticket\\Hook',
@ -43,6 +37,10 @@ return array (
array (
0 => 'app\\plugins\\vr_ticket\\hook\\AdminGoodsSaveHandle',
),
'plugins_css_data' =>
array (
0 => 'app\\plugins\\vr_ticket\\hook\\ViewGoodsCss',
),
),
);
?>

View File

@ -142,6 +142,7 @@ class Goods extends Common
MyViewAssign([
'vr_seat_template' => $viewData['vr_seat_template'] ?? null,
'goods_spec_data' => $viewData['goods_spec_data'] ?? [],
'seatSpecMap' => $viewData['seatSpecMap'] ?? [],
]);
// 使用绝对路径 + fetch() 方法,让 Think 驱动正确解析 include 路径
$tplFile = ROOT . 'app' . DS . 'plugins' . DS . 'vr_ticket' . DS . 'view' . DS . 'goods' . DS . 'ticket_detail.html';

View File

@ -1043,6 +1043,33 @@ class Admin extends Common
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 格式)
*/

View File

@ -35,6 +35,9 @@
],
"plugins_service_goods_save_thing_end": [
"app\\plugins\\vr_ticket\\hook\\AdminGoodsSaveHandle"
],
"plugins_css_data": [
"app\\plugins\\vr_ticket\\hook\\ViewGoodsCss"
]
}
}

View File

@ -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';
}
}

View File

@ -353,7 +353,7 @@ class SeatSkuService extends BaseService
* 获取商品前端展示数据(供 ticket_detail.html 模板使用)
*
* @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
{
@ -362,7 +362,7 @@ class SeatSkuService extends BaseService
$vrGoodsConfig = json_decode($goods['vr_goods_config'] ?? '', true);
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)) {
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];
$templateId = intval($config['template_id'] ?? 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
@ -407,7 +407,7 @@ class SeatSkuService extends BaseService
} else {
\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 字符串)
@ -418,15 +418,10 @@ class SeatSkuService extends BaseService
}
}
// 解码 spec_base_id_map JSON
if (!empty($seatTemplate['spec_base_id_map'])) {
$decoded = json_decode($seatTemplate['spec_base_id_map'], true);
if (json_last_error() === JSON_ERROR_NONE) {
$seatTemplate['spec_base_id_map'] = $decoded;
}
}
// ========== 新增:构建 seatSpecMap ==========
$seatSpecMap = self::buildSeatSpecMap($goodsId, $seatTemplate);
// 构建场次列表goods_spec_data
// ========== 构建场次列表goods_spec_data==========
$sessions = $config['sessions'] ?? [];
$goodsSpecData = [];
@ -435,48 +430,198 @@ class SeatSkuService extends BaseService
$end = $session['end'] ?? '';
$timeRange = $start && $end ? "{$start}-{$end}" : ($start ?: $end);
// 查找该场次对应的 spec_base_id
$specValue = \think\facade\Db::name('goods_spec_value')
->alias('sv')
->join('goods_spec_base sb', 'sb.id = sv.goods_spec_base_id')
->where('sv.goods_id', $goodsId)
->where('sv.value', $timeRange)
->where('sb.price', '>', 0)
->find();
// 查找该场次对应的最低价格(从 seatSpecMap 中获取)
$sessionPrice = null;
foreach ($seatSpecMap as $seatKey => $info) {
foreach ($info['spec'] as $specItem) {
$specType = $specItem['type'] ?? '';
$specValue = $specItem['value'] ?? '';
if ($specType === '$vr-场次' && $specValue === $timeRange) {
if ($sessionPrice === null || $info['price'] < $sessionPrice) {
$sessionPrice = $info['price'];
}
break 2;
}
}
}
$goodsSpecData[] = [
'spec_id' => $specValue['goods_spec_base_id'] ?? 0,
'spec_id' => 0, // 不再需要 spec_id前端用 seatSpecMap
'spec_name' => $timeRange,
'price' => $specValue['price'] ?? floatval($goods['price'] ?? 0),
'price' => $sessionPrice ?? floatval($goods['price'] ?? 0),
'start' => $start,
'end' => $end,
];
}
// 如果没有从配置读取到场次,尝试从数据库直接读取场次类规格值
// 如果没有从配置读取到场次,尝试从 seatSpecMap 提取唯一场次
if (empty($goodsSpecData)) {
$sessionValues = \think\facade\Db::name('goods_spec_value')
->alias('sv')
->join('goods_spec_base sb', 'sb.id = sv.goods_spec_base_id')
->field('sv.goods_spec_base_id as spec_id, sv.value as spec_name, sb.price')
->where('sv.goods_id', $goodsId)
->where('sb.price', '>', 0)
->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']),
];
$sessionMap = [];
foreach ($seatSpecMap as $info) {
foreach ($info['spec'] as $specItem) {
$specType = $specItem['type'] ?? '';
if ($specType === '$vr-场次') {
$sessionMap[$specItem['value'] ?? ''] = $specItem['value'] ?? '';
}
}
}
foreach ($sessionMap as $timeRange) {
$goodsSpecData[] = [
'spec_id' => 0,
'spec_name' => $timeRange,
'price' => floatval($goods['price'] ?? 0),
'start' => '',
'end' => '',
];
}
}
return [
'vr_seat_template' => $seatTemplate ?: null,
'goods_spec_data' => $goodsSpecData,
'seatSpecMap' => $seatSpecMap,
'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. 构建 seatSpecMapseat_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;
}
}

View File

@ -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; }

View File

@ -12,32 +12,40 @@ $security_desc = $shopxo_config['security_desc'] ?? '';
</div><!-- end .vr-ticket-page -->
<style>
.vr-footer {
padding: 30px 20px;
text-align: center;
background: #f8f9fa;
border-top: 1px solid #e8e8e8;
margin-top: 80px; /* 避开固定底部购买栏 */
}
.vr-footer-links {
margin-bottom: 12px;
}
.vr-footer-links a {
color: #666;
font-size: 13px;
text-decoration: none;
margin: 0 12px;
}
.vr-footer-links a:hover { color: #409eff; }
.vr-footer-copy {
font-size: 12px;
color: #999;
margin-bottom: 6px;
}
.vr-footer-icp {
font-size: 11px;
color: #bbb;
}
.vr-footer {
padding: 30px 20px;
text-align: center;
background: #f8f9fa;
border-top: 1px solid #e8e8e8;
margin-top: 80px;
/* 避开固定底部购买栏 */
}
.vr-footer-links {
margin-bottom: 12px;
}
.vr-footer-links a {
color: #666;
font-size: 13px;
text-decoration: none;
margin: 0 12px;
}
.vr-footer-links a:hover {
color: #409eff;
}
.vr-footer-copy {
font-size: 12px;
color: #999;
margin-bottom: 6px;
}
.vr-footer-icp {
font-size: 11px;
color: #bbb;
}
</style>
<div class="vr-footer">
@ -45,13 +53,19 @@ $security_desc = $shopxo_config['security_desc'] ?? '';
<a href="<?php echo Config('shopxo.host_url'); ?>">返回首页</a>
</div>
<div class="vr-footer-copy">
&copy; <?php echo date('Y'); ?> <?php echo htmlspecialchars($shop_name, ENT_QUOTES, 'UTF-8'); ?> All Rights Reserved.
&copy;
<?php echo date('Y'); ?>
<?php echo htmlspecialchars($shop_name, ENT_QUOTES, 'UTF-8'); ?> All Rights Reserved.
</div>
<?php if (!empty($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>
<?php endif; ?>
</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>

View File

@ -1,128 +1,19 @@
<?php echo ModuleInclude('public/header'); ?>
<style>
/* VR票务 - 票务商品详情页 */
/* 完全独立于 ShopXO 标准商品页,不加载 goods-detail 相关 CSS */
/* 页面布局 */
.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>
<!-- VR票务样式 -->
<link rel="stylesheet" type="text/css"
href="<?php echo Config('shopxo.host_url'); ?>plugins/vr_ticket/static/css/ticket.css?v=1.0.0" />
<!-- 页面内容 -->
<div class="vr-ticket-page" id="vrTicketApp">
<!-- 商品头部 -->
<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-subtitle"><?php echo htmlspecialchars($goods['simple_desc'] ?? '', ENT_QUOTES, 'UTF-8'); ?></div>
<div class="vr-event-title">
<?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>
<!-- 场次选择 -->
@ -136,7 +27,8 @@
<!-- 座位图 -->
<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-seat-map-wrapper">
<div class="vr-stage">舞 台</div>
@ -161,7 +53,9 @@
<?php if (!empty($goods['content'])): ?>
<div class="vr-seat-section">
<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>
<?php endif; ?>
@ -180,272 +74,283 @@
<?php echo ModuleInclude('public/footer'); ?>
<script>
(function() {
var app = {
goodsId: <?php echo intval($goods['id'] ?? 0); ?>,
seatMap: <?php echo json_encode($vr_seat_template['seat_map'] ?? []); ?>,
specBaseIdMap: <?php echo json_encode($vr_seat_template['spec_base_id_map'] ?? []); ?>,
selectedSeats: [], // [{row, col, char, price, label, classes}]
soldSeats: {}, // {row_col: true}
currentSession: null,
sessionSpecId: null,
requestUrl: '<?php echo Config("shopxo.host_url"); ?>',
userId: <?php echo IsMobileLogin(); ?>,
(function () {
var app = {
goodsId: <?php echo intval($goods['id'] ?? 0); ?>,
seatMap: <?php echo json_encode($vr_seat_template['seat_map'] ?? []); ?>,
seatSpecMap: <?php echo json_encode($seatSpecMap ?? []); ?>,
selectedSeats: [], // [{seatKey, price, rowLabel, colNum, section}]
soldSeats: { }, // {seatKey: true}
currentSession: null, // 当前选中场次 value (e.g. "15:00-16:59")
currentVenue: null, // 当前选中场馆 value
currentSection: null, // 当前选中分区 char
init: function() {
this.renderSessions();
this.bindEvents();
this.loadSoldSeats();
},
init: function() {
this.renderSessions();
this.bindEvents();
},
// 渲染场次列表(基于 ShopXO spec 数据)
renderSessions: function() {
var specData = <?php echo json_encode($goods_spec_data ?? []); ?> || [];
var html = '';
if (specData.length > 0) {
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)">' +
'<div class="date">'+spec.spec_name+'</div>' +
'<div class="price">¥'+spec.price+'</div></div>';
// 渲染场次列表
renderSessions: function() {
var specData = <?php echo json_encode($goods_spec_data ?? []); ?> || [];
var html = '';
if (specData.length > 0) {
specData.forEach(function (spec) {
html += '<div class="vr-session-item" data-session="' + spec.spec_name + '" onclick="vrTicketApp.selectSession(this)">' +
'<div class="date">' + spec.spec_name + '</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.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 = 'block';
document.getElementById('selectedSection').style.display = 'none';
document.getElementById('attendeeSection').style.display = 'none';
// 显示座位图
document.getElementById('seatSection').style.display = 'block';
document.getElementById('attendeeSection').style.display = 'block';
this.renderSeatMap();
this.loadSoldSeats();
},
this.renderSeatMap();
this.loadSoldSeats();
},
renderSeatMap: function() {
var map = this.seatMap;
if (!map) {
document.getElementById('seatRows').innerHTML = '<div style="text-align:center;color:#999;padding:40px">座位图数据为空</div>';
return;
}
renderSeatMap: function() {
var map = this.seatMap;
if (!map || !map.map || map.map.length === 0) {
document.getElementById('seatRows').innerHTML = '<div style="text-align:center;color:#999;padding:40px">座位图加载失败</div>';
return;
}
var mapData = map.map || (map.rooms && map.rooms[0] ? map.rooms[0].map : null);
if (!mapData || mapData.length === 0) {
document.getElementById('seatRows').innerHTML = '<div style="text-align:center;color:#999;padding:40px">座位图加载失败</div>';
return;
}
// 渲染图例
var legendHtml = '';
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 seats = map.seats || (map.rooms && map.rooms[0] ? map.rooms[0].seats || {} : {});
var sections = map.sections || (map.rooms && map.rooms[0] ? map.rooms[0].sections || [] : []);
// 渲染座位图
var rowsContainer = document.getElementById('seatRows');
var row_labels = map.row_labels || [];
var rowsHtml = '';
// 渲染图例
var legendHtml = '';
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;
map.map.forEach(function(rowStr, rowIndex) {
var rowLabel = row_labels[rowIndex] || String.fromCharCode(65 + rowIndex);
rowsHtml += '<div class="vr-seat-row">';
rowsHtml += '<div class="vr-row-label">'+rowLabel+'</div>';
var rowsContainer = document.getElementById('seatRows');
var rowsHtml = '';
var chars = rowStr.split('');
chars.forEach(function(char, colIndex) {
if (char === '_' || char === '-') {
rowsHtml += '<div class="vr-seat space" style="width:28px;height:28px"></div>';
} else {
var seatInfo = seats[char] || {};
var color = seatInfo.color || '#409eff';
mapData.forEach(function (rowStr, rowIndex) {
var rowLabel = String.fromCharCode(65 + rowIndex);
var chars = rowStr.split('');
var rowHtml = '<div class="vr-seat-row"><span class="vr-row-label">' + rowLabel + '</span>';
chars.forEach(function (char, colIndex) {
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 label = seatInfo.label || '';
rowsHtml += '<div class="vr-seat '+(seatInfo.classes||'')+'" '+
'style="background:'+color+'" '+
'data-row="'+rowIndex+'" data-col="'+colIndex+'" '+
'data-row-label="'+rowLabel+'" data-col-num="'+(colIndex+1)+'" '+
'data-char="'+char+'" data-price="'+price+'" '+
'data-seat-id="'+char+'" data-label="'+rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座" '+
'onclick="vrTicketApp.toggleSeat(this)"></div>';
if (char === '_' || char === '-') {
rowHtml += '<div class="vr-seat space"></div>';
} else if (seatInfo.inventory > 0 && !app.soldSeats[seatKey]) {
rowHtml += '<div class="vr-seat available" ' +
'data-seat-key="' + seatKey + '" ' +
'data-row="' + rowLabel + '" ' +
'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) {
if (el.classList.contains('sold')) return;
var index = this.selectedSeats.findIndex(function(s) { return s.seatKey === seatKey; });
var row = el.dataset.row;
var col = el.dataset.col;
var rowLabel = el.dataset.rowLabel;
var colNum = el.dataset.colNum;
var seatKey = rowLabel + '_' + colNum; // e.g. "A_1" — matches specBaseIdMap key format
var seat = {
row: parseInt(row),
col: parseInt(col),
rowLabel: rowLabel,
colNum: parseInt(colNum),
seatKey: seatKey, // 用于 specBaseIdMap 查找
char: el.dataset.char,
price: parseFloat(el.dataset.price),
label: el.dataset.label,
seatId: el.dataset.seatId,
};
if (index >= 0) {
// 取消选中
this.selectedSeats.splice(index, 1);
el.classList.remove('selected');
} else {
// 选中
this.selectedSeats.push({
seatKey: seatKey,
price: price,
rowLabel: row,
colNum: parseInt(col),
section: seatInfo.section || {}
});
el.classList.add('selected');
}
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();
}
},
},
renderAttendeeForms: function() {
var html = '';
this.selectedSeats.forEach(function(seat, i) {
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;
},
updateSelectedUI: function() {
var count = this.selectedSeats.length;
document.getElementById('selectedCount').textContent = count;
loadSoldSeats: function() {
// TODO: 从后端加载已售座位
// $.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
// goods_id: this.goodsId,
// spec_base_id: this.sessionSpecId
// }, function(res) {
// // 标记已售座位
// });
},
if (count > 0) {
document.getElementById('selectedSection').style.display = 'block';
document.getElementById('attendeeSection').style.display = 'block';
bindEvents: function() {
// 空实现,后续扩展
},
var total = this.selectedSeats.reduce(function(sum, s) { return sum + s.price; }, 0);
document.getElementById('totalPrice').textContent = total.toFixed(2);
submit: function() {
if (this.selectedSeats.length === 0) {
alert('请先选择座位');
return;
}
if (!this.userId) {
alert('请先登录');
location.href = this.requestUrl + '?s=index/user/logininfo';
return;
}
this.renderAttendeeForms();
} else {
document.getElementById('selectedSection').style.display = 'none';
document.getElementById('attendeeSection').style.display = 'none';
}
},
// 收集观演人信息(按座位顺序索引)
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;
});
renderAttendeeForms: function() {
var html = '';
this.selectedSeats.forEach(function(seat, i) {
var seatLabel = seat.rowLabel + seat.colNum + '座';
html += '<div class="vr-attendee-form" data-index="' + i + '">' +
'<div class="form-title">观演人 ' + (i+1) + ' (' + seatLabel + ')</div>' +
'<div class="form-row"><label>姓名</label><input type="text" data-index="' + i + '" data-field="real_name" placeholder="请输入姓名"></div>' +
'<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逐座提交
// spec_base_id 从 specBaseIdMap[seatKey] 获取;若未生成 SKUPlan B 过渡期),降级用 sessionSpecId
var self = this;
var goodsParamsList = this.selectedSeats.map(function(seat, i) {
// Plan A: 座位级 SKUspecBaseIdMap key 格式 = rowLabel_colNum如 "A_1"
// Plan B 回退: sessionSpecIdZone 级别 SKU
// PHP 返回格式: specBaseIdMap['A_1'] = 2001整数非对象
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: {
row: seat.row,
col: seat.col,
rowLabel: seat.rowLabel,
colNum: seat.colNum,
seatKey: seat.seatKey,
label: seat.label,
price: seat.price
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 格式
// ⚠️ 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' +
'&goods_params=' + encodeURIComponent(goodsParams);
location.href = checkoutUrl;
}
};
// 5. 隐藏表单 POST 到 ShopXO Buy 链路
var form = document.createElement('form');
form.method = 'POST';
form.action = requestUrl + '?s=index/buy/index';
document.body.appendChild(form);
window.vrTicketApp = app;
app.init();
})();
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'goods_data';
input.value = JSON.stringify(goodsDataList); // 直接 JSONBuyService 自动处理
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>
<?php echo ModuleInclude('public/footer'); ?>

View File

@ -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; }