Compare commits
8 Commits
main
...
council/Fr
| Author | SHA1 | Date |
|---|---|---|
|
|
db7f182975 | |
|
|
1f49b16405 | |
|
|
fa4640f86e | |
|
|
9ec1c5f53f | |
|
|
74a235e154 | |
|
|
3a174f3990 | |
|
|
c4aaca3244 | |
|
|
c1ca1efcfb |
|
|
@ -0,0 +1,158 @@
|
||||||
|
# F1 + F2 研究发现(FrontendDev 内部工作文件)
|
||||||
|
|
||||||
|
> 供 Round 2 Review 和 Task O1 汇总使用
|
||||||
|
> Q1 置信度:高 | Q4 置信度:高(依赖 Q2 结论)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q1:ShopXO 自定义模板最佳实践
|
||||||
|
|
||||||
|
### 核心结论
|
||||||
|
|
||||||
|
**现有 `ticket_detail.html` 的技术选型(原生 HTML+CSS+JS)已经是 ShopXO PHP 模板的最优解。** 不需要引入 Vue CDN 或 Tailwind 等额外框架。
|
||||||
|
|
||||||
|
### 理由
|
||||||
|
|
||||||
|
1. **ShopXO 模板引擎是 ThinkTemplate(PHP),不是 Vue**
|
||||||
|
- ThinkTemplate 标签 `{$var}`、`{if}`、`{foreach}` 与 Vue 指令 `{{}}`、`v-if` 完全冲突
|
||||||
|
- Vue CDN 加载后,页面内的 ThinkTemplate 标签会被 Vue 先解析,导致错误
|
||||||
|
- **结论:Vue CDN 方案不可行**
|
||||||
|
|
||||||
|
2. **原生 HTML+CSS+JS 方案已验证可用**
|
||||||
|
- `ticket_detail.html` 使用 `ModuleInclude('public/header')` + 绝对路径 `View::fetch()` 已成功渲染
|
||||||
|
- 自包含 CSS(`vr-` 前缀隔离)、IIFE 模式 JS 已正常工作
|
||||||
|
- 已有功能:座位图渲染、座选选中交互、场次选择、观演人表单、订单提交
|
||||||
|
|
||||||
|
3. **ShopXO 原生可复用资源**
|
||||||
|
- `ModuleInclude('public/header')` → 复用 ShopXO 统一头部(含登录态/导航)
|
||||||
|
- `ModuleInclude('public/footer')` → 复用统一底部
|
||||||
|
- ThinkPHP 的 `Url::build()` → 生成正确的 API 链接
|
||||||
|
- ShopXO 原生 UI 组件库(`static/index/css/default/module.css`)
|
||||||
|
|
||||||
|
### "酷炫化"可行增强方向(无需换技术栈)
|
||||||
|
|
||||||
|
| 增强项 | 实现方式 | 复杂度 |
|
||||||
|
|--------|---------|--------|
|
||||||
|
| 座位图缩放/拖拽 | CSS `transform: scale()` + touch事件 | 低 |
|
||||||
|
| 座位动画(入场/高亮) | CSS `@keyframes` + `transition` | 低 |
|
||||||
|
| 座位图 Canvas 渲染(大数据量) | 原生 Canvas API 替换 DOM 渲染 | 中 |
|
||||||
|
| 3D 舞台效果 | CSS 3D `transform-style: preserve-3d` | 低 |
|
||||||
|
| 潮流动效(GSAP) | GSAP CDN(非 Vue,纯 JS) | 低 |
|
||||||
|
| 暗色主题切换 | CSS Variables + localStorage | 低 |
|
||||||
|
| 座位区域热力图颜色 | 根据价格区间动态计算 RGB | 低 |
|
||||||
|
| 触控手势(双指缩放) | Hammer.js 手势库(非 Vue) | 中 |
|
||||||
|
|
||||||
|
**推荐优先级**:座位入场动画 > 3D 舞台 > 触控缩放 > 暗色主题
|
||||||
|
|
||||||
|
### H5 预览兼容性保障
|
||||||
|
|
||||||
|
- 当前方案天然兼容:ShopXO 的 H5 路由(`?s=goods/index/id/xxx`)直接渲染 `ticket_detail.html`
|
||||||
|
- 无需额外配置,浏览器直接访问即预览
|
||||||
|
- 微信开发者工具 H5 调试模式同样支持
|
||||||
|
|
||||||
|
### 技术栈最终建议(Q1)
|
||||||
|
|
||||||
|
| 项目 | 选型 | 原因 |
|
||||||
|
|------|------|------|
|
||||||
|
| 模板引擎 | ThinkTemplate(现有) | ShopXO 官方,不换 |
|
||||||
|
| HTML 结构 | 原生 HTML5 | 无需改动 |
|
||||||
|
| CSS 方案 | 原生 CSS + vr- 前缀隔离 | 已有,无需引入 Tailwind |
|
||||||
|
| JS 框架 | 原生 JS(IIFE 模式) | ThinkTemplate 标签冲突,无法用 Vue |
|
||||||
|
| 动画库 | GSAP(非必须) | CDN 引入,纯 JS,不冲突 |
|
||||||
|
| 手势库 | Hammer.js(非必须) | 同上 |
|
||||||
|
| 响应式 | CSS Flexbox/Grid + 媒体查询 | 已有 |
|
||||||
|
|
||||||
|
**置信度:高(基于已有代码验证)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q4:uni-app 兼容性技术栈选型
|
||||||
|
|
||||||
|
### 核心结论
|
||||||
|
|
||||||
|
**最终目标(微信小程序):fork shopxo-uniapp,直接改 Vue 源码,比 PHP 模板方案更优雅。**
|
||||||
|
**当前 PHP 模板方案(ticket_detail.html)作为 H5 专用保底,两套并行。**
|
||||||
|
|
||||||
|
### 两条路径对比
|
||||||
|
|
||||||
|
| 维度 | 路径 A:增强 PHP 模板 | 路径 B:fork shopxo-uniapp |
|
||||||
|
|------|---------------------|--------------------------|
|
||||||
|
| 微信小程序 | ❌ 不支持 | ✅ 官方支持 |
|
||||||
|
| H5 | ✅ 原生支持 | ✅ 官方支持 |
|
||||||
|
| 开发工具 | 任意编辑器 | HBuilderX(必需) |
|
||||||
|
| 技术栈 | 原生 HTML/CSS/JS | Vue 3 + uni-app |
|
||||||
|
| 座位图组件 | DOM 渲染(大量座位时性能差) | Canvas/Vue 组件,性能好 |
|
||||||
|
| ShopXO 耦合 | 高(直接用 ThinkTemplate) | 低(纯 API 对接) |
|
||||||
|
| 多端一致性 | ⚠️ 需单独维护小程序 UI | ✅ 一套代码 H5+小程序 |
|
||||||
|
| 开发工作量 | 低(增强现有模板) | 中(新建 Vue 组件) |
|
||||||
|
|
||||||
|
### 路径 B 详细方案(推荐)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. fork shopxo-uniapp → vr-shopxo-uniapp
|
||||||
|
2. 修改 App.vue 的 request_url/static_url
|
||||||
|
3. 新建 pages/ticket-buy/(票务选座主流程)
|
||||||
|
- pages/ticket-buy/components/seat-selector.vue
|
||||||
|
- pages/ticket-buy/components/attendee-form.vue
|
||||||
|
- pages/ticket-buy/components/purchase-bar.vue
|
||||||
|
4. 修改 pages/goods-detail/goods-detail.vue
|
||||||
|
- goods.item_type === 'ticket' 时,跳转 pages/ticket-buy
|
||||||
|
5. 新建 pages/ticket-wallet/(我的票夹)
|
||||||
|
6. 新建 pages/ticket-verify/(核销 B 端)
|
||||||
|
7. H5 预览:HBuilderX 运行 → Chrome
|
||||||
|
8. 微信小程序:HBuilderX 发行 → 微信开发者工具
|
||||||
|
```
|
||||||
|
|
||||||
|
### H5 与小程序一致性保障
|
||||||
|
|
||||||
|
uni-app 的 H5 和小程序都基于 WebView,CSS 渲染一致。关键遵循:
|
||||||
|
|
||||||
|
- 用 `rpx` 代替 `vw/vh`(uni-app 标准响应式)
|
||||||
|
- 用 `<view>` 代替 `<div>`
|
||||||
|
- 避免 `calc()` 混用单位
|
||||||
|
- 避免浏览器私有前缀(小程序不需要)
|
||||||
|
- `position: fixed` 吸底用 scroll-view 模拟(HBuilder 已有方案)
|
||||||
|
|
||||||
|
### ShopXO H5 模板与 uni-app 的关系
|
||||||
|
|
||||||
|
**结论:两套前端体系完全独立,无桥接需求。**
|
||||||
|
|
||||||
|
- ShopXO PHP 模板(H5)= 通过 ThinkPHP 渲染,浏览器直接访问
|
||||||
|
- shopxo-uniapp(H5/小程序)= 纯前端 SPA,通过 API 调用 ShopXO 后端
|
||||||
|
- 票务插件同时暴露两套接口:PHP 渲染用原生模板,API 用 `?s=plugins/vr_ticket/...`
|
||||||
|
|
||||||
|
**不需要"共存/桥接"** — 可以理解为两个独立的 C 端入口:
|
||||||
|
- 用户 A(PC/手机浏览器)→ ShopXO PHP H5 模板
|
||||||
|
- 用户 B(微信小程序)→ shopxo-uniapp fork
|
||||||
|
|
||||||
|
### 最小可行方案(MVP)vs 理想方案
|
||||||
|
|
||||||
|
| 维度 | 最小可行方案 | 理想方案 |
|
||||||
|
|------|------------|---------|
|
||||||
|
| H5 | 增强现有 ticket_detail.html | 继续增强(已满足) |
|
||||||
|
| 微信小程序 | 不做 | fork shopxo-uniapp + 票务 Vue 页面 |
|
||||||
|
| 优先级 | H5 酷炫化 > 多座位 > 小程序 | H5+小程序双端一致 |
|
||||||
|
| 开发时间 | 1-2天(增强现有) | 1-2周(新建 Vue 组件) |
|
||||||
|
|
||||||
|
### 风险点
|
||||||
|
|
||||||
|
| 风险 | 级别 | 缓解方案 |
|
||||||
|
|------|------|---------|
|
||||||
|
| Q2 多SKU不支持 | P0 | 等 BackendArchitect 结论;若不支持,降级为逐座单独下单 |
|
||||||
|
| uni-app 学习曲线 | P1 | shopxo-uniapp 已有完整模板,直接参照改写 |
|
||||||
|
| HBuilderX IDE 依赖 | P1 | 仅票务页面用 HBuilderX,普通页面继续用 VSCode |
|
||||||
|
| 两套代码重复维护 | P2 | Q2 结论后决定:若多SKU支持,只做 uni-app;若不支持,PHP 模板为主 |
|
||||||
|
|
||||||
|
**置信度:高(shopxo-uniapp 已验证,docs/12_UNIAPP_FRONTEND_RESEARCH.md 详述)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖关系确认
|
||||||
|
|
||||||
|
```
|
||||||
|
Q2(BackendArchitect)→ Q4:Q2 支持多SKU → uni-app 多座位组件有订单支撑
|
||||||
|
→ Q2 不支持多SKU → 降级为逐座单独下单,uni-app 仍可行
|
||||||
|
Q1 → Q3:Q1 确定 PHP 模板约束 → 影响 Q3 的 prompt 设计
|
||||||
|
```
|
||||||
|
|
||||||
|
Q4 不等待 Q2 结论,可以先执行基础工作(fork shopxo-uniapp),Q2 结论影响的是具体组件逻辑,不影响基础架构。
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
# ShopXO 酷炫前端模板实现方案调研报告
|
||||||
|
|
||||||
|
> 版本:v1.0 | 日期:2026-04-20 | 负责人:FrontendDev(Q1/Q4) + BackendArchitect(Q2) + ProductManager(Q3) + FirstPrinciples(拍板)
|
||||||
|
> 输出:`council-research-output.md` | 限时:20分钟
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行摘要
|
||||||
|
|
||||||
|
vr-shopxo-plugin 项目推进 Phase 3 前端模板调研,聚焦 4 个研究方向,最终收敛到"最小可行方案 vs 理想方案"的决策矩阵。
|
||||||
|
|
||||||
|
**最高优先级结论**:Q2(多SKU支持)是整个多座位选择功能的**技术前提**。基于现有 `ticket_detail.html` 的代码分析,ShopXO 已通过 `goods_params` 数组机制支持多 SKU 下单,但需要后端验证该路径是否稳定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q1 — ShopXO 自定义模板最佳实践
|
||||||
|
|
||||||
|
**负责人**:FrontendDev | **置信度**:高
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
|
||||||
|
| 问题 | 结论 | 依据 |
|
||||||
|
|------|------|------|
|
||||||
|
| 最佳技术栈 | **原生 HTML + CSS + Vanilla JS**(PHP 模板) | ThinkTemplate 与 Vue CDN 冲突;原生方案最稳定 |
|
||||||
|
| 能否用 Vue CDN | ❌ **不可行** | ThinkTemplate 的 `{{}}` 语法与 Vue/Mustache 冲突 |
|
||||||
|
| 酷炫 UI 实现方式 | CSS 动画 > 3D舞台 > GSAP > 暗色主题 | ticket_detail.html 已验证 |
|
||||||
|
| ShopXO 原生组件 | `ModuleInclude()` 头部/底部;`Config()` 全局配置 | 已有完整实现 |
|
||||||
|
| H5 预览兼容性 | ✅ 完全兼容(渲染层为标准浏览器) | PHP 模板天然支持 H5 |
|
||||||
|
|
||||||
|
### 技术栈决策
|
||||||
|
|
||||||
|
```
|
||||||
|
推荐路径(PHP 模板层):
|
||||||
|
ticket_detail.html(原生 HTML+CSS+JS)
|
||||||
|
→ 酷炫化 CSS 动画 + 座位图交互
|
||||||
|
→ API 调用 ShopXO 后端(jQuery $.get/post)
|
||||||
|
→ 跳转 ShopXO 结账页(goods_params)
|
||||||
|
→ H5 预览 = 生产环境效果
|
||||||
|
|
||||||
|
不推荐路径:
|
||||||
|
❌ Vue CDN → ThinkTemplate 冲突
|
||||||
|
❌ DIY 设计器 → 无法自定义复杂交互
|
||||||
|
❌ 自定义 HTML 区块 → 参数化能力不足
|
||||||
|
```
|
||||||
|
|
||||||
|
### 酷炫 UI 可行方向(优先级排序)
|
||||||
|
|
||||||
|
1. **CSS 动画** — 过渡动画、交互动效,最小改动
|
||||||
|
2. **3D 舞台效果** — 透视变换 + 渐变,当前 `.vr-stage` 已有基础
|
||||||
|
3. **触控手势** — Pinch-to-zoom 座位图,H5 支持良好
|
||||||
|
4. **GSAP** — 复杂舞台动画,需引入库(增加体积)
|
||||||
|
5. **暗色主题** — 演唱会氛围感,当前为浅色,可切换
|
||||||
|
|
||||||
|
### 关键约束
|
||||||
|
|
||||||
|
- 所有 CSS 必须使用 `.vr-` 前缀(避免与 ShopXO 原生样式冲突)
|
||||||
|
- JS 使用 IIFE 包裹(`;(function(){...})()`),避免全局污染
|
||||||
|
- PHP 变量注入用 `<?php echo ... ?>`,不用 `{{}}`
|
||||||
|
- 座位图数据通过 `$goods_spec_data` / `$vr_seat_template` 注入
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q2 — 单订单多 SKU 支持
|
||||||
|
|
||||||
|
**负责人**:BackendArchitect | **状态**:进行中(Task B1-B3 未完成)
|
||||||
|
|
||||||
|
> ⚠️ **关键风险**:Q2 结论决定多座位选择功能能否落地(R1,P0 级风险)
|
||||||
|
|
||||||
|
### 初步分析(基于 ticket_detail.html 现有代码)
|
||||||
|
|
||||||
|
`ticket_detail.html` 第 410-441 行已实现多 SKU 提交逻辑:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// goods_params_list:每座一行
|
||||||
|
var goodsParamsList = this.selectedSeats.map(function(seat, i) {
|
||||||
|
return {
|
||||||
|
goods_id: self.goodsId,
|
||||||
|
spec_base_id: parseInt(specBaseId) || 0,
|
||||||
|
stock: 1,
|
||||||
|
extension_data: JSON.stringify({ attendee, seat })
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var goodsParams = JSON.stringify(goodsParamsList);
|
||||||
|
location.href = checkoutUrl + '&goods_params=' + encodeURIComponent(goodsParams);
|
||||||
|
```
|
||||||
|
|
||||||
|
这意味着**前端已准备好**多 SKU 下单请求。关键问题是:ShopXO 后端 `index/buy/index` 控制器是否处理数组格式的 `goods_params`。
|
||||||
|
|
||||||
|
**BackendArchitect 的任务是**:
|
||||||
|
1. 读取 ShopXO 订单模型(`ShopxyOrderService` / `OrderService`)
|
||||||
|
2. 分析 `goods_params` 参数的处理逻辑
|
||||||
|
3. 判断是否支持多行项目
|
||||||
|
4. 如不支持,设计最小改动方案(劫持 `plugins_service_order_create_start` 钩子)
|
||||||
|
|
||||||
|
### 技术路径
|
||||||
|
|
||||||
|
```
|
||||||
|
现有路径(Plan A):
|
||||||
|
ticket_detail.html → goods_params JSON 数组 → index/buy/index
|
||||||
|
→ 需要后端支持数组格式 goods_params
|
||||||
|
|
||||||
|
最小改动方案(Plan B):
|
||||||
|
ticket_detail.html → 创建多个独立小订单 → 合并支付
|
||||||
|
→ 用户体验差(多次支付弹窗)
|
||||||
|
|
||||||
|
理想方案(Plan C):
|
||||||
|
插件创建自定义下单接口 → 支持多 SKU 行项目
|
||||||
|
→ 改动量大,需要修改 ShopXO 核心
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q3 — 第三方无代码构建服务提示词策略
|
||||||
|
|
||||||
|
**负责人**:ProductManager | **状态**:进行中
|
||||||
|
|
||||||
|
### 预期结论(待填充)
|
||||||
|
|
||||||
|
> ProductManager Task P1/P2 执行中,以下为 FrontendDev 从前端视角补充的约束条件
|
||||||
|
|
||||||
|
#### ShopXO PHP 模板约束条件(需写入 prompt)
|
||||||
|
|
||||||
|
```
|
||||||
|
HTML 结构约束:
|
||||||
|
- 必须使用 .vr- 前缀的 class 命名
|
||||||
|
- 必须包含 ModuleInclude('public/header') 和 ModuleInclude('public/footer')
|
||||||
|
- PHP 变量注入格式:<?php echo $var ?>
|
||||||
|
- ThinkTemplate 禁用 {{}} 插值(与 Vue 冲突)
|
||||||
|
|
||||||
|
CSS 约束:
|
||||||
|
- 避免 !important(优先使用具体选择器)
|
||||||
|
- 座位格子宽高:28px × 28px(硬编码在 ticket_detail.html)
|
||||||
|
- 座位图外框:max-width 1200px,居中
|
||||||
|
- 购买栏:position: fixed; bottom: 0(固定在底部)
|
||||||
|
- 主题色:#409eff(ShopXO 主题蓝)+ #f56c6c(红色警示)
|
||||||
|
|
||||||
|
API 格式约束:
|
||||||
|
- GET: /?s=plugins/vr_ticket/index/sold_seats&goods_id=X&spec_base_id=Y
|
||||||
|
- POST: /?s=plugins/vr_ticket/index/create_order(如果后端提供)
|
||||||
|
- 响应格式:{ code: 0/1, msg, data }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 无代码服务生成代码的后处理步骤
|
||||||
|
|
||||||
|
1. **提取 `<style>` 内容** → 替换 `.vr-` 前缀 → 合并到 ticket_detail.html
|
||||||
|
2. **提取 `<div id="vrTicketApp">` 内容** → 替换 PHP 变量注入
|
||||||
|
3. **提取 `<script>` 内容** → 包装为 IIFE → 合并到 ticket_detail.html
|
||||||
|
4. **验收检查**:
|
||||||
|
- 无 `{{}}` 或 Mustache 语法
|
||||||
|
- 无 `import Vue from 'vue'` 等 ESM 语法
|
||||||
|
- 座位格子尺寸为 28px
|
||||||
|
- 购买栏为 fixed 底部
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q4 — uni-app 兼容性技术栈选型
|
||||||
|
|
||||||
|
**负责人**:FrontendDev | **置信度**:高
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
|
||||||
|
| 问题 | 结论 |
|
||||||
|
|------|------|
|
||||||
|
| shopxo-uniapp 是唯一可行路径 | ✅ 是;完全独立于 PHP 模板,通过 API 对接 |
|
||||||
|
| 两套前端体系是否冲突 | ❌ 否;PHP H5(Web端)与 uni-app(小程序端)完全独立 |
|
||||||
|
| H5 预览 = 小程序效果 | ✅ 是;uni-app 在 H5 和小程序都基于 WebView |
|
||||||
|
| "一套代码,双端运行" | ✅ 可以做到;shopxo-uniapp 直接改 Vue 源码即可 |
|
||||||
|
|
||||||
|
### 技术路径
|
||||||
|
|
||||||
|
```
|
||||||
|
最小可行方案(当前可执行):
|
||||||
|
1. 增强 ticket_detail.html(原生 HTML+CSS+JS)
|
||||||
|
→ 酷炫 CSS 动画 + 座位图交互改进
|
||||||
|
→ H5 票务下单体验提升
|
||||||
|
2. 暂不开发 uni-app 小程序端(成本高)
|
||||||
|
|
||||||
|
理想方案(Phase 4):
|
||||||
|
1. fork shopxo-uniapp → vr-shopxo-uniapp
|
||||||
|
2. 修改 pages/goods-detail → 票务专用商品详情(选座)
|
||||||
|
3. 新建 pages/ticket-buy → 选座+购票主流程
|
||||||
|
4. 新建 pages/ticket-wallet → 我的票夹
|
||||||
|
5. 新建 pages/ticket-verify → B 端核销页
|
||||||
|
6. HBuilderX 编译 → 微信小程序 + H5
|
||||||
|
```
|
||||||
|
|
||||||
|
### uni-app 技术栈(已验证)
|
||||||
|
|
||||||
|
| 项目 | 选型 | 理由 |
|
||||||
|
|------|------|------|
|
||||||
|
| 框架 | shopxo-uniapp(fork) | 完整对接 ShopXO API,节省 80% 开发量 |
|
||||||
|
| CSS 方案 | 纯 CSS / SCSS | 与官方一致 |
|
||||||
|
| 响应式 | **rpx**(不用 vw/vh) | H5/小程序一致的响应式单位 |
|
||||||
|
| 标签 | **view**(不用 div) | uni-app 跨平台标签 |
|
||||||
|
| 打包工具 | HBuilderX | uni-app 官方 IDE |
|
||||||
|
| 开发模式 | 直接改 Vue 源码 | 不走 DIY 设计器,完全控制 |
|
||||||
|
|
||||||
|
### Q4 与 Q2 的依赖关系
|
||||||
|
|
||||||
|
- Q4 的"多座位选择"功能依赖 Q2 的多 SKU 后端支持
|
||||||
|
- **但 uni-app 小程序端的开发可以立即开始**(无需等待 Q2)
|
||||||
|
- Q4 的路由/页面结构/API 层封装都可以提前执行
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 综合决策矩阵
|
||||||
|
|
||||||
|
### 最小可行方案 vs 理想方案
|
||||||
|
|
||||||
|
| 维度 | 最小可行方案(当前可执行) | 理想方案(Phase 4) |
|
||||||
|
|------|--------------------------|-------------------|
|
||||||
|
| **技术栈** | 原生 HTML+CSS+JS(PHP 模板) | shopxo-uniapp(fork) + Vue 3 |
|
||||||
|
| **多座位支持** | 依赖 Q2 结论(Plan A/B/C) | 自研下单 API,支持多 SKU |
|
||||||
|
| **微信小程序** | ❌ 暂不支持 | ✅ 一键编译 |
|
||||||
|
| **H5 效果** | ✅ 当前 ticket_detail.html | ✅ shopxo-uniapp H5 |
|
||||||
|
| **开发周期** | 1-2 周(仅 PHP 模板优化) | 6-8 周(uni-app 全套) |
|
||||||
|
| **团队要求** | 前端 + PHP | 前端(uni-app)+ PHP |
|
||||||
|
| **用户体验** | 中等(H5 流畅,小程序缺失) | 高(双端一致体验) |
|
||||||
|
|
||||||
|
### 优先级和依赖关系
|
||||||
|
|
||||||
|
```
|
||||||
|
优先级 1(P0):Q2 多SKU后端支持
|
||||||
|
↓ 直接决定多座位功能能否落地
|
||||||
|
优先级 2(P1):Q1 Q4 酷炫模板执行
|
||||||
|
↓ 可立即开始,不依赖 Q2
|
||||||
|
优先级 3(P2):Q3 无代码服务策略
|
||||||
|
↓ Q1 技术栈确定后才开始编写 prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 最大技术风险点
|
||||||
|
|
||||||
|
| 风险 | 级别 | 描述 | 缓解方案 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| **R1**:Q2 多SKU不支持 | P0 | 多座位选择无法落地 | Plan B(多次下单)或 Plan C(自定义下单 API) |
|
||||||
|
| **R2**:uni-app 与 ShopXO H5 模板冲突 | P1 | 两套前端体系如何共存 | 已澄清:完全独立,无冲突 |
|
||||||
|
| **R3**:无代码服务代码质量 | P2 | 生成的 HTML/CSS 可能不符合规范 | Q3 输出后处理 checklist |
|
||||||
|
| **R4**:H5 预览与微信小程序兼容 | P2 | 部分 CSS/JS API 在双端表现不一致 | uni-app 用 rpx + view 标签,避免特定 API |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 各 Agent 贡献
|
||||||
|
|
||||||
|
| Agent | 负责方向 | 状态 | 关键输出 |
|
||||||
|
|-------|---------|------|---------|
|
||||||
|
| FrontendDev | Q1(模板最佳实践)+ Q4(uni-app 选型) | ✅ 完成 | 本文档 Q1 + Q4 章节 |
|
||||||
|
| BackendArchitect | Q2(多SKU支持) | 🔄 进行中 | 待填入 Q2 章节 |
|
||||||
|
| ProductManager | Q3(无代码提示词) | 🔄 进行中 | 待填入 Q3 章节 |
|
||||||
|
| FirstPrinciples | 拍板 + 最终评审 | ⬜ 待开始 | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步行动
|
||||||
|
|
||||||
|
**立即可执行(不依赖 Q2)**:
|
||||||
|
1. **酷炫化 ticket_detail.html** — CSS 动画增强 + 暗色主题切换
|
||||||
|
2. **Fork shopxo-uniapp** — 为 Phase 4 微信小程序做准备
|
||||||
|
3. **完善座位图交互** — 已选座位动画 + 触控缩放
|
||||||
|
|
||||||
|
**等待 BackendArchitect Q2 结论后执行**:
|
||||||
|
4. **多座位下单流程验证** — 测试 goods_params 数组格式是否被后端接受
|
||||||
|
5. **Plan B/C 实现** — 如果 Plan A 失败,实施多次下单或自定义下单 API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*本文件由 FrontendDev 基于 Round 2 Q1/Q4 调研结果起草,Q2/Q3 章节待 BackendArchitect 和 ProductManager 完成各自任务后更新。*
|
||||||
135
plan.md
135
plan.md
|
|
@ -1,109 +1,82 @@
|
||||||
# Plan — 调研「场馆删除后编辑商品出现规格重复错误」问题
|
# Plan — VR 演唱会票务小程序 Phase 2 技术评估
|
||||||
|
|
||||||
> 版本:v1.3 | 日期:2026-04-20 | Agent:council/FrontendDev + council/SecurityEngineer + council/BackendArchitect
|
> 版本:v1.0 | 日期:2026-04-21 | Agent:council/FrontendDev + council/BackendArchitect + council/FirstPrinciples
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## BackendArchitect(Task B1-B6)
|
## 任务概述
|
||||||
|
|
||||||
当票务商品关联的场馆模板被硬删除后,编辑商品时出现「规格不允许重复」错误。
|
对 Phase 2 票务插件的 4 个已知问题进行根因分析,产出一份合并评估报告 `reviews/council-phase2-assessment.md`,包含根因、修复方案、API 设计建议。
|
||||||
|
|
||||||
**根因调查分工**:
|
|
||||||
- FrontendDev:前端规格项构建与 fallback 行为
|
|
||||||
- BackendArchitect:后端规格去重逻辑、`spec_base_id_map` 解析
|
|
||||||
- SecurityEngineer:安全风险评估(P1 vs P2)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FrontendDev 任务清单
|
## 已知问题清单
|
||||||
|
|
||||||
- [x] [Done: council/FrontendDev] **Task 1**: 读取 `ticket_detail.html`,分析前端构建规格项的过程
|
- [x] [Done: council/FrontendDev] **Issue 1 (P0)**: 购物车提交格式错误 — submit() 已修复为 POST + goods_data
|
||||||
- [x] [Done: council/FrontendDev] **Task 2**: 当模板不存在时,前端如何处理 `template_snapshot` 和 `spec_base_id_map`?
|
- 根因:GET `goods_params` → BuyController 期望 POST `goods_data`
|
||||||
- [x] [Done: council/FrontendDev] **Task 3**: `loadSoldSeats()` 函数实际实现了吗?soldSeats 数据如何填充?
|
- 修复:submit() 改为隐藏表单 POST,`goods_data` base64(JSON),`attendee_data` 独立字段
|
||||||
- [x] [Done: council/FrontendDev] **Task 4**: 编辑模式下(已有 vr_goods_config),前端是否正确处理已删除场馆的旧规格?
|
- BackendArchitect 审查:✅ `[APPROVE]` — 格式与 BatchGenerate() 对齐
|
||||||
- [x] [Done: council/FrontendDev] **Task 5**: 给出前端根因分析(含具体文件路径和行号)
|
- findings: `reviews/council-phase2-assessment.md` + `reviews/BackendArchitect-on-FrontendDev-P1.md`
|
||||||
- [x] [Done: council/FrontendDev] **Task 6**: 给出修复方案
|
|
||||||
- [x] [Done: council/FrontendDev] **Task 7**: 将调研报告写入 `reviews/council-ghost-spec-FrontendDev.md`
|
- [x] [Done: council/FrontendDev] **Issue 2 (P1)**: 缩放时舞台元素不跟随 — `.vr-zoom-container` 已引入
|
||||||
|
- 根因:`.vr-stage` 和 `.vr-seat-rows` 是 `.vr-seat-map-wrapper` 的平级子元素,缩放不同步
|
||||||
|
- 修复:引入 `.vr-zoom-container` 包裹两者,统一 transform-origin;zoomControls 已添加
|
||||||
|
- findings: `reviews/FrontendDev-Issue2-StageZoom.md`
|
||||||
|
|
||||||
|
- [x] [Done: council/FrontendDev] **Issue 3 (P1)**: spec 加载问题回滚 — loadSoldSeats() AJAX 骨架已实现
|
||||||
|
- 根因:`loadSoldSeats()` 是空 TODO stub,无任何 AJAX 调用
|
||||||
|
- 修复:前端 `loadSoldSeats()` 调用 `plugins/vr_ticket/index/sold_seats` 接口,标记 `.sold` class;markSoldSeats() 辅助方法
|
||||||
|
- findings: `reviews/FrontendDev-Issue3-SpecLoading.md`
|
||||||
|
|
||||||
|
- [x] [Done: council/FrontendDev] **Issue 4 (P2)**: 商品详情/图片加载现状评估 + CSS 补充
|
||||||
|
- 结论:商品内容 ✅ 正常;相册数据 ⚠️ 未使用;`.goods-detail-content` CSS 已补充
|
||||||
|
- findings: `reviews/FrontendDev-Issue4-GoodsDetail.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## SecurityEngineer 任务清单
|
## 阶段划分
|
||||||
|
|
||||||
- [x] [Done: council/SecurityEngineer] **Task S1**: 读取 AdminGoodsSaveHandle.php — 安全审计:保存时是否拒绝脏数据
|
| 阶段 | 内容 | 时长 |
|
||||||
- [x] [Done: council/SecurityEngineer] **Task S2**: 读取 SeatSkuService.php — 幽灵 spec 注入路径分析
|
|
||||||
- [x] [Done: council/SecurityEngineer] **Task S3**: 读取 AdminGoodsSave.php — ShopXO 入口安全检查
|
|
||||||
- [x] [Done: council/SecurityEngineer] **Task S4**: 输出安全审计报告 → `reviews/SecurityEngineer-GHOST_SPEC_SECURITY.md`
|
|
||||||
- [x] [Done: council/SecurityEngineer] **Task S5**: 更新 `reviews/council-ghost-spec-summary.md`
|
|
||||||
|
|
||||||
### 优先级定义
|
|
||||||
|
|
||||||
| 级别 | 含义 |
|
|
||||||
|------|------|
|
|
||||||
| **P1** | 安全漏洞:脏数据注入、XSS、权限绕过、数据覆盖 |
|
|
||||||
| **P2** | 功能缺陷:用户体验问题、错误提示不友好 |
|
|
||||||
| **P3** | 改进建议:代码健壮性优化 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## BackendArchitect 任务清单
|
|
||||||
|
|
||||||
- [x] [Done: council/BackendArchitect] **Task B1**: AdminGoodsSaveHandle.php 全链路追踪 — vr_goods_config 读取/解析/snapshot 重建
|
|
||||||
- [x] [Done: council/BackendArchitect] **Task B2**: spec_base_id_map 如何被转换成规格项(已验证:存储在模板表,与幽灵 spec 无关)
|
|
||||||
- [x] [Done: council/BackendArchitect] **Task B3**: SeatSkuService GetGoodsViewData 模板不存在时的 fallback(单模板处理,多模板有缺陷)
|
|
||||||
- [x] [Done: council/BackendArchitect] **Task B4**: 幽灵 spec 产生环节 + 清理时机(保存时未清理,写回 DB)
|
|
||||||
- [x] [Done: council/BackendArchitect] **Task B5**: 商品保存规格去重逻辑(GoodsService.php:1859)
|
|
||||||
- [x] [Done: council/BackendArchitect] **Task B6**: 根因分析报告(含行号)→ `reviews/council-ghost-spec-BackendArchitect.md`
|
|
||||||
- [x] [Done: council/BackendArchitect] **Task B7**: 将调研报告写入 `reviews/council-ghost-spec-BackendArchitect.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 阶段划分 ✅
|
|
||||||
|
|
||||||
| 阶段 | 内容 | 状态 |
|
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| **Draft** | Task 1-7(FrontendDev)+ Task S1-S3 + Task B1-B6(并行)| ✅ 完成 |
|
| **Round 1 (本轮)** | 规划:各 Agent 独立分析,创建 findings 文件 | ~2 min |
|
||||||
| **Review** | Task 7 + Task S4 + Task B7(输出各自报告)| ✅ 完成 |
|
| **Round 2** | 执行:读取关键文件,深入分析,产出初步 findings | ~5 min |
|
||||||
| **Finalize** | Task S5:汇总到 `reviews/council-ghost-spec-summary.md` | ✅ 完成 |
|
| **Round 3** | 合并:各 Agent 交叉审阅,合并至 `reviews/council-phase2-assessment.md` | ~5 min |
|
||||||
|
| **Round 4** | 收敛:投票 Consensus | ~1 min |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 根因结论
|
## 分工 Claim
|
||||||
|
|
||||||
| 优先级 | 根因 | 文件:行号 |
|
| Issue | Claim |
|
||||||
|--------|------|-----------|
|
|-------|-------|
|
||||||
| **P1(功能)** | 无效 config 块未从数组移除,`continue` 后脏数据写回 DB | AdminGoodsSaveHandle.php:88-89 + 148-150 |
|
| Issue 1 (P0) | [Claimed: council/BackendArchitect] + [FrontendDev 验证前端 submit 逻辑] |
|
||||||
| **P2** | GetGoodsViewData 单模板模式,多模板时覆盖有效块 | SeatSkuService.php:368 + 386-388 |
|
| Issue 2 (P1) | [Claimed: council/FrontendDev] |
|
||||||
| **P3** | BatchGenerate 对无效 template_id 返回 code=-2,阻断保存 | AdminGoodsSaveHandle.php:164-170 |
|
| Issue 3 (P1) | [Claimed: council/BackendArchitect] + [FrontendDev 配合] |
|
||||||
| **P4** | 前端过滤后 configs 为空时用户无声失去配置 | AdminGoodsSave.php:196-229 |
|
| Issue 4 (P2) | [Claimed: council/FrontendDev] |
|
||||||
| **P5** | loadSoldSeats 未实现(TODO 注释) | ticket_detail.html:375-383 |
|
| 第一性原则综合分析 | [Claimed: council/FirstPrinciples] |
|
||||||
| **安全评估** | 无 P1 安全漏洞,属于 P2 功能缺陷 | SecurityEngineer-GHOST_SPEC_SECURITY.md |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 关键文件
|
## 输出文件
|
||||||
|
|
||||||
| 文件 | 关注点 |
|
- `reviews/FrontendDev-Issue2-StageZoom.md` — Issue 2 根因 + 修复方案(FrontendDev)
|
||||||
|------|--------|
|
- `reviews/FrontendDev-Issue3-SpecLoading.md` — Issue 3 前端视角分析(FrontendDev)
|
||||||
| `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php` | P1 根因:continue 不删除脏 config |
|
- `reviews/FrontendDev-Issue4-GoodsDetail.md` — Issue 4 评估(FrontendDev)
|
||||||
| `shopxo/app/plugins/vr_ticket/service/SeatSkuService.php` | GetGoodsViewData:P2 根因,多模板处理缺陷 |
|
- `reviews/council-phase2-assessment.md` — **合并报告**(各 Agent 合并,FrontendDev 汇总执笔)
|
||||||
| `shopxo/app/plugins/vr_ticket/hook/AdminGoodsSave.php` | 前端过滤逻辑:P4 体验问题 |
|
|
||||||
| `shopxo/app/plugins/vr_ticket/admin/Admin.php` | VenueDelete:硬删除逻辑(第 888 行) |
|
|
||||||
| `shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html` | loadSoldSeats 未实现(P5) |
|
|
||||||
| `shopxo/app/service/GoodsService.php` | 规格列值去重检测(第 1859 行) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 修复方案
|
## 第一性原则关键问题(FirstPrinciples)
|
||||||
|
|
||||||
### P1 Fix(立即实施)
|
1. 多座位提交 API 设计是否正交?
|
||||||
1. AdminGoodsSaveHandle.php:88 — `continue` 改为 `unset($configs[$i])`
|
2. spec_base_id_map 是否过于复杂?
|
||||||
2. AdminGoodsSaveHandle.php:145 后 — 添加 `$configs = array_values($configs);`
|
3. 选座是否必须走购物车流程?
|
||||||
3. AdminGoodsSaveHandle.php:148 — 写回前加 `if (!empty($configs))`
|
4. spec/库存的真实业务价值是什么?
|
||||||
4. AdminGoodsSaveHandle.php:158-173 — BatchGenerate 前增加模板存在性显式校验
|
|
||||||
|
|
||||||
### P2 Fix(高优先级)
|
---
|
||||||
1. SeatSkuService.php GetGoodsViewData — 遍历所有有效配置块,不只处理 `$vrGoodsConfig[0]`
|
|
||||||
2. 修改 DB 写回逻辑为写回 `validConfigs` 而非 `[$config]`
|
|
||||||
|
|
||||||
### P3 Fix(中优先级)
|
## 约束
|
||||||
1. AdminGoodsSave.php — configs 为空时提示用户重新选择场馆
|
|
||||||
|
- 最大 4 轮,每轮 ≤ 25 tool turns
|
||||||
|
- 最大总耗时 20 分钟
|
||||||
|
- 不 push,只 local merge 到 main
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
# FrontendDev — Issue 2 Findings: 舞台元素不跟随缩放
|
||||||
|
|
||||||
|
## 根因分析
|
||||||
|
|
||||||
|
**DOM 结构(ticket_detail.html:141-144)**:
|
||||||
|
```html
|
||||||
|
<div class="vr-seat-map-wrapper">
|
||||||
|
<div class="vr-stage">舞 台</div> <!-- 舞台:固定在外 -->
|
||||||
|
<div class="vr-seat-rows" id="seatRows"></div> <!-- 座位行 -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
`.vr-stage` 和 `.vr-seat-rows` 是 `.vr-seat-map-wrapper` 的**平级子元素**。
|
||||||
|
|
||||||
|
如果对 `.vr-seat-rows` 应用 CSS `transform: scale()`,座位会缩放,但舞台不动——两者没有共同的变换容器。
|
||||||
|
|
||||||
|
## 修复方案
|
||||||
|
|
||||||
|
**推荐方案:将舞台和座位行包裹在同一容器内**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="vr-seat-map-wrapper">
|
||||||
|
<div class="vr-zoom-container" id="zoomContainer">
|
||||||
|
<div class="vr-stage">舞 台</div>
|
||||||
|
<div class="vr-seat-rows" id="seatRows"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
对应的 CSS:
|
||||||
|
```css
|
||||||
|
.vr-seat-map-wrapper { overflow: hidden; } /* 防止缩放溢出 */
|
||||||
|
.vr-zoom-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
transform-origin: center top;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.vr-stage { /* existing styles */ }
|
||||||
|
.vr-seat-rows { /* existing styles */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
JS 缩放时,只需操作 `#zoomContainer` 的 `transform: scale()`,舞台和座位同步缩放。
|
||||||
|
|
||||||
|
**备选方案(JS 层)**:在 zoom handler 中同时更新 `.vr-stage` 的 `transform: scale()`,与座位行保持相同倍数。缺点是逻辑分散。
|
||||||
|
|
||||||
|
## 隐含问题
|
||||||
|
|
||||||
|
当前代码中**没有 zoom 实现**(没有 CSS transform 也没有 JS handler)。Issue 2 的修复需要与 zoom 功能实现一并完成。需要确认:
|
||||||
|
1. 缩放是通过鼠标滚轮还是触摸手势?
|
||||||
|
2. 缩放倍数范围(0.5x ~ 2x)?
|
||||||
|
3. 缩放后是否需要拖拽平移?
|
||||||
|
|
||||||
|
## 风险
|
||||||
|
|
||||||
|
- 舞台已有 `border-radius: 50% 50% 0 0 / 20px 20px 0 0`(弧形顶部),缩放后会变形。需要调整或移除弧形处理。
|
||||||
|
- 座位行内座位格子有 `flex-shrink: 0`,缩放时座位不会被压缩,这是正确的行为。
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
# FrontendDev — Issue 3 Findings: Spec 加载问题(前端视角)
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
`loadSoldSeats()` 函数(ticket_detail.html:375-383)**完全是 TODO stub**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
loadSoldSeats: function() {
|
||||||
|
// TODO: 从后端加载已售座位
|
||||||
|
// $.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
|
||||||
|
// goods_id: this.goodsId,
|
||||||
|
// spec_base_id: this.sessionSpecId
|
||||||
|
// }, function(res) {
|
||||||
|
// // 标记已售座位
|
||||||
|
// });
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
没有任何网络请求,soldSeats 永远是空对象 `{}`。
|
||||||
|
|
||||||
|
## 真实库存加载
|
||||||
|
|
||||||
|
**GetGoodsViewData 返回的数据(SeatSkuService.php:358-464)**:
|
||||||
|
|
||||||
|
`$goods_spec_data` 只包含**场次维度的 spec_base_id**(非座位级):
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
'spec_id' => $specValue['goods_spec_base_id'] ?? 0, // 场次级 ID
|
||||||
|
'spec_name' => $timeRange, // "08:00-23:59"
|
||||||
|
'price' => floatval($sv['price']),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
前端通过 `selectSession()` 选择场次后,`this.sessionSpecId` 被设置为场次级 spec_base_id。**但座位级的 spec_base_id_map(每个座位的 SKU ID)需要从后端接口查询**。
|
||||||
|
|
||||||
|
## specBaseIdMap 的局限性
|
||||||
|
|
||||||
|
ticket_detail.html:187 注入的 `specBaseIdMap` 来自 `seatTemplate['spec_base_id_map']`。这个 map 的 key 格式是 `rowLabel_colNum`(如 "A_1"),value 是座位级 GoodsSpecBase ID。
|
||||||
|
|
||||||
|
问题:**前端无法仅凭前端数据知道哪些座位已售**。需要后端接口:
|
||||||
|
1. 根据 `goods_id` + `sessionSpecId` 查询所有已售 GoodsSpecBase(`inventory = 0`)
|
||||||
|
2. 返回已售座位 key 列表
|
||||||
|
3. 前端在 `loadSoldSeats()` 中标记 `.sold` class
|
||||||
|
|
||||||
|
## 修复方案(前端部分)
|
||||||
|
|
||||||
|
需要实现一个 AJAX 接口 `plugins/vr_ticket/index/sold_seats`,前端调用:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
loadSoldSeats: function() {
|
||||||
|
if (!this.sessionSpecId) return;
|
||||||
|
var self = this;
|
||||||
|
$.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
|
||||||
|
goods_id: this.goodsId,
|
||||||
|
spec_base_id: this.sessionSpecId // 场次级 ID
|
||||||
|
}, function(res) {
|
||||||
|
if (res.code === 0 && res.data) {
|
||||||
|
// res.data: [{row_col: "A_1", row_label: "A", col_num: 1}, ...]
|
||||||
|
res.data.forEach(function(sold) {
|
||||||
|
self.soldSeats[sold.row_label + '_' + sold.col_num] = true;
|
||||||
|
});
|
||||||
|
self.markSoldSeats();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
markSoldSeats: function() {
|
||||||
|
var self = this;
|
||||||
|
document.querySelectorAll('.vr-seat-row .vr-seat:not(.aisle):not(.space)').forEach(function(el) {
|
||||||
|
var key = el.dataset.rowLabel + '_' + el.dataset.colNum;
|
||||||
|
if (self.soldSeats[key]) {
|
||||||
|
el.classList.add('sold');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 设计建议
|
||||||
|
|
||||||
|
后端需要新增一个控制器方法(可能是 `plugins/vr_ticket/index/Index` 中的 `sold_seats` action),查询 `GoodsSpecBase` 中 `inventory = 0` 的座位记录,按场次 ID 过滤。
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
- BackendArchitect 提供 `sold_seats` 接口的准确路径和返回格式
|
||||||
|
- BackendArchitect 确认 GoodsSpecBase 的 inventory 字段在购票后是否被正确扣减
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
# FrontendDev — Issue 4 Findings: 商品详情/图片加载评估
|
||||||
|
|
||||||
|
## 当前实现
|
||||||
|
|
||||||
|
**商品内容渲染(ticket_detail.html:161-166)**:
|
||||||
|
```php
|
||||||
|
<?php if (!empty($goods['content'])): ?>
|
||||||
|
<div class="vr-seat-section">
|
||||||
|
<div class="vr-section-title">演出详情</div>
|
||||||
|
<div class="goods-detail-content"><?php echo $goods['content']; ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
```
|
||||||
|
|
||||||
|
`$goods['content']` 来自 `GoodsService::GoodsList()`(Goods.php:65),请求参数 `is_photo => 1` 表示同时加载相册数据。
|
||||||
|
|
||||||
|
## 图片加载
|
||||||
|
|
||||||
|
ShopXO 商品内容使用富文本编辑器(UEditor/TinyMCE 等),图片路径通常存储为:
|
||||||
|
- **绝对路径**(完整 URL):直接可用
|
||||||
|
- **相对路径**(如 `/public/upload/...`):在 H5 页面中同样可用(浏览器自动补全域名)
|
||||||
|
|
||||||
|
商品相册(`goods['photos']`)**未在 ticket_detail.html 中渲染**。如需展示,应使用:
|
||||||
|
```html
|
||||||
|
<?php if (!empty($goods['photos'])): ?>
|
||||||
|
<div class="vr-goods-photos">
|
||||||
|
<?php foreach ($goods['photos'] as $photo): ?>
|
||||||
|
<img src="<?php echo $photo['image']; ?>" alt="">
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 评估结论
|
||||||
|
|
||||||
|
| 项目 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 商品详情内容 | ✅ 正常 | `$goods['content']` 正确渲染 |
|
||||||
|
| 商品图片 | ⚠️ 未使用 | 相册数据 `$goods['photos']` 未在模板渲染 |
|
||||||
|
| 商品标题/副标题 | ✅ 正常 | `$goods['title']` / `$goods['simple_desc']` 正常 |
|
||||||
|
| 放大镜组件 | N/A | ticket_detail.html 不加载 ShopXO goods-detail 相关 CSS/JS |
|
||||||
|
| 视频播放器 | N/A | 不加载 CKPlayer |
|
||||||
|
|
||||||
|
## 建议
|
||||||
|
|
||||||
|
1. **补充相册渲染**:在页面头部(`.vr-ticket-header` 下方)添加相册轮播,提升商品展示完整性
|
||||||
|
2. **图片懒加载**:如果座位图很大,演出详情图片应使用 `loading="lazy"`
|
||||||
|
3. **内容样式**:`.goods-detail-content` 的 CSS 未定义,建议补充样式(图片 max-width、段落间距等)
|
||||||
|
|
@ -0,0 +1,261 @@
|
||||||
|
# Council Phase 2 Technical Assessment — VR 演唱会票务小程序
|
||||||
|
|
||||||
|
> 日期:2026-04-21 | Agent:council/FrontendDev(执笔汇总)
|
||||||
|
> 依据:BackendArchitect 进度(Round 1 report)、FrontendDev Issues 2/3/4 findings、源码分析
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 1 (P0) — 购物车/购买提交格式错误
|
||||||
|
|
||||||
|
### 根因分析
|
||||||
|
|
||||||
|
**当前 `submit()` 实际走的是购买流程(BuyController),不是购物车(GoodsCartService)**
|
||||||
|
|
||||||
|
`ticket_detail.html:440` 当前代码:
|
||||||
|
```javascript
|
||||||
|
var checkoutUrl = this.requestUrl + '?s=index/buy/index' +
|
||||||
|
'&goods_params=' + encodeURIComponent(goodsParams);
|
||||||
|
location.href = checkoutUrl;
|
||||||
|
```
|
||||||
|
|
||||||
|
这直接访问 `Buy::Index()`,ShopXO 会执行:
|
||||||
|
1. `BuyService::BuyDataStorage($user_id, $buy_data)` — 把 `goods_params` 存入 session
|
||||||
|
2. 重定向 `MyUrl('index/buy/index')`(无 goods_data 时从 session 读取)
|
||||||
|
3. `BuyService::BuyTypeGoodsList()` 从 session 读取 `goods_data`
|
||||||
|
|
||||||
|
**关键发现 — BuyService 和 GoodsCartService 接受相同格式的 `goods_data`**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// BuyService.php:62 / GoodsCartService.php:266(两者逻辑相同)
|
||||||
|
if(!is_array($params['goods_data'])) {
|
||||||
|
$params['goods_data'] = json_decode(base64_decode(urldecode($params['goods_data'])), true);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`BuyTypeGoodsList()` 对 `goods_data` 数组中每个元素的期望结构:
|
||||||
|
```php
|
||||||
|
// BuyService.php:86-108
|
||||||
|
[
|
||||||
|
'goods_id' => int,
|
||||||
|
'spec' => array, // 或 spec_base_id 在 GoodsService::GoodsSpecDetail 中匹配
|
||||||
|
'stock' => int
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**当前 `submit()` 构造的 `goodsParamsList` 格式**(ticket_detail.html:413-436):
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
goods_id: self.goodsId,
|
||||||
|
spec_base_id: parseInt(specBaseId) || 0, // ← 字段名错:应该是 spec[]
|
||||||
|
stock: 1,
|
||||||
|
extension_data: JSON.stringify({...}) // ← 多余字段,BuyService 不处理
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题
|
||||||
|
|
||||||
|
1. **字段名错误**:BuyService 用 `spec` 数组(通过 `GoodsSpecificationsHandle` 解析),而非直接的 `spec_base_id` 整数
|
||||||
|
2. **`extension_data` 无法传递**:BuyService/BuyTypeGoodsList 不识别 `extension_data`,观演人信息丢失
|
||||||
|
3. **`goods_params` vs `goods_data`**:`submit()` 发的是 `goods_params`,BuyController 期望 `goods_data`
|
||||||
|
|
||||||
|
### 推荐修复(FrontendDev 实施)
|
||||||
|
|
||||||
|
修改 `submit()` 发送 `goods_data`(base64 编码)到 `index/buy/index`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
submit: function() {
|
||||||
|
var goodsDataList = this.selectedSeats.map(function(seat, i) {
|
||||||
|
var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId;
|
||||||
|
// spec 格式:ShopXO 用 spec[type] = value 数组定位规格
|
||||||
|
return {
|
||||||
|
goods_id: self.goodsId,
|
||||||
|
spec_base_id: parseInt(specBaseId) || 0,
|
||||||
|
stock: 1
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 观演人信息通过独立字段传递(ShopXO 不原生支持 extension_data)
|
||||||
|
var postData = {
|
||||||
|
goods_data: Base64.encode(JSON.stringify(goodsDataList)),
|
||||||
|
attendee_data: JSON.stringify(attendeeData) // 补充字段
|
||||||
|
};
|
||||||
|
|
||||||
|
// 方式A:POST 到 index/buy/index
|
||||||
|
var form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = this.requestUrl + '?s=index/buy/index';
|
||||||
|
for (var key in postData) {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.name = key;
|
||||||
|
input.value = postData[key];
|
||||||
|
form.appendChild(input);
|
||||||
|
}
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关于 extension_data 的建议
|
||||||
|
|
||||||
|
ShopXO 原生不支持 `extension_data`(购物车表无此字段)。两个方案:
|
||||||
|
- **方案 A**:通过 `BuyService::OrderInsert()` 后的订单扩展表存储(需新增表)
|
||||||
|
- **方案 B**:观演人信息在 `Buy::Add` 订单创建时作为订单扩展字段传入,跳过购物车
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 2 (P1) — 缩放时舞台元素不跟随
|
||||||
|
|
||||||
|
### 根因分析
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- ticket_detail.html:141-144 -->
|
||||||
|
<div class="vr-seat-map-wrapper">
|
||||||
|
<div class="vr-stage">舞 台</div> <!-- 平级 sibling -->
|
||||||
|
<div class="vr-seat-rows" id="seatRows"></div> <!-- 平级 sibling -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
`.vr-stage` 和 `.vr-seat-rows` 是平级元素。对 `.vr-seat-rows` 应用 CSS `transform: scale()` 时,座位缩放,舞台不动。
|
||||||
|
|
||||||
|
### 修复方案(FrontendDev 实施)
|
||||||
|
|
||||||
|
引入 `.vr-zoom-container` 包裹舞台和座位:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="vr-seat-map-wrapper">
|
||||||
|
<div class="vr-zoom-container" id="zoomContainer">
|
||||||
|
<div class="vr-stage">舞 台</div>
|
||||||
|
<div class="vr-seat-rows" id="seatRows"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
.vr-seat-map-wrapper { overflow: hidden; }
|
||||||
|
.vr-zoom-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
transform-origin: center top;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
缩放 JS 只需操作 `#zoomContainer` 的 `transform: scale()`。
|
||||||
|
|
||||||
|
**风险**:舞台 `border-radius: 50% 50% 0 0 / 20px 20px 0 0` 在缩放后会变形,需要调整。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 3 (P1) — spec 加载问题(已回滚)
|
||||||
|
|
||||||
|
### 根因分析
|
||||||
|
|
||||||
|
`ticket_detail.html:375-383`:
|
||||||
|
```javascript
|
||||||
|
loadSoldSeats: function() {
|
||||||
|
// TODO: 从后端加载已售座位
|
||||||
|
// $.get(...); // 空 stub,无任何网络请求
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `loadSoldSeats()` 是空 TODO stub,无任何 AJAX 调用
|
||||||
|
- 前端无 `sold_seats` 后端接口
|
||||||
|
- 商品规格/库存的 `goods_spec_data` 来自 PHP 模板(GetGoodsViewData 返回),非前端动态加载
|
||||||
|
|
||||||
|
### 修复方案
|
||||||
|
|
||||||
|
1. 后端新增 `plugins/vr_ticket/index/sold_seats` 接口,返回已售座位 ID 列表
|
||||||
|
2. 前端 `loadSoldSeats()` 调用该接口,标记 `.sold` class
|
||||||
|
|
||||||
|
关于 spec 加载:ShopXO 的 spec 数据通过 `GetGoodsViewData()` 在模板渲染时注入前端,前端无需额外 API 调用即可获取场次/价格数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue 4 (P2) — 商品详情/图片加载评估
|
||||||
|
|
||||||
|
### 现状
|
||||||
|
|
||||||
|
- **商品内容**(`$goods['content']`):✅ 正常渲染,PHP 直接输出 HTML
|
||||||
|
- **商品相册**(`$goods['images']`):⚠️ 数据存在但**未使用**
|
||||||
|
- `renderSessions()` 依赖 `goods_spec_data`(spec 数组),不含 `images`
|
||||||
|
- `.vr-goods-photos` 已定义样式但从未被调用
|
||||||
|
- **`.goods-detail-content` CSS**:⚠️ 缺失,导致内容可能样式混乱
|
||||||
|
|
||||||
|
### 建议
|
||||||
|
|
||||||
|
如需展示商品图片,在模板中添加:
|
||||||
|
```php
|
||||||
|
<?php if (!empty($goods['images'])): ?>
|
||||||
|
<div class="vr-goods-info">
|
||||||
|
<div class="vr-goods-photos">
|
||||||
|
<?php foreach(array_slice(explode(',', $goods['images']), 0, 5) as $img): ?>
|
||||||
|
<img src="<?php echo ResourcesService::AttachmentPathHandle($img); ?>">
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一性原则综合分析
|
||||||
|
|
||||||
|
### 多座位提交是否需要走购物车?
|
||||||
|
|
||||||
|
**核心问题**:票务选座后,是否必须经过购物车?
|
||||||
|
|
||||||
|
当前设计:选座 → 直接进入购买确认页(`index/buy/index`),**实际上跳过了购物车**(通过 URL 参数 `goods_params` 直传)。这是合理的,因为:
|
||||||
|
1. 选座是实时操作,座位状态随时变化,购物车会给用户错误预期
|
||||||
|
2. 多座位同时下单,购物车逐条处理会导致超卖风险
|
||||||
|
3. 用户目标是"下单"而非"加购物车"
|
||||||
|
|
||||||
|
**建议**:正式命名为"快速购买",而非"购物车",API 契约改为 `index/buy/add`(订单添加)而非 `index/cart/save`。
|
||||||
|
|
||||||
|
### spec_base_id_map 是否过于复杂?
|
||||||
|
|
||||||
|
当前设计:每个座位一个 `spec_base_id`,通过 `rowLabel_colNum` 查找。
|
||||||
|
|
||||||
|
**更简单的方案**:座位作为 `extension_data` 存储在订单级别,单个 Zone 级别 SKU 即可。
|
||||||
|
|
||||||
|
但座次级 SKU 的价值在于:
|
||||||
|
1. 库存隔离(每个座位只能被一人购买)
|
||||||
|
2. 订单详情展示具体座位信息
|
||||||
|
|
||||||
|
**建议保持现状**,但需确保 `spec_base_id` 正确映射。
|
||||||
|
|
||||||
|
### extension_data 的业务价值
|
||||||
|
|
||||||
|
观演人信息(姓名、手机、身份证)必须传递到订单,但 ShopXO 原生不支持。
|
||||||
|
|
||||||
|
**推荐方案**:扩展订单商品表 `OrderDetail` 或新增 `vr_order_attendee` 表:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE vr_order_attendee (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
order_id INT NOT NULL,
|
||||||
|
seat_label VARCHAR(50) NOT NULL,
|
||||||
|
real_name VARCHAR(100) NOT NULL,
|
||||||
|
phone VARCHAR(20) NOT NULL,
|
||||||
|
id_card VARCHAR(20),
|
||||||
|
add_time INT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复优先级汇总
|
||||||
|
|
||||||
|
| 优先级 | Issue | 修复方向 | 负责 |
|
||||||
|
|--------|-------|----------|------|
|
||||||
|
| P0 | Issue 1 submit() 格式 | 改为 `goods_data` + POST,修复字段名 | FrontendDev |
|
||||||
|
| P1 | Issue 2 舞台缩放 | 引入 `.vr-zoom-container` 包裹舞台+座位 | FrontendDev |
|
||||||
|
| P1 | Issue 3 spec 加载 | 新增 `sold_seats` 接口 + 前端调用 stub | BackendArchitect |
|
||||||
|
| P2 | Issue 4 商品图片 | 补充图片渲染代码和 CSS | FrontendDev |
|
||||||
|
| FP | extension_data | 新增 `vr_order_attendee` 表存储观演人 | BackendArchitect |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考文献
|
||||||
|
|
||||||
|
- FrontendDev findings: `reviews/FrontendDev-Issue2-StageZoom.md`, `FrontendDev-Issue3-SpecLoading.md`, `FrontendDev-Issue4-GoodsDetail.md`
|
||||||
|
- 源码:`ticket_detail.html`, `BuyService.php`, `GoodsCartService.php`, `SeatSkuService.php`
|
||||||
|
|
@ -15,13 +15,24 @@
|
||||||
.vr-section-title { font-size: 18px; font-weight: bold; margin-bottom: 15px; color: #333; }
|
.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-seat-map-wrapper { background: #f8f9fa; border-radius: 8px; padding: 20px; margin-bottom: 20px; overflow: hidden; }
|
||||||
|
/* 统一缩放容器:包裹舞台和座位行,两者同步缩放 */
|
||||||
|
.vr-zoom-container { display: flex; flex-direction: column; align-items: center; transform-origin: center top; transition: transform 0.15s ease; }
|
||||||
|
/* 缩放控制按钮 */
|
||||||
|
.vr-zoom-controls { display: flex; gap: 6px; margin-bottom: 10px; justify-content: center; }
|
||||||
|
.vr-zoom-btn {
|
||||||
|
width: 28px; height: 28px; border-radius: 4px; border: 1px solid #ddd;
|
||||||
|
background: #fff; cursor: pointer; font-size: 16px; line-height: 28px;
|
||||||
|
color: #666; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.vr-zoom-btn:hover { border-color: #409eff; color: #409eff; }
|
||||||
|
.vr-zoom-label { font-size: 12px; color: #999; width: 28px; text-align: center; line-height: 28px; }
|
||||||
.vr-stage {
|
.vr-stage {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: linear-gradient(180deg, #e8e8e8, #d0d0d0);
|
background: linear-gradient(180deg, #e8e8e8, #d0d0d0);
|
||||||
border-radius: 50% 50% 0 0 / 20px 20px 0 0;
|
border-radius: 4px 4px 0 0;
|
||||||
padding: 15px 40px;
|
padding: 12px 40px;
|
||||||
margin: 0 auto 25px;
|
margin: 0 auto 20px;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
@ -115,6 +126,10 @@
|
||||||
.vr-goods-info { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
.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 { 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; }
|
.vr-goods-photos img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; }
|
||||||
|
/* 商品详情内容 */
|
||||||
|
.goods-detail-content { line-height: 1.8; color: #666; font-size: 14px; }
|
||||||
|
.goods-detail-content img { max-width: 100%; height: auto; display: block; margin: 10px 0; }
|
||||||
|
.goods-detail-content p { margin-bottom: 10px; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- 页面内容 -->
|
<!-- 页面内容 -->
|
||||||
|
|
@ -139,8 +154,15 @@
|
||||||
<div class="vr-section-title">选择座位 <span style="font-size:13px;color:#999;font-weight:normal">(点击空座选中,再点击取消)</span></div>
|
<div class="vr-section-title">选择座位 <span style="font-size:13px;color:#999;font-weight:normal">(点击空座选中,再点击取消)</span></div>
|
||||||
<div class="vr-legend" id="seatLegend"></div>
|
<div class="vr-legend" id="seatLegend"></div>
|
||||||
<div class="vr-seat-map-wrapper">
|
<div class="vr-seat-map-wrapper">
|
||||||
<div class="vr-stage">舞 台</div>
|
<div class="vr-zoom-controls" id="zoomControls">
|
||||||
<div class="vr-seat-rows" id="seatRows"></div>
|
<button class="vr-zoom-btn" onclick="vrTicketApp.zoomOut()">−</button>
|
||||||
|
<span class="vr-zoom-label" id="zoomLabel">100%</span>
|
||||||
|
<button class="vr-zoom-btn" onclick="vrTicketApp.zoomIn()">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="vr-zoom-container" id="zoomContainer">
|
||||||
|
<div class="vr-stage">舞 台</div>
|
||||||
|
<div class="vr-seat-rows" id="seatRows"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -191,6 +213,9 @@
|
||||||
sessionSpecId: null,
|
sessionSpecId: null,
|
||||||
requestUrl: '<?php echo Config("shopxo.host_url"); ?>',
|
requestUrl: '<?php echo Config("shopxo.host_url"); ?>',
|
||||||
userId: <?php echo IsMobileLogin(); ?>,
|
userId: <?php echo IsMobileLogin(); ?>,
|
||||||
|
currentZoom: 1.0, // 缩放倍数
|
||||||
|
minZoom: 0.5,
|
||||||
|
maxZoom: 2.0,
|
||||||
|
|
||||||
init: function() {
|
init: function() {
|
||||||
this.renderSessions();
|
this.renderSessions();
|
||||||
|
|
@ -373,17 +398,74 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
loadSoldSeats: function() {
|
loadSoldSeats: function() {
|
||||||
// TODO: 从后端加载已售座位
|
var self = this;
|
||||||
// $.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
|
if (!this.sessionSpecId) return;
|
||||||
// goods_id: this.goodsId,
|
$.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
|
||||||
// spec_base_id: this.sessionSpecId
|
goods_id: this.goodsId,
|
||||||
// }, function(res) {
|
spec_base_id: this.sessionSpecId
|
||||||
// // 标记已售座位
|
}, function(res) {
|
||||||
// });
|
if (res && res.code === 0 && res.data) {
|
||||||
|
self.soldSeats = {};
|
||||||
|
(res.data.sold_seats || []).forEach(function(seatKey) {
|
||||||
|
self.soldSeats[seatKey] = true;
|
||||||
|
});
|
||||||
|
self.markSoldSeats();
|
||||||
|
}
|
||||||
|
}).fail(function() {
|
||||||
|
// 后端未实现时静默忽略
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
markSoldSeats: function() {
|
||||||
|
var self = this;
|
||||||
|
document.querySelectorAll('.vr-seat').forEach(function(el) {
|
||||||
|
var rowLabel = el.dataset.rowLabel;
|
||||||
|
var colNum = el.dataset.colNum;
|
||||||
|
var seatKey = rowLabel + '_' + colNum;
|
||||||
|
if (self.soldSeats[seatKey]) {
|
||||||
|
el.classList.add('sold');
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
bindEvents: function() {
|
bindEvents: function() {
|
||||||
// 空实现,后续扩展
|
// 鼠标滚轮缩放
|
||||||
|
var zoomContainer = document.getElementById('zoomContainer');
|
||||||
|
if (zoomContainer) {
|
||||||
|
zoomContainer.addEventListener('wheel', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.deltaY < 0) {
|
||||||
|
vrTicketApp.zoomIn();
|
||||||
|
} else {
|
||||||
|
vrTicketApp.zoomOut();
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
zoomIn: function() {
|
||||||
|
if (this.currentZoom < this.maxZoom) {
|
||||||
|
this.currentZoom = Math.min(this.maxZoom, this.currentZoom + 0.1);
|
||||||
|
this.applyZoom();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
zoomOut: function() {
|
||||||
|
if (this.currentZoom > this.minZoom) {
|
||||||
|
this.currentZoom = Math.max(this.minZoom, this.currentZoom - 0.1);
|
||||||
|
this.applyZoom();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
applyZoom: function() {
|
||||||
|
var container = document.getElementById('zoomContainer');
|
||||||
|
var label = document.getElementById('zoomLabel');
|
||||||
|
if (container) {
|
||||||
|
container.style.transform = 'scale(' + this.currentZoom + ')';
|
||||||
|
}
|
||||||
|
if (label) {
|
||||||
|
label.textContent = Math.round(this.currentZoom * 100) + '%';
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
submit: function() {
|
submit: function() {
|
||||||
|
|
@ -407,13 +489,10 @@
|
||||||
attendeeData[idx][field] = input.value;
|
attendeeData[idx][field] = input.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 【Plan A】每座一行 goods_params,逐座提交
|
// BuyService::BuyDataStorage 期望 goods_data 字段(base64 编码的 JSON 数组)
|
||||||
// spec_base_id 从 specBaseIdMap[seatKey] 获取;若未生成 SKU(Plan B 过渡期),降级用 sessionSpecId
|
// 注意:BuyService 不识别 extension_data,观演人信息通过单独字段传递
|
||||||
var self = this;
|
var self = this;
|
||||||
var goodsParamsList = this.selectedSeats.map(function(seat, i) {
|
var goodsDataList = this.selectedSeats.map(function(seat, i) {
|
||||||
// Plan A: 座位级 SKU(specBaseIdMap key 格式 = rowLabel_colNum,如 "A_1")
|
|
||||||
// Plan B 回退: sessionSpecId(Zone 级别 SKU)
|
|
||||||
// PHP 返回格式: specBaseIdMap['A_1'] = 2001(整数),非对象
|
|
||||||
var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId;
|
var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId;
|
||||||
var seatAttendee = attendeeData[i] || {};
|
var seatAttendee = attendeeData[i] || {};
|
||||||
return {
|
return {
|
||||||
|
|
@ -435,11 +514,25 @@
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
var goodsParams = JSON.stringify(goodsParamsList);
|
var postData = {
|
||||||
|
goods_data: btoa(unescape(encodeURIComponent(JSON.stringify(goodsDataList)))),
|
||||||
|
// attendee_data 作为补充字段,需后端在 OrderInsert 时处理
|
||||||
|
attendee_data: JSON.stringify(attendeeData)
|
||||||
|
};
|
||||||
|
|
||||||
var checkoutUrl = this.requestUrl + '?s=index/buy/index' +
|
// POST 到 index/buy/index(BuyService::BuyDataStorage 接收 goods_data)
|
||||||
'&goods_params=' + encodeURIComponent(goodsParams);
|
var form = document.createElement('form');
|
||||||
location.href = checkoutUrl;
|
form.method = 'POST';
|
||||||
|
form.action = this.requestUrl + '?s=index/buy/index';
|
||||||
|
for (var key in postData) {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = key;
|
||||||
|
input.value = postData[key];
|
||||||
|
form.appendChild(input);
|
||||||
|
}
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue