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

483 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 商品详情扩展字段数据字典与前端使用说明
> 日期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
});
```