feat(ticket): 票务管理后台增强 - 订单信息展示、城市筛选修复、观影人管理

- Admin.php: 票详情页新增订单信息、用户信息展示
- Hook.php: 修复城市筛选只对票务商品生效
- Ticket.php: 核销记录新增短码、观演人手机号、订单号、购买者信息
- AdminGoodsSaveHandle.php: 新增商品保存后置处理 - SKU重建、价格刷新、缓存清理
- AdminGoodsSave.php: 观影人配置UI优化
- detail.html: 票详情页重构 - 新增订单信息展示面板
- list.html: 票列表新增订单号、手机号列,优化列宽
fix/ticket-select-refresh
Council 2026-06-25 17:16:58 +08:00
parent 2ceb7824aa
commit e053e03c7c
7 changed files with 230 additions and 99 deletions

View File

@ -508,6 +508,7 @@ class Hook
* 触发位置plugins_service_goods_list_begin
*
* 向下兼容:无 user_lng/user_lat 参数时,不添加任何筛选,返回全部商品
* 修复只对票务商品vr_goods_config 非空)应用城市筛选,非票务商品不受影响
*
* @param array &$params 钩子参数引用 ['hook_name','is_backend','params','where','field','order_by','m','n']
*/
@ -540,12 +541,22 @@ class Hook
return;
}
// 注入 produce_region 城市筛选条件
// 修复:只对票务商品应用城市筛选
// 逻辑:(非票务商品 vr_goods_config='') OR (票务商品且城市匹配)
$where = &$params['where'];
if (!is_array($where)) {
$where = [];
}
$where[] = ['g.produce_region', '=', $cityId];
// 使用闭包实现 OR 条件vr_goods_config 为空(非票务) OR produce_region = 城市ID
$where[] = function($query) use ($cityId) {
$query->where(function($q) {
$q->where('g.vr_goods_config', '=', '');
})->whereOr(function($q) use ($cityId) {
$q->where('g.vr_goods_config', '<>', '')->where('g.produce_region', '=', $cityId);
});
};
// @file_put_contents($debugFile, date('Y-m-d H:i:s') . " OnGoodsListBegin: city filter applied to ticket goods only, cityId=$cityId\n", FILE_APPEND);
}
/**
@ -553,6 +564,7 @@ class Hook
* 触发位置plugins_service_search_goods_list_begin
*
* 向下兼容:无 user_lng/user_lat 参数时,不添加任何筛选,返回全部商品
* 修复只对票务商品vr_goods_config 非空)应用城市筛选,非票务商品不受影响
*
* @param array &$params 钩子参数引用 ['hook_name','is_backend','params','where_base','where_keywords','where_screening_price','field','order_by','page','page_start','page_size']
*/
@ -586,13 +598,23 @@ class Hook
return;
}
// 注入 produce_region 城市筛选条件SearchService 使用 where_base 而非 where
// 修复:只对票务商品应用城市筛选
// 逻辑:(非票务商品 vr_goods_config='') OR (票务商品且城市匹配)
// SearchService 使用 where_base 而非 where
$whereBase = &$params['where_base'];
if (!is_array($whereBase)) {
$whereBase = [];
}
$whereBase[] = ['g.produce_region', '=', $cityId];
// [调试代码] @file_put_contents($debugFile, date('Y-m-d H:i:s') . " OnSearchListBegin: city filter applied, cityId=$cityId\n", FILE_APPEND);
// 使用闭包实现 OR 条件vr_goods_config 为空(非票务) OR produce_region = 城市ID
$whereBase[] = function($query) use ($cityId) {
$query->where(function($q) {
$q->where('g.vr_goods_config', '=', '');
})->whereOr(function($q) use ($cityId) {
$q->where('g.vr_goods_config', '<>', '')->where('g.produce_region', '=', $cityId);
});
};
// [调试代码] @file_put_contents($debugFile, date('Y-m-d H:i:s') . " OnSearchListBegin: city filter applied to ticket goods only, cityId=$cityId\n", FILE_APPEND);
}
/**

View File

@ -197,6 +197,18 @@ class Admin extends Common
$goods = \think\facade\Db::name('Goods')->find($ticket['goods_id']);
// 查询订单信息
$order = [];
$user = [];
if ($ticket['order_id'] > 0) {
$order = \think\facade\Db::name('Order')->find($ticket['order_id']);
// 查询用户信息
if (!empty($order) && $order['user_id'] > 0) {
$user = \think\facade\Db::name('User')->find($order['user_id']);
}
}
$verifier = [];
if ($ticket['verifier_id'] > 0) {
$verifier = \think\facade\Db::name('vr_verifiers')->find($ticket['verifier_id']);
@ -204,6 +216,12 @@ class Admin extends Common
$ticket['qr_code_url'] = \app\plugins\vr_ticket\service\TicketService::getQrCodeUrl($ticket['ticket_code']);
// 提取短码(从 qr_data 的前半部分)
$short_code = '';
if (!empty($ticket['qr_data']) && strpos($ticket['qr_data'], '|') !== false) {
$short_code = explode('|', $ticket['qr_data'], 2)[0];
}
$verifiers = \think\facade\Db::name('vr_verifiers')
->where('status', 1)
->order('id', 'asc')
@ -212,8 +230,11 @@ class Admin extends Common
return MyView('../../../plugins/vr_ticket/view/admin/ticket/detail', [
'ticket' => $ticket,
'goods' => $goods,
'order' => $order,
'user' => $user,
'verifier' => $verifier,
'verifiers' => $verifiers,
'short_code'=> $short_code,
]);
}

View File

@ -381,7 +381,8 @@ class Ticket
->where($where)
->order('id', 'desc')
->page($page, $page_size)
->select();
->select()
->toArray();
$total = \think\facade\Db::name('vr_verifications')
->where($where)
->count();
@ -389,12 +390,28 @@ class Ticket
// 关联票和商品信息
$ticket_ids = array_filter(array_column($list, 'ticket_id'));
$tickets_map = [];
$user_ids = [];
if (!empty($ticket_ids)) {
$tickets_raw = \think\facade\Db::name('vr_tickets')
->where('id', 'in', $ticket_ids)
->select();
->select()
->toArray();
foreach ($tickets_raw as $t) {
$tickets_map[$t['id']] = $t;
if (!empty($t['user_id'])) {
$user_ids[] = $t['user_id'];
}
}
}
$users_map = [];
if (!empty($user_ids)) {
$users_raw = \think\facade\Db::name('User')
->where('id', 'in', array_unique($user_ids))
->select()
->toArray();
foreach ($users_raw as $u) {
$users_map[$u['id']] = !empty($u['nickname']) ? $u['nickname'] : (!empty($u['username']) ? $u['username'] : '用户' . $u['id']);
}
}
@ -410,12 +427,27 @@ class Ticket
$result = [];
foreach ($list as $v) {
$ticket = $tickets_map[$v['ticket_id']] ?? [];
// 提取短码(从 qr_data 的前半部分)
$short_code = '';
if (!empty($ticket['qr_data']) && strpos($ticket['qr_data'], '|') !== false) {
$short_code = explode('|', $ticket['qr_data'], 2)[0];
} else {
$short_code = !empty($ticket['ticket_code']) ? substr($ticket['ticket_code'], 0, 8) : '';
}
$buyer_name = $users_map[$ticket['user_id'] ?? 0] ?? '';
$result[] = [
'id' => $v['id'],
'ticket_id' => $v['ticket_id'],
'ticket_code' => $v['ticket_code'],
'short_code' => $short_code,
'seat_info' => $ticket['seat_info'] ?? '',
'real_name' => $ticket['real_name'] ?? '',
'real_name' => $ticket['real_name'] ?? '',
'phone' => $ticket['phone'] ?? '',
'order_no' => $ticket['order_no'] ?? '',
'buyer_name' => $buyer_name,
'goods_title' => $goods_map[$v['goods_id']] ?? '已下架商品',
'created_at' => $v['created_at'],
];

View File

@ -140,7 +140,7 @@ class AdminGoodsSave
<div v-show="config.viewer_config.require_viewer" style="margin-left:20px; margin-top:10px; padding-left:15px; border-left:3px solid #ddd;">
<div style="margin-bottom:8px;">
<label class="am-checkbox-inline">
<input type="checkbox" v-model="config.viewer_config.require_viewer_mobile" disabled checked />
<input type="checkbox" v-model="config.viewer_config.require_viewer_mobile" checked disabled />
<span style="margin-left:5px;">必填手机号</span>
<small style="color:#999; margin-left:8px;">(核心字段,默认必填)</small>
</label>
@ -157,11 +157,11 @@ class AdminGoodsSave
<label class="am-checkbox-inline">
<input type="checkbox" v-model="config.viewer_config.require_viewer_idcard" />
<span style="margin-left:5px;">必填身份证号</span>
<small style="color:#999; margin-left:8px;">(预留功能)</small>
</label>
</div>
<div style="margin-bottom:8px;">
<!-- 每个座位独立填写观影人阶段2功能暂时隐藏 -->
<div v-if="false" style="margin-bottom:8px;">
<label class="am-checkbox-inline">
<input type="checkbox" v-model="config.viewer_config.require_viewer_per_seat" disabled />
<span style="margin-left:5px;">每个座位独立填写观影人</span>

View File

@ -230,6 +230,9 @@ class AdminGoodsSaveHandle
// c) 重新计算 goods.price / goods.inventory
SeatSkuService::refreshGoodsBase($goodsId);
// d) 清除 Tree API 缓存(避免前端看到过期数据)
self::clearTreeCache($goodsId);
}
}
}
@ -237,6 +240,37 @@ class AdminGoodsSaveHandle
return ['code' => 0];
}
return ['code' => 0];
}
/**
* 清除指定商品的 Tree API 缓存
*
* @param int $goodsId 商品ID
* @return void
*/
private static function clearTreeCache($goodsId)
{
if ($goodsId <= 0) {
return;
}
// Tree API 缓存 key 格式vr_tree_v4_{goods_id}_{md5(group_by)}
// 由于 group_by 参数可能有多种组合,清除所有可能的缓存
$possibleGroupBys = [
'venue,session,room,section', // 默认组合
'session,venue,room,section', // 按场次优先
'venue,room,session,section', // 其他可能组合
'session,room,section', // 简化组合
];
foreach ($possibleGroupBys as $groupBy) {
$cacheKey = 'vr_tree_v4_' . $goodsId . '_' . md5($groupBy);
\think\facade\Cache::delete($cacheKey);
}
// 记录日志
\think\facade\Log::info('[VR Ticket] Cleared Tree API cache for goods: ' . $goodsId);
}
}

