fix(Task1): 票夹API双斜杠404 — 修复apiBase构造 + 同步static文件到public/

根因:ticket_card.js 的 apiBase 动态构造错误(双斜杠)
+ static文件只更新了app/未同步public/导致Nginx仍serve旧版

详见 docs/DEBUG_STATIC_FILE_SYNC.md(第17条踩坑经验)
feat/phase-b-verification
Council 2026-04-24 15:39:43 +08:00
parent b27467035c
commit f8bb136d97
7 changed files with 1580 additions and 0 deletions

View File

@ -34,6 +34,7 @@
| [docs/09_SHOPXO_HOOKS_REFERENCE.md](docs/09_SHOPXO_HOOKS_REFERENCE.md) | ShopXO 全部钩子清单(从源码提取) |
| [docs/07_SHOPXO_PLUGIN_MECHANISM.md](docs/07_SHOPXO_PLUGIN_MECHANISM.md) | 插件开发机制 |
| [docs/08_SHOPXO_REQUIREMENTS_MAPPING.md](docs/08_SHOPXO_REQUIREMENTS_MAPPING.md) | 票务需求 → ShopXO 机制映射 |
| [docs/DEBUG_STATIC_FILE_SYNC.md](docs/DEBUG_STATIC_FILE_SYNC.md) | ⚠️ **插件静态文件双目录陷阱**app/ vs public/|
### 📖 调研存档
@ -108,6 +109,7 @@
2. Vue 3 `[[ ]]` 插值禁止用于 `<textarea>` → 浏览器卡死
3. 字段名不能猜,必须查源码
4. ShopXO `MyView()` 加载插件模板时 view_path 被覆盖 → 影响 ModuleInclude 解析
5. **⚠️ 插件静态文件双目录陷阱**`app/`PHP runtime`public/`Nginx webroot各有一份副本修改后必须同步 → [docs/DEBUG_STATIC_FILE_SYNC.md](docs/DEBUG_STATIC_FILE_SYNC.md)
---

View File

