vr-shopxo-plugin/docs/PLAN_PHASE3_EXECUTION.md

514 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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