514 lines
17 KiB
Markdown
514 lines
17 KiB
Markdown
# 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` 的 `<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 第一版返回空数组,后续迭代接入 |
|
||
|
||
---
|
||
|
||
## 六、验收测试总表
|
||
|
||
### 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
|
||
```
|
||
|
||
**建议**:
|
||
1. 先完成 Step 1 + Step 2,立即浏览器验证
|
||
2. Step 3 需要后端配合,可与前端并行准备
|
||
3. Step 4 可在 Step 1-2 验证通过后再做
|
||
4. Step 5 作为可选优化项
|