feat(Phase 3-1): Venue.php CRUD + list.html + BatchGenerate venue.name 动态读取

- 新增 admin/controller/Venue.php:场馆配置 CRUD
  - list(): 解析 seat_map.venue.name 展示,zone_count / seat_count
  - save(): 构建 v3.0 seat_map JSON(venue + map + seats + sections)
  - delete(): 软删除 + 审计日志
  - preview(): 调试接口,返回 seat_map JSON + seat_count

- 新增 admin/view/venue/list.html:场馆列表页

- 改造 SeatSkuService.php BatchGenerate:
  - ensureVrSpecTypes() 增加 $venueName 参数
  - $vr-场馆 spec 值从 seat_map.venue.name 读取,不再硬编码
  - 降级:取模板 name 或 '未命名场馆'

关联:docs/11_EDITOR_AND_INJECTION_DESIGN.md v3.0
refactor/vr-ticket-20260416
Council 2026-04-15 22:02:03 +08:00
parent cd5160793d
commit 136efb9b92
3 changed files with 457 additions and 13 deletions

View File

@ -0,0 +1,318 @@
<?php
/**
* VR票务插件 - 场馆配置管理
*
* 管理 venue + zone + seat layout 配置。
* 数据存储在 vr_seat_templates seat_map JSON 格式遵循 v3.0 规范。
*
* @package vr_ticket\admin\controller
*/
namespace app\plugins\vr_ticket\admin\controller;
class Venue extends Base
{
public function __construct()
{
parent::__construct();
}
/**
* 场馆列表
*/
public function list()
{
$where = [];
$name = input('name', '', null);
if ($name !== '') {
$where[] = ['name', 'like', "%{$name}%"];
}
$status = input('status', '', null);
if ($status !== '' && $status !== null) {
$where[] = ['status', '=', intval($status)];
}
$list = \Db::name('plugins_vr_seat_templates')
->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 JSONvenue 顶层嵌入)
$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;
}
}

View File

@ -0,0 +1,113 @@
<!DOCTYPE html>
<html>
<head>
<include file="public::head" />
<style>
.venue-preview-img { width: 60px; height: 40px; object-fit: cover; border-radius: 4px; }
.zone-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; margin: 2px; }
</style>
</head>
<body>
<div class="layui-fluid">
<div class="layui-card">
<div class="layui-card-header">
<span>场馆配置管理</span>
<a href="{:MyUrl('plugins_vr_ticket/admin/venue/save')}" class="layui-btn layui-btn-sm layui-btn-normal fr">
<i class="layui-icon layui-icon-add-1"></i> 添加场馆
</a>
</div>
<div class="layui-card-body">
<!-- 搜索栏 -->
<form class="layui-form layui-form-pane" method="get">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">场馆名称</label>
<div class="layui-input-inline" style="width:200px;">
<input type="text" name="name" value="{:input('name')}" placeholder="搜索场馆名称" class="layui-input" />
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">状态</label>
<div class="layui-input-inline" style="width:120px;">
<select name="status" lay-search="">
<option value="">全部</option>
<option value="1" {:input('status')=== '1' ? 'selected' : ''}>启用</option>
<option value="0" {:input('status')=== '0' ? 'selected' : ''}>禁用</option>
</select>
</div>
</div>
<div class="layui-inline">
<button type="submit" class="layui-btn layui-btn-primary"><i class="layui-icon layui-icon-search"></i> 搜索</button>
<a href="{:MyUrl('plugins_vr_ticket/admin/venue/list')}" class="layui-btn layui-btn-primary">重置</a>
</div>
</div>
</form>
<!-- 数据表格 -->
<table class="layui-table" lay-skin="line">
<thead>
<tr>
<th>ID</th>
<th>场馆名称</th>
<th>场馆地址</th>
<th>分区数</th>
<th>座位数</th>
<th>绑定分类</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<notempty name="list">
<foreach name="list" item="vo">
<tr>
<td>{$vo.id}</td>
<td>{$vo.venue_name}</td>
<td class="layui-elip" style="max-width:200px;">{$vo.venue_address|default='—'}</td>
<td><span class="layui-badge">{$vo.zone_count}</span></td>
<td><span class="layui-badge layui-bg-blue">{$vo.seat_count}</span></td>
<td>{$vo.category_name|default='—'}</td>
<td>
<eq name="vo.status" value="1">
<span class="layui-badge layui-bg-green">启用</span>
<else/>
<span class="layui-badge layui-bg-gray">禁用</span>
</eq>
</td>
<td>
<a href="{:MyUrl('plugins_vr_ticket/admin/venue/save', ['id'=>$vo['id']])}" class="layui-btn layui-btn-xs">编辑</a>
<a href="{:MyUrl('plugins_vr_ticket/admin/seat_template/save', ['id'=>$vo['id']])}" class="layui-btn layui-btn-xs layui-btn-primary">座位模板</a>
<a class="layui-btn layui-btn-xs layui-btn-danger js-delete" data-id="{$vo.id}">删除</a>
</td>
</tr>
</foreach>
<else/>
<tr><td colspan="8" class="layui-text-center">暂无数据</td></tr>
</notempty>
</tbody>
</table>
<div>{$page|raw}</div>
</div>
</div>
</div>
<include file="public::foot" />
<script>
$('.js-delete').on('click', function() {
var id = $(this).data('id');
layer.confirm('确认删除该场馆?', function(index) {
$.post('{:MyUrl("plugins_vr_ticket/admin/venue/delete")}', {id: id}, function(res) {
if (res.code === 0) {
layer.msg(res.msg, {icon: 1});
location.reload();
} else {
layer.msg(res.msg, {icon: 2});
}
});
layer.close(index);
});
});
</script>
</body>
</html>

View File

@ -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,
];
}
}