vr-shopxo-plugin/docs/SPEC_SELECTOR_DATA_DICTIONA...

483 lines
17 KiB
Markdown
Raw Normal View History

# 商品详情扩展字段数据字典与前端使用说明
> 日期2026-04-21
> 用途:前端 agentantigravity / cursor拿到商品详情页时扩展字段里有哪些可用数据如何用
---
## 一、核心数据结构全貌
商品详情页加载时PHP 后端向模板注入以下变量:
| 模板变量名 | 来源 | 说明 |
|-----------|------|------|
| `$goods` | ShopXO GoodsService | ShopXO 原生商品数据id/title/price/content/images 等) |
| `$vr_seat_template` | `SeatSkuService::GetGoodsViewData()` | 票务插件扩展数据 |
| `$goods_spec_data` | `SeatSkuService::GetGoodsViewData()` | 场次列表 |
---
## 二、vr_goods_configgoods 表扩展字段)
存储位置:`goods.vr_goods_config`JSON 字段)
这是商品发布时由管理员配置的数据快照,**前端只能读,不能写**。
### 完整 JSON 示例(商品 118VR 演唱会)
```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=房间IDvalue=该房间选中的分区字符数组 |
| `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. 查询对应的 GoodsSpecValue4个维度的值
$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
});
```