diff --git a/docs/AGENT_PROMPT.md b/docs/AGENT_PROMPT.md new file mode 100644 index 0000000..e393fee --- /dev/null +++ b/docs/AGENT_PROMPT.md @@ -0,0 +1,160 @@ +# Agent 执行 Prompt — VR 演唱会票务小程序 Phase 2 + +## 前提条件(必读) + +你正在帮助开发一个 **ShopXO 票务插件(vr_ticket)**。 + +- 仓库:`http://xmhome.ow-my.com:3000/sileya-ai/vr-shopxo-plugin` +- 本地路径:`/Users/bigemon/WorkSpace/vr-shopxo-plugin` +- ShopXO 容器:localhost:10000(Web)/ localhost:10001(MySQL)/ localhost:9000(PHP-FPM) +- DB 用户:root / shopxo_root_2024,表前缀:`vrt_` + +**完整文档路径**:`/Users/bigemon/WorkSpace/vr-shopxo-plugin/docs/FULL_PLAN.md` + +> ⚠️ 在做任何事情之前,**必须先读 `FULL_PLAN.md`**,理解完整上下文后再开始。 + +--- + +## 项目背景(一句话) + +VR 演唱会票务微信小程序插件。用户选座 → 填观演人 → 微信支付 → 电子票二维码 → 现场扫码核销。 + +--- + +## 当前优先级 + +### P0(阻塞一切) + +**Issue 1 修复**:购买提交流程完全失效,有三层叠加问题。 + +**顺序**: + +1. **后端**:修改 `SeatSkuService::GetGoodsViewData()`,新增 `seatSpecMap` 生成逻辑 + - 查询 `GoodsSpecBase` + `GoodsSpecValue` + `GoodsSpecBase.extends.seat_key` + - 输出 `seatSpecMap[seatKey] = {spec_base_id, price, inventory, spec: [...]}` + - 完整逻辑见 `FULL_PLAN.md` 第 4.3 节 + +2. **后端**:修改 `Goods.php`,在 `MyViewAssign` 中加入 `seatSpecMap` + +3. **前端**:修改 `ticket_detail.html`,用 `seatSpecMap` 替代当前错误的 `specBaseIdMap` + +4. **前端**:修复 `submit()` 函数 + - 改 GET → POST 隐藏表单(**不是** `location.href`) + - spec 必须是**完整的 4 维数组**:`[{type:'$vr-场馆',value:'...'},{type:'$vr-分区',value:'...'},{type:'$vr-座位号',value:'...'},{type:'$vr-场次',value:'...'}]` + - **不是** `{type:'$vr-座位号', value: seatKey}` — 这是错的 + - spec 从 `seatSpecMap[seatKey].spec` 读取,**不要自己构造** + - `extension_data` 必须嵌套在 `order_base` 内,**不是平铺在第一层** + - 直接 `JSON.stringify`,**不需要 base64** + +### P1 + +5. **前端**:`ticket_detail.html` 新增场次/场馆/分区选择器 UI + `filterSeatMap()` 联动过滤 +6. **前端**:缩放时舞台跟随(zoom wrapper 方案) +7. **后端**:新增 `sold_seats` API 端点 + 前端 `loadSoldSeats()` 调用 + +### P2 + +8. 商品详情图片展示(确认需求) +9. `GetGoodsViewData()` 多场次返回数组而非 `validConfigs[0]` +10. `onOrderPaid` spec 匹配审计(未来关注,不阻塞) + +--- + +## 绝对禁止事项 + +- ❌ **不要**用 `location.href` 传递购买参数(ShopXO 只在 POST 时存储数据) +- ❌ **不要**把 spec 格式写成 `{type:'$vr-座位号', value: 'room_001_A_3'}` — 这是错的 +- ❌ **不要**把 `extension_data` 平铺在 `goods_data` 第一层 — 必须嵌套在 `order_base` 里 +- ❌ **不要**在 submit() 里对 `goods_data` 做 base64 — 直接 `JSON.stringify` 即可 +- ❌ **不要**修改 `BuyService.php` 的核心逻辑 — 所有修复都在前端和插件后端做 +- ❌ **不要**新建数据库表来存观演人信息 — 用 ShopXO 原生的 `order.extension_data` + +--- + +## 常见错误警告 + +### spec 数组格式(最高频错误) + +``` +错误: +spec: [{type:'$vr-座位号', value: 'room_001_A_3'}] + +正确(完整4维): +spec: [ + {type:'$vr-场馆', value: 'VR 演唱会馆'}, + {type:'$vr-分区', value: 'VR 演唱会馆-1号演播厅-VIP区'}, + {type:'$vr-座位号', value: 'VR 演唱会馆-1号演播厅-VIP区-A-1排3座'}, + {type:'$vr-场次', value: '15:00-16:59'} +] +``` + +### order_base 嵌套(第二高频错误) + +``` +错误: +{ + goods_id: 118, + spec: [...], + extension_data: {...} ← 平铺!错! +} + +正确: +{ + goods_id: 118, + spec: [...], + order_base: { ← 必须嵌套在 order_base 内! + extension_data: {...} + } +} +``` + +### goods_data 编码(第三高频错误) + +``` +错误: +input.value = btoa(JSON.stringify(goodsDataList)) ← 不需要 base64! + +正确: +input.value = JSON.stringify(goodsDataList) ← 直接 JSON 字符串 +``` + +ShopXO `BuyService::BuyGoods` 第60行判断 `!is_array($_POST['goods_data'])` 才会 decode,直接 POST JSON 字符串即可。 + +--- + +## 快速参考 + +| 我需要知道 | 去哪里找 | +|-----------|---------| +| 完整上下文 + 修复方案 | `FULL_PLAN.md` | +| 原始 goods.vr_goods_config 数据 | `FULL_PLAN.md` 第二章 | +| seatSpecMap 正确结构 | `FULL_PLAN.md` 4.3 节 | +| submit() 正确实现 | `FULL_PLAN.md` 第六章 | +| Buy 链路数据流 | `FULL_PLAN.md` 6.3 节 | +| 关键代码行号索引 | `FULL_PLAN.md` 第八章 | +| spec 选择器设计 | `FULL_PLAN.md` 第五章 | +| 座位图渲染方法 | `FULL_PLAN.md` 5.3 节 | + +--- + +## 工作流程 + +1. **读** `FULL_PLAN.md` 全文(必读) +2. **确认**你理解了 4 维 spec 结构 + seatSpecMap 映射关系 +3. **按优先级顺序**处理 P0 → P1 → P2 +4. **每完成一个模块**,在本地测试验证后再继续 +5. **commit 前**:`git status` 检查暂存区,不提交 binary(图片/压缩包),不在本仓库 push 到远程 + +--- + +## commit 规范 + +``` +feat(Phase2): [模块名] [简短描述] + +示例: +feat(Phase2): SeatSkuService GetGoodsViewData 新增 seatSpecMap 生成 +feat(Phase2): ticket_detail.html 修复 submit() POST + 4维spec数组 +``` + +**注意**:本仓库是 fork,不直接 push 到 upstream。只 commit 到本地,汇报给大头后由他处理上游合并。 diff --git a/docs/COUNCIL_PHASE2_ASSESSMENT_CORRECTED.md b/docs/COUNCIL_PHASE2_ASSESSMENT_CORRECTED.md new file mode 100644 index 0000000..3056691 --- /dev/null +++ b/docs/COUNCIL_PHASE2_ASSESSMENT_CORRECTED.md @@ -0,0 +1,255 @@ +# VR 演唱会票务小程序 Phase 2 技术评估报告(修正版) + +> 日期:2026-04-21 +> 协作产出:BackendArchitect、FrontendDev、FirstPrinciples +> 修正:大头 + 西莉雅(2026-04-21 上午) +> 源码依据:BuyService.php、GoodsCartService.php、SeatSkuService.php、ticket_detail.html、vr_tickets install.sql + +--- + +## 执行摘要 + +Phase 2 完成 4 个已知问题的根因分析 + 1 个新发现潜在 Bug。**经大头确认后,修正了 FirstPrinciples 的关键错误结论。** + +**核心修正**:FirstPrinciples「购物车对票务无价值」的结论是**错误的**。Buy 链路是正确方向,ShopXO 原生支持多 SKU 合并下单 + extension_data 透传 + onOrderPaid 写入 vr_tickets。只需修复 submit() 的传递方式。 + +--- + +## 问题总览 + +| # | 问题 | 优先级 | 根因 | +|---|------|--------|------| +| 1 | 购买提交流程失效 | **P0** | GET→POST 机制错误 + spec 字段格式错误 | +| 2 | 缩放时舞台不跟随 | **P1** | DOM 结构导致 transform 不共享 | +| 3 | spec 加载问题(已回滚) | **P1** | loadSoldSeats() 是空 stub + 需 sold_seats API | +| 4 | 商品详情/图片加载 | **P2** | 模板未引入内容组件 | + +**新发现**: +| # | 问题 | 优先级 | +|---|------|--------| +| 5 | GetGoodsViewData() 只返回第一个场次 | **P2 潜在** | + +--- + +## Issue 1(P0):购买提交流程失效 + +### 根因(三层叠加) + +**第一层(致命)**:`location.href` 产生 GET,但 `Buy::Index()` 只在 POST 时调用 `BuyDataStorage()`。 + +```php +// Buy.php:58-61 +public function Index() { + if($this->data_post) { + BuyService::BuyDataStorage($user_id, $this->data_post); // ← POST 才执行 + return MyRedirect(MyUrl('index/buy/index')); + } else { + $buy_data = BuyService::BuyDataRead($user_id); // GET → 读 session → 空 + } +} +``` + +→ `goods_params` URL 参数从未被读取 → `BuyDataStorage` 未被调用 → buy 确认页收不到数据 → "商品数据为空"。 + +**第二层(严重)**:字段名 `goods_params` vs 期望的 `goods_data`。 + +**第三层(中等)**:spec 格式不匹配: +- 当前:`spec_base_id: int`(直接传 ID) +- ShopXO:`spec: [{type, value}]` 字符串匹配 GoodsSpecValue 表 + +### ShopXO Buy 链路完全支持多座位合并下单 + +**ShopXO 原生能力验证**: +- `BuyService::BuyGoods` 第86行:`foreach($params['goods_data'] as $v)` — 原生遍历多 SKU +- `BuyService::OrderInsertHandle` 第773行:`'extension_data' => json_encode($v['order_base']['extension_data'])` — 原生写入 extension_data +- `vr_tickets` install.sql 已有:`real_name`, `phone`, `id_card` 字段 ✅ +- `TicketService::issueTicket()` 第141行:从 `$order['extension_data']` 读取观演人 ✅ + +### 正确修复方案(只需改 submit()) + +```javascript +// var self = this; — 原始代码第6行已有此声明,确保 submit() 上方作用域有 var self = this +submit: function() { + var self = this; // 如作用域内已有则忽略此行 + + // 1. 收集观演人 + var inputs = document.querySelectorAll('#attendeeList input'); + var attendeeData = {}; + inputs.forEach(function(input) { + var idx = input.dataset.index; + var field = input.dataset.field; + if (!attendeeData[idx]) attendeeData[idx] = {}; + attendeeData[idx][field] = input.value; + }); + + // 2. 构建 ShopXO 原生 goods_data 格式 + // + // ⚠️ 【必须】order_base.extension_data 是 BuyService 隐含契约(第86行 $v['order_base']) + // 必须嵌套在 order_base 内!不能平铺在 goods_data 第一层! + // ⚠️ 【必须】直接传 JSON 字符串,不需要 base64 + // BuyService 第60行判断:!is_array($_POST['goods_data']) → json_decode() + // ShopXO 标准 buy/index.html 第871行也是直接输出 JSON 字符串,不 base64 + var goodsDataList = this.selectedSeats.map(function(seat, i) { + var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId; + return { + goods_id: self.goodsId, + spec: [{type: '$vr-座位号', value: seat.seatKey}], + stock: 1, + order_base: { // ← 必须嵌套!不能平铺! + extension_data: { + attendee: { + real_name: attendeeData[i]?.real_name || '', + phone: attendeeData[i]?.phone || '', + id_card: attendeeData[i]?.id_card || '' + } + } + } + }; + }); + + // 3. 隐藏表单 POST 到 Buy 链路 + // + // ⚠️ requestUrl 来自 PHP 模板注入(ticket_detail.html 第6行): + // var requestUrl = ''; + // 确认 Goods.php 在传给票务模板时已在 $assign 中注入 host_url + var form = document.createElement('form'); + form.method = 'POST'; + form.action = requestUrl + '?s=index/buy/index'; // 用模板注入的全局 requestUrl 变量 + var input = document.createElement('input'); + input.name = 'goods_data'; + input.value = JSON.stringify(goodsDataList); // 直接 JSON,BuyService 自动处理 + form.appendChild(input); + document.body.appendChild(form); + form.submit(); +} +``` + +**完整数据流**(ShopXO 原生,无需扩展): +``` +submit() POST goods_data(含 order_base.extension_data) + → Buy::Index → BuyDataStorage(user_id, data_post) [存入 session] + → 跳转确认页(GET)→ form hidden field 携带 goods_data + → Buy::Add → BuyGoods → OrderInsertHandle + → order.extension_data 写入 Order 表 + → 支付成功 → onOrderPaid → issueTicket() + → 从 $order['extension_data'] 读取观演人 → 写入 vr_tickets(real_name/phone/id_card) ✅ +``` + +--- + +## Issue 2(P1):缩放时舞台不跟随 + +### 根因 + +`.vr-stage` 和 `.vr-seat-rows` 是平级兄弟元素,transform 只作用于子树。 + +### 修复方案 + +```html +
+
+
舞 台
+
+
+
+``` + +```css +.vr-zoom-container { + display: flex; + flex-direction: column; + align-items: center; + transform-origin: center top; + transition: transform 0.2s ease; +} +``` + +缩放 JS 操作 `#zoomContainer` 的 `transform: scale()`,舞台和座位同步缩放。 + +--- + +## Issue 3(P1):spec 加载问题(已回滚) + +### 根因 + +- `loadSoldSeats()` 是空 TODO stub,无任何 AJAX 调用 +- 后端无 `sold_seats` API 端点 + +### 修复方案 + +**后端**:新增 `plugins/vr_ticket/index/soldSeats` API 端点 +``` +GET /?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=index&pluginsaction=soldSeats +Query: goods_id, spec_base_id +Response: {code: 0, data: {sold_seats: ["A_1", "A_2", "B_5"]}} +``` + +**前端**:`loadSoldSeats()` 调用该接口,标记 `.sold` class。 + +--- + +## Issue 4(P2):商品详情/图片加载 + +- `$goods['content']`:✅ 正常渲染 +- `$goods['images']`:⚠️ 数据存在但未使用 +- `.goods-detail-content` CSS:⚠️ 缺失 + +如需展示商品图片,在模板中添加图片渲染逻辑。如票务详情页不需要 ShopXO 商品内容,可降级为「确认不需要」。 + +--- + +## Issue 5(P2 潜在):GetGoodsViewData 只返回第一个场次 + +`SeatSkuService::GetGoodsViewData()` 第368行返回 `validConfigs[0]`,多场次商品只显示第一个场次。 + +### 修复方向 + +修改返回值格式为数组,前端根据选中场次索引读取对应数据。 + +--- + +## 第一性原则视角(修正后) + +1. **Issue 1 是「传输机制损坏」,不是「流程错误」**:Buy 链路完全正确,多 SKU 合并下单是 ShopXO 原生能力,不需要绕过。 + +2. **extension_data 存储完全在 ShopXO 生态内**:`order.extension_data` → `onOrderPaid` → `vr_tickets` 全链路原生打通,不需要新建表或扩展字段。 + +3. **`spec_base_id_map` 是性能缓存**:如果 `onOrderPaid` 能通过 seatKey(spec value 字符串)查询 spec_base_id,map 可以去掉。但保留是合理的优化。 + +4. **`onOrderPaid` 是座位唯一性权威**(未审计):在 Issue 1 修复部署前,必须验证此 Hook 是否正确实现了座位锁定(幂等 + FOR UPDATE)。这是防双售的核心。 + +5. **onOrderPaid spec 匹配存在潜在 bug(⚠️ 新增)**:`BatchGenerate` 写入 GoodsSpecValue.value 的格式是 `"{$venueName}-{$roomName}-{$char}-{$rowLabel}{$col}"`(如 "场馆A-放映室1-A-A3座"),而前端 seatKey 格式是 `"roomId_A_3"`,两者不匹配。`TicketService::issueTicket` 第57-77行通过 `type='$vr-座位号'` 匹配 GoodsSpecValue.value 的逻辑会失效。目前不影响功能是因为幂等靠 `seat_info` 字段(不需要 spec_base_id),但如果未来需要精确关联,此处需修复 value 写入格式或改为读 GoodsSpecBase.extends.seat_key。 + +6. **最小修复范围**:只需修改 `submit()` 函数(POST + 正确 goods_data 格式 + extension_data)。不需要重构 spec 系统,不需要新建表,不需要绕过 Buy 链路。 + +--- + +## 修复优先级 + +| 优先级 | Issue | 负责 | 修复说明 | +|--------|------|------|---------| +| P0 | Issue 1 submit() | FrontendDev | 改隐藏表单 POST,正确构造 goods_data + extension_data | +| P1 | Issue 2 舞台缩放 | FrontendDev | 新增 zoom wrapper 容器 | +| P1 | Issue 3 spec 加载 | BackendArchitect | 新增 sold_seats API + 前端调用 | +| P2 | Issue 4 商品详情 | FrontendDev | 确认是否需要,补充 CSS | +| P2 | Issue 5 多场次 | BackendArchitect | GetGoodsViewData 返回数组格式 | + +--- + +## 附录:ShopXO Buy 链路关键代码索引 + +| 文件 | 行号 | 说明 | +|------|------|------| +| `Buy.php` | 58-61 | Index() — POST/GET 分支,BuyDataStorage/BuyDataRead | +| `BuyService.php` | 51-62 | BuyGoods — goods_data 参数校验 + base64/JSON 解码 | +| `BuyService.php` | 86 | foreach($params['goods_data'] as $v) — 多 SKU 原生遍历 | +| `BuyService.php` | 104-109 | GoodsSpecDetail 调用 — spec.value 字符串匹配 | +| `BuyService.php` | 773 | OrderInsertHandle — extension_data 写入 order 表 | +| `BuyService.php` | 1932 | BuyDataStorage — 21600s TTL session 缓存 | +| `Buy/index.html` | 871 | 确认表单 hidden goods_data field(原生包含) | +| `TicketService.php` | 141-143 | issueTicket — 从 $order['extension_data'] 读观演人 | +| `SeatSkuService.php` | ~368 | GetGoodsViewData — validConfigs[0] 多场次 Bug | + +--- + +*VR 演唱会票务小程序 Phase 2 技术评估 — Council 协作完成,2026-04-21 修正版* diff --git a/docs/FULL_PLAN.md b/docs/FULL_PLAN.md new file mode 100644 index 0000000..7fc5ada --- /dev/null +++ b/docs/FULL_PLAN.md @@ -0,0 +1,680 @@ +# VR 演唱会票务小程序 — 完整实现文档 + +> 最后更新:2026-04-21 +> 用途:给任意 agent 独立阅读并推进事务 +> 仓库:`http://xmhome.ow-my.com:3000/sileya-ai/vr-shopxo-plugin` +> 本地路径:`/Users/bigemon/WorkSpace/vr-shopxo-plugin` +> ShopXO 容器:localhost:10000(Web)/ localhost:10001(MySQL)/ localhost:9000(PHP-FPM) + +--- + +## 一、项目概览 + +### 1.1 目标产品 + +VR 演唱会票务微信小程序。用户选座 → 填观演人 → 微信支付 → 获取电子票二维码 → 现场扫码核销。 + +### 1.2 技术栈 + +- **前端**:原生 HTML + CSS + JS(无框架),商品详情页使用 `ticket_detail.html` +- **后端**:ShopXO(ThinkPHP 8)插件 `vr_ticket` +- **数据库**:ShopXO MySQL,表前缀 `vrt_` +- **微信支付**:ShopXO 原生微信支付 + +### 1.3 核心表结构 + +| 表名 | 用途 | +|------|------| +| `vrt_vr_seat_templates` | 座位模板(座位图画法 + 绑定分类) | +| `vrt_vr_tickets` | 电子票(order_id + seat_info + real_name/phone/id_card) | +| `vrt_vr_verifiers` | 核销员 | +| `vrt_vr_verifications` | 核销记录 | +| `vrt_vr_audit_log` | 操作审计日志 | + +ShopXO 原生表: +| 表名 | 用途 | +|------|------| +| `goods` | 商品(含 `vr_goods_config` 扩展 JSON 字段) | +| `goods_spec_base` | SKU(库存/价格),`extends` 含 `seat_key` | +| `goods_spec_value` | spec 维度值(4维度:场馆/分区/座位号/场次) | +| `order` | 订单(含 `extension_data` JSON 字段) | +| `order_detail` | 订单明细 | + +### 1.4 spec 四维度说明 + +ShopXO 每个 GoodsSpecBase(SKU)由 4 个 spec type-value 联合确定: + +| type | 说明 | 示例 value | +|------|------|-----------| +| `$vr-场馆` | 场馆名 | `VR 体验馆` | +| `$vr-分区` | 场馆+演播厅+分区 | `VR 体验馆-1号演播厅-VIP区` | +| `$vr-座位号` | 完整路径座位名 | `VR 体验馆-1号演播厅-VIP区-A-1排3座` | +| `$vr-场次` | 场次时间 | `15:00-16:59` | + +**注意**:spec value 是**完整路径字符串**,不是 `"A_3"` 或 `"roomId_A_3"` 这种短格式。 + +### 1.5 座位的唯一标识(seatKey) + +前后端共用同一个格式:`{roomId}_{rowLabel}_{colNum}` +- `roomId`:`rooms[].id`,来自 `vr_goods_config.template_snapshot.rooms` +- `rowLabel`:座位行标签,`A`/`B`/`C`(由 map 行索引计算:`String.fromCharCode(65 + rowIndex)`) +- `colNum**:列号(从 1 开始:`colIndex + 1`) + +示例:`"room_001_A_3"` = room_001 的 A排 第3列 + +seatKey 对应 `GoodsSpecBase.extends.seat_key`,用于关联 GoodsSpecBase 和前端座位 DOM。 + +--- + +## 二、现状与已知问题 + +### Phase 0/1 完成情况 + +✅ `Goods.php` 判断 `item_type='ticket'` → 渲染 `ticket_detail.html` +✅ `ticket_detail.html` 座位图渲染 + 选座 JS + 观演人表单 +✅ `SeatSkuService::GetGoodsViewData()` 返回座位图数据 +✅ `TicketService::onOrderPaid()` 支付成功后生成 `vr_tickets` +✅ 4 个后台管理控制器(座位模板/票/核销员/核销记录) +✅ 基础防超卖幂等保护 + +### Phase 2 待修复问题(源自 Council 评估 + 大头确认) + +| # | 问题 | 优先级 | 状态 | +|---|------|--------|------| +| Issue 1 | 购买提交流程失效(GET→POST 机制错误 + spec 格式错误 + 缺 seatSpecMap) | **P0** | 待修复 | +| Issue 2 | 缩放时舞台不跟随 | **P1** | 待修复 | +| Issue 3 | spec 加载(loadSoldSeats 空 stub + 无 sold_seats API) | **P1** | 待修复 | +| Issue 4 | 商品详情/图片加载 | **P2** | 待修复 | +| Issue 5 | GetGoodsViewData 只返回第一个场次 | **P2** | 待修复 | + +**核心问题说明**(Issue 1 P0): +Issue 1 不是单一 bug,而是三层叠加问题: +1. `submit()` 用 `location.href`(GET),ShopXO `Buy::Index` 只在 POST 时调用 `BuyDataStorage` +2. spec 格式错误:只传 1 维度而非 4 维度 +3. **最严重**:前端根本没有 seatSpecMap,无法把座位 DOM 映射到正确的 GoodsSpecBase + +--- + +## 三、商品118 vr_goods_config(原始数据库数据) + +存储位置:`goods` 表 `vr_goods_config` JSON 字段(商品 ID = 118) + +这是从数据库直接读取的原始数据,**所有其他数据结构均派生于此**。 + +```json +[ + { + "version": 3.0, + "template_id": 4, + "selected_rooms": ["room_001", "room_002"], + "selected_sections": { + "room_001": ["A", "B"], + "room_002": ["A"] + }, + "sessions": [ + { "start": "15:00", "end": "16:59" }, + { "start": "18:00", "end": "20:59" } + ], + "template_snapshot": { + "venue": { + "name": "VR 演唱会馆", + "address": "北京市朝阳区建国路88号", + "location": { "lng": "116.45792", "lat": "39.90745" }, + "images": [ + "/static/attachments/202603/venue_001.jpg", + "/static/attachments/202603/venue_002.jpg" + ] + }, + "rooms": [ + { + "id": "room_001", + "name": "1号演播厅", + "map": [ + "AAAAA_____BBBBB", + "AAAAA_____BBBBB", + "AAAAA_____BBBBB", + "CCCCCCCCCCCCCCC", + "CCCCCCCCCCCCCCC" + ], + "sections": [ + { "char": "A", "name": "VIP区", "price": 380, "color": "#f06292" }, + { "char": "B", "name": "看台区", "price": 180, "color": "#4fc3f7" }, + { "char": "C", "name": "普通区", "price": 80, "color": "#81c784" } + ], + "seats": { + "A": { "char": "A", "name": "VIP区", "price": 380, "color": "#f06292" }, + "B": { "char": "B", "name": "看台区", "price": 180, "color": "#4fc3f7" }, + "C": { "char": "C", "name": "普通区", "price": 80, "color": "#81c784" } + } + }, + { + "id": "room_002", + "name": "2号演播厅(副厅)", + "map": [ + "DDDDDDD", + "DDDDDDD", + "EEEEEEE" + ], + "sections": [ + { "char": "D", "name": "互动区", "price": 280, "color": "#ffb74d" }, + { "char": "E", "name": "站票区", "price": 50, "color": "#90a4ae" } + ], + "seats": { + "D": { "char": "D", "name": "互动区", "price": 280, "color": "#ffb74d" }, + "E": { "char": "E", "name": "站票区", "price": 50, "color": "#90a4ae" } + } + } + ] + } + } +] +``` + +### 字段说明 + +| 字段 | 含义 | 前端是否可用 | +|------|------|------------| +| `version` | 协议版本(当前 3.0) | ❌ 内部使用 | +| `template_id` | 关联座位模板 ID | ❌ 内部使用 | +| `selected_rooms` | 启用的房间 ID 列表 | ✅ 用于初始化 | +| `selected_sections` | 每个房间选中的分区字符 | ✅ 用于默认高亮 | +| `sessions` | 场次列表(start/end) | ✅ **场次选择器数据源** | +| `template_snapshot.venue` | 场馆信息 | ✅ Banner/详情展示 | +| `template_snapshot.rooms[].id` | 房间唯一 ID | ✅ **seatKey 构造必需** | +| `template_snapshot.rooms[].map` | 座位图字符矩阵 | ✅ **座位图渲染必需** | +| `template_snapshot.rooms[].sections` | 分区列表(char→name/price/color) | ✅ **图例+分区选择器** | +| `template_snapshot.rooms[].seats` | char→座位属性映射 | ✅ **查座位详情** | + +### map 格式说明 + +``` +"AAAAA_____BBBBB" + ↓分解为字符数组↓ +['A','A','A','A','A','_','_','_','_','_','B','B','B','B','B'] + ←VIP区×5→←空位×5→←看台区×5→ + +字符含义: + A/B/C/D/E = 座位(通过 rooms[i].seats[char] 查属性) + '_' / '-' = 空位(不渲染座位) + 其他非字母 = 不渲染 +``` + +### rooms.seats 与 rooms.sections 的关系 + +同一个 char 在不同房间代表不同分区: +- `room_001` 的 `A` = VIP区(红色,380元) +- `room_002` 的 `D` = 互动区(橙色,280元) + +**分区信息在 `sections[]` 里**,不要直接用 char 本身判断分区名称或价格。 + +--- + +## 四、后端注入的模板数据 + +`Goods.php` 在渲染 `ticket_detail.html` 前,通过 `SeatSkuService::GetGoodsViewData()` 向模板注入以下变量: + +```php +MyViewAssign([ + 'vr_seat_template' => $viewData['vr_seat_template'], // 座位图原始数据 + 'goods_spec_data' => $viewData['goods_spec_data'], // 场次列表 + 'seatSpecMap' => $viewData['seatSpecMap'] ?? [], // 【待新增】座位→4维spec映射 +]); +``` + +模板中接收方式: +```javascript +var vrSeatTemplate = ; +var goodsSpecData = ; +var seatSpecMap = ; +``` + +### 4.1 vr_seat_template(透传 template_snapshot) + +```javascript +{ + venue: { + name: "VR 演唱会馆", + address: "北京市朝阳区建国路88号", + location: { lng: "116.45792", lat: "39.90745" }, + images: ["/static/attachments/202603/venue_001.jpg"] + }, + rooms: [ + { + id: "room_001", + name: "1号演播厅", + map: ["AAAAA_____BBBBB", "AAAAA_____BBBBB", "AAAAA_____BBBBB", "CCCCCCCCCCCCCCC", "CCCCCCCCCCCCCCC"], + sections: [ + { char: "A", name: "VIP区", price: 380, color: "#f06292" }, + { char: "B", name: "看台区", price: 180, color: "#4fc3f7" }, + { char: "C", name: "普通区", price: 80, color: "#81c784" } + ], + seats: { /* 同第二章 seats */ } + }, + { + id: "room_002", + name: "2号演播厅(副厅)", + map: ["DDDDDDD", "DDDDDDD", "EEEEEEE"], + sections: [ /* 同第二章 sections */ ], + seats: { /* 同第二章 seats */ } + } + ], + sessions: [ + { start: "15:00", end: "16:59" }, + { start: "18:00", end: "20:59" } + ], + selectedRooms: ["room_001", "room_002"], + selectedSections: { "room_001": ["A", "B"], "room_002": ["A"] } +} +``` + +### 4.2 goods_spec_data(场次列表) + +```javascript +// 来源:goods.vr_goods_config.sessions + ShopXO GoodsSpecBase.price +[ + { spec_id: 2001, spec_name: "15:00-16:59", price: 380, start: "15:00", end: "16:59" }, + { spec_id: 2002, spec_name: "18:00-20:59", price: 280, start: "18:00", end: "20:59" } +] +``` + +### 4.3 seatSpecMap(待新增,核心数据结构) + +**来源**:`GetGoodsViewData()` 查询 `GoodsSpecBase` + `GoodsSpecValue` + `GoodsSpecBase.extends`,动态构建 + +**用途**:前端选中座位后,查表获取该座位的完整 4 维 spec 数组 + 对应 GoodsSpecBase + +```javascript +// key 格式:{roomId}_{rowLabel}_{colNum} +// 示例:room_001_A_3 = room_001 的 A排 第3列 + +{ + "room_001_A_1": { + spec_base_id: 10001, + price: 380, + inventory: 1, // 0 = 已售,1 = 可购 + rowLabel: "A", + colNum: 3, + roomId: "room_001", + section: { char: "A", name: "VIP区", color: "#f06292" }, + // === 4维 spec 数组(submit() 时直接使用)=== + spec: [ + { type: "$vr-场馆", value: "VR 演唱会馆" }, + { type: "$vr-分区", value: "VR 演唱会馆-1号演播厅-VIP区" }, + { type: "$vr-座位号", value: "VR 演唱会馆-1号演播厅-VIP区-A-1排1座" }, + { type: "$vr-场次", value: "15:00-16:59" } + ] + }, + "room_001_A_2": { /* 同上,A排第2座 */ }, + "room_001_B_8": { spec_base_id: 10025, price: 180, inventory: 0, /* 已售 */ }, + "room_002_D_1": { spec_base_id: 20001, price: 280, inventory: 1, /* 互动区 */ }, + // ...每个可购座位一行 +} +``` + +#### seatSpecMap 生成逻辑(GetGoodsViewData 中实现) + +```php +// 1. 查询所有有效 GoodsSpecBase(含 extends.seat_key) +$specs = Db::name('GoodsSpecBase') + ->where('goods_id', $goodsId) + ->where('inventory', '>', 0) // 只取有库存的 + ->select(); + +// 2. 查询对应的 GoodsSpecValue(4个维度的 type/value) +$specIds = array_column($specs->toArray(), 'id'); +$specValues = Db::name('GoodsSpecValue') + ->whereIn('goods_spec_base_id', $specIds) + ->select(); + +// 3. 按 spec_base_id 分组,构建 4维 spec 数组 +$specByBaseId = []; +foreach ($specValues as $sv) { + $specByBaseId[$sv['goods_spec_base_id']][] = [ + 'type' => $sv['type'], + 'value' => $sv['value'], + ]; +} + +// 4. 构建 seatSpecMap +$seatSpecMap = []; +foreach ($specs as $spec) { + $extends = json_decode($spec['extends'] ?? '{}', true); + $seatKey = $extends['seat_key'] ?? ''; + if (empty($seatKey)) continue; + + $seatSpecMap[$seatKey] = [ + 'spec_base_id' => intval($spec['id']), + 'price' => floatval($spec['price']), + 'inventory' => intval($spec['inventory']), + 'spec' => $specByBaseId[$spec['id']] ?? [], + ]; +} +``` + +--- + +## 五、产品形态:多维度 spec 选择器 + 多座位选择 + +### 5.1 界面结构 + +``` +┌─────────────────────────────────────────────────────┐ +│ 顶部 Banner(venue.images) │ +│ │ +│ 场次选择 │ +│ [●15:00-16:59 ¥380] [ 18:00-20:59 ¥280 ] │ +│ │ +│ 场馆/分区选择(spec 选择器交互) │ +│ [●1号演播厅] [ 2号演播厅 ] │ +│ [●VIP区380] [ 看台区180 ] [ 普通区80 ] │ +│ │ +│ ─────────── 座位图(多选)───────────────────── │ +│ 舞 台 │ +│ A排 [■■■■■] ← 可选(VIP,红色) │ +│ B排 [■■■■■] ← 可选(看台,蓝色) │ +│ C排 [灰掉] ← 不在当前分区 │ +│ │ +│ 图例:[■]可选 [██]已售 [░░]不可选 │ +│ │ +│ ─────────── 观演人表单 ───────────────────────── │ +│ 第1张票:张三 138****000 身份证(选填) │ +│ 第2张票:李四 139****111 身份证(选填) │ +│ │ +│ ─────────── 底部价格栏 ───────────────────────── │ +│ 已选 2 座,合计 ¥760 [提交订单] │ +└─────────────────────────────────────────────────────┘ +``` + +### 5.2 spec 选择器交互(参考原生 ShopXO spec 选择器行为) + +用户切换场次/场馆/分区时,未在当前选择分支内的座位自动变灰/隐藏: + +``` +切换场次 → 重置场馆/分区/座位 → 用 seatSpecMap 过滤出该场次所有座位 +切换场馆 → 重置分区/座位 → 用 seatSpecMap 过滤出该场馆所有座位 +切换分区 → 只灰掉其他分区座位 → 用 seatSpecMap 过滤出该分区座位 +点击座位 → 复选/取消 → 更新 selectedSeats[] +``` + +```javascript +// 过滤函数 +function filterSeatMap(currentSession, currentVenueName, currentSectionChar) { + Object.entries(seatSpecMap).forEach(function([seatKey, seatInfo]) { + var spec = seatInfo.spec; // 4维数组 + + var hasSession = spec.some(function(s) { + return s.type === '$vr-场次' && s.value === currentSession; + }); + var hasVenue = spec.some(function(s) { + return s.type === '$vr-场馆' && s.value.includes(currentVenueName); + }); + var hasSection = !currentSectionChar || spec.some(function(s) { + return s.type === '$vr-分区' && s.value.includes(currentSectionChar); + }); + var isAvailable = seatInfo.inventory > 0; + + var seatEl = document.querySelector('[data-seat-key="' + seatKey + '"]'); + if (!seatEl) return; + + if (hasSession && hasVenue && hasSection) { + seatEl.classList.toggle('sold', !isAvailable); + seatEl.classList.toggle('disabled', false); + } else { + seatEl.classList.add('disabled'); + seatEl.classList.remove('sold'); + } + }); +} +``` + +### 5.3 从 vr_seat_template 渲染座位图 + +```javascript +function renderSeatMap() { + var rooms = vrSeatTemplate.rooms; + + rooms.forEach(function(room) { + room.map.forEach(function(rowStr, rowIndex) { + var rowLabel = String.fromCharCode(65 + rowIndex); // 0→A, 1→B + var chars = rowStr.split(''); // 逐字符(PHP mb_str_split 兼容) + + chars.forEach(function(char, colIndex) { + if (char === '_' || char === '-') { + // 渲染空白格子 + return; + } + var colNum = colIndex + 1; // 列号从 1 开始 + var seatKey = room.id + '_' + rowLabel + '_' + colNum; // "room_001_A_3" + var seatInfo = room.seats[char]; // 查到座位属性 + + // 创建座位 DOM 元素 + var seatEl = document.createElement('div'); + seatEl.className = 'vr-seat'; + seatEl.dataset.seatKey = seatKey; + seatEl.dataset.rowLabel = rowLabel; + seatEl.dataset.colNum = colNum; + seatEl.dataset.char = char; + seatEl.dataset.roomId = room.id; + seatEl.style.backgroundColor = seatInfo.color; + seatEl.textContent = rowLabel + colNum; + + // 点击事件:选座/取消 + seatEl.addEventListener('click', function() { toggleSeat(seatEl, seatKey); }); + + document.getElementById('room_' + room.id + '_seats').appendChild(seatEl); + }); + }); + }); +} +``` + +--- + +## 六、submit() 正确实现(P0 Issue 1 核心修复) + +### 6.1 当前错误代码 + +原始 `ticket_detail.html` 中的 `submit()` 使用 `location.href`(GET),ShopXO `Buy::Index` 只在 POST 时存储数据,导致购买流程失效。 + +### 6.2 修复后的 submit() + +```javascript +// var self = this; — 原始代码第6行已有此声明 +submit: function() { + var self = this; + + // 1. 收集观演人 + var inputs = document.querySelectorAll('#attendeeList input'); + var attendeeData = []; + inputs.forEach(function(input) { + var idx = parseInt(input.dataset.index); + if (!attendeeData[idx]) attendeeData[idx] = {}; + attendeeData[idx][input.dataset.field] = input.value; + }); + + // 2. 验证已选座位和观演人数量匹配 + if (this.selectedSeats.length === 0) { + alert('请至少选择一个座位'); + return; + } + if (this.selectedSeats.length !== attendeeData.length) { + alert('座位数与观演人信息数量不匹配'); + return; + } + + // 3. 构建 ShopXO 原生 goods_data 格式 + // + // ⚠️ 【必须】order_base.extension_data 是 BuyService 隐含契约(BuyService.php 第86行) + // 必须嵌套在 order_base 内!不能平铺在 goods_data 第一层! + // + // ⚠️ 【必须】直接传 JSON 字符串,不需要 base64 + // BuyService.php 第60行:!is_array($_POST['goods_data']) → json_decode() + // ShopXO 标准 buy/index.html 第871行也是直接输出 JSON 字符串,不 base64 + // + // ⚠️ 【必须】spec 是完整的 4维数组,不是 1 维! + // 从 seatSpecMap[seatKey].spec 读取,不要自己构造 + // + // ⚠️ requestUrl 来自 PHP 模板注入:var requestUrl = ''; + // 确认 Goods.php 在传给票务模板时已在 $assign 中注入 host_url + // + var goodsDataList = this.selectedSeats.map(function(seat, i) { + var seatInfo = seatSpecMap[seat.seatKey]; // 从注入的 seatSpecMap 查 + if (!seatInfo) { + console.error('seatSpecMap missing key:', seat.seatKey); + return null; + } + return { + goods_id: self.goodsId, + spec: seatInfo.spec, // 4维完整 spec 数组!从 seatSpecMap 来! + stock: 1, + order_base: { // ← 必须嵌套!不能平铺! + extension_data: { + attendee: { + real_name: attendeeData[i]?.real_name || '', + phone: attendeeData[i]?.phone || '', + id_card: attendeeData[i]?.id_card || '' + } + } + } + }; + }).filter(Boolean); + + // 4. 过滤无效座位 + if (goodsDataList.length === 0) { + alert('座位信息无效,请重新选择'); + return; + } + + // 5. 隐藏表单 POST 到 ShopXO Buy 链路 + var form = document.createElement('form'); + form.method = 'POST'; + form.action = requestUrl + '?s=index/buy/index'; + document.body.appendChild(form); + + var input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'goods_data'; + input.value = JSON.stringify(goodsDataList); // 直接 JSON,BuyService 自动处理 + form.appendChild(input); + + form.submit(); // POST → Buy::Index → BuyDataStorage → 跳转确认页 +} +``` + +### 6.3 ShopXO Buy 链路完整数据流(已验证可用) + +``` +submit() POST goods_data(含 4维spec + extension_data) + │ + ├─→ Buy::Index (POST) → BuyDataStorage(user_id, data_post) [存入 session, TTL=21600s] + │ ↑ + │ goods_data 是数组,json_encode 存入 session + │ + └─→ 跳转 Buy::Index (GET) → BuyDataRead → 显示确认页 + │ + ┌───────────────────────────────┘ + │ + └─→ form submit → Buy::Add → BuyService::OrderInsert($params) + │ + BuyTypeGoodsList($params) → BuyGoods($params) + │ + foreach($params['goods_data'] as $v) ← 多 SKU 原生遍历 + │ + GoodsSpecificationsHandle($v) → GoodsSpecDetail() + │ 4维 type-value 匹配 GoodsSpecValue 表 + ↓ + OrderInsertHandle($order_data) + │ + BuyService.php 第773行: + 'extension_data' => json_encode($v['order_base']['extension_data']) + │ + Db::name('order')->insertGetId($order) ← extension_data 写入 Order 表 + │ + 微信支付... + │ + ┌────────────────────────────────┘ + │ + └─→ 支付成功 → Hook: plugins_service_order_pay_success_handle_end + │ + TicketService::onOrderPaid($params) + │ + Db::name('order')->find($order_id) + ↓ + json_decode($order['extension_data']) → 观演人信息 + ↓ + foreach($order_goods as $og) { + issueTicket($order, $og) // 幂等保护:seat_info 查重 + } + │ + Db::name('vr_tickets')->insertGetId([ + 'order_id' => $order['id'], + 'seat_info' => $spec_name, + 'real_name' => $attendee['real_name'], + 'phone' => $attendee['phone'], + 'id_card' => $attendee['id_card'], + 'ticket_code'=> $uuid, + 'qr_data' => AES加密(payload), + ]); +``` + +--- + +## 七、完整修复清单 + +| 优先级 | Issue | 任务 | 依赖 | 负责 | +|--------|-------|------|------|------| +| **P0** | Issue 1 | 重构 `GetGoodsViewData()` 新增 `seatSpecMap` | 后端 | BackendArchitect | +| **P0** | Issue 1 | 前端 JS 用 `seatSpecMap` 替代 `specBaseIdMap` | P0 前置 | FrontendDev | +| **P0** | Issue 1 | 修复 `submit()`:GET→POST + 正确 4维 spec 数组 | P0 前置 | FrontendDev | +| **P0** | Issue 1 | Goods.php `MyViewAssign` 加入 `seatSpecMap` | P0 前置 | BackendArchitect | +| **P1** | Issue 1 | 实现场次/场馆/分区 spec 选择器 UI + `filterSeatMap()` | P0 前置 | FrontendDev | +| **P1** | Issue 1 | `selectSession()` / `selectVenue()` / `selectSection()` 联动逻辑 | P1 前置 | FrontendDev | +| **P1** | Issue 2 | 缩放时舞台跟随(zoom wrapper 方案) | 无 | FrontendDev | +| **P1** | Issue 3 | 新增 `sold_seats` API 端点 | 无 | BackendArchitect | +| **P1** | Issue 3 | 前端 `loadSoldSeats()` 调用 API + 标记 `.sold` | P1 前置 | FrontendDev | +| **P2** | Issue 4 | 商品详情图片展示(确认需求,补充 CSS) | 无 | FrontendDev | +| **P2** | Issue 5 | `GetGoodsViewData()` 返回数组而非 `validConfigs[0]` | 无 | BackendArchitect | +| **P2** | 审计 | 验证 `onOrderPaid` spec 匹配 + 幂等保护(FOR UPDATE) | 无 | BackendArchitect | + +--- + +## 八、关键代码索引 + +| 文件 | 行号 | 说明 | +|------|------|------| +| `Buy.php` | 58-61 | Index() — POST/GET 分支,BuyDataStorage/BuyDataRead | +| `BuyService.php` | 51-62 | BuyGoods — goods_data 参数校验 + JSON decode(非 base64) | +| `BuyService.php` | 86 | `foreach($params['goods_data'] as $v)` — 多 SKU 原生遍历 | +| `BuyService.php` | 104-109 | GoodsSpecDetail — 4维 type-value 匹配 GoodsSpecValue | +| `BuyService.php` | 773 | `extension_data => json_encode($v['order_base']['extension_data'])` | +| `BuyService.php` | 1932 | BuyDataStorage — 21600s TTL session 缓存 | +| `buy/index.html` | 871 | 原生 form hidden goods_data field(JSON 字符串,非 base64) | +| `TicketService.php` | 21-22 | Hook: `plugins_service_order_pay_success_handle_end` → `onOrderPaid` | +| `TicketService.php` | 141-143 | `issueTicket` — 从 `$order['extension_data']` 读观演人 | +| `SeatSkuService.php` | 40-45 | `SPEC_DIMS = ['$vr-场馆','$vr-分区','$vr-座位号','$vr-场次']` | +| `SeatSkuService.php` | ~368 | GetGoodsViewData — validConfigs[0] 多场次 Bug | +| `SeatSkuService.php` | ~131 | BatchGenerate — 4维 spec value 构建(完整路径字符串) | +| `Hook.php` | 21-22 | `plugins_service_order_pay_success_handle_end` → TicketService::onOrderPaid | + +--- + +## 九、第一性原则(设计决策记录) + +1. **座位唯一性靠 ShopXO 原生 inventory**:每个 GoodsSpecBase 的 `inventory=1`,ShopXO 在 `OrderInsertHandle` 中扣库存,防超卖由 ShopXO 原生保证,不需要自己实现锁。 + +2. **`spec_base_id_map` 是性能缓存**:理想情况下 `onOrderPaid` 通过 `seat_key` 查询 GoodsSpecBase 即可,不需要 map 字段。但保留是合理的优化。 + +3. **`extension_data` 存储完全在 ShopXO 生态内**:不新建表,不扩展 ShopXO 字段,`order.extension_data` → `onOrderPaid` → `vr_tickets` 全链路 ShopXO 原生。 + +4. **`onOrderPaid` spec 匹配存在潜在 bug**(⚠️ 未来需关注): + - `BatchGenerate` 写入 GoodsSpecValue.value 格式:`"VR 演唱会馆-1号演播厅-VIP区-A-1排3座"`(长路径字符串) + - 前端 seatKey 格式:`"room_001_A_3"`(短格式) + - 两者不匹配,`issueTicket` 第57-77行的反向 spec 查找会失效 + - 目前不影响功能(幂等靠 `seat_info` 字段,不依赖 spec_base_id) + - 未来如需精确关联,需修复 BatchGenerate 的 value 写入格式 + +5. **最小修复原则**:Issue 1 的修复只需改 `submit()` 函数(POST + 正确 4维 spec 格式 + extension_data)。不需要重构 spec 系统,不需要绕过 Buy 链路。 + +--- + +*本文档为 vr-shopxo-plugin Phase 2 完整实现文档,Agent 可独立阅读并推进事务。* diff --git a/docs/PLAN_PHASE3_EXECUTION.md b/docs/PLAN_PHASE3_EXECUTION.md new file mode 100644 index 0000000..e83073c --- /dev/null +++ b/docs/PLAN_PHASE3_EXECUTION.md @@ -0,0 +1,513 @@ +# Phase 3 前端执行计划 + +> 日期:2026-04-21 | 状态:✅ 已完成 +> 关联:PLAN_PHASE3_FRONTEND.md + Issue #17 +> 策略:谨慎保守,稳扎稳打 + +--- + +## 一、目标 + +**1 天内上线可演示的多座位下单 Demo**,验证购物车路线可行性。 + +--- + +## 二、现状盘点 + +| 文件 | 当前状态 | 问题 | +|------|---------|------| +| `ticket_detail.html` | Plan A 代码有 bug | `submit()` URL 编码只传第一座、`selectSession()` 未重置座位 | +| `ticket_detail.html` | 桩代码 | `loadSoldSeats()` 无实现 | +| `ticket_detail.html` | 内联样式 | CSS 未分离,色值硬编码 | + +--- + +## 三、执行步骤 + +### Step 1:修复 `submit()` 函数(P0) + +**文件**:`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` + +**改动**:替换 `submit()` 函数,改走购物车 API。 + +```javascript +submit: function() { + // 1. 前置检查 + if (this.selectedSeats.length === 0) { + alert('请先选择座位'); + return; + } + if (!this.userId) { + alert('请先登录'); + location.href = this.requestUrl + '?s=index/user/logininfo'; + return; + } + + // 2. 收集观演人信息 + var inputs = document.querySelectorAll('#attendeeList input'); + var attendeeData = {}; + inputs.forEach(function(input) { + var idx = input.dataset.index; + var field = input.dataset.field; + if (!attendeeData[idx]) attendeeData[idx] = {}; + attendeeData[idx][field] = input.value; + }); + + // 3. 构建 goodsParamsList + var self = this; + var goodsParamsList = this.selectedSeats.map(function(seat, i) { + var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId; + return { + goods_id: self.goodsId, + spec_base_id: parseInt(specBaseId) || 0, + stock: 1 + }; + }); + + // 4. 逐座提交到购物车(避免并发竞态,逐座串行提交) + function submitNext(index) { + if (index >= goodsParamsList.length) { + // 全部成功 → 跳转购物车 + location.href = self.requestUrl + '?s=index/cart/index'; + return; + } + + var params = goodsParamsList[index]; + $.post(__goods_cart_save_url__, params, function(res) { + if (res.code === 0 && res.data && res.data.id) { + submitNext(index + 1); + } else { + alert('座位 [' + self.selectedSeats[index].label + '] 提交失败:' + (res.msg || '库存不足')); + } + }).fail(function() { + alert('网络错误,请重试'); + }); + } + + submitNext(0); +} +``` + +**保守策略**: +- 使用**串行** `submitNext()` 递归,避免并发竞态 +- 每个座位单独请求,成功后提交下一个 +- 任意失败立即中断并弹窗提示 + +**验收测试**: +- [ ] 选择 3 个座位 → 点击提交 → 购物车页显示 3 条商品 +- [ ] 座位 2 库存不足 → 弹窗提示,座位 1 不在购物车 + +--- + +### Step 2:修复场次切换状态重置(P0) + +**文件**:`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` + +**改动**:在 `selectSession()` 函数开头添加状态重置。 + +```javascript +selectSession: function(el) { + // 【新增】切换场次时重置已选座位 + this.selectedSeats = []; + + // 移除其他选中样式 + document.querySelectorAll('.vr-session-item').forEach(function(item) { + item.classList.remove('selected'); + }); + el.classList.add('selected'); + this.currentSession = el.dataset.specId; + this.sessionSpecId = el.dataset.specBaseId; + + // 隐藏座位图和观演人区域(等待渲染) + document.getElementById('seatSection').style.display = 'none'; + document.getElementById('selectedSection').style.display = 'none'; + document.getElementById('attendeeSection').style.display = 'none'; + + this.renderSeatMap(); + this.loadSoldSeats(); +} +``` + +**保守策略**: +- 重置后隐藏座位图和观演人区域,避免旧数据残留 +- 渲染完成后由 `updateSelectedUI()` 显示 + +**验收测试**: +- [ ] 选择场次 A → 选 2 个座位 → 切换场次 B → 确认已选座位清零 +- [ ] 切换回场次 A → 确认已选座位仍然清零(严格隔离) + +--- + +### Step 3:实现 `loadSoldSeats()`(P1) + +#### 3.1 后端接口 + +**文件**:`shopxo/app/plugins/vr_ticket/controller/Index.php` + +**新增方法**: + +```php +/** + * 获取场次已售座位列表 + * @method POST + * @param goods_id 商品ID + * @param spec_base_id 规格ID(场次) + * @return json {code:0, data:{sold_seats:['A_1','A_2','B_5']}} + */ +public function SoldSeats() +{ + // 鉴权 + if (!IsMobileLogin()) { + return json_encode(['code' => 401, 'msg' => '请先登录']); + } + + // 获取参数 + $goodsId = input('goods_id', 0, 'intval'); + $specBaseId = input('spec_base_id', 0, 'intval'); + + if (empty($goodsId) || empty($specBaseId)) { + return json_encode(['code' => 400, 'msg' => '参数错误']); + } + + // 查询已支付订单中的座位 + // 简化版:直接从已支付订单 item 的 extension_data 解析 + $orderService = new \app\service\OrderService(); + // 注意:此处需根据实际的 QR 票订单表结构查询 + + $soldSeats = []; + return json_encode(['code' => 0, 'data' => ['sold_seats' => $soldSeats]]); +} +``` + +**保守策略**: +- 第一版只返回空数组(不查数据库) +- 后续迭代再接入真实数据 + +#### 3.2 前端调用 + +**文件**:`shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` + +**改动 `loadSoldSeats()`**: + +```javascript +loadSoldSeats: function() { + if (!this.currentSession || !this.goodsId) return; + + var self = this; + $.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', { + goods_id: this.goodsId, + spec_base_id: this.sessionSpecId + }, function(res) { + if (res.code === 0 && res.data && res.data.sold_seats) { + res.data.sold_seats.forEach(function(seatKey) { + self.soldSeats[seatKey] = true; + }); + self.markSoldSeats(); + } + }); +}, + +markSoldSeats: function() { + var self = this; + document.querySelectorAll('.vr-seat').forEach(function(el) { + var seatKey = el.dataset.rowLabel + '_' + el.dataset.colNum; + if (self.soldSeats[seatKey]) { + el.classList.add('sold'); + } + }); +} +``` + +**验收测试**: +- [ ] 后端接口返回 `{"code":0,"data":{"sold_seats":["A_1","A_2"]}}` → A_1、A_2 标记为灰色已售 + +--- + +### Step 4:CSS 文件分离(P1) + +#### 4.1 新建 CSS 文件 + +**文件**:`shopxo/app/plugins/vr_ticket/static/css/ticket.css` + +**内容**(从 `ticket_detail.html` 的 ` -' : ''; ?> +' : ''; ?> + \ No newline at end of file diff --git a/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html b/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html index f4ea8e4..c93670d 100644 --- a/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html +++ b/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html @@ -1,128 +1,19 @@ - + +
-
-
+
+ +
+
+ +
@@ -136,7 +27,8 @@