@ -0,0 +1,148 @@
# ShopXO 插件静态文件同步陷阱
> **2026-04-24 票夹 API 请求 404 根因分析**
>
> 关键词ShopXO、插件静态文件、public/ vs app/、Nginx root、docker cp、双目录陷阱
---
## 现象
票夹页面加载正常,但所有 API 请求返回 404
```
curl http://localhost:10000/plugins/vr_ticket//api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=list
→ 404
```
浏览器 Network 面板显示 JS 正在请求 `/plugins/vr_ticket//api.php`(双斜杠),但该路径不存在。
---
## 根因ShopXO 插件静态文件有**两套副本**
ShopXO 插件文件在容器内存在于两个位置:
| 路径 | 用途 | Nginx 是否 serve |
|------|------|-----------------|
| `/var/www/html/app/plugins/vr_ticket/` | PHP 运行时源码 | ❌ PHP 代码目录 |
| `/var/www/html/public/plugins/vr_ticket/` | Nginx webroot 静态副本 | ✅ **Nginx 直接 serve** |
**Nginx 的 `root` 指令是 `/var/www/html/public`**,因此所有浏览器请求的 URL 路径 `/plugins/vr_ticket/static/js/xxx.js` 都会被 Nginx 从 `public/plugins/` 目录读取。
PHPThinkPHP运行时加载插件类文件时使用 `app/plugins/` 目录。
### 为什么会产生两份不同的文件?
**正常情况bind mount**:如果 `app/``public/` 都是同一个 bind mount 的子目录(`/var/www/html` → host 的 shopxo 目录),它们应该是同一个源。
**异常情况**当手动在容器内修改文件、Docker cp 上传文件、或者插件重装时,这两个目录可能产生分歧:
1. `docker cp` 直接写入容器路径 → 如果写入 `app/``public/` 是镜像层预置,则不同步
2. 插件重装时 ShopXO 同步静态文件到 `public/` 但没有同步 `app/`
3. 容器内手动编辑 `public/` 后没有更新 `app/`
### 验证方法
```bash
# 对比两边的 MD5不一致 = 有问题)
docker exec shopxo-php md5sum \
/var/www/html/app/plugins/vr_ticket/static/js/ticket_card.js \
/var/www/html/public/plugins/vr_ticket/static/js/ticket_card.js
# 如果 MD5 不同,需要手动同步
docker cp /path/to/local/file.js \
shopxo-php:/var/www/html/public/plugins/vr_ticket/static/js/file.js
# 同时也要更新 app/(运行时需要)
docker cp /path/to/local/file.js \
shopxo-php:/var/www/html/app/plugins/vr_ticket/static/js/file.js
```
---
## 本次 Caseticket_card.js 的 apiBase 构造错误
### 问题代码ticket_card.js
```javascript
var apiBase = document.currentScript ?
document.currentScript.src.replace(/static\/js\/[^/]+$/, '') + '/api.php?...':
'/api.php?...';
```
当脚本被加载为 `/plugins/vr_ticket/static/js/ticket_card.js` 时:
- `document.currentScript.src` = `http://localhost:10000/plugins/vr_ticket/static/js/ticket_card.js`
- `replace(/static\/js\/[^/]+$/, '')` = `http://localhost:10000/plugins/vr_ticket/`
- 拼接 `/api.php?...`**`http://localhost:10000/plugins/vr_ticket/api.php?...`**双斜杠404
### 修复方案
硬编码 `apiBase` 为绝对路径,绕过动态检测:
```javascript
var apiBase = '/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=';
```
### 为什么之前修错了位置?
`docker cp` 更新了 `app/plugins/vr_ticket/static/js/ticket_card.js`,但浏览器被 Nginx serve 的是 `public/plugins/vr_ticket/static/js/ticket_card.js`(旧版)。直到把修复也同步到 `public/` 后才生效。
---
## 经验总结
### 修改插件静态文件的标准流程
1. **同时修改 `app/``public/` 两个副本**
```bash
# 方式A先改源码再同步副本
vim app/plugins/vr_ticket/static/js/xxx.js
cp app/plugins/vr_ticket/static/js/xxx.js public/plugins/vr_ticket/static/js/xxx.js
# 方式B直接用 docker cp 同步到两边
docker cp local.js shopxo-php:/var/www/html/app/plugins/vr_ticket/static/js/xxx.js
docker cp local.js shopxo-php:/var/www/html/public/plugins/vr_ticket/static/js/xxx.js
```
2. **修改后立即验证 MD5**
```bash
docker exec shopxo-php md5sum \
/var/www/html/app/plugins/vr_ticket/static/js/xxx.js \
/var/www/html/public/plugins/vr_ticket/static/js/xxx.js
# 两个 MD5 必须一致
```
3. **如果用了 ThinkPHP 模板缓存**,清理模板编译缓存:
```bash
docker exec shopxo-php find /var/www/html/runtime/index/temp -name "*.php" -delete
docker exec shopxo-php find /var/www/html/runtime/cache/shopxo -name "*.php" -delete
```
### ShopXO 静态文件分布图
```
宿主机bind mount 源)
/Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/
├── app/plugins/vr_ticket/ ← PHP 运行时读取
│ └── static/js/ticket_card.js
└── public/plugins/vr_ticket/ ← Nginx webroot浏览器访问
└── static/js/ticket_card.js
↓ bind mount
容器 /var/www/html/
├── app/plugins/vr_ticket/ ← PHP runtime
└── public/plugins/vr_ticket/ ← Nginx root
```
### 预防措施
- **不要在容器内直接修改文件**(用 `docker cp``app/` 后手动复制到 `public/`
- **插件重装后立即检查两边 MD5**
- **如果 bind mount 正常,两边应该永远同步**;如果不同步,检查是否有额外的 Docker 镜像层覆盖了 `public/`
---
## 相关文档
- [EXPERIENCES.md](EXPERIENCES.md) — 踩坑经验汇总
- [DEPLOYMENT.md](DEPLOYMENT.md) — 部署文档
- [docs/09_SHOPXO_CACHE_HANDBOOK.md](09_SHOPXO_CACHE_HANDBOOK.md) — 缓存机制

View File

@ -283,6 +283,12 @@ const zone = zones.find(z => z.char === seatChar);
---
### 17. ShopXO 插件静态文件双目录陷阱 → 详见 [DEBUG_STATIC_FILE_SYNC.md](DEBUG_STATIC_FILE_SYNC.md)
> ⚠️ **高危**:插件 JS/CSS 文件在 `app/`PHP runtime`public/`Nginx webroot各有一份副本。修改后必须同步两边并验证 MD5。详情见专项文档。
---
## 📌 开发前检查清单
接手本插件时,逐项确认以下内容:

View File

@ -0,0 +1,255 @@
/* VR票务 - 票卡片组件样式 */
/* 供票夹页面和用户中心页面共享 */
.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;
}
/* 票夹页面样式 */
.vr-ticket-wallet-page {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.vr-wallet-header {
text-align: center;
margin-bottom: 30px;
}
.vr-wallet-title {
font-size: 28px;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
.vr-wallet-subtitle {
font-size: 14px;
color: #666;
}
.vr-ticket-list {
min-height: 200px;
}
.vr-ticket-loading,
.vr-ticket-empty,
.vr-ticket-error {
text-align: center;
padding: 40px;
color: #999;
}
.vr-ticket-error {
color: #f56c6c;
}
/* 用户中心票夹入口样式 */
.vr-wallet-entrance {
margin-top: 20px;
}

View File

@ -0,0 +1,457 @@
/**
* VR票务 - 票夹核心JS
*
* ticket_wallet.html 和用户中心页面引用
* 提供 VrTicketWallet 对象用于票夹功能
*/
var VrTicketWallet = (function() {
var apiBase = '/api.php?s=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 (modal && 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');
if (!modalBody) {
// 如果弹窗不存在,先创建
createModal();
modalBody = document.getElementById('vrTicketModalBody');
}
modalBody.innerHTML = '<div style="text-align:center;padding:40px;">加载中...</div>';
document.getElementById('vrTicketModal').classList.add('active');
if (ticket) {
showTicketBasic(ticket);
loadQrPayload(ticketId);
} else {
$.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>' +
(ticket.verify_status === 0 ? '<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>' : '');
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;
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.setItem(cacheKey, JSON.stringify({
payload: ticket.qr_payload,
expires_at: now + expiresIn
}));
renderQrCode(ticket.qr_payload, expiresIn);
}
},
error: function() {
var qrBox = document.getElementById('vrQrcodeBox');
if (qrBox) qrBox.innerHTML = '<div style="color:#f56c6c;">QR加载失败</div>';
}
});
}
/**
* 渲染 QR
*/
function renderQrCode(payload, expiresIn) {
var qrBox = document.getElementById('vrQrcodeBox');
var expireEl = document.getElementById('vrQrExpire');
if (!qrBox) return;
try {
var json = atob(payload);
qrBox.innerHTML = '';
$(qrBox).qrcode({
text: json,
width: 200,
height: 200
});
if (expiresIn > 0 && expireEl) {
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
}));
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 createModal() {
var html = '<div id="vrTicketModal" class="vr-ticket-modal">' +
'<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>' +
'<style>' +
'.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;}' +
'.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;}' +
'.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;}' +
'</style>';
document.body.insertAdjacentHTML('beforeend', html);
}
/**
* 关闭弹窗
*/
function closeModal() {
var modal = document.getElementById('vrTicketModal');
if (modal) modal.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
};
})();