View File

@ -19,21 +19,25 @@
<td width="100" class="am-text-gray">票码</td>
<td>{{$ticket.ticket_code}}</td>
</tr>
<tr>
<td class="am-text-gray">短码</td>
<td><strong style="font-size: 16px; color: #0e90d2; font-family: monospace; letter-spacing: 1px;">{{if !empty($short_code)}}{{$short_code}}{{else}}-{{/if}}</strong></td>
</tr>
<tr>
<td class="am-text-gray">订单号</td>
<td>{{$ticket.order_no}}</td>
</tr>
<tr>
<td class="am-text-gray">商品名</td>
<td>{{$ticket.goods_name}}</td>
<td>{{if !empty($goods)}}{{$goods.title}}{{else}}已删除商品{{/if}}</td>
</tr>
<tr>
<td class="am-text-gray">观演人</td>
<td>{{$ticket.visitor_name}}</td>
<td>{{if !empty($ticket.real_name)}}{{$ticket.real_name}}{{else}}-{{/if}}</td>
</tr>
<tr>
<td class="am-text-gray">手机号</td>
<td>{{$ticket.mobile}}</td>
<td>{{if !empty($ticket.phone)}}{{$ticket.phone}}{{else}}-{{/if}}</td>
</tr>
<tr>
<td class="am-text-gray">身份证</td>
@ -46,27 +50,27 @@
<tr>
<td class="am-text-gray">状态</td>
<td>
{{if $ticket.status == 0}}
{{if $ticket.verify_status == 0}}
<span class="am-badge am-badge-warning">未核销</span>
{{elseif $ticket.status == 1}}
{{elseif $ticket.verify_status == 1}}
<span class="am-badge am-badge-success">已核销</span>
{{elseif $ticket.status == 2}}
{{elseif $ticket.verify_status == 2}}
<span class="am-badge am-badge-danger">已退款</span>
{{/if}}
</td>
</tr>
<tr>
<td class="am-text-gray">发放时间</td>
<td>{{$ticket.create_time}}</td>
<td>{{if !empty($ticket.issued_at)}}{{:date('Y-m-d H:i:s', $ticket.issued_at)}}{{else}}-{{/if}}</td>
</tr>
{{if $ticket.status == 1}}
{{if $ticket.verify_status == 1}}
<tr>
<td class="am-text-gray">核销时间</td>
<td>{{$ticket.verify_time}}</td>
<td>{{if !empty($ticket.verify_time)}}{{:date('Y-m-d H:i:s', $ticket.verify_time)}}{{else}}-{{/if}}</td>
</tr>
<tr>
<td class="am-text-gray">核销人</td>
<td>{{if !empty($ticket.verifier_name)}}{{$ticket.verifier_name}}{{else}}未知{{/if}}</td>
<td>{{if !empty($verifier)}}{{$verifier.name}}{{else}}未知{{/if}}</td>
</tr>
{{/if}}
</table>
@ -74,89 +78,89 @@
</div>
</div>
<!-- 右侧:二维码 -->
<!-- 右侧:订单信息和核销操作 -->
<div class="am-u-sm-6">
<!-- 订单信息 -->
<div class="am-panel am-panel-default">
<div class="am-panel-hd">票条形码</div>
<div class="am-panel-bd am-text-center">
<div id="qrcode-container" class="am-margin-bottom">
<!-- QR 码将由 JsBarcode 生成 -->
<svg id="qrcode-svg"></svg>
</div>
<p class="am-text-gray am-text-sm">票码:{{$ticket.ticket_code}}</p>
<!-- 条形码 -->
<div class="am-margin-top">
<svg id="barcode"></svg>
</div>
</div>
</div>
<!-- 核销操作(仅未核销状态显示) -->
{{if $ticket.status == 0}}
<div class="am-panel am-panel-default am-margin-top">
<div class="am-panel-hd">核销操作</div>
<div class="am-panel-hd">订单信息</div>
<div class="am-panel-bd">
<form class="am-form form-validation" id="verify-form">
<input type="hidden" name="ticket_code" value="{{$ticket.ticket_code}}" />
<div class="am-form-group">
<button type="submit" class="am-btn am-btn-primary am-btn-block am-radius" data-am-loading="{spinner: 'circle-o-notch', loadingText: '核销中...'}">
<i class="am-icon-check"></i> 立即核销
</button>
</div>
</form>
{{if !empty($order)}}
<table class="am-table am-text-sm">
<tr>
<td width="100" class="am-text-gray">订单号</td>
<td>{{$order.order_no}}</td>
</tr>
<tr>
<td class="am-text-gray">用户昵称</td>
<td>{{if !empty($user)}}{{$user.nickname|default=$user.username|default='未设置'}}{{else}}-{{/if}}</td>
</tr>
<tr>
<td class="am-text-gray">订单状态</td>
<td>
{{if $order.status == 0}}
<span class="am-badge am-badge-secondary">待确认</span>
{{elseif $order.status == 1}}
<span class="am-badge am-badge-warning">待支付</span>
{{elseif $order.status == 2}}
<span class="am-badge am-badge-primary">待发货</span>
{{elseif $order.status == 3}}
<span class="am-badge am-badge-primary">待收货</span>
{{elseif $order.status == 4}}
<span class="am-badge am-badge-success">已完成</span>
{{elseif $order.status == 5}}
<span class="am-badge am-badge-danger">已取消</span>
{{elseif $order.status == 6}}
<span class="am-badge am-badge-danger">已关闭</span>
{{else}}
<span class="am-badge">未知</span>
{{/if}}
</td>
</tr>
<tr>
<td class="am-text-gray">支付状态</td>
<td>
{{if isset($order.pay_status) && $order.pay_status == 1}}
<span class="am-badge am-badge-success">已支付</span>
{{else}}
<span class="am-badge am-badge-warning">未支付</span>
{{/if}}
</td>
</tr>
<tr>
<td class="am-text-gray">订单金额</td>
<td class="am-text-danger"><strong>¥{{$order.total_price|default='0.00'}}</strong></td>
</tr>
<tr>
<td class="am-text-gray">购买数量</td>
<td><strong>{{$order.buy_number_count|default='0'}}</strong> 张票</td>
</tr>
<tr>
<td class="am-text-gray">支付方式</td>
<td>{{if !empty($order.payment_name)}}{{$order.payment_name}}{{else}}-{{/if}}</td>
</tr>
<tr>
<td class="am-text-gray">支付时间</td>
<td>{{if !empty($order.pay_time) && $order.pay_time > 0}}{{:date('Y-m-d H:i:s', $order.pay_time)}}{{else}}<span class="am-text-warning">未支付</span>{{/if}}</td>
</tr>
<tr>
<td class="am-text-gray">下单时间</td>
<td>{{if !empty($order.add_time)}}{{:date('Y-m-d H:i:s', $order.add_time)}}{{else}}-{{/if}}</td>
</tr>
<tr>
<td class="am-text-gray">用户备注</td>
<td>{{if !empty($order.user_note)}}{{$order.user_note}}{{else}}<span class="am-text-gray"></span>{{/if}}</td>
</tr>
</table>
{{else}}
<p class="am-text-center am-text-gray am-padding">订单信息不存在或已删除</p>
{{/if}}
</div>
</div>
{{/if}}
</div>
</div>
</div>
<script src="{{$public_host}}static/common/lib/JsBarcode/JsBarcode.all.min.js"></script>
<script>
$(function() {
// 生成条形码
var ticketCode = '{{$ticket.ticket_code}}';
if (typeof JsBarcode !== 'undefined') {
JsBarcode('#barcode', ticketCode, {
format: 'CODE128',
width: 2,
height: 60,
displayValue: true,
fontSize: 14
});
}
// 核销表单提交
$('#verify-form').on('submit', function(e) {
e.preventDefault();
var $btn = $(this).find('button[type="submit"]');
$btn.button('loading');
$.ajax({
url: '{{:PluginsAdminUrl("vr_ticket", "admin", "ticketverify")}}',
type: 'POST',
data: { ticket_code: ticketCode },
dataType: 'json',
success: function(res) {
$btn.button('reset');
if (res.code === 0) {
alert('核销成功');
location.reload();
} else {
alert(res.msg || '核销失败');
}
},
error: function() {
$btn.button('reset');
alert('网络请求失败');
}
});
});
});
</script>
{{:ModuleInclude('public/footer')}}

