17 KiB
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。
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() 函数开头添加状态重置。
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
新增方法:
/**
* 获取场次已售座位列表
* @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():
loadSoldSeats: function() {
if (!this.currentSession || !this.goodsId) return;
var self = this;
$.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
goods_id: this.goodsId,
spec_base_id: this.sessionSpecId
}, function(res) {
if (res.code === 0 && res.data && res.data.sold_seats) {
res.data.sold_seats.forEach(function(seatKey) {
self.soldSeats[seatKey] = true;
});
self.markSoldSeats();
}
});
},
markSoldSeats: function() {
var self = this;
document.querySelectorAll('.vr-seat').forEach(function(el) {
var seatKey = el.dataset.rowLabel + '_' + el.dataset.colNum;
if (self.soldSeats[seatKey]) {
el.classList.add('sold');
}
});
}
验收测试:
- 后端接口返回
{"code":0,"data":{"sold_seats":["A_1","A_2"]}}→ A_1、A_2 标记为灰色已售
Step 4:CSS 文件分离(P1)
4.1 新建 CSS 文件
文件:shopxo/app/plugins/vr_ticket/static/css/ticket.css
内容(从 ticket_detail.html 的 <style> 块抽取):
/* 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
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() 或类似方法中添加:
/**
* 获取插件 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),保留注释占位:
<!-- 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 支持滚轮缩放 + 鼠标拖拽。
bindEvents: function() {
var wrapper = document.querySelector('.vr-seat-map-wrapper');
if (!wrapper) return;
var scale = 1;
var isDragging = false;
var startX, startY, translateX = 0, translateY = 0;
// 滚轮缩放
wrapper.addEventListener('wheel', function(e) {
e.preventDefault();
var delta = e.deltaY > 0 ? -0.1 : 0.1;
scale = Math.max(0.5, Math.min(3, scale + delta));
var inner = wrapper.querySelector('.vr-seat-rows');
if (inner) {
inner.style.transform = 'scale(' + scale + ') translate(' + translateX + 'px, ' + translateY + 'px)';
}
}, { passive: false });
// 拖拽平移
wrapper.addEventListener('mousedown', function(e) {
isDragging = true;
startX = e.clientX - translateX;
startY = e.clientY - translateY;
});
document.addEventListener('mousemove', function(e) {
if (!isDragging) return;
translateX = e.clientX - startX;
translateY = e.clientY - startY;
var inner = wrapper.querySelector('.vr-seat-rows');
if (inner) {
inner.style.transform = 'scale(' + scale + ') translate(' + translateX + 'px, ' + translateY + 'px)';
}
});
document.addEventListener('mouseup', function() {
isDragging = false;
});
}
验收测试:
- 滚轮向上滚动 → 座位图放大
- 滚轮向下滚动 → 座位图缩小
- 鼠标按住拖拽 → 座位图平移
四、文件清单
| 操作 | 文件 | 类型 |
|---|---|---|
| 修改 | shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html |
改 |
| 新建 | shopxo/app/plugins/vr_ticket/controller/Index.php 方法 |
改 |
| 新建 | shopxo/app/plugins/vr_ticket/static/css/ticket.css |
新 |
| 新建 | shopxo/app/plugins/vr_ticket/hook/ViewGoodsCss.php |
新 |
| 修改 | shopxo/app/plugins/vr_ticket/service/VrTicketService.php |
改 |
五、技术风险
| 风险 | 严重 | 缓解 |
|---|---|---|
购物车 CartSave 接口返回格式不一致 |
🔴 | Step 1 加 console.log(res) 临时调试 |
plugins_css_data 钩子注册方式不确定 |
🟡 | Step 4 前先查 ShopXO 文档确认 |
| 已售座位数据查询依赖订单表结构 | 🟡 | Step 3 第一版返回空数组,后续迭代接入 |
六、验收测试总表
P0(Step 1 + Step 2)
| # | 测试场景 | 预期结果 |
|---|---|---|
| 1 | 选择 3 个座位 → 提交 | 购物车页显示 3 条商品 |
| 2 | 座位 2 库存不足 | 弹窗提示,已选座位清零 |
| 3 | 选择场次 A → 选 2 座 → 切换场次 B | 已选座位清零,购买栏归零 |
| 4 | 切换回场次 A | 座位图重新渲染,无旧数据残留 |
P1(Step 3 + Step 4)
| # | 测试场景 | 预期结果 |
|---|---|---|
| 5 | SoldSeats() 返回 ["A_1","A_2"] |
A_1、A_2 标记灰色已售 |
| 6 | 访问 ticket_detail.html |
DevTools Network 可见 ticket.css 请求 |
| 7 | 页面各区块布局 | 与内联样式版本一致 |
P2(Step 5)
| # | 测试场景 | 预期结果 |
|---|---|---|
| 8 | 滚轮缩放 | 座位图平滑缩放(0.5x - 3x) |
| 9 | 鼠标拖拽 | 座位图平滑平移 |
七、执行顺序
Step 1 → Step 2 → Step 3 → Step 4 → Step 5
↑ ↑ ↑ ↑
P0 P0 P1 P1 P2
建议:
- 先完成 Step 1 + Step 2,立即浏览器验证
- Step 3 需要后端配合,可与前端并行准备
- Step 4 可在 Step 1-2 验证通过后再做
- Step 5 作为可选优化项