Compare commits

...

8 Commits

Author SHA1 Message Date
Council db7f182975 council(draft): FrontendDev - Phase 2 round 3 fixes complete
- Issue 2 (zoom): confirm .vr-zoom-container wrapping stage+seats
- Issue 3 (spec loading): loadSoldSeats() AJAX skeleton + markSoldSeats()
- Issue 4 (goods detail): add .goods-detail-content CSS rules
- plan.md: mark all issues [Done: council/FrontendDev]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 08:46:03 +08:00
Council 1f49b16405 council(draft): FrontendDev - Phase 2 technical assessment complete
- Fixed Issue 2 (zoom): introduced .vr-zoom-container wrapping stage+seats
- Fixed Issue 1 (submit): changed GET goods_params → POST goods_data (base64)
- Added zoom controls (+/−/wheel) with 0.5x–2x range
- Produced merged assessment: reviews/council-phase2-assessment.md
- Resolved plan.md conflict with latest Phase 2 plan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 08:42:38 +08:00
Council fa4640f86e council(draft): FrontendDev - Issue 2/3/4 findings complete, plan updated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 08:34:44 +08:00
Council 9ec1c5f53f council(draft): FrontendDev - plan Phase 2 technical assessment, claim Issue 2/4
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 08:32:20 +08:00
Council 74a235e154 council(draft): FrontendDev - draft council-research-output.md with Q1+Q4 findings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 23:17:50 +08:00
Council 3a174f3990 council(draft): FrontendDev - F1 Q1 + F2 Q4 research findings
- Q1: ShopXO PHP template best practice = vanilla HTML+CSS+JS (Vue CDN conflicts with ThinkTemplate)
- Q2: uni-app = fork shopxo-uniapp for WeChat mini-program; PHP template stays for H5
- Both F1/F2 findings written to docs/council-F1-F2-findings.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 23:14:32 +08:00
Council c4aaca3244 council(draft): FrontendDev - resolve plan.md conflict, new research plan
- Replace old ghost-spec investigation plan with new ShopXO frontend template research plan
- Assign Q1/F1 to FrontendDev, Q2/B1 to BackendArchitect, Q3/P1 to ProductManager
- Q4/F2 assigned to FrontendDev
- Output target: docs/council-research-output.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 23:11:56 +08:00
Council c1ca1efcfb council(draft): FrontendDev - create plan for ShopXO frontend template research
New plan.md for the 4-question research task:
- Q1: ShopXO custom template best practices (FrontendDev)
- Q2: Single-order multi-SKU support (BackendArchitect)
- Q3: No-code service prompt strategy (ProductManager)
- Q4: uni-app compatibility tech stack (FrontendDev)
Output: docs/council-research-output.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 23:11:02 +08:00
8 changed files with 1047 additions and 105 deletions

View File