View File

@ -9,6 +9,20 @@
border-bottom: 2px solid #e8e8e8;
margin-bottom: 20px;
}
/* 列宽优化 */
.ticket-list-table th:nth-child(4), /* 手机号 */
.ticket-list-table td:nth-child(4) {
width: 12%; /* 增加100% */
min-width: 110px;
}
.ticket-list-table th:nth-child(5), /* 座位信息 */
.ticket-list-table td:nth-child(5) {
width: 8%; /* 缩减30% */
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
<div class="right-content">
@ -89,11 +103,13 @@
<div class="am-panel am-panel-default">
<div class="am-panel-hd">电子票列表 <span class="am-text-xs am-padding-left">共 {{$count|default=0}} 条</span></div>
<div class="am-panel-bd">
<table class="am-table am-table-striped am-table-hover am-text-middle">
<table class="am-table am-table-striped am-table-hover am-text-middle ticket-list-table">
<thead>
<tr>
<th>订单号</th>
<th>票码</th>
<th>观演人</th>
<th>手机号</th>
<th>座位信息</th>
<th>商品名</th>
<th>状态</th>
@ -105,8 +121,10 @@
{{if !empty($list)}}
{{volist name="list" id="ticket"}}
<tr>
<td>{{$ticket.order_no|default='—'}}</td>
<td>{{$ticket.ticket_code}}</td>
<td>{{$ticket.real_name|default='—'}}</td>
<td>{{$ticket.phone|default='—'}}</td>
<td>{{if !empty($ticket.seat_info)}}{{$ticket.seat_info}}{{else}}无{{/if}}</td>
<td>{{$ticket.goods_title|default='已删除商品'}}</td>
<td>
@ -121,7 +139,7 @@
<td>{{if !empty($ticket.issued_at)}}{{:date('Y-m-d H:i', $ticket.issued_at)}}{{else}}—{{/if}}
</td>
<td>
<a href="{{:PluginsAdminUrl('vr_ticket', 'admin', 'ticketdetail')}}?id={{$ticket.id}}"
<a href="{{:PluginsAdminUrl('vr_ticket', 'admin', 'TicketDetail', ['id' => $ticket.id])}}"
class="am-btn am-btn-default am-btn-xs am-radius">
<i class="am-icon-eye"></i> 查看详情
</a>
@ -130,7 +148,7 @@
{{/volist}}
{{else}}
<tr>
<td colspan="7" class="am-text-center">暂无数据</td>
<td colspan="9" class="am-text-center">暂无数据</td>
</tr>
{{/if}}
</tbody>