vr-shopxo-plugin/docs/PLAN_PHASE3_EXECUTION.md

514 lines
17 KiB
Markdown
Raw Permalink Normal View History

# 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 作为可选优化项