vr-shopxo-plugin/docs/PLAN_PHASE3_EXECUTION.md

17 KiB
Raw Blame 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。

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 4CSS 文件分离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 第一版返回空数组,后续迭代接入

六、验收测试总表

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