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

17 KiB
Raw Permalink Blame 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_configJSON 字段)

这是商品发布时由管理员配置的数据快照,前端只能读,不能写

完整 JSON 示例(商品 118VR 演唱会)

[
  {
    "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 是一个字符串数组,每行对应座位图的一行:

["AAAAA_____BBBBB", "AAAAA_____BBBBB"]
  • 字符 'A' / 'B' / 'C' = 座位char通过 seats[char] 查到座位属性(分区/价格/颜色)
  • 字符 '_' = 空位(不渲染座位元素)
  • 字符 '-' = 空位(不渲染座位元素)
  • 其他非字母字符 = 不渲染

如何从 map 渲染座位

// 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 注入变量总览

// 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

// 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 数据结构

{
    // === 直接透传 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场次列表

// 来源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

// 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 中实现)

// 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

// 正确:从 rooms[i].id 取
var roomId = rooms[i].id;  // "room_001"

// 错误:硬编码或自行生成
var roomId = "room_001";   // ❌ 如果 rooms 结构变了就错了

6.2 colNum 从哪里来?

colNum 是列号(从 1 开始),不是数组索引:

// 正确
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 个下划线。座位图渲染时:

chars.forEach(function(char, colIndex) {
    if (char === '_' || char === '-') {
        // 渲染一个空白格子(不绑定座位)
        return;
    }
    // 渲染座位colNum = colIndex + 1
});