# 商品详情扩展字段数据字典与前端使用说明 > 日期:2026-04-21 > 用途:前端 agent(antigravity / cursor)拿到商品详情页时,扩展字段里有哪些可用数据?如何用? --- ## 一、核心数据结构全貌 商品详情页加载时,PHP 后端向模板注入以下变量: | 模板变量名 | 来源 | 说明 | |-----------|------|------| | `$goods` | ShopXO GoodsService | ShopXO 原生商品数据(id/title/price/content/images 等) | | `$vr_seat_template` | `SeatSkuService::GetGoodsViewData()` | 票务插件扩展数据 | | `$goods_spec_data` | `SeatSkuService::GetGoodsViewData()` | 场次列表 | --- ## 二、vr_goods_config(goods 表扩展字段) 存储位置:`goods.vr_goods_config`(JSON 字段) 这是商品发布时由管理员配置的数据快照,**前端只能读,不能写**。 ### 完整 JSON 示例(商品 118,VR 演唱会) ```json [ { "version": 3.0, "template_id": 4, "selected_rooms": ["room_001", "room_002"], "selected_sections": { "room_001": ["A", "B"], "room_002": ["A"] }, "sessions": [ { "start": "15:00", "end": "16:59" }, { "start": "18:00", "end": "20:59" } ], "template_snapshot": { "venue": { "name": "VR 体验馆", "address": "北京市朝阳区建国路88号", "location": { "lng": "116.45792", "lat": "39.90745" }, "images": [ "/static/attachments/202603/venue_001.jpg", "/static/attachments/202603/venue_002.jpg" ] }, "rooms": [ { "id": "room_001", "name": "1号演播厅", "map": [ "AAAAA_____BBBBB", "AAAAA_____BBBBB", "AAAAA_____BBBBB", "CCCCCCCCCCCCCCC", "CCCCCCCCCCCCCCC" ], "sections": [ { "char": "A", "name": "VIP区", "price": 380, "color": "#f06292" }, { "char": "B", "name": "看台区", "price": 180, "color": "#4fc3f7" }, { "char": "C", "name": "普通区", "price": 80, "color": "#81c784" } ], "seats": { "A": { "char": "A", "name": "VIP区", "price": 380, "color": "#f06292" }, "B": { "char": "B", "name": "看台区", "price": 180, "color": "#4fc3f7" }, "C": { "char": "C", "name": "普通区", "price": 80, "color": "#81c784" } } }, { "id": "room_002", "name": "2号演播厅(副厅)", "map": [ "DDDDDDD", "DDDDDDD", "EEEEEEE" ], "sections": [ { "char": "D", "name": "互动区", "price": 280, "color": "#ffb74d" }, { "char": "E", "name": "站票区", "price": 50, "color": "#90a4ae" } ], "seats": { "D": { "char": "D", "name": "互动区", "price": 280, "color": "#ffb74d" }, "E": "char": "E", "name": "站票区", "price": 50, "color": "#90a4ae" } } } ] } } ] ``` ### 字段说明表 #### 顶层字段 | 字段 | 类型 | 前端可用性 | 说明 | |------|------|-----------|------| | `version` | float | ⚪ 不需要 | 协议版本,用于兼容判断 | | `template_id` | int | ⚪ 不需要 | 关联的座位模板 ID,内部使用 | | `selected_rooms` | string[] | ✅ 可用 | 用户在后台选中的房间 ID 列表 | | `selected_sections` | object | ✅ 可用 | key=房间ID,value=该房间选中的分区字符数组 | | `sessions` | object[] | ✅ 可用(**重要**) | 场次列表,每个场次有 start/end/price | | `template_snapshot` | object | ✅ 可用(**核心**) | 座位图的完整快照,前端渲染数据来源 | #### template_snapshot.venue | 字段 | 前端可用性 | 说明 | |------|-----------|------| | `name` | ✅ 可用 | 场馆名称(用于展示) | | `address` | ✅ 可用 | 场馆地址(用于展示) | | `location.lng/lat` | ⚠️ 可选 | 经纬度,用于地图展示 | | `images` | ✅ 可用 | 场馆图片列表(用于顶部 Banner) | #### template_snapshot.rooms[](每个房间) | 字段 | 前端可用性 | 说明 | |------|-----------|------| | `id` | ✅ 可用(**重要**) | 房间唯一 ID,用于前端 seatKey 构造 | | `name` | ✅ 可用 | 房间名称(用于场馆切换选择器) | | `map` | ✅ 可用(**核心**) | 座位图字符矩阵,用于渲染座位行 | | `sections[]` | ✅ 可用 | 分区列表(char→name/price/color,用于图例 + 分区切换) | | `seats` | ✅ 可用 | char→座位属性映射,用于查找座位详情 | #### template_snapshot.rooms[].map 格式说明 `map` 是一个字符串数组,每行对应座位图的一行: ```json ["AAAAA_____BBBBB", "AAAAA_____BBBBB"] ``` - 字符 `'A'` / `'B'` / `'C'` = 座位(char),通过 `seats[char]` 查到座位属性(分区/价格/颜色) - 字符 `'_'` = 空位(不渲染座位元素) - 字符 `'-'` = 空位(不渲染座位元素) - 其他非字母字符 = 不渲染 **如何从 map 渲染座位**: ```javascript // map = ["AAAAA_____BBBBB", "AAAAA_____BBBBB"] map.forEach(function(rowStr, rowIndex) { var rowLabel = String.fromCharCode(65 + rowIndex); // 0→A, 1→B, ... var chars = rowStr.split(''); // ['A','A','A','A','A','_',...,'B','B','B','B','B'] chars.forEach(function(char, colIndex) { if (char === '_' || char === '-') return; // 跳过空位 var seatInfo = rooms[i].seats[char]; // 查到座位属性 // colIndex + 1 = colNum(列号,从1开始) }); }); ``` **注意**:PHP `mb_str_split()` 在某些环境不可用,用 `split('')` 即可(座位字符都是 ASCII)。 --- ## 三、GetGoodsViewData() 注入的模板数据 这是后端处理后注入到模板的变量,**前端可以直接使用**。 ### 3.1 注入变量总览 ```php // Goods.php 票务判断块 $viewData = \app\plugins\vr_ticket\service\SeatSkuService::GetGoodsViewData($goods_id); MyViewAssign([ 'vr_seat_template' => $viewData['vr_seat_template'], // 座位图数据 'goods_spec_data' => $viewData['goods_spec_data'], // 场次列表 // 【待新增】 'seatSpecMap' => $viewData['seatSpecMap'] ?? [], // 座位→规格映射 ]); ``` ### 3.2 vr_seat_template(注入后模板中访问 `$vr_seat_template`) ```javascript // PHP 模板输出(JSON 注入) var vrSeatTemplate = ; var seatSpecMap = ; var goodsSpecData = ; ``` #### vr_seat_template 数据结构 ```javascript { // === 直接透传 template_snapshot(来源:goods.vr_goods_config)=== venue: { name: "VR 体验馆", address: "北京市朝阳区建国路88号", location: { lng: "116.45792", lat: "39.90745" }, images: ["/static/attachments/202603/venue_001.jpg"] }, rooms: [ { id: "room_001", name: "1号演播厅", map: ["AAAAA_____BBBBB", "AAAAA_____BBBBB", "AAAAA_____BBBBB", "CCCCCCCCCCCCCCC", "CCCCCCCCCCCCCCC"], sections: [ { char: "A", name: "VIP区", price: 380, color: "#f06292" }, { char: "B", name: "看台区", price: 180, color: "#4fc3f7" }, { char: "C", name: "普通区", price: 80, color: "#81c784" } ], seats: { A: { char: "A", name: "VIP区", price: 380, color: "#f06292" }, B: { char: "B", name: "看台区", price: 180, color: "#4fc3f7" }, C: { char: "C", name: "普通区", price: 80, color: "#81c784" } } }, { id: "room_002", name: "2号演播厅(副厅)", map: ["DDDDDDD", "DDDDDDD", "EEEEEEE"], sections: [ { char: "D", name: "互动区", price: 280, color: "#ffb74d" }, { char: "E", name: "站票区", price: 50, color: "#90a4ae" } ], seats: { D: { char: "D", name: "互动区", price: 280, color: "#ffb74d" }, E: { char: "E", name: "站票区", price: 50, color: "#90a4ae" } } } ], sessions: [ { start: "15:00", end: "16:59" }, { start: "18:00", end: "20:59" } ], // === 来自 goods.vr_goods_config 的原始选择数据 === selectedRooms: ["room_001", "room_002"], selectedSections: { "room_001": ["A", "B"], "room_002": ["A"] } } ``` #### goods_spec_data(场次列表) ```javascript // 来源:goods.vr_goods_config.sessions + GoodsSpecBase.price [ { spec_id: 2001, spec_name: "15:00-16:59", price: 380, start: "15:00", end: "16:59" }, { spec_id: 2002, spec_name: "18:00-20:59", price: 280, start: "18:00", end: "20:59" } ] // ⚠️ 注意:spec_id 是 GoodsSpecBase ID(场次级别,非座位级别) // 前端不需要直接使用 spec_id,直接使用 sessions 数组即可 ``` ### 3.3 seatSpecMap(待新增:GetGoodsViewData 返回的核心数据) **来源**:`GetGoodsViewData()` 查询 GoodsSpecBase + GoodsSpecValue + GoodsSpecBase.extends,动态构建 **用途**:前端选中座位后,查表获取该座位的完整 4 维 spec 数组 + 对应 spec_base_id ```javascript // key 格式:{roomId}_{rowLabel}_{colNum} // 例如:room_001_A_3 = room_001 的 A排 第3列 { "room_001_A_1": { spec_base_id: 10001, price: 380, inventory: 1, rowLabel: "A", colNum: 3, roomId: "room_001", section: { char: "A", name: "VIP区", color: "#f06292" }, // === 4维 spec 数组(submit() 时直接使用)=== spec: [ { type: "$vr-场馆", value: "VR 体验馆" }, { type: "$vr-分区", value: "VR 体验馆-1号演播厅-VIP区" }, { type: "$vr-座位号", value: "VR 体验馆-1号演播厅-VIP区-A-1排3座" }, { type: "$vr-场次", value: "15:00-16:59" } ] }, "room_001_A_2": { spec_base_id: 10002, price: 380, inventory: 1, /* ... */ }, "room_001_A_3": { /* 同上,A排第3座 */ }, "room_001_B_8": { spec_base_id: 10025, price: 180, inventory: 0, /* 已售 */ }, "room_002_D_1": { spec_base_id: 20001, price: 280, inventory: 1, /* ... */ }, // ... 每个可购座位一行 } ``` #### seatSpecMap 生成逻辑(GetGoodsViewData 中实现) ```php // 1. 查询所有有效 GoodsSpecBase(含 extends.seat_key) $specs = Db::name('GoodsSpecBase') ->where('goods_id', $goodsId) ->where('inventory', '>', 0) // 只取有库存的 ->select(); // 2. 查询对应的 GoodsSpecValue(4个维度的值) $specIds = array_column($specs->toArray(), 'id'); $specValues = Db::name('GoodsSpecValue') ->whereIn('goods_spec_base_id', $specIds) ->select(); // 3. 按 spec_base_id 分组,构建 4维 spec 数组 $specByBaseId = []; foreach ($specValues as $sv) { $specByBaseId[$sv['goods_spec_base_id']][] = [ 'type' => $sv['type'], // "$vr-场馆" / "$vr-分区" / "$vr-座位号" / "$vr-场次" 'value' => $sv['value'], // 完整路径字符串 ]; } // 4. 构建 seatSpecMap $seatSpecMap = []; foreach ($specs as $spec) { $extends = json_decode($spec['extends'] ?? '{}', true); $seatKey = $extends['seat_key'] ?? ''; // "room_001_A_3" 格式 if (empty($seatKey)) continue; $seatSpecMap[$seatKey] = [ 'spec_base_id' => intval($spec['id']), 'price' => floatval($spec['price']), 'inventory' => intval($spec['inventory']), 'spec' => $specByBaseId[$spec['id']] ?? [], ]; } ``` --- ## 四、前端数据使用对照表 ### 4.1 渲染座位图(使用 vr_seat_template) ``` 数据来源:vr_seat_template.rooms[].map ↓ 渲染流程: rooms[i].map.forEach((rowStr, rowIndex) => { chars = rowStr.split('') // 逐字符 chars.forEach((char, colIndex) => { if (char === '_') → 跳过(空位) seatInfo = rooms[i].seats[char] // 通过 char 查座位属性 seatKey = rooms[i].id + '_' + rowLabel + '_' + (colIndex+1) // rowLabel = String.fromCharCode(65 + rowIndex) // A/B/C... }); }); 前端关键变量: - rooms[i].id → roomId(用于 seatKey 构造) - rooms[i].map → 座位行渲染数据 - rooms[i].seats[char] → 座位属性(name/price/color) - rooms[i].sections → 图例 + 分区切换 - vrSeatTemplate.selectedRooms → 当前选中的房间列表 - vrSeatTemplate.selectedSections → 当前选中的分区 ``` ### 4.2 构建 spec 数组(使用 seatSpecMap) ``` 数据来源:seatSpecMap[seatKey] ↓ 选中座位后: seatKey = clickedEl.dataset.rowLabel + '_' + clickedEl.dataset.colNum = "room_001_A_3" seatInfo = seatSpecMap[seatKey] submit() 时使用: goods_data[i].spec = seatInfo.spec // 4维完整 spec 数组! goods_data[i].stock = 1 ShopXO BuyService 匹配: → GoodsSpecValue WHERE type="$vr-场馆" AND value="VR 体验馆" AND type="$vr-分区" AND value="VR 体验馆-1号演播厅-VIP区" AND type="$vr-座位号" AND value="VR 体验馆-1号演播厅-VIP区-A-1排3座" AND type="$vr-场次" AND value="15:00-16:59" → 返回 spec_base_id → 拿到 inventory=1, price=380 ``` ### 4.3 spec 选择器联动过滤(使用 seatSpecMap) ``` 数据来源:seatSpecMap(所有座位的完整信息) ↓ filterSeatMap(currentSession, currentVenueId, currentSectionChar): seatSpecMap 的每一个 entry: seatInfo.spec 是一个4元素数组 判断逻辑(某座位是否在当前选择分支内): hasSession = spec.some(s => s.type==='$vr-场次' && s.value===currentSessionValue) hasVenue = spec.some(s => s.type==='$vr-场馆' && s.value.includes(currentVenueName)) hasSection = !currentSectionChar || spec.some(s => s.type==='$vr-分区' && s.value.includes(currentSectionChar)) isAvailable = seatInfo.inventory > 0 结果: hasSession && hasVenue && hasSection && isAvailable → 可选(正常显示) hasSession && hasVenue && hasSection && !isAvailable → 已售(灰色+sold class) 否则 → 不在分支内(灰色+disabled class) ``` ### 4.4 加载已售座位(使用 seatSpecMap.inventory) ``` 数据来源:seatSpecMap[seatKey].inventory ↓ 页面初始化时,遍历 seatSpecMap: Object.entries(seatSpecMap).forEach(([seatKey, seatInfo]) => { if (seatInfo.inventory <= 0) { // 该座位已售 document.querySelector(`[data-seat-key="${seatKey}"]`).classList.add('sold'); } }); ⚠️ 注意:inventory 字段来自 GoodsSpecBase,库存扣减由 ShopXO 原生处理。 这是当前座位的实时库存,优先于任何前端缓存。 ``` --- ## 五、前端完整数据流图 ``` 后端 GetGoodsViewData() │ ├── vr_seat_template.venue ──────────────────→ 顶部 Banner / 场馆信息 ├── vr_seat_template.rooms[].map ─────────────────→ 座位图渲染 ├── vr_seat_template.rooms[].sections ────────────→ 图例 + 分区选择器 ├── vr_seat_template.selectedSections ────────────→ 默认选中的分区(用于高亮) ├── goods_spec_data / vr_seat_template.sessions ──→ 场次选择器 └── seatSpecMap (新增) ─────────────────────────────→ 核心! │ ├── seatSpecMap[seatKey].spec ────────→ submit() 构造 goods_data.spec ├── seatSpecMap[seatKey].inventory ──→ 标记已售 / 灰色 ├── seatSpecMap[seatKey].price ──────→ 计算总价 └── filterSeatMap() ─────────────────→ spec 选择器联动过滤 ``` --- ## 六、注意事项 ### 6.1 roomId 从哪里来? `rooms[i].id`(来自 template_snapshot.rooms)就是 roomId。这是 UUID 或字符串 ID。 **前端构造 seatKey 时必须使用这个 ID**: ```javascript // 正确:从 rooms[i].id 取 var roomId = rooms[i].id; // "room_001" // 错误:硬编码或自行生成 var roomId = "room_001"; // ❌ 如果 rooms 结构变了就错了 ``` ### 6.2 colNum 从哪里来? colNum 是列号(从 1 开始),不是数组索引: ```javascript // 正确 var colNum = colIndex + 1; // 0-based 数组索引 → 1-based 列号 // seatKey 格式:{roomId}_{rowLabel}_{colNum} // 例如:room_001_A_3 = room_001 的 A排 第3列 ``` ### 6.3 同一个 char 在不同房间代表不同分区 room_001 的 "A" 是 VIP区(红色),room_002 的 "D" 是互动区(橙色)。 **分区信息在 rooms[i].sections 里**,不要直接用 char 本身判断分区。 ### 6.4 map 中下划线数量的处理 `"AAAAA_____BBBBB"` 中有 5 个下划线。座位图渲染时: ```javascript chars.forEach(function(char, colIndex) { if (char === '_' || char === '-') { // 渲染一个空白格子(不绑定座位) return; } // 渲染座位,colNum = colIndex + 1 }); ```