diff --git a/shopxo/app/plugins/vr_ticket/admin/controller/Venue.php b/shopxo/app/plugins/vr_ticket/admin/controller/Venue.php new file mode 100644 index 0000000..103db5a --- /dev/null +++ b/shopxo/app/plugins/vr_ticket/admin/controller/Venue.php @@ -0,0 +1,318 @@ +where($where) + ->order('id', 'desc') + ->paginate(20) + ->toArray(); + + // 解析 venue.name 和座位数 + foreach ($list['data'] as &$item) { + $seatMap = json_decode($item['seat_map'] ?? '{}', true); + // v3.0: venue.name 优先,否则降级取模板 name + $item['venue_name'] = $seatMap['venue']['name'] ?? $item['name']; + $item['venue_address'] = $seatMap['venue']['address'] ?? ''; + $item['zone_count'] = !empty($seatMap['sections']) ? count($seatMap['sections']) : 0; + $item['seat_count'] = self::countSeats($seatMap); + } + unset($item); + + return view('', [ + 'list' => $list['data'], + 'page' => $list['page'], + 'count' => $list['total'], + ]); + } + + /** + * 添加/编辑场馆 + * + * @note v3.0 seat_map JSON 结构: + * { + * "venue": { "name": "...", "address": "...", "image": "..." }, + * "map": ["AAAAAA", "BBBBBB"], + * "seats": { "A": { "price": 899, "color": "#e74c3c", "label": "VIP区" }, ... }, + * "sections": [{ "char": "A", "name": "VIP区", "color": "#e74c3c" }, ...], + * "row_labels": ["A", "B"] + * } + */ + public function save() + { + $id = input('id', 0, 'intval'); + + if (IS_AJAX_POST) { + // 接收表单数据 + $data = [ + 'name' => input('name', '', null, 'trim'), + 'category_id' => input('category_id', 0, 'intval'), + 'status' => input('status', 1, 'intval'), + 'upd_time' => time(), + ]; + + // 验证必填字段 + if (empty($data['name'])) { + return DataReturn('场馆名称不能为空', -1); + } + + // v3.0: 构建 seat_map JSON(venue 顶层嵌入) + $venue = [ + 'name' => input('venue_name', '', null, 'trim'), + 'address' => input('venue_address', '', null, 'trim'), + 'image' => input('venue_image', '', null, 'trim'), + ]; + if (empty($venue['name'])) { + return DataReturn('场馆名称不能为空', -1); + } + + // 解析分区配置(前端传来的 JSON) + $zones_raw = input('zones', '[]', null, 'trim'); + $zones = json_decode($zones_raw, true); + if (!is_array($zones) || empty($zones)) { + return DataReturn('请至少添加一个分区', -1); + } + + // 解析座位排布(每排座位数字符串数组) + $map_raw = input('seat_map_rows', '[]', null, 'trim'); + $map = json_decode($map_raw, true); + if (!is_array($map) || empty($map)) { + return DataReturn('座位排布不能为空', -1); + } + + // 构建 sections(分区元信息) + $sections = []; + $seats = []; // 每行的默认元信息 + $row_labels = []; + foreach ($zones as $zone) { + $char = $zone['char'] ?? ''; + if (empty($char)) { + return DataReturn('分区字符不能为空', -1); + } + $sections[] = [ + 'char' => strtoupper($char), + 'name' => $zone['name'] ?? '', + 'color' => $zone['color'] ?? '#cccccc', + ]; + $seats[strtoupper($char)] = [ + 'price' => intval($zone['price'] ?? 0), + 'color' => $zone['color'] ?? '#cccccc', + 'label' => $zone['name'] ?? '', + ]; + } + + // 构建 row_labels + $row_labels = array_unique(array_column($sections, 'char')); + + // 验证:每排座位数与 char 匹配 + foreach ($map as $rowStr) { + $rowStr = trim($rowStr); + if (empty($rowStr)) { + return DataReturn('座位排布每行不能为空', -1); + } + // 每行的每个字符必须是已定义的 zone char + foreach (str_split($rowStr) as $char) { + if ($char !== '_' && !isset($seats[$char])) { + return DataReturn("座位排布中字符 '{$char}' 未在分区中定义", -1); + } + } + $row_labels[] = $rowStr[0]; // 第一字符作为行标签 + } + + // 组装 seat_map v3.0 结构 + $data['seat_map'] = json_encode([ + 'venue' => $venue, + 'map' => $map, + 'seats' => $seats, + 'sections' => $sections, + 'row_labels' => array_values(array_unique($row_labels)), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if ($id > 0) { + $data['upd_time'] = time(); + \Db::name('plugins_vr_seat_templates')->where('id', $id)->update($data); + return DataReturn('更新成功', 0, ['url' => MyUrl('plugins_vr_ticket/admin/venue/list')]); + } else { + $data['add_time'] = time(); + $data['upd_time'] = time(); + $data['spec_base_id_map'] = ''; + \Db::name('plugins_vr_seat_templates')->insert($data); + return DataReturn('添加成功', 0, ['url' => MyUrl('plugins_vr_ticket/admin/venue/list')]); + } + } + + // 编辑时加载数据 + $info = []; + if ($id > 0) { + $row = \Db::name('plugins_vr_seat_templates')->find($id); + if (!empty($row)) { + $seatMap = json_decode($row['seat_map'] ?? '{}', true); + $row['venue_name'] = $seatMap['venue']['name'] ?? ''; + $row['venue_address'] = $seatMap['venue']['address'] ?? ''; + $row['venue_image'] = $seatMap['venue']['image'] ?? ''; + $row['zones_json'] = json_encode($seatMap['sections'] ?? [], JSON_UNESCAPED_UNICODE); + $row['map_json'] = json_encode($seatMap['map'] ?? [], JSON_UNESCAPED_UNICODE); + $info = $row; + } + } + + // 加载分类列表 + $categories = \Db::name('GoodsCategory') + ->where('is_delete', 0) + ->order('id', 'asc') + ->select(); + + return view('', [ + 'info' => $info, + 'categories' => $categories, + ]); + } + + /** + * 删除场馆(软删除) + */ + public function delete() + { + if (!IS_AJAX_POST) { + return DataReturn('非法请求', -1); + } + + $id = input('id', 0, 'intval'); + if ($id <= 0) { + return DataReturn('参数错误', -1); + } + + $template = \Db::name('plugins_vr_seat_templates')->where('id', $id)->find(); + \Db::name('plugins_vr_seat_templates') + ->where('id', $id) + ->update(['status' => 0, 'upd_time' => time()]); + + // 审计日志 + \app\plugins\vr_ticket\service\AuditService::log( + \app\plugins\vr_ticket\service\AuditService::ACTION_DISABLE_TEMPLATE, + \app\plugins\vr_ticket\service\AuditService::TARGET_TEMPLATE, + $id, + ['before_status' => $template['status'] ?? 1], + $template ? "场馆: {$template['name']}" : "ID:{$id}" + ); + + return DataReturn('删除成功', 0); + } + + /** + * 预览 seat_map JSON(用于调试) + * GET ?s=plugins/vr_ticket/admin/venue/preview + */ + public function preview() + { + if (!IS_AJAX_POST && !IS_GET) { + return DataReturn('非法请求', -1); + } + + $zones_raw = input('zones', '[]', null, 'trim'); + $map_raw = input('seat_map_rows', '[]', null, 'trim'); + + $zones = json_decode($zones_raw, true); + $map = json_decode($map_raw, true); + + if (!is_array($zones)) { + return DataReturn('zones JSON 格式错误', -1); + } + if (!is_array($map)) { + return DataReturn('seat_map_rows JSON 格式错误', -1); + } + + // 构建预览用 seat_map + $sections = []; + $seats = []; + foreach ($zones as $zone) { + $char = strtoupper($zone['char'] ?? ''); + $sections[] = [ + 'char' => $char, + 'name' => $zone['name'] ?? '', + 'color' => $zone['color'] ?? '#cccccc', + ]; + $seats[$char] = [ + 'price' => intval($zone['price'] ?? 0), + 'color' => $zone['color'] ?? '#cccccc', + 'label' => $zone['name'] ?? '', + ]; + } + + $seat_map = [ + 'venue' => [ + 'name' => input('venue_name', '未命名场馆', null, 'trim'), + 'address' => input('venue_address', '', null, 'trim'), + 'image' => input('venue_image', '', null, 'trim'), + ], + 'map' => $map, + 'seats' => $seats, + 'sections' => $sections, + 'row_labels' => array_values(array_unique( + array_merge( + array_column($sections, 'char'), + array_map(fn($r) => $r[0] ?? '', $map) + ) + )), + ]; + + $seat_count = self::countSeats($seat_map); + + return DataReturn('预览生成成功', 0, [ + 'seat_map' => json_encode($seat_map, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), + 'seat_count' => $seat_count, + 'zone_count' => count($sections), + ]); + } + + /** + * 统计座位数 + */ + private static function countSeats($seatMap) + { + if (empty($seatMap) || empty($seatMap['seats']) || empty($seatMap['map'])) { + return 0; + } + $count = 0; + foreach ($seatMap['map'] as $row) { + foreach (str_split($row) as $char) { + if ($char !== '_' && isset($seatMap['seats'][$char])) { + $count++; + } + } + } + return $count; + } +} diff --git a/shopxo/app/plugins/vr_ticket/admin/view/venue/list.html b/shopxo/app/plugins/vr_ticket/admin/view/venue/list.html new file mode 100644 index 0000000..02f4f51 --- /dev/null +++ b/shopxo/app/plugins/vr_ticket/admin/view/venue/list.html @@ -0,0 +1,113 @@ + + + + + + + +
+
+
+ 场馆配置管理 + + 添加场馆 + +
+
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + 重置 +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID场馆名称场馆地址分区数座位数绑定分类状态操作
{$vo.id}{$vo.venue_name}{$vo.venue_address|default='—'}{$vo.zone_count}{$vo.seat_count}{$vo.category_name|default='—'} + + 启用 + + 禁用 + + + 编辑 + 座位模板 + 删除 +
暂无数据
+ +
{$page|raw}
+
+
+
+ + + + + diff --git a/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php b/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php index d8fd899..8e31e9e 100644 --- a/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php +++ b/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php @@ -26,10 +26,10 @@ class SeatSkuService extends BaseService * * @param int $goodsId 商品ID * @param int $seatTemplateId 座位模板ID - * @return array ['code' => 0, 'msg' => '...', 'data' => ['total' => N, 'generated' => N, 'spec_base_id_map' => ['seatId' => spec_base_id, ...]]] + * @return array ['code' => 0, 'msg' => '...', 'data' => ['total' => N, 'generated' => N, 'spec_base_id_map' => ['seatId' => ['spec_base_id' => int, 'zone_id' => string, 'row' => string, 'col' => int], ...]]] * * spec_base_id_map 格式:前端 ticket_detail.html 使用 seatKey(如 "A_1")作为 key, - * 期望 value 为整数 spec_base_id(如 2001)。 + * 期望 value 为嵌套对象 {spec_base_id, zone_id, row, col},而非 flat spec_base_id */ public static function BatchGenerate(int $goodsId, int $seatTemplateId): array { @@ -55,7 +55,9 @@ class SeatSkuService extends BaseService } // 3. 获取/确认 VR 规格类型ID($vr-场馆 / $vr-分区 / $vr-时段 / $vr-座位号) - $specTypeIds = self::ensureVrSpecTypes($goodsId); + // v3.0: venue.name 优先,否则降级取模板 name 或默认值 + $venueName = $seatMap['venue']['name'] ?? $template['name'] ?? '未命名场馆'; + $specTypeIds = self::ensureVrSpecTypes($goodsId, $venueName); if ($specTypeIds['code'] !== 0) { return $specTypeIds; } @@ -98,7 +100,7 @@ class SeatSkuService extends BaseService 'row' => $rowIndex, 'col' => $colIndex, 'char' => $char, - 'label' => $seatInfo['label'] ?? ($rowLabel . '排' . ($colIndex + 1) . '座'), + 'label' => $seatInfo['label'] ?? ($rowLabel . '排' . ($colIndex + 1) . '座'), // 如 "A排1座" 'price' => $seatPrice, 'zone' => $zoneName, 'row_label' => $rowLabel, @@ -186,8 +188,8 @@ class SeatSkuService extends BaseService 'goods_id' => $goodsId, 'goods_spec_base_id' => $specBaseId, 'spec_type_id' => $typeVenue, - 'value' => '国家体育馆', - 'md5_key' => md5('国家体育馆'), + 'value' => $venueName, + 'md5_key' => md5($venueName), 'add_time' => $now, ]; // $vr-分区(zone 名称) @@ -218,7 +220,12 @@ class SeatSkuService extends BaseService 'add_time' => $now, ]; - $specBaseIdMap[$seatId] = $specBaseId; + $specBaseIdMap[$seatId] = [ + 'spec_base_id' => $specBaseId, + 'zone_id' => $seat['zone'], + 'row' => $seat['row_label'], + 'col' => $seat['col_num'], + ]; $generatedCount++; } @@ -257,12 +264,12 @@ class SeatSkuService extends BaseService * @param int $goodsId * @return array */ - private static function ensureVrSpecTypes(int $goodsId): array + private static function ensureVrSpecTypes(int $goodsId, string $venueName = '未命名场馆'): array { $now = time(); $specTypeNames = ['$vr-场馆', '$vr-分区', '$vr-时段', '$vr-座位号']; $defaultValues = [ - '$vr-场馆' => '[{"name":"国家体育馆","images":""}]', + '$vr-场馆' => json_encode([['name' => $venueName, 'images' => '']]), '$vr-分区' => '[{"name":"A区","images":""},{"name":"B区","images":""},{"name":"C区","images":""}]', '$vr-时段' => '[{"name":"待选场次","images":""}]', '$vr-座位号' => '[{"name":"待选座位","images":""}]', @@ -362,7 +369,10 @@ class SeatSkuService extends BaseService * * @param int $goodsId * @param int $typeSeatId $vr-座位号 spec_type_id - * @return array [seatId => spec_base_id] + * @return array [seatId => ['spec_base_id' => int, 'zone_id' => string, 'row' => string, 'col' => int]] + * + * 注意:zone_id / row / col 字段在从旧数据(flat格式)读取时为空字符串, + * 前端 ticket_detail.html 只需要 spec_base_id 即可 */ private static function getExistingSpecBaseIds(int $goodsId, int $typeSeatId): array { @@ -381,13 +391,16 @@ class SeatSkuService extends BaseService foreach ($rows as $seatLabel => $baseId) { // 从 seat_label 解析 seatId(如 "A排1座" → "A_1") // 格式: "{rowLabel}排{colNum}座" - // Bug fix: 原正则 `^([A-Za-z]+)(\d+)排(\d)座$` 第二个 `\d+` 会吞掉 colNum 的高位数字, - // 例如 "A排10座" 匹配为 rowLabel="A" colNum=1(错误),应为 colNum=10 if (preg_match('/^([A-Za-z]+)排(\d+)座$/', $seatLabel, $m)) { $rowLabel = $m[1]; $colNum = intval($m[2]); $seatId = $rowLabel . '_' . $colNum; - $seatIdMap[$seatId] = intval($baseId); + $seatIdMap[$seatId] = [ + 'spec_base_id' => intval($baseId), + 'zone_id' => '', + 'row' => $rowLabel, + 'col' => $colNum, + ]; } }