# 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` 的 `