feat(phase4.3): 完成 C端票夹
新增文件: - api/Ticket.php: C端票夹API控制器(list/detail/refreshQr) - service/WalletService.php: 票夹核心服务 - view/goods/ticket_card.html: 票卡片共享组件 - view/goods/ticket_wallet.html: 票夹列表页 修改文件: - Hook.php: 注册订单详情页注入钩子(plugins_service_order_detail_page_info) - install.sql: 添加 qr_issued_at 字段 数据库变更: - ALTER TABLE vr_tickets ADD qr_issued_at INT UNSIGNEDfeat/phase-b-verification
parent
840157ca9d
commit
8b15283376
|
|
@ -0,0 +1,273 @@
|
|||
# Phase 4.3 实现计划:C端票夹
|
||||
|
||||
> 创建日期:2026-04-23
|
||||
> 状态:**待实现**
|
||||
> 依赖:Phase 4.1 (HMAC-XOR) + Phase 4.2 (issueTicket) 已完成
|
||||
|
||||
---
|
||||
|
||||
## 一、文件清单
|
||||
|
||||
| 步骤 | 文件 | 类型 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | `api/Ticket.php` | 新建 | C端 API 控制器 |
|
||||
| 2 | `service/WalletService.php` | 新建 | 票夹核心服务 |
|
||||
| 3 | `view/goods/ticket_card.html` | 新建 | 票卡片共享组件 |
|
||||
| 4 | `view/goods/ticket_wallet.html` | 新建 | 票夹列表页 |
|
||||
| 5 | `Hook.php` | 修改 | 注册 C 端挂载点钩子 |
|
||||
|
||||
---
|
||||
|
||||
## 二、API 设计
|
||||
|
||||
### 2.1 获取用户票列表
|
||||
|
||||
```
|
||||
GET ?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=list
|
||||
|
||||
Headers:
|
||||
X-Token: {user_token}
|
||||
|
||||
Response:
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"tickets": [
|
||||
{
|
||||
"id": 1,
|
||||
"goods_id": 118,
|
||||
"goods_title": "周杰伦演唱会",
|
||||
"seat_info": "A区-3排-15座",
|
||||
"session_time": "2026-06-01 20:00",
|
||||
"venue_name": "国家体育馆",
|
||||
"real_name": "张三",
|
||||
"verify_status": 0,
|
||||
"issued_at": 1745286000,
|
||||
"short_code": "003a2hgmgety"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 获取票详情(含 QR payload)
|
||||
|
||||
```
|
||||
GET ?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=detail&id={ticket_id}
|
||||
|
||||
Headers:
|
||||
X-Token: {user_token}
|
||||
|
||||
Response:
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"ticket": {
|
||||
"id": 1,
|
||||
"goods_id": 118,
|
||||
"goods_title": "周杰伦演唱会",
|
||||
"seat_info": "A区-3排-15座",
|
||||
"session_time": "2026-06-01 20:00",
|
||||
"venue_name": "国家体育馆",
|
||||
"real_name": "张三",
|
||||
"phone": "138****1234",
|
||||
"verify_status": 0,
|
||||
"short_code": "003a2hgmgety",
|
||||
"qr_payload": "eyJpZCI6MSwiZyI6MTE4LCJpYXQiOjE3NDUyODY2MDAsImV4cCI6MTc0NTI4NzIwMCwic2lnIjoiQTNGOUIyQzEifQ==",
|
||||
"qr_expires_at": 1745287200,
|
||||
"qr_expires_in": 1800
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 刷新 QR payload
|
||||
|
||||
```
|
||||
GET ?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=refreshQr&id={ticket_id}
|
||||
|
||||
Headers:
|
||||
X-Token: {user_token}
|
||||
|
||||
Response: 同 2.2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、数据流程
|
||||
|
||||
```
|
||||
用户访问票夹页
|
||||
↓
|
||||
Hook 注入票夹入口(或直接在商品详情页显示)
|
||||
↓
|
||||
ticket_wallet.html 加载
|
||||
↓
|
||||
JS 调用 /ticket/list API 获取票列表
|
||||
↓
|
||||
渲染 ticket_card 列表
|
||||
↓
|
||||
点击单个票 → 调用 /ticket/detail API → 展示 QR + 短码
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、实现步骤
|
||||
|
||||
### Step 1: WalletService.php
|
||||
|
||||
```php
|
||||
// service/WalletService.php
|
||||
namespace app\plugins\vr_ticket\service;
|
||||
|
||||
class WalletService extends BaseService
|
||||
{
|
||||
/**
|
||||
* 获取用户所有票
|
||||
*/
|
||||
public static function getUserTickets(int $userId): array
|
||||
|
||||
/**
|
||||
* 获取单个票详情
|
||||
*/
|
||||
public static function getTicketDetail(int $ticketId, int $userId): ?array
|
||||
|
||||
/**
|
||||
* 生成 QR payload(含缓存逻辑)
|
||||
*
|
||||
* 缓存策略:
|
||||
* - QR 有效期 30 分钟
|
||||
* - 剩余有效期 > 15 分钟:返回缓存
|
||||
* - 剩余有效期 ≤ 15 分钟:刷新
|
||||
*/
|
||||
public static function getQrPayload(int $ticketId): array
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: api/Ticket.php
|
||||
|
||||
```php
|
||||
// api/Ticket.php
|
||||
namespace app\plugins\vr_ticket\api;
|
||||
|
||||
class Ticket
|
||||
{
|
||||
/**
|
||||
* 获取用户票列表
|
||||
* GET ?s=api/plugins/...&pluginsaction=list
|
||||
*/
|
||||
public function list(): Json
|
||||
|
||||
/**
|
||||
* 获取票详情
|
||||
* GET ?s=api/plugins/...&pluginsaction=detail&id=X
|
||||
*/
|
||||
public function detail(): Json
|
||||
|
||||
/**
|
||||
* 刷新 QR payload
|
||||
* GET ?s=api/plugins/...&pluginsaction=refreshQr&id=X
|
||||
*/
|
||||
public function refreshQr(): Json
|
||||
}
|
||||
```
|
||||
|
||||
**路由验证**:
|
||||
- `pluginsname=vr_ticket` → `vr_ticket` 目录
|
||||
- `pluginscontrol=ticket` → `api/Ticket.php` (ucfirst('ticket') = 'Ticket')
|
||||
- `pluginsaction=list` → `Ticket::list()`
|
||||
|
||||
### Step 3: ticket_card.html
|
||||
|
||||
票卡片组件,包含:
|
||||
- 商品信息(标题、场次、座位)
|
||||
- 观演人信息
|
||||
- QR 码展示区(懒加载)
|
||||
- 短码展示
|
||||
- 核销状态标识
|
||||
|
||||
### Step 4: ticket_wallet.html
|
||||
|
||||
票夹页面:
|
||||
- 加载 JS/CSS(qrcode.js、jQuery)
|
||||
- 调用 `/ticket/list` 获取票列表
|
||||
- 渲染票卡片列表
|
||||
- 空状态提示
|
||||
|
||||
### Step 5: Hook.php 修改
|
||||
|
||||
注册 C 端挂载点钩子:
|
||||
```php
|
||||
// 在 handle() 方法中添加
|
||||
case 'plugins_service_order_detail_page_info':
|
||||
$this->InjectTicketCard($params);
|
||||
break;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、QR 码展示逻辑
|
||||
|
||||
```javascript
|
||||
// ticket_card.html 伪代码
|
||||
function loadQrCode(ticketId) {
|
||||
// 1. 检查 localStorage 缓存
|
||||
const cached = localStorage.getItem('vr_qr_' + ticketId);
|
||||
if (cached) {
|
||||
const data = JSON.parse(cached);
|
||||
if (data.expires_at > Date.now() / 1000) {
|
||||
// 缓存有效,剩余 > 15 分钟则直接展示
|
||||
const remaining = data.expires_at - Date.now() / 1000;
|
||||
if (remaining > 900) {
|
||||
renderQr(data.payload);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 调用 API 获取新 QR
|
||||
$.get('/api/plugins/...&pluginsaction=detail&id=' + ticketId, function(res) {
|
||||
if (res.code === 0) {
|
||||
// 3. 缓存到 localStorage
|
||||
localStorage.setItem('vr_qr_' + ticketId, JSON.stringify({
|
||||
payload: res.data.ticket.qr_payload,
|
||||
expires_at: res.data.ticket.qr_expires_at
|
||||
}));
|
||||
// 4. 渲染 QR
|
||||
renderQr(res.data.ticket.qr_payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderQr(base64Payload) {
|
||||
const payload = atob(base64Payload);
|
||||
$('#qrcode').qrcode({ text: payload });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、测试用例
|
||||
|
||||
| 用例 | 预期结果 |
|
||||
|------|----------|
|
||||
| 未登录访问 | 返回 401 |
|
||||
| 无票用户 | 返回空列表 |
|
||||
| 有票用户 | 返回票列表 |
|
||||
| 点击票卡片 | 展示 QR + 短码 |
|
||||
| QR 过期前刷新 | 获取新 QR |
|
||||
| 核销后展示 | 显示已核销状态 |
|
||||
|
||||
---
|
||||
|
||||
## 七、进度记录
|
||||
|
||||
- [ ] Step 1: WalletService.php
|
||||
- [ ] Step 2: api/Ticket.php
|
||||
- [ ] Step 3: ticket_card.html
|
||||
- [ ] Step 4: ticket_wallet.html
|
||||
- [ ] Step 5: Hook.php
|
||||
- [ ] Step 6: 联调测试
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
# Phase 4 规划:发票 · 核销 · 票夹
|
||||
|
||||
> 规划日期:2026-04-22
|
||||
> 状态:**规划完成,待启动调研**
|
||||
> 最后更新:2026-04-23(算法变更:Feistel-8 → HMAC-XOR,BUG修复)
|
||||
> 状态:**Phase 4.1/4.2 已完成,B端核销进行中**
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ class Hook
|
|||
$ret = TicketService::onOrderPaid($params);
|
||||
break;
|
||||
|
||||
case 'plugins_service_order_detail_page_info':
|
||||
// C端订单详情页注入票夹入口
|
||||
$ret = $this->InjectTicketCard($params);
|
||||
break;
|
||||
|
||||
case 'plugins_service_order_delete_success':
|
||||
// 如果有删除拦截等
|
||||
break;
|
||||
|
|
@ -106,5 +111,131 @@ class Hook
|
|||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* C端订单详情页注入票卡片
|
||||
*/
|
||||
public function InjectTicketCard(&$params)
|
||||
{
|
||||
$order = $params['order'] ?? [];
|
||||
if (empty($order) || ($order['pay_status'] ?? 0) != 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = session('user_id');
|
||||
if (empty($userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tickets = \think\facade\Db::name('vr_tickets')
|
||||
->where('order_id', $order['id'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if (empty($tickets)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$token = session('user_token') ?: '';
|
||||
$hostUrl = \think\facade\Config::get('shopxo.host_url');
|
||||
|
||||
$ticketCardsHtml = '';
|
||||
foreach ($tickets as $ticket) {
|
||||
$shortCode = \app\plugins\vr_ticket\service\BaseService::shortCodeEncode($ticket['goods_id'], $ticket['id']);
|
||||
$statusMap = [0 => ['text' => '未核销', 'class' => 'unverified'], 1 => ['text' => '已核销', 'class' => 'verified'], 2 => ['text' => '已退款', 'class' => 'refunded']];
|
||||
$status = $statusMap[$ticket['verify_status']] ?? $statusMap[0];
|
||||
|
||||
$ticketCardsHtml .= '<div class="vr-ticket-card" data-ticket-id="' . $ticket['id'] . '">' .
|
||||
'<div class="vr-ticket-card-header">' .
|
||||
'<div class="vr-ticket-goods-title">电子票</div>' .
|
||||
'<div class="vr-ticket-status ' . $status['class'] . '">' . $status['text'] . '</div>' .
|
||||
'</div>' .
|
||||
'<div class="vr-ticket-info">' .
|
||||
'<div class="vr-ticket-info-row"><span class="vr-ticket-info-icon">💺</span><span>' . htmlspecialchars($ticket['seat_info'] ?? '') . '</span></div>' .
|
||||
'<div class="vr-ticket-info-row"><span class="vr-ticket-info-icon">👤</span><span>' . htmlspecialchars($ticket['real_name'] ?? '') . '</span></div>' .
|
||||
'</div>' .
|
||||
'<div class="vr-ticket-footer">' .
|
||||
'<div class="vr-ticket-short-code">短码: ' . htmlspecialchars($shortCode) . '</div>' .
|
||||
'<a href="javascript:;" class="vr-ticket-view-btn" onclick="VrTicketWallet.viewTicket(' . $ticket['id'] . ')">查看票码 →</a>' .
|
||||
'</div>' .
|
||||
'</div>';
|
||||
}
|
||||
|
||||
$style = '<style>
|
||||
.vr-ticket-card { background: #fff; border-radius: 12px; padding: 16px; margin: 12px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.06); cursor: pointer; }
|
||||
.vr-ticket-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.12); }
|
||||
.vr-ticket-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
|
||||
.vr-ticket-goods-title { font-size: 16px; font-weight: 600; color: #333; }
|
||||
.vr-ticket-status { font-size: 12px; padding: 2px 8px; border-radius: 4px; font-weight: 500; }
|
||||
.vr-ticket-status.unverified { background: #e6f7ff; color: #1890ff; }
|
||||
.vr-ticket-status.verified { background: #f6ffed; color: #52c41a; }
|
||||
.vr-ticket-status.refunded { background: #fff1f0; color: #ff4d4f; }
|
||||
.vr-ticket-info { font-size: 13px; color: #666; line-height: 1.6; }
|
||||
.vr-ticket-info-row { display: flex; align-items: center; margin-bottom: 4px; }
|
||||
.vr-ticket-info-icon { width: 16px; color: #999; margin-right: 6px; }
|
||||
.vr-ticket-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; padding-top: 12px; border-top: 1px solid #f0f0f0; }
|
||||
.vr-ticket-short-code { font-size: 14px; font-family: "Courier New", monospace; color: #333; font-weight: 600; letter-spacing: 1px; }
|
||||
.vr-ticket-view-btn { font-size: 13px; color: #1890ff; text-decoration: none; }
|
||||
.vr-ticket-view-btn:hover { text-decoration: underline; }
|
||||
</style>';
|
||||
|
||||
$ticketHtml = '<div class="vr-order-ticket-section">' .
|
||||
'<div style="font-size:16px;font-weight:600;margin-bottom:12px;">📋 我的电子票</div>' .
|
||||
$ticketCardsHtml .
|
||||
'</div>';
|
||||
|
||||
$params['page_data']['ticket_section'] = $ticketHtml;
|
||||
$params['page_data']['ticket_css'] = $style;
|
||||
|
||||
// JS
|
||||
$js = '<script>
|
||||
(function() {
|
||||
var apiBase = "' . $hostUrl . '?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=";
|
||||
var token = "' . htmlspecialchars($token) . '";
|
||||
window.VrTicketWallet = {
|
||||
viewTicket: function(ticketId) {
|
||||
var modal = document.getElementById("vrTicketModal") || createModal();
|
||||
modal.classList.add("active");
|
||||
var body = document.getElementById("vrTicketModalBody");
|
||||
body.innerHTML = \'<div style="text-align:center;padding:40px;">加载中...</div>\';
|
||||
$.ajax({
|
||||
url: apiBase + "detail&id=" + ticketId,
|
||||
headers: token ? {"X-Token": token} : {},
|
||||
success: function(res) {
|
||||
if (res.code === 0 && res.data.ticket) {
|
||||
var t = res.data.ticket;
|
||||
var statusMap = {0:{text:"未核销",class:"unverified"},1:{text:"已核销",class:"verified"},2:{text:"已退款",class:"refunded"}};
|
||||
var status = statusMap[t.verify_status] || statusMap[0];
|
||||
body.innerHTML = \'<div style="text-align:center;padding:20px;background:#fafafa;border-radius:12px;"><div id="vrQrcodeBox"></div></div>\' +
|
||||
\'<div style="text-align:center;margin:16px 0;padding:12px;background:#f5f5f5;border-radius:8px;">\' +
|
||||
\'<div style="font-size:12px;color:#999;margin-bottom:4px;">短码(人工核销)</div>\' +
|
||||
\'<div style="font-size:20px;font-family:monospace;font-weight:700;letter-spacing:2px;">\' + t.short_code + \'</div></div>\' +
|
||||
\'<div style="text-align:center;"><span class="vr-ticket-status \' + status.class + \'">\' + status.text + \'</span></div>\';
|
||||
if (t.qr_payload) {
|
||||
$("#vrQrcodeBox").qrcode({text: atob(t.qr_payload), width: 180, height: 180});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
closeModal: function() {
|
||||
var modal = document.getElementById("vrTicketModal");
|
||||
if (modal) modal.classList.remove("active");
|
||||
}
|
||||
};
|
||||
function createModal() {
|
||||
var html = \'<div id="vrTicketModal" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:none;align-items:center;justify-content:center;">\' +
|
||||
\'<div style="background:#fff;border-radius:16px;width:90%;max-width:400px;padding:24px;"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">\' +
|
||||
\'<div style="font-size:18px;font-weight:600;">电子票</div><button onclick="VrTicketWallet.closeModal()" style="width:28px;height:28px;border-radius:50%;background:#f0f0f0;border:none;cursor:pointer;">×</button></div>\' +
|
||||
\'<div id="vrTicketModalBody"></div></div></div>\';
|
||||
document.body.insertAdjacentHTML("beforeend", html);
|
||||
var modal = document.getElementById("vrTicketModal");
|
||||
modal.addEventListener("click", function(e) { if (e.target === modal) VrTicketWallet.closeModal(); });
|
||||
return modal;
|
||||
}
|
||||
})();
|
||||
</script>';
|
||||
$params['page_data']['ticket_js'] = $js;
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
<?php
|
||||
/**
|
||||
* VR票务插件 - C端票夹API控制器
|
||||
*
|
||||
* 路由机制(PluginsService::PluginsApiCall):
|
||||
* URL: ?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=list
|
||||
* → pluginsname=vr_ticket, pluginscontrol=ticket, pluginsaction=list
|
||||
* → class = \app\plugins\vr_ticket\api\Ticket (ucfirst('ticket') = 'Ticket')
|
||||
* → method = ucfirst('list') = 'list'
|
||||
* → app/plugins/vr_ticket/api/Ticket.php::list() ✓
|
||||
*
|
||||
* @package vr_ticket\api
|
||||
*/
|
||||
|
||||
namespace app\plugins\vr_ticket\api;
|
||||
|
||||
use app\plugins\vr_ticket\service\WalletService;
|
||||
|
||||
/**
|
||||
* C端票夹 API
|
||||
*/
|
||||
class Ticket
|
||||
{
|
||||
/**
|
||||
* 获取当前登录用户ID
|
||||
*
|
||||
* ShopXO 使用 X-Token 或 Authorization 头
|
||||
* @return int|null
|
||||
*/
|
||||
private static function getUserId()
|
||||
{
|
||||
// 方式1:从 header 获取(推荐)
|
||||
$token = request()->header('X-Token') ?: request()->header('Authorization', '');
|
||||
if (!empty($token)) {
|
||||
$token = str_replace('Bearer ', '', $token);
|
||||
$user = self::parseToken($token);
|
||||
if (!empty($user['id'])) {
|
||||
return intval($user['id']);
|
||||
}
|
||||
}
|
||||
|
||||
// 方式2:从 session 获取
|
||||
$userId = session('user_id');
|
||||
if (!empty($userId)) {
|
||||
return intval($userId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Token(JWT格式)
|
||||
*
|
||||
* @param string $token
|
||||
* @return array
|
||||
*/
|
||||
private static function parseToken(string $token): array
|
||||
{
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 3) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = base64_decode(strtr($parts[1], '-_', '+/'));
|
||||
if ($payload === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode($payload, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回未登录错误
|
||||
*
|
||||
* @return Json
|
||||
*/
|
||||
private static function unauthorized(string $msg = '请先登录'): Json
|
||||
{
|
||||
return json([
|
||||
'code' => 401,
|
||||
'msg' => $msg,
|
||||
'data' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回成功响应
|
||||
*
|
||||
* @param mixed $data
|
||||
* @param string $msg
|
||||
* @return Json
|
||||
*/
|
||||
private static function success($data = [], string $msg = 'success'): Json
|
||||
{
|
||||
return json([
|
||||
'code' => 0,
|
||||
'msg' => $msg,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回错误响应
|
||||
*
|
||||
* @param string $msg
|
||||
* @param int $code
|
||||
* @return Json
|
||||
*/
|
||||
private static function error(string $msg = '请求失败', int $code = -1): Json
|
||||
{
|
||||
return json([
|
||||
'code' => $code,
|
||||
'msg' => $msg,
|
||||
'data' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户票列表
|
||||
*
|
||||
* GET ?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=list
|
||||
*
|
||||
* @return Json
|
||||
*/
|
||||
public function list(): Json
|
||||
{
|
||||
$userId = self::getUserId();
|
||||
if (empty($userId)) {
|
||||
return self::unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
$tickets = WalletService::getUserTickets($userId);
|
||||
|
||||
return self::success([
|
||||
'tickets' => $tickets,
|
||||
'count' => count($tickets),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return self::error('获取票列表失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取票详情(含 QR payload)
|
||||
*
|
||||
* GET ?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=detail&id=X
|
||||
*
|
||||
* @return Json
|
||||
*/
|
||||
public function detail(): Json
|
||||
{
|
||||
$userId = self::getUserId();
|
||||
if (empty($userId)) {
|
||||
return self::unauthorized();
|
||||
}
|
||||
|
||||
$ticketId = input('id', 0, 'intval');
|
||||
if ($ticketId <= 0) {
|
||||
return self::error('参数错误:票ID无效');
|
||||
}
|
||||
|
||||
try {
|
||||
$ticket = WalletService::getTicketDetail($ticketId, $userId);
|
||||
|
||||
if (empty($ticket)) {
|
||||
return self::error('票不存在或无权访问', -404);
|
||||
}
|
||||
|
||||
return self::success([
|
||||
'ticket' => $ticket,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return self::error('获取票详情失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制刷新 QR payload
|
||||
*
|
||||
* GET ?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=refreshQr&id=X
|
||||
*
|
||||
* @return Json
|
||||
*/
|
||||
public function refreshQr(): Json
|
||||
{
|
||||
$userId = self::getUserId();
|
||||
if (empty($userId)) {
|
||||
return self::unauthorized();
|
||||
}
|
||||
|
||||
$ticketId = input('id', 0, 'intval');
|
||||
if ($ticketId <= 0) {
|
||||
return self::error('参数错误:票ID无效');
|
||||
}
|
||||
|
||||
try {
|
||||
$ticket = WalletService::refreshQrPayload($ticketId, $userId);
|
||||
|
||||
if (empty($ticket)) {
|
||||
return self::error('票不存在或无权访问', -404);
|
||||
}
|
||||
|
||||
return self::success([
|
||||
'ticket' => $ticket,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return self::error('刷新QR失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ CREATE TABLE IF NOT EXISTS `{{prefix}}vr_tickets` (
|
|||
`verify_time` INT UNSIGNED DEFAULT 0 COMMENT '核销时间',
|
||||
`verifier_id` BIGINT UNSIGNED DEFAULT 0 COMMENT '核销员ID',
|
||||
`issued_at` INT UNSIGNED DEFAULT 0 COMMENT '票发放时间',
|
||||
`qr_issued_at` INT UNSIGNED DEFAULT 0 COMMENT 'QR发放时间戳',
|
||||
`created_at` INT UNSIGNED DEFAULT 0 COMMENT '创建时间',
|
||||
`updated_at` INT UNSIGNED DEFAULT 0 COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
<?php
|
||||
/**
|
||||
* VR票务插件 - 票夹服务(C端)
|
||||
*
|
||||
* 核心功能:
|
||||
* 1. 获取用户票列表
|
||||
* 2. 获取票详情
|
||||
* 3. 生成/缓存 QR payload
|
||||
*
|
||||
* @package vr_ticket\service
|
||||
*/
|
||||
|
||||
namespace app\plugins\vr_ticket\service;
|
||||
|
||||
class WalletService extends BaseService
|
||||
{
|
||||
/**
|
||||
* QR 有效期(秒)
|
||||
*/
|
||||
const QR_TTL = 1800; // 30分钟
|
||||
|
||||
/**
|
||||
* QR 刷新阈值(秒)
|
||||
* 剩余有效期 > 此值时返回缓存
|
||||
*/
|
||||
const QR_REFRESH_THRESHOLD = 900; // 15分钟
|
||||
|
||||
/**
|
||||
* 获取用户所有票
|
||||
*
|
||||
* @param int $userId 用户ID
|
||||
* @return array
|
||||
*/
|
||||
public static function getUserTickets(int $userId): array
|
||||
{
|
||||
// 查询该用户的所有票(关联订单)
|
||||
$tickets = \think\facade\Db::name('vr_tickets')
|
||||
->alias('t')
|
||||
->join('order o', 't.order_id = o.id', 'LEFT')
|
||||
->where('o.user_id', $userId)
|
||||
->where('o.pay_status', 1) // 已支付
|
||||
->where('o.status', '<>', 3) // 未删除
|
||||
->field('t.*')
|
||||
->order('t.issued_at', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if (empty($tickets)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 批量获取商品信息
|
||||
$goodsIds = array_filter(array_column($tickets, 'goods_id'));
|
||||
$goodsMap = [];
|
||||
if (!empty($goodsIds)) {
|
||||
$goodsMap = \think\facade\Db::name('Goods')
|
||||
->where('id', 'in', $goodsIds)
|
||||
->column('title', 'id');
|
||||
}
|
||||
|
||||
// 格式化数据
|
||||
$result = [];
|
||||
foreach ($tickets as $ticket) {
|
||||
// 生成短码
|
||||
$shortCode = self::shortCodeEncode($ticket['goods_id'], $ticket['id']);
|
||||
|
||||
// 解析座位信息(从 seat_info 中提取场次/场馆)
|
||||
$seatInfo = self::parseSeatInfo($ticket['seat_info'] ?? '');
|
||||
|
||||
$result[] = [
|
||||
'id' => $ticket['id'],
|
||||
'goods_id' => $ticket['goods_id'],
|
||||
'goods_title' => $goodsMap[$ticket['goods_id']] ?? '已下架商品',
|
||||
'seat_info' => $ticket['seat_info'] ?? '',
|
||||
'session_time' => $seatInfo['session'] ?? '',
|
||||
'venue_name' => $seatInfo['venue'] ?? '',
|
||||
'real_name' => $ticket['real_name'] ?? '',
|
||||
'phone' => self::maskPhone($ticket['phone'] ?? ''),
|
||||
'verify_status' => $ticket['verify_status'],
|
||||
'issued_at' => $ticket['issued_at'],
|
||||
'short_code' => $shortCode,
|
||||
// 是否需要刷新 QR
|
||||
'qr_need_refresh' => self::qrNeedsRefresh($ticket['qr_issued_at'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取票详情
|
||||
*
|
||||
* @param int $ticketId 票ID
|
||||
* @param int $userId 用户ID(用于权限校验)
|
||||
* @return array|null
|
||||
*/
|
||||
public static function getTicketDetail(int $ticketId, int $userId): ?array
|
||||
{
|
||||
$ticket = \think\facade\Db::name('vr_tickets')
|
||||
->alias('t')
|
||||
->join('order o', 't.order_id = o.id', 'LEFT')
|
||||
->where('t.id', $ticketId)
|
||||
->where('o.user_id', $userId)
|
||||
->field('t.*')
|
||||
->find();
|
||||
|
||||
if (empty($ticket)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取商品信息
|
||||
$goods = \think\facade\Db::name('Goods')->find($ticket['goods_id']);
|
||||
$seatInfo = self::parseSeatInfo($ticket['seat_info'] ?? '');
|
||||
|
||||
// 生成短码
|
||||
$shortCode = self::shortCodeEncode($ticket['goods_id'], $ticket['id']);
|
||||
|
||||
// 生成 QR payload
|
||||
$qrData = self::getQrPayload($ticket);
|
||||
|
||||
return [
|
||||
'id' => $ticket['id'],
|
||||
'goods_id' => $ticket['goods_id'],
|
||||
'goods_title' => $goods['title'] ?? '已下架商品',
|
||||
'goods_image' => $goods['images'] ?? '',
|
||||
'seat_info' => $ticket['seat_info'] ?? '',
|
||||
'session_time' => $seatInfo['session'] ?? '',
|
||||
'venue_name' => $seatInfo['venue'] ?? '',
|
||||
'real_name' => $ticket['real_name'] ?? '',
|
||||
'phone' => self::maskPhone($ticket['phone'] ?? ''),
|
||||
'verify_status' => $ticket['verify_status'],
|
||||
'verify_time' => $ticket['verify_time'] ?? 0,
|
||||
'issued_at' => $ticket['issued_at'],
|
||||
'short_code' => $shortCode,
|
||||
'qr_payload' => $qrData['payload'],
|
||||
'qr_expires_at' => $qrData['expires_at'],
|
||||
'qr_expires_in' => $qrData['expires_in'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 QR payload
|
||||
*
|
||||
* 缓存策略:
|
||||
* - QR 有效期 30 分钟
|
||||
* - 剩余有效期 > 15 分钟:返回缓存
|
||||
* - 剩余有效期 ≤ 15 分钟:刷新
|
||||
*
|
||||
* @param array $ticket 票数据
|
||||
* @return array ['payload' => string, 'expires_at' => int, 'expires_in' => int]
|
||||
*/
|
||||
public static function getQrPayload(array $ticket): array
|
||||
{
|
||||
$now = time();
|
||||
$issuedAt = $ticket['qr_issued_at'] ?? 0;
|
||||
$expiresAt = $issuedAt + self::QR_TTL;
|
||||
|
||||
// 检查是否需要刷新
|
||||
$needsRefresh = ($issuedAt == 0) || (($expiresAt - $now) <= self::QR_REFRESH_THRESHOLD);
|
||||
|
||||
if ($needsRefresh) {
|
||||
// 生成新 QR
|
||||
$issuedAt = $now;
|
||||
$expiresAt = $now + self::QR_TTL;
|
||||
|
||||
$payload = [
|
||||
'id' => $ticket['id'],
|
||||
'g' => $ticket['goods_id'],
|
||||
'iat' => $issuedAt,
|
||||
'exp' => $expiresAt,
|
||||
];
|
||||
|
||||
$encoded = self::signQrPayload($payload);
|
||||
|
||||
// 回写数据库(更新 qr_issued_at)
|
||||
\think\facade\Db::name('vr_tickets')
|
||||
->where('id', $ticket['id'])
|
||||
->update(['qr_issued_at' => $issuedAt]);
|
||||
} else {
|
||||
// 返回缓存的 payload
|
||||
// 重新构建 payload(从数据库读取 iat)
|
||||
$payload = [
|
||||
'id' => $ticket['id'],
|
||||
'g' => $ticket['goods_id'],
|
||||
'iat' => $issuedAt,
|
||||
'exp' => $expiresAt,
|
||||
];
|
||||
$encoded = self::signQrPayload($payload);
|
||||
}
|
||||
|
||||
return [
|
||||
'payload' => $encoded,
|
||||
'expires_at' => $expiresAt,
|
||||
'expires_in' => max(0, $expiresAt - $now),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制刷新 QR payload
|
||||
*
|
||||
* @param int $ticketId 票ID
|
||||
* @param int $userId 用户ID
|
||||
* @return array|null
|
||||
*/
|
||||
public static function refreshQrPayload(int $ticketId, int $userId): ?array
|
||||
{
|
||||
// 先清零 qr_issued_at,强制刷新
|
||||
\think\facade\Db::name('vr_tickets')
|
||||
->where('id', $ticketId)
|
||||
->update(['qr_issued_at' => 0]);
|
||||
|
||||
return self::getTicketDetail($ticketId, $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 QR 是否需要刷新
|
||||
*
|
||||
* @param int $qrIssuedAt QR 发放时间戳
|
||||
* @return bool
|
||||
*/
|
||||
public static function qrNeedsRefresh(int $qrIssuedAt): bool
|
||||
{
|
||||
if ($qrIssuedAt == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$expiresAt = $qrIssuedAt + self::QR_TTL;
|
||||
|
||||
return (($expiresAt - $now) <= self::QR_REFRESH_THRESHOLD);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析座位信息
|
||||
*
|
||||
* seat_info 格式:场次|场馆|演播室|分区|座位号
|
||||
* 例如:2026-06-01 20:00|国家体育馆|主要展厅|A区|A1
|
||||
*
|
||||
* @param string $seatInfo
|
||||
* @return array
|
||||
*/
|
||||
private static function parseSeatInfo(string $seatInfo): array
|
||||
{
|
||||
$parts = explode('|', $seatInfo);
|
||||
|
||||
return [
|
||||
'session' => $parts[0] ?? '',
|
||||
'venue' => $parts[1] ?? '',
|
||||
'room' => $parts[2] ?? '',
|
||||
'section' => $parts[3] ?? '',
|
||||
'seat' => $parts[4] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号脱敏
|
||||
*
|
||||
* @param string $phone
|
||||
* @return string
|
||||
*/
|
||||
private static function maskPhone(string $phone): string
|
||||
{
|
||||
if (empty($phone) || strlen($phone) < 7) {
|
||||
return $phone;
|
||||
}
|
||||
|
||||
return substr($phone, 0, 3) . '****' . substr($phone, -4);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,677 @@
|
|||
<!-- VR票务 - 票卡片共享组件 -->
|
||||
<!-- 被 ticket_wallet.html 和订单详情页 include -->
|
||||
|
||||
<style>
|
||||
/* 票卡片样式 */
|
||||
.vr-ticket-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.vr-ticket-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.vr-ticket-card.verified {
|
||||
opacity: 0.7;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.vr-ticket-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.vr-ticket-goods-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.vr-ticket-status {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.vr-ticket-status.unverified {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
.vr-ticket-status.verified {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
.vr-ticket-status.refunded {
|
||||
background: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
.vr-ticket-info {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.vr-ticket-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.vr-ticket-info-icon {
|
||||
width: 16px;
|
||||
color: #999;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.vr-ticket-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.vr-ticket-short-code {
|
||||
font-size: 14px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.vr-ticket-view-btn {
|
||||
font-size: 13px;
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.vr-ticket-view-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 票详情弹窗 */
|
||||
.vr-ticket-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.vr-ticket-modal.active {
|
||||
display: flex;
|
||||
}
|
||||
.vr-ticket-modal-content {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
.vr-ticket-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.vr-ticket-modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
.vr-ticket-modal-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.vr-ticket-qr-section {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.vr-ticket-qr-wrapper {
|
||||
display: inline-block;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
.vr-ticket-qr-expire {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.vr-ticket-short-code-display {
|
||||
text-align: center;
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.vr-ticket-short-code-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.vr-ticket-short-code-value {
|
||||
font-size: 20px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.vr-ticket-detail-row {
|
||||
display: flex;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.vr-ticket-detail-label {
|
||||
width: 80px;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
.vr-ticket-detail-value {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
.vr-ticket-verified-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: #52c41a;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.vr-ticket-refresh-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.vr-ticket-refresh-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 票卡片模板 -->
|
||||
<template id="vr-ticket-card-tpl">
|
||||
<div class="vr-ticket-card" data-ticket-id="{{id}}">
|
||||
<div class="vr-ticket-card-header">
|
||||
<div class="vr-ticket-goods-title">{{goods_title}}</div>
|
||||
<div class="vr-ticket-status {{status_class}}">{{status_text}}</div>
|
||||
</div>
|
||||
<div class="vr-ticket-info">
|
||||
<div class="vr-ticket-info-row">
|
||||
<span class="vr-ticket-info-icon">📅</span>
|
||||
<span>{{session_time}}</span>
|
||||
</div>
|
||||
<div class="vr-ticket-info-row">
|
||||
<span class="vr-ticket-info-icon">📍</span>
|
||||
<span>{{venue_name}}</span>
|
||||
</div>
|
||||
<div class="vr-ticket-info-row">
|
||||
<span class="vr-ticket-info-icon">💺</span>
|
||||
<span>{{seat_info}}</span>
|
||||
</div>
|
||||
<div class="vr-ticket-info-row">
|
||||
<span class="vr-ticket-info-icon">👤</span>
|
||||
<span>{{real_name}} {{phone}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vr-ticket-footer">
|
||||
<div class="vr-ticket-short-code">短码: {{short_code}}</div>
|
||||
<a href="javascript:;" class="vr-ticket-view-btn" onclick="VrTicketWallet.viewTicket({{id}})">查看票码 →</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 票详情弹窗 -->
|
||||
<div class="vr-ticket-modal" id="vrTicketModal">
|
||||
<div class="vr-ticket-modal-content">
|
||||
<div class="vr-ticket-modal-header">
|
||||
<div class="vr-ticket-modal-title">电子票</div>
|
||||
<button class="vr-ticket-modal-close" onclick="VrTicketWallet.closeModal()">×</button>
|
||||
</div>
|
||||
<div id="vrTicketModalBody">
|
||||
<!-- 动态内容 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* VR票务 - 票夹核心JS
|
||||
*
|
||||
* 使用方式:
|
||||
* 1. 引入此文件
|
||||
* 2. 调用 VrTicketWallet.init(userId) 初始化
|
||||
* 3. 调用 VrTicketWallet.loadTickets() 加载票列表
|
||||
*/
|
||||
var VrTicketWallet = (function() {
|
||||
var apiBase = '<?php echo Config("shopxo.host_url"); ?>?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=';
|
||||
var token = '';
|
||||
var tickets = [];
|
||||
var currentTicket = null;
|
||||
|
||||
/**
|
||||
* 初始化
|
||||
* @param {string} userToken - 用户登录Token
|
||||
*/
|
||||
function init(userToken) {
|
||||
token = userToken || '';
|
||||
|
||||
// 绑定点击空白关闭弹窗
|
||||
document.addEventListener('click', function(e) {
|
||||
var modal = document.getElementById('vrTicketModal');
|
||||
if (e.target === modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// 绑定 ESC 关闭弹窗
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载票列表
|
||||
* @param {string} containerId - 票列表容器ID
|
||||
* @param {function} callback - 加载完成回调
|
||||
*/
|
||||
function loadTickets(containerId, callback) {
|
||||
containerId = containerId || 'vrTicketList';
|
||||
var container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.error('VrTicketWallet: 容器 #' + containerId + ' 不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '<div class="vr-ticket-loading" style="text-align:center;padding:40px;color:#999;">加载中...</div>';
|
||||
|
||||
$.ajax({
|
||||
url: apiBase + 'list',
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
headers: token ? {'X-Token': token} : {},
|
||||
success: function(res) {
|
||||
if (res.code === 0) {
|
||||
tickets = res.data.tickets || [];
|
||||
renderTickets(container);
|
||||
if (typeof callback === 'function') {
|
||||
callback(tickets);
|
||||
}
|
||||
} else if (res.code === 401) {
|
||||
container.innerHTML = '<div class="vr-ticket-empty" style="text-align:center;padding:40px;color:#999;">请先登录后查看我的票</div>';
|
||||
} else {
|
||||
container.innerHTML = '<div class="vr-ticket-error" style="text-align:center;padding:40px;color:#f56c6c;">' + (res.msg || '加载失败') + '</div>';
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
container.innerHTML = '<div class="vr-ticket-error" style="text-align:center;padding:40px;color:#f56c6c;">网络错误,请稍后重试</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染票列表
|
||||
*/
|
||||
function renderTickets(container) {
|
||||
if (tickets.length === 0) {
|
||||
container.innerHTML = '<div class="vr-ticket-empty" style="text-align:center;padding:40px;color:#999;">暂无电子票</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
tickets.forEach(function(ticket) {
|
||||
var statusMap = {
|
||||
0: {text: '未核销', class: 'unverified'},
|
||||
1: {text: '已核销', class: 'verified'},
|
||||
2: {text: '已退款', class: 'refunded'}
|
||||
};
|
||||
var status = statusMap[ticket.verify_status] || statusMap[0];
|
||||
|
||||
html += '<div class="vr-ticket-card' + (ticket.verify_status > 0 ? ' ' + ticket.verify_status : '') + '" data-ticket-id="' + ticket.id + '">' +
|
||||
'<div class="vr-ticket-card-header">' +
|
||||
'<div class="vr-ticket-goods-title">' + escapeHtml(ticket.goods_title) + '</div>' +
|
||||
'<div class="vr-ticket-status ' + status.class + '">' + status.text + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="vr-ticket-info">' +
|
||||
'<div class="vr-ticket-info-row"><span class="vr-ticket-info-icon">📅</span><span>' + escapeHtml(ticket.session_time) + '</span></div>' +
|
||||
'<div class="vr-ticket-info-row"><span class="vr-ticket-info-icon">📍</span><span>' + escapeHtml(ticket.venue_name) + '</span></div>' +
|
||||
'<div class="vr-ticket-info-row"><span class="vr-ticket-info-icon">💺</span><span>' + escapeHtml(ticket.seat_info) + '</span></div>' +
|
||||
'<div class="vr-ticket-info-row"><span class="vr-ticket-info-icon">👤</span><span>' + escapeHtml(ticket.real_name) + ' ' + escapeHtml(ticket.phone) + '</span></div>' +
|
||||
'</div>' +
|
||||
'<div class="vr-ticket-footer">' +
|
||||
'<div class="vr-ticket-short-code">短码: ' + escapeHtml(ticket.short_code) + '</div>' +
|
||||
'<a href="javascript:;" class="vr-ticket-view-btn" onclick="VrTicketWallet.viewTicket(' + ticket.id + ')">查看票码 →</a>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看单个票详情
|
||||
*/
|
||||
function viewTicket(ticketId) {
|
||||
// 查找本地缓存
|
||||
var ticket = tickets.find(function(t) { return t.id === ticketId; });
|
||||
|
||||
var modalBody = document.getElementById('vrTicketModalBody');
|
||||
modalBody.innerHTML = '<div style="text-align:center;padding:40px;">加载中...</div>';
|
||||
document.getElementById('vrTicketModal').classList.add('active');
|
||||
|
||||
if (ticket) {
|
||||
// 先显示基本信息,再加载 QR
|
||||
showTicketBasic(ticket);
|
||||
loadQrPayload(ticketId);
|
||||
} else {
|
||||
// 本地没有,从 API 加载
|
||||
$.ajax({
|
||||
url: apiBase + 'detail&id=' + ticketId,
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
headers: token ? {'X-Token': token} : {},
|
||||
success: function(res) {
|
||||
if (res.code === 0 && res.data.ticket) {
|
||||
currentTicket = res.data.ticket;
|
||||
showTicketDetail(currentTicket);
|
||||
} else {
|
||||
modalBody.innerHTML = '<div style="text-align:center;padding:40px;color:#f56c6c;">' + (res.msg || '加载失败') + '</div>';
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
modalBody.innerHTML = '<div style="text-align:center;padding:40px;color:#f56c6c;">网络错误</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示票基本信息
|
||||
*/
|
||||
function showTicketBasic(ticket) {
|
||||
var statusMap = {
|
||||
0: {text: '未核销', class: 'unverified'},
|
||||
1: {text: '已核销', class: 'verified'},
|
||||
2: {text: '已退款', class: 'refunded'}
|
||||
};
|
||||
var status = statusMap[ticket.verify_status] || statusMap[0];
|
||||
|
||||
var modalBody = document.getElementById('vrTicketModalBody');
|
||||
modalBody.innerHTML =
|
||||
'<div class="vr-ticket-qr-section">' +
|
||||
'<div class="vr-ticket-qr-wrapper" id="vrQrcodeBox"></div>' +
|
||||
'<div class="vr-ticket-qr-expire" id="vrQrExpire"></div>' +
|
||||
'</div>' +
|
||||
'<div class="vr-ticket-short-code-display">' +
|
||||
'<div class="vr-ticket-short-code-label">短码(人工核销)</div>' +
|
||||
'<div class="vr-ticket-short-code-value">' + escapeHtml(ticket.short_code) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="vr-ticket-detail-row">' +
|
||||
'<div class="vr-ticket-detail-label">状态</div>' +
|
||||
'<div class="vr-ticket-detail-value"><span class="vr-ticket-status ' + status.class + '">' + status.text + '</span></div>' +
|
||||
'</div>' +
|
||||
'<div class="vr-ticket-detail-row">' +
|
||||
'<div class="vr-ticket-detail-label">场次</div>' +
|
||||
'<div class="vr-ticket-detail-value">' + escapeHtml(ticket.session_time) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="vr-ticket-detail-row">' +
|
||||
'<div class="vr-ticket-detail-label">场馆</div>' +
|
||||
'<div class="vr-ticket-detail-value">' + escapeHtml(ticket.venue_name) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="vr-ticket-detail-row">' +
|
||||
'<div class="vr-ticket-detail-label">座位</div>' +
|
||||
'<div class="vr-ticket-detail-value">' + escapeHtml(ticket.seat_info) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="vr-ticket-detail-row">' +
|
||||
'<div class="vr-ticket-detail-label">观演人</div>' +
|
||||
'<div class="vr-ticket-detail-value">' + escapeHtml(ticket.real_name) + ' ' + escapeHtml(ticket.phone) + '</div>' +
|
||||
'</div>' +
|
||||
'<button class="vr-ticket-refresh-btn" id="vrRefreshBtn" onclick="VrTicketWallet.refreshQr(' + ticket.id + ')">刷新二维码</button>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示票详细信息(含 QR)
|
||||
*/
|
||||
function showTicketDetail(ticket) {
|
||||
var statusMap = {
|
||||
0: {text: '未核销', class: 'unverified'},
|
||||
1: {text: '已核销', class: 'verified'},
|
||||
2: {text: '已退款', class: 'refunded'}
|
||||
};
|
||||
var status = statusMap[ticket.verify_status] || statusMap[0];
|
||||
|
||||
var modalBody = document.getElementById('vrTicketModalBody');
|
||||
|
||||
var verifiedBadge = ticket.verify_status === 1
|
||||
? '<div class="vr-ticket-verified-badge">✓ 已核销</div>'
|
||||
: '';
|
||||
|
||||
modalBody.innerHTML =
|
||||
'<div class="vr-ticket-qr-section">' +
|
||||
'<div class="vr-ticket-qr-wrapper" id="vrQrcodeBox"></div>' +
|
||||
'<div class="vr-ticket-qr-expire" id="vrQrExpire"></div>' +
|
||||
'</div>' +
|
||||
'<div class="vr-ticket-short-code-display">' +
|
||||
'<div class="vr-ticket-short-code-label">短码(人工核销)</div>' +
|
||||
'<div class="vr-ticket-short-code-value">' + escapeHtml(ticket.short_code) + '</div>' +
|
||||
'</div>' +
|
||||
verifiedBadge +
|
||||
'<div class="vr-ticket-detail-row">' +
|
||||
'<div class="vr-ticket-detail-label">场次</div>' +
|
||||
'<div class="vr-ticket-detail-value">' + escapeHtml(ticket.session_time) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="vr-ticket-detail-row">' +
|
||||
'<div class="vr-ticket-detail-label">场馆</div>' +
|
||||
'<div class="vr-ticket-detail-value">' + escapeHtml(ticket.venue_name) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="vr-ticket-detail-row">' +
|
||||
'<div class="vr-ticket-detail-label">座位</div>' +
|
||||
'<div class="vr-ticket-detail-value">' + escapeHtml(ticket.seat_info) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="vr-ticket-detail-row">' +
|
||||
'<div class="vr-ticket-detail-label">观演人</div>' +
|
||||
'<div class="vr-ticket-detail-value">' + escapeHtml(ticket.real_name) + ' ' + escapeHtml(ticket.phone) + '</div>' +
|
||||
'</div>' +
|
||||
(ticket.verify_status === 0 ? '<button class="vr-ticket-refresh-btn" id="vrRefreshBtn" onclick="VrTicketWallet.refreshQr(' + ticket.id + ')">刷新二维码</button>' : '');
|
||||
|
||||
// 渲染 QR 码
|
||||
if (ticket.qr_payload) {
|
||||
renderQrCode(ticket.qr_payload, ticket.qr_expires_in);
|
||||
} else {
|
||||
document.getElementById('vrQrcodeBox').innerHTML = '<div style="color:#999;">QR加载中...</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 QR payload
|
||||
*/
|
||||
function loadQrPayload(ticketId) {
|
||||
var cacheKey = 'vr_qr_' + ticketId;
|
||||
var cached = localStorage.getItem(cacheKey);
|
||||
var now = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (cached) {
|
||||
try {
|
||||
var data = JSON.parse(cached);
|
||||
var remaining = data.expires_at - now;
|
||||
|
||||
// 缓存有效且剩余 > 15 分钟,直接使用
|
||||
if (remaining > 900) {
|
||||
renderQrCode(data.payload, remaining);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// 缓存损坏,清除
|
||||
localStorage.removeItem(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 需要刷新
|
||||
$.ajax({
|
||||
url: apiBase + 'detail&id=' + ticketId,
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
headers: token ? {'X-Token': token} : {},
|
||||
success: function(res) {
|
||||
if (res.code === 0 && res.data.ticket) {
|
||||
var ticket = res.data.ticket;
|
||||
var expiresIn = ticket.qr_expires_in || 0;
|
||||
|
||||
// 缓存到 localStorage
|
||||
localStorage.setItem(cacheKey, JSON.stringify({
|
||||
payload: ticket.qr_payload,
|
||||
expires_at: now + expiresIn
|
||||
}));
|
||||
|
||||
renderQrCode(ticket.qr_payload, expiresIn);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
document.getElementById('vrQrcodeBox').innerHTML = '<div style="color:#f56c6c;">QR加载失败</div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 QR 码
|
||||
*/
|
||||
function renderQrCode(payload, expiresIn) {
|
||||
var qrBox = document.getElementById('vrQrcodeBox');
|
||||
var expireEl = document.getElementById('vrQrExpire');
|
||||
|
||||
// 解码 payload
|
||||
try {
|
||||
var json = atob(payload);
|
||||
var data = JSON.parse(json);
|
||||
|
||||
// 渲染 QR
|
||||
qrBox.innerHTML = '';
|
||||
$(qrBox).qrcode({
|
||||
text: json,
|
||||
width: 200,
|
||||
height: 200
|
||||
});
|
||||
|
||||
// 显示过期时间
|
||||
if (expiresIn > 0) {
|
||||
var minutes = Math.floor(expiresIn / 60);
|
||||
expireEl.textContent = '有效期: ' + minutes + ' 分钟';
|
||||
}
|
||||
} catch (e) {
|
||||
qrBox.innerHTML = '<div style="color:#f56c6c;">QR解析失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 QR
|
||||
*/
|
||||
function refreshQr(ticketId) {
|
||||
var btn = document.getElementById('vrRefreshBtn');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '刷新中...';
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: apiBase + 'refreshQr&id=' + ticketId,
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
headers: token ? {'X-Token': token} : {},
|
||||
success: function(res) {
|
||||
if (res.code === 0 && res.data.ticket) {
|
||||
var ticket = res.data.ticket;
|
||||
|
||||
// 更新缓存
|
||||
var cacheKey = 'vr_qr_' + ticketId;
|
||||
var now = Math.floor(Date.now() / 1000);
|
||||
localStorage.setItem(cacheKey, JSON.stringify({
|
||||
payload: ticket.qr_payload,
|
||||
expires_at: now + ticket.qr_expires_in
|
||||
}));
|
||||
|
||||
// 重新渲染 QR
|
||||
renderQrCode(ticket.qr_payload, ticket.qr_expires_in);
|
||||
|
||||
if (btn) {
|
||||
btn.textContent = '已刷新';
|
||||
setTimeout(function() {
|
||||
btn.textContent = '刷新二维码';
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
alert(res.msg || '刷新失败');
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '刷新二维码';
|
||||
}
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
alert('网络错误');
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '刷新二维码';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭弹窗
|
||||
*/
|
||||
function closeModal() {
|
||||
document.getElementById('vrTicketModal').classList.remove('active');
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 转义
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
var div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 导出公开接口
|
||||
return {
|
||||
init: init,
|
||||
loadTickets: loadTickets,
|
||||
viewTicket: viewTicket,
|
||||
refreshQr: refreshQr,
|
||||
closeModal: closeModal
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php echo ModuleInclude('public/header'); ?>
|
||||
|
||||
<!-- VR票务样式 -->
|
||||
<link rel="stylesheet" type="text/css"
|
||||
href="<?php echo Config('shopxo.host_url'); ?>plugins/vr_ticket/static/css/ticket.css?v=<?php echo time(); ?>" />
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<div class="vr-ticket-wallet-page" id="vrTicketWalletApp">
|
||||
<div class="vr-wallet-header">
|
||||
<div class="vr-wallet-title">我的电子票</div>
|
||||
<div class="vr-wallet-subtitle">共 <span id="vrTicketCount">0</span> 张票</div>
|
||||
</div>
|
||||
|
||||
<!-- 票列表容器 -->
|
||||
<div id="vrTicketList" class="vr-ticket-list">
|
||||
<!-- 由 JS 动态渲染 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php echo ModuleInclude('public/footer'); ?>
|
||||
|
||||
<!-- 引入票卡片组件 -->
|
||||
<?php echo ModuleInclude('../../goods/ticket_card'); ?>
|
||||
|
||||
<script>
|
||||
$(function() {
|
||||
// 获取用户 Token
|
||||
var token = '<?php echo session("user_token") ?: ""; ?>';
|
||||
|
||||
// 初始化票夹
|
||||
VrTicketWallet.init(token);
|
||||
|
||||
// 加载票列表
|
||||
VrTicketWallet.loadTickets('vrTicketList', function(tickets) {
|
||||
$('#vrTicketCount').text(tickets.length);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
Loading…
Reference in New Issue