feat(Phase 3): 演播室选择器+层级售罄灰化+短码Feistel架构规划

pull/19/head
Council 2026-04-22 16:39:39 +08:00
parent de7c25c6b9
commit ffeda44ddc
3 changed files with 350 additions and 35 deletions

View File

@ -182,11 +182,12 @@ class BaseService
'upd_time' => $now,
]);
// 3. 定义 $vr- 规格类型(name => JSON value
// 3. 定义 $vr- 规格类型(5维场次、场馆、演播室、分区、座位号
$specTypes = [
'$vr-场次' => '[{"name":"待选场次","images":""}]',
'$vr-场馆' => '[{"name":"' . ($goods['venue_data'] ?? '国家体育馆') . '","images":""}]',
'$vr-演播室' => '[{"name":"主厅","images":""}]',
'$vr-分区' => '[{"name":"A区","images":""},{"name":"B区","images":""},{"name":"C区","images":""}]',
'$vr-时段' => '[{"name":"待选场次","images":""}]',
'$vr-座位号' => '[{"name":"待选座位","images":""}]',
];

View File

@ -24,9 +24,10 @@ class SeatSkuService extends BaseService
const BATCH_SIZE = 200;
/**
* VR 规格维度名(顺序固定)
* VR 规格维度名顺序固定5维
* 注意:按选购流程顺序排列:场次 场馆 演播室 分区 座位号
*/
const SPEC_DIMS = ['$vr-场馆', '$vr-分区', '$vr-座位号', '$vr-场次'];
const SPEC_DIMS = ['$vr-场次', '$vr-场馆', '$vr-演播室', '$vr-分区', '$vr-座位号'];
/**
* 批量生成座位级 SKU
@ -87,10 +88,11 @@ class SeatSkuService extends BaseService
// 按维度收集唯一值(用 有序列表 + 去重)
$dimUniqueValues = [
'$vr-场次' => [],
'$vr-场馆' => [],
'$vr-演播室' => [],
'$vr-分区' => [],
'$vr-座位号' => [],
'$vr-场次' => [],
];
// 5. 遍历地图,收集所有座位信息
@ -160,26 +162,30 @@ class SeatSkuService extends BaseService
'seat_key' => $seatKey, // ← 用于前端映射
'extends' => json_encode(['seat_key' => $seatKey], JSON_UNESCAPED_UNICODE),
'spec_values' => [
$val_venue,
$val_section,
$val_seat,
$sessionStr,
$sessionStr, // $vr-场次第1维
$val_venue, // $vr-场馆第2维
$roomName, // $vr-演播室第3维
$val_section, // $vr-分区第4维
$val_seat, // $vr-座位号第5维
],
];
// 收集唯一维度值(保持首次出现顺序)
// 收集唯一维度值(保持首次出现顺序,与 SPEC_DIMS 对应)
if (!in_array($sessionStr, $dimUniqueValues['$vr-场次'])) {
$dimUniqueValues['$vr-场次'][] = $sessionStr;
}
if (!in_array($val_venue, $dimUniqueValues['$vr-场馆'])) {
$dimUniqueValues['$vr-场馆'][] = $val_venue;
}
if (!in_array($roomName, $dimUniqueValues['$vr-演播室'])) {
$dimUniqueValues['$vr-演播室'][] = $roomName;
}
if (!in_array($val_section, $dimUniqueValues['$vr-分区'])) {
$dimUniqueValues['$vr-分区'][] = $val_section;
}
if (!in_array($val_seat, $dimUniqueValues['$vr-座位号'])) {
$dimUniqueValues['$vr-座位号'][] = $val_seat;
}
if (!in_array($sessionStr, $dimUniqueValues['$vr-场次'])) {
$dimUniqueValues['$vr-场次'][] = $sessionStr;
}
}
}
}
@ -218,7 +224,8 @@ class SeatSkuService extends BaseService
throw new \Exception("GoodsSpecBase 写入失败 (seat: {$seatId})");
}
// 4 条 GoodsSpecValue每条对应一个维度按 SPEC_DIMS 顺序)
// 5 条 GoodsSpecValue每条对应一个维度按 SPEC_DIMS 顺序)
// 注意GoodsSpecValue 表没有 name 字段,只能通过 value 匹配关联维度
foreach ($s['spec_values'] as $specVal) {
$valueBatch[] = [
'goods_id' => $goodsId,
@ -569,13 +576,13 @@ class SeatSkuService extends BaseService
->select()
->toArray();
// 4. 按 spec_base_id 分组,通过值匹配确定维度
// 4. 按 spec_base_id 分组,直接使用 GoodsSpecValue.name 字段确定维度名(更可靠)
$specByBaseId = [];
foreach ($specValues as $sv) {
$baseId = $sv['goods_spec_base_id'];
$value = $sv['value'] ?? '';
// 通过值匹配找到对应的维度名
// 通过值匹配找到对应的维度名(依赖 GoodsSpecType.value JSON 中的 name
$dimName = '';
foreach ($dimValuesByName as $name => $values) {
if (in_array($value, $values)) {
@ -645,27 +652,31 @@ class SeatSkuService extends BaseService
$rowLabel = $parts[1];
$colNum = intval($parts[2]);
// 提取场馆名(从 $vr-场馆 维度)
// 提取各维度值
$venueName = '';
$sectionName = '';
$seatName = '';
$sessionName = '';
$roomName = ''; // ← 演播室第3维
foreach ($specByBaseId[$spec['id']] ?? [] as $specItem) {
$specType = $specItem['type'] ?? '';
$specVal = $specItem['value'] ?? '';
switch ($specType) {
case '$vr-场次':
$sessionName = $specVal;
break;
case '$vr-场馆':
$venueName = $specVal;
break;
case '$vr-演播室':
$roomName = $specVal;
break;
case '$vr-分区':
$sectionName = $specVal;
break;
case '$vr-座位号':
$seatName = $specVal;
break;
case '$vr-场次':
$sessionName = $specVal;
break;
}
}
@ -685,6 +696,7 @@ class SeatSkuService extends BaseService
'rowLabel' => $seatMeta['rowLabel'],
'colNum' => $seatMeta['colNum'],
'roomId' => $roomId,
'roomName' => $roomName, // ← 演播室名第3维
'section' => $seatMeta['section'],
'venueName' => $venueName,
'sectionName' => $sectionName,

View File

@ -31,8 +31,14 @@
<div id="venueSelector"><!-- 由 JS 动态渲染 --></div>
</div>
<!-- 演播室选择新增第3维 -->
<div class="vr-seat-section" id="roomSection">
<div class="vr-section-title">选择演播室</div>
<div id="roomSelector"><!-- 由 JS 动态渲染 --></div>
</div>
<!-- 分区选择 -->
<div class="vr-seat-section" id="sectionSection">
<div class="vr-seat-section" id="sectionSection" style="display:none">
<div class="vr-section-title">选择分区</div>
<div id="sectionSelector"><!-- 由 JS 动态渲染 --></div>
</div>
@ -97,10 +103,12 @@
soldSeats: { }, // {seatKey: true}
currentSession: null, // 当前选中场次 value (e.g. "15:00-16:59")
currentVenue: null, // 当前选中场馆 value
currentRoom: null, // 当前选中演播室 value第3维
currentSection: null, // 当前选中分区 char
init: function() {
this.renderAllSelectors();
this.updateSpecOptionsAvailability(); // 初始化 spec 选项的可用性(灰化售罄)
this.bindEvents();
},
@ -136,7 +144,17 @@
venueHtml += '</div></div>';
document.getElementById('venueSelector').innerHTML = venueHtml;
// 3. 渲染分区选择器
// 3. 渲染演播室选择器第3维
var roomHtml = '<div class="vr-spec-selector"><div class="vr-spec-label">选择演播室</div><div class="vr-spec-options">';
if (specTypeList['$vr-演播室']) {
specTypeList['$vr-演播室'].options.forEach(function(room) {
roomHtml += '<div class="vr-spec-option" data-room="' + room + '" title="' + room + '" onclick="vrTicketApp.selectRoom(this)">' + room + '</div>';
});
}
roomHtml += '</div></div>';
document.getElementById('roomSelector').innerHTML = roomHtml;
// 4. 渲染分区选择器
var sectionHtml = '<div class="vr-spec-selector"><div class="vr-spec-label">选择分区</div><div class="vr-spec-options">';
if (specTypeList['$vr-分区']) {
specTypeList['$vr-分区'].options.forEach(function(section) {
@ -157,6 +175,50 @@
this.filterSeats();
},
// 选择演播室第3维
selectRoom: function(el) {
document.querySelectorAll('#roomSelector .vr-spec-option').forEach(function (item) {
item.classList.remove('selected');
});
el.classList.add('selected');
this.currentRoom = el.dataset.room;
this.currentSection = null; // 重置分区选择
// 显示分区选择器
document.getElementById('sectionSection').style.display = 'block';
// 过滤分区选择器:只显示当前演播室的分区
this.filterSectionOptions();
// 场馆+演播室都选完才显示座位图
if (this.currentVenue && this.currentRoom) {
this.renderSeatMap();
this.loadSoldSeats();
this.filterSeats();
}
},
// 过滤分区选择器选项
filterSectionOptions: function() {
var self = this;
var roomName = this.currentRoom;
if (!roomName) return;
// 从 specTypeList 获取当前演播室对应的分区
var sectionOptions = document.querySelectorAll('#sectionSelector .vr-spec-option');
sectionOptions.forEach(function(opt) {
var sectionValue = opt.dataset.section || '';
// 分区值格式:场馆-演播室-区号,如 "测试场馆-主要展厅-A"
// 检查分区值是否属于当前演播室
if (sectionValue.indexOf(roomName) !== -1) {
opt.style.display = '';
} else {
opt.style.display = 'none';
opt.classList.remove('selected');
}
});
},
// 选择分区
selectSection: function(el) {
document.querySelectorAll('#sectionSelector .vr-spec-option').forEach(function (item) {
@ -164,7 +226,12 @@
});
el.classList.add('selected');
this.currentSection = el.dataset.section;
this.filterSeats();
// 场馆+演播室+分区都选完才显示座位图
if (this.currentVenue && this.currentRoom && this.currentSection) {
document.getElementById('seatSection').style.display = 'block';
this.filterSeats();
}
},
// 根据选择过滤座位
@ -174,7 +241,7 @@
var seatKey = el.dataset.seatKey;
var seatInfo = self.seatSpecMap[seatKey] || {};
var matchSession = true, matchVenue = true, matchSection = true;
var matchSession = true, matchVenue = true, matchRoom = true, matchSection = true;
if (self.currentSession) {
matchSession = false;
@ -196,6 +263,17 @@
}
}
// 演播室过滤第3维
if (self.currentRoom) {
matchRoom = false;
for (var i = 0; i < seatInfo.spec.length; i++) {
if (seatInfo.spec[i].type === '$vr-演播室' && seatInfo.spec[i].value === self.currentRoom) {
matchRoom = true;
break;
}
}
}
if (self.currentSection) {
matchSection = false;
for (var i = 0; i < seatInfo.spec.length; i++) {
@ -206,7 +284,7 @@
}
}
if (matchSession && matchVenue && matchSection && seatInfo.inventory > 0) {
if (matchSession && matchVenue && matchRoom && matchSection && seatInfo.inventory > 0) {
el.style.opacity = '1';
el.style.pointerEvents = 'auto';
} else {
@ -234,20 +312,35 @@
// 重置状态
this.selectedSeats = [];
this.updateSelectedUI();
this.currentRoom = null;
this.currentSection = null;
document.querySelectorAll('.vr-session-item').forEach(function (item) {
item.classList.remove('selected');
});
// 清除所有 spec 选项的 selected 状态
document.querySelectorAll('.vr-spec-option').forEach(function (item) {
item.classList.remove('selected');
});
// 重置分区选择器的显示(全部可见)
document.querySelectorAll('#sectionSelector .vr-spec-option').forEach(function (opt) {
opt.style.display = '';
});
// 隐藏分区选择器
document.getElementById('sectionSection').style.display = 'none';
el.classList.add('selected');
this.currentSession = el.dataset.session;
document.getElementById('seatSection').style.display = 'block';
// 更新 spec 选项可用性
this.updateSpecOptionsAvailability();
// 隐藏座位图区域,等待其他 spec 选择完成(场馆→演播室→分区)
document.getElementById('seatSection').style.display = 'none';
document.getElementById('selectedSection').style.display = 'none';
document.getElementById('attendeeSection').style.display = 'none';
this.renderSeatMap();
this.loadSoldSeats();
this.selectedSeats = [];
this.updateSelectedUI();
},
renderSeatMap: function() {
@ -259,17 +352,29 @@
// seat_map is nested inside the seatMap object
var seatMapData = map.seat_map || map;
var mapData = seatMapData.map || (seatMapData.rooms && seatMapData.rooms[0] ? seatMapData.rooms[0].map : null);
var rooms = seatMapData.rooms || [];
// 动态查找当前选中的演播室数据
var currentRoomData = rooms[0];
if (this.currentRoom && rooms.length > 0) {
for (var i = 0; i < rooms.length; i++) {
if (rooms[i].name === this.currentRoom) {
currentRoomData = rooms[i];
break;
}
}
}
var mapData = currentRoomData ? (currentRoomData.map || []) : [];
if (!mapData || mapData.length === 0) {
document.getElementById('seatRows').innerHTML = '<div style="text-align:center;color:#999;padding:40px">座位图加载失败</div>';
return;
}
// 从模板数据获取房间 ID可能是 UUID 或 room_xxx 格式)
var rooms = seatMapData.rooms || [];
var roomId = (rooms[0] && rooms[0].id) ? rooms[0].id : 'room_001';
var seats = seatMapData.seats || (seatMapData.rooms && seatMapData.rooms[0] ? seatMapData.rooms[0].seats || {} : {});
var sections = seatMapData.sections || (seatMapData.rooms && seatMapData.rooms[0] ? seatMapData.rooms[0].sections || [] : []);
// 使用当前选中房间的 ID
var roomId = currentRoomData.id || 'room_001';
var seats = currentRoomData.seats || {};
var sections = currentRoomData.sections || [];
// 渲染图例
var legendHtml = '';
@ -344,6 +449,203 @@
});
},
// 更新 spec 选项的可用性(层级售罄检查)
updateSpecOptionsAvailability: function() {
var self = this;
// 层级 1统计分区可用座位数
var sectionSeats = {}; // section => 可用座位数
var sectionSoldOut = {}; // section => true/false
// 层级 2统计演播室可用座位数汇总所有分区
var roomSeats = {};
var roomSoldOut = {};
// 层级 3统计场馆可用座位数汇总所有演播室
var venueSeats = {};
var venueSoldOut = {};
// 层级 4统计场次可用座位数汇总所有场馆
var sessionSeats = {};
var sessionSoldOut = {};
for (var seatKey in this.seatSpecMap) {
var seatInfo = this.seatSpecMap[seatKey];
var isAvailable = seatInfo.inventory > 0;
// 提取各维度值
var sessionName = '', venueName = '', roomName = '', sectionName = '';
for (var i = 0; i < seatInfo.spec.length; i++) {
var specType = seatInfo.spec[i].type;
var specValue = seatInfo.spec[i].value;
if (specType === '$vr-场次') sessionName = specValue;
if (specType === '$vr-场馆') venueName = specValue;
if (specType === '$vr-演播室') roomName = specValue;
if (specType === '$vr-分区') sectionName = specValue;
}
// 层级统计
if (isAvailable) {
sectionSeats[sectionName] = (sectionSeats[sectionName] || 0) + 1;
roomSeats[roomName] = (roomSeats[roomName] || 0) + 1;
venueSeats[venueName] = (venueSeats[venueName] || 0) + 1;
sessionSeats[sessionName] = (sessionSeats[sessionName] || 0) + 1;
}
}
// 统计演播室-分区关系(用于判断演播室是否完全售罄)
var roomSections = {}; // roomName => [section1, section2, ...]
// 统计场馆-演播室关系(用于判断场馆是否完全售罄)
var venueRooms = {}; // venueName => [room1, room2, ...]
// 统计场次-场馆关系(用于判断场次是否完全售罄)
var sessionVenues = {}; // sessionName => [venue1, venue2, ...]
// 再次遍历获取完整维度关系
for (var seatKey in this.seatSpecMap) {
var seatInfo = this.seatSpecMap[seatKey];
var sessionName = '', venueName = '', roomName = '', sectionName = '';
for (var i = 0; i < seatInfo.spec.length; i++) {
var specType = seatInfo.spec[i].type;
var specValue = seatInfo.spec[i].value;
if (specType === '$vr-场次') sessionName = specValue;
if (specType === '$vr-场馆') venueName = specValue;
if (specType === '$vr-演播室') roomName = specValue;
if (specType === '$vr-分区') sectionName = specValue;
}
// 构建关系
if (!roomSections[roomName]) roomSections[roomName] = new Set();
roomSections[roomName].add(sectionName);
if (!venueRooms[venueName]) venueRooms[venueName] = new Set();
venueRooms[venueName].add(roomName);
if (!sessionVenues[sessionName]) sessionVenues[sessionName] = new Set();
sessionVenues[sessionName].add(venueName);
}
// 判断分区是否售罄(座位数 === 0
// 关键:需要对比渲染的分区选项,因为可能存在选项中有但 seatSpecMap 中没有的情况
for (var section in sectionSeats) {
sectionSoldOut[section] = sectionSeats[section] === 0;
}
// 遍历渲染的分区选项,确保所有选项都有对应的售罄状态
var self = this;
document.querySelectorAll('#sectionSelector .vr-spec-option').forEach(function(opt) {
var section = opt.dataset.section || '';
// 如果选项不在 sectionSeats 中(意味着没有座位),也标记为售罄
if (sectionSeats[section] === undefined) {
sectionSoldOut[section] = true;
}
});
// 判断演播室是否完全售罄(所有分区都售罄)
for (var room in roomSections) {
var allSoldOut = true;
roomSections[room].forEach(function(section) {
if (!sectionSoldOut[section]) allSoldOut = false;
});
roomSoldOut[room] = allSoldOut;
}
// 判断场馆是否完全售罄(所有演播室都售罄)
for (var venue in venueRooms) {
var allSoldOut = true;
venueRooms[venue].forEach(function(room) {
if (!roomSoldOut[room]) allSoldOut = false;
});
venueSoldOut[venue] = allSoldOut;
}
// 判断场次是否完全售罄(所有场馆都售罄)
for (var session in sessionVenues) {
var allSoldOut = true;
sessionVenues[session].forEach(function(venue) {
if (!venueSoldOut[venue]) allSoldOut = false;
});
sessionSoldOut[session] = allSoldOut;
}
// 更新场次选项可用性
document.querySelectorAll('#sessionGrid .vr-session-item').forEach(function(item) {
var session = item.dataset.session || '';
if (sessionSoldOut[session]) {
item.classList.add('sold-out');
item.style.opacity = '0.4';
item.style.pointerEvents = 'none';
if (!item.querySelector('.sold-tag')) {
item.innerHTML += '<span class="sold-tag" style="color:#f56c6c;font-size:12px;margin-left:4px">(售罄)</span>';
}
} else {
item.classList.remove('sold-out');
item.style.opacity = '';
item.style.pointerEvents = '';
var tag = item.querySelector('.sold-tag');
if (tag) tag.remove();
}
});
// 更新场馆选项可用性
document.querySelectorAll('#venueSelector .vr-spec-option').forEach(function(opt) {
var venue = opt.dataset.venue || '';
if (venueSoldOut[venue]) {
opt.classList.add('sold-out');
opt.style.opacity = '0.4';
opt.style.pointerEvents = 'none';
if (!opt.querySelector('.sold-tag')) {
opt.innerHTML += '<span class="sold-tag" style="color:#f56c6c;font-size:12px;margin-left:4px">(售罄)</span>';
}
} else {
opt.classList.remove('sold-out');
opt.style.opacity = '';
opt.style.pointerEvents = '';
var tag = opt.querySelector('.sold-tag');
if (tag) tag.remove();
}
});
// 更新演播室选项可用性
document.querySelectorAll('#roomSelector .vr-spec-option').forEach(function(opt) {
var room = opt.dataset.room || '';
if (roomSoldOut[room]) {
opt.classList.add('sold-out');
opt.style.opacity = '0.4';
opt.style.pointerEvents = 'none';
if (!opt.querySelector('.sold-tag')) {
opt.innerHTML += '<span class="sold-tag" style="color:#f56c6c;font-size:12px;margin-left:4px">(售罄)</span>';
}
} else {
opt.classList.remove('sold-out');
opt.style.opacity = '';
opt.style.pointerEvents = '';
var tag = opt.querySelector('.sold-tag');
if (tag) tag.remove();
}
});
// 更新分区选项可用性
document.querySelectorAll('#sectionSelector .vr-spec-option').forEach(function(opt) {
var section = opt.dataset.section || '';
if (sectionSoldOut[section]) {
opt.classList.add('sold-out');
opt.style.opacity = '0.4';
opt.style.pointerEvents = 'none';
if (!opt.querySelector('.sold-tag')) {
opt.innerHTML += '<span class="sold-tag" style="color:#f56c6c;font-size:12px;margin-left:4px">(售罄)</span>';
}
} else {
opt.classList.remove('sold-out');
opt.style.opacity = '';
opt.style.pointerEvents = '';
var tag = opt.querySelector('.sold-tag');
if (tag) tag.remove();
}
});
},
toggleSeat: function(el) {
var seatKey = el.dataset.seatKey;
var price = parseFloat(el.dataset.price) || 0;