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