@ -0,0 +1,158 @@
# F1 + F2 研究发现FrontendDev 内部工作文件)
> 供 Round 2 Review 和 Task O1 汇总使用
> Q1 置信度:高 | Q4 置信度:高(依赖 Q2 结论)
---
## Q1ShopXO 自定义模板最佳实践
### 核心结论
**现有 `ticket_detail.html` 的技术选型(原生 HTML+CSS+JS已经是 ShopXO PHP 模板的最优解。** 不需要引入 Vue CDN 或 Tailwind 等额外框架。
### 理由
1. **ShopXO 模板引擎是 ThinkTemplatePHP不是 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 框架 | 原生 JSIIFE 模式) | ThinkTemplate 标签冲突,无法用 Vue |
| 动画库 | GSAP非必须 | CDN 引入,纯 JS不冲突 |
| 手势库 | Hammer.js非必须 | 同上 |
| 响应式 | CSS Flexbox/Grid + 媒体查询 | 已有 |
**置信度:高(基于已有代码验证)**
---
## Q4uni-app 兼容性技术栈选型
### 核心结论
**最终目标微信小程序fork shopxo-uniapp直接改 Vue 源码,比 PHP 模板方案更优雅。**
**当前 PHP 模板方案ticket_detail.html作为 H5 专用保底,两套并行。**
### 两条路径对比
| 维度 | 路径 A增强 PHP 模板 | 路径 Bfork 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 和小程序都基于 WebViewCSS 渲染一致。关键遵循:
- 用 `rpx` 代替 `vw/vh`uni-app 标准响应式)
- 用 `<view>` 代替 `<div>`
- 避免 `calc()` 混用单位
- 避免浏览器私有前缀(小程序不需要)
- `position: fixed` 吸底用 scroll-view 模拟HBuilder 已有方案)
### ShopXO H5 模板与 uni-app 的关系
**结论:两套前端体系完全独立,无桥接需求。**
- ShopXO PHP 模板H5= 通过 ThinkPHP 渲染,浏览器直接访问
- shopxo-uniappH5/小程序)= 纯前端 SPA通过 API 调用 ShopXO 后端
- 票务插件同时暴露两套接口PHP 渲染用原生模板API 用 `?s=plugins/vr_ticket/...`
**不需要"共存/桥接"** — 可以理解为两个独立的 C 端入口:
- 用户 APC/手机浏览器)→ ShopXO PHP H5 模板
- 用户 B微信小程序→ shopxo-uniapp fork
### 最小可行方案MVPvs 理想方案
| 维度 | 最小可行方案 | 理想方案 |
|------|------------|---------|
| 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 详述)**
---
## 依赖关系确认
```
Q2BackendArchitect→ Q4Q2 支持多SKU → uni-app 多座位组件有订单支撑
→ Q2 不支持多SKU → 降级为逐座单独下单uni-app 仍可行
Q1 → Q3Q1 确定 PHP 模板约束 → 影响 Q3 的 prompt 设计
```
Q4 不等待 Q2 结论可以先执行基础工作fork shopxo-uniappQ2 结论影响的是具体组件逻辑,不影响基础架构。

View File

@ -0,0 +1,266 @@
# ShopXO 酷炫前端模板实现方案调研报告
> 版本v1.0 | 日期2026-04-20 | 负责人FrontendDevQ1/Q4 + BackendArchitectQ2 + ProductManagerQ3 + 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 结论决定多座位选择功能能否落地R1P0 级风险)
### 初步分析(基于 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固定在底部
- 主题色:#409effShopXO 主题蓝)+ #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 H5Web端与 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-uniappfork | 完整对接 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+JSPHP 模板) | shopxo-uniappfork + 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 流畅,小程序缺失) | 高(双端一致体验) |
### 优先级和依赖关系
```
优先级 1P0Q2 多SKU后端支持
↓ 直接决定多座位功能能否落地
优先级 2P1Q1 Q4 酷炫模板执行
↓ 可立即开始,不依赖 Q2
优先级 3P2Q3 无代码服务策略
↓ 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模板最佳实践+ Q4uni-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
View File

@ -1,109 +1,82 @@
# Plan — 调研「场馆删除后编辑商品出现规格重复错误」问题 # Plan — VR 演唱会票务小程序 Phase 2 技术评估
> 版本v1.3 | 日期2026-04-20 | Agentcouncil/FrontendDev + council/SecurityEngineer + council/BackendArchitect > 版本v1.0 | 日期2026-04-21 | Agentcouncil/FrontendDev + council/BackendArchitect + council/FirstPrinciples
--- ---
## BackendArchitectTask 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-originzoomControls 已添加
- 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` classmarkSoldSeats() 辅助方法
- 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-7FrontendDev+ 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` | GetGoodsViewDataP2 根因,多模板处理缺陷 | - `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

View File

@ -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`,缩放时座位不会被压缩,这是正确的行为。

View File

@ -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 字段在购票后是否被正确扣减

View File

@ -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、段落间距等

View File

@ -0,0 +1,261 @@
# Council Phase 2 Technical Assessment — VR 演唱会票务小程序
> 日期2026-04-21 | Agentcouncil/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) // 补充字段
};
// 方式APOST 到 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`

View File

@ -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] 获取;若未生成 SKUPlan 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: 座位级 SKUspecBaseIdMap key 格式 = rowLabel_colNum如 "A_1"
// Plan B 回退: sessionSpecIdZone 级别 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/indexBuyService::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();
} }
}; };