483 lines
17 KiB
Markdown
483 lines
17 KiB
Markdown
# 商品详情扩展字段数据字典与前端使用说明
|
||
|
||
> 日期: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 = <?php echo json_encode($vr_seat_template ?? [], JSON_UNESCAPED_UNICODE); ?>;
|
||
var seatSpecMap = <?php echo json_encode($seatSpecMap ?? [], JSON_UNESCAPED_UNICODE); ?>;
|
||
var goodsSpecData = <?php echo json_encode($goods_spec_data ?? [], JSON_UNESCAPED_UNICODE); ?>;
|
||
```
|
||
|
||
#### 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
|
||
});
|
||
```
|