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
parent
b27467035c
commit
f8bb136d97
|
|
@ -34,6 +34,7 @@
|
||||||
| [docs/09_SHOPXO_HOOKS_REFERENCE.md](docs/09_SHOPXO_HOOKS_REFERENCE.md) | ShopXO 全部钩子清单(从源码提取) |
|
| [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/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/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>` → 浏览器卡死
|
2. Vue 3 `[[ ]]` 插值禁止用于 `<textarea>` → 浏览器卡死
|
||||||
3. 字段名不能猜,必须查源码
|
3. 字段名不能猜,必须查源码
|
||||||
4. ShopXO `MyView()` 加载插件模板时 view_path 被覆盖 → 影响 ModuleInclude 解析
|
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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/` 目录读取。
|
||||||
|
|
||||||
|
PHP(ThinkPHP)运行时加载插件类文件时,使用 `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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 本次 Case:ticket_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) — 缓存机制
|
||||||
|
|
@ -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。详情见专项文档。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 📌 开发前检查清单
|
## 📌 开发前检查清单
|
||||||
|
|
||||||
接手本插件时,逐项确认以下内容:
|
接手本插件时,逐项确认以下内容:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
})();
|
||||||
Loading…
Reference in New Issue