View File

@ -0,0 +1,255 @@
/* VR票务 - 票卡片组件样式 */
/* 供票夹页面和用户中心页面共享 */
.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;
}
/* 票夹页面样式 */
.vr-ticket-wallet-page {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.vr-wallet-header {
text-align: center;
margin-bottom: 30px;
}
.vr-wallet-title {
font-size: 28px;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
.vr-wallet-subtitle {
font-size: 14px;
color: #666;
}
.vr-ticket-list {
min-height: 200px;
}
.vr-ticket-loading,
.vr-ticket-empty,
.vr-ticket-error {
text-align: center;
padding: 40px;
color: #999;
}
.vr-ticket-error {
color: #f56c6c;
}
/* 用户中心票夹入口样式 */
.vr-wallet-entrance {
margin-top: 20px;
}

View File

@ -0,0 +1,457 @@
/**
* VR票务 - 票夹核心JS
*
* ticket_wallet.html 和用户中心页面引用
* 提供 VrTicketWallet 对象用于票夹功能
*/
var VrTicketWallet = (function() {
var apiBase = '/api.php?s=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 (modal && 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');
if (!modalBody) {
// 如果弹窗不存在,先创建
createModal();
modalBody = document.getElementById('vrTicketModalBody');
}
modalBody.innerHTML = '<div style="text-align:center;padding:40px;">加载中...</div>';
document.getElementById('vrTicketModal').classList.add('active');
if (ticket) {
showTicketBasic(ticket);
loadQrPayload(ticketId);
} else {
$.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>' +
(ticket.verify_status === 0 ? '<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>' : '');
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;
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.setItem(cacheKey, JSON.stringify({
payload: ticket.qr_payload,
expires_at: now + expiresIn
}));
renderQrCode(ticket.qr_payload, expiresIn);
}
},
error: function() {
var qrBox = document.getElementById('vrQrcodeBox');
if (qrBox) qrBox.innerHTML = '<div style="color:#f56c6c;">QR加载失败</div>';
}
});
}
/**
* 渲染 QR
*/
function renderQrCode(payload, expiresIn) {
var qrBox = document.getElementById('vrQrcodeBox');
var expireEl = document.getElementById('vrQrExpire');
if (!qrBox) return;
try {
var json = atob(payload);
qrBox.innerHTML = '';
$(qrBox).qrcode({
text: json,
width: 200,
height: 200
});
if (expiresIn > 0 && expireEl) {
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
}));
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 createModal() {
var html = '<div id="vrTicketModal" class="vr-ticket-modal">' +
'<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>' +
'<style>' +
'.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;}' +
'.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;}' +
'.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;}' +
'</style>';
document.body.insertAdjacentHTML('beforeend', html);
}
/**
* 关闭弹窗
*/
function closeModal() {
var modal = document.getElementById('vrTicketModal');
if (modal) modal.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
};
})();