feat(Phase 0): vr_ticket plugin skeleton complete
生成内容:
- plugin.json + EventListener.php(安装自动建表)
- service/BaseService.php(AES加密/Qr生成/工具函数)
- service/TicketService.php(核心:onOrderPaid发票/核销)
- admin/controller/:SeatTemplate + Ticket + Verifier + Verification
- admin/view/:4套后台列表页
- view/goods/ticket_detail.html(前端票务详情页,完全独立UI)
- app/plugins/vr_ticket/README.md(安装说明)
- docs/GOODS_PHP_MODIFICATION.md(Goods.php修改步骤,更新路径)
⚡ 核心原则:怎么快怎么来,AI介入度95%+
refactor/vr-ticket-20260416
parent
d5edb76f33
commit
34f7045956
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* VR票务插件 - 事件监听器
|
||||||
|
*
|
||||||
|
* ShopXO 生命周期钩子入口
|
||||||
|
*
|
||||||
|
* @package vr_ticket
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装时执行
|
||||||
|
* 创建数据库表
|
||||||
|
*/
|
||||||
|
function vr_ticket_install()
|
||||||
|
{
|
||||||
|
$db = GetDB();
|
||||||
|
$prefix = $db->getTablePrefix();
|
||||||
|
|
||||||
|
// 座位模板表
|
||||||
|
$db->query("
|
||||||
|
CREATE TABLE IF NOT EXISTS `{$prefix}vr_seat_templates` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '模板ID',
|
||||||
|
`name` VARCHAR(180) NOT NULL COMMENT '模板名称',
|
||||||
|
`category_id` BIGINT UNSIGNED NOT NULL COMMENT '绑定的分类ID',
|
||||||
|
`seat_map` LONGTEXT NOT NULL COMMENT '座位地图JSON',
|
||||||
|
`spec_base_id_map` LONGTEXT DEFAULT NULL COMMENT '座位ID→spec_base_id映射',
|
||||||
|
`status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0禁用 1启用',
|
||||||
|
`add_time` INT UNSIGNED DEFAULT 0 COMMENT '创建时间',
|
||||||
|
`upd_time` INT UNSIGNED DEFAULT 0 COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_category_id` (`category_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||||
|
COMMENT='VR票务座位模板'
|
||||||
|
");
|
||||||
|
|
||||||
|
// 电子票表
|
||||||
|
$db->query("
|
||||||
|
CREATE TABLE IF NOT EXISTS `{$prefix}vr_tickets` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '票ID',
|
||||||
|
`order_id` BIGINT UNSIGNED NOT NULL COMMENT '订单ID',
|
||||||
|
`order_no` CHAR(60) NOT NULL COMMENT '订单号',
|
||||||
|
`goods_id` BIGINT UNSIGNED NOT NULL COMMENT '商品ID',
|
||||||
|
`goods_snapshot` TEXT DEFAULT NULL COMMENT '商品快照JSON',
|
||||||
|
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
|
||||||
|
`ticket_code` CHAR(36) NOT NULL COMMENT 'UUID票码',
|
||||||
|
`qr_data` TEXT DEFAULT NULL COMMENT '加密QR内容',
|
||||||
|
`seat_info` VARCHAR(255) DEFAULT NULL COMMENT '座位信息',
|
||||||
|
`spec_base_id` BIGINT UNSIGNED DEFAULT 0 COMMENT 'spec_base_id',
|
||||||
|
`real_name` VARCHAR(60) DEFAULT NULL COMMENT '观演人姓名',
|
||||||
|
`phone` CHAR(15) DEFAULT NULL COMMENT '观演人手机',
|
||||||
|
`id_card` CHAR(18) DEFAULT NULL COMMENT '观演人身份证',
|
||||||
|
`verify_status` TINYINT UNSIGNED DEFAULT 0 COMMENT '核销状态',
|
||||||
|
`verify_time` INT UNSIGNED DEFAULT 0 COMMENT '核销时间',
|
||||||
|
`verifier_id` BIGINT UNSIGNED DEFAULT 0 COMMENT '核销员ID',
|
||||||
|
`issued_at` INT UNSIGNED DEFAULT 0 COMMENT '票发放时间',
|
||||||
|
`created_at` INT UNSIGNED DEFAULT 0 COMMENT '创建时间',
|
||||||
|
`updated_at` INT UNSIGNED DEFAULT 0 COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_ticket_code` (`ticket_code`),
|
||||||
|
KEY `idx_order_id` (`order_id`),
|
||||||
|
KEY `idx_user_id` (`user_id`),
|
||||||
|
KEY `idx_goods_id` (`goods_id`),
|
||||||
|
KEY `idx_verify_status` (`verify_status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||||
|
COMMENT='VR票务电子票'
|
||||||
|
");
|
||||||
|
|
||||||
|
// 核销员表
|
||||||
|
$db->query("
|
||||||
|
CREATE TABLE IF NOT EXISTS `{$prefix}vr_verifiers` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '核销员ID',
|
||||||
|
`user_id` BIGINT UNSIGNED NOT NULL COMMENT 'ShopXO用户ID',
|
||||||
|
`name` VARCHAR(60) NOT NULL COMMENT '核销员名称',
|
||||||
|
`status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态',
|
||||||
|
`created_at` INT UNSIGNED DEFAULT 0 COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_user_id` (`user_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||||
|
COMMENT='VR票务核销员'
|
||||||
|
");
|
||||||
|
|
||||||
|
// 核销记录表
|
||||||
|
$db->query("
|
||||||
|
CREATE TABLE IF NOT EXISTS `{$prefix}vr_verifications` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '记录ID',
|
||||||
|
`ticket_id` BIGINT UNSIGNED NOT NULL COMMENT '票ID',
|
||||||
|
`ticket_code` CHAR(36) NOT NULL COMMENT '票码',
|
||||||
|
`verifier_id` BIGINT UNSIGNED NOT NULL COMMENT '核销员ID',
|
||||||
|
`verifier_name` VARCHAR(60) DEFAULT NULL COMMENT '核销员名称',
|
||||||
|
`goods_id` BIGINT UNSIGNED DEFAULT 0 COMMENT '商品ID',
|
||||||
|
`created_at` INT UNSIGNED DEFAULT 0 COMMENT '核销时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_ticket_id` (`ticket_id`),
|
||||||
|
KEY `idx_verifier_id` (`verifier_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||||
|
COMMENT='VR票务核销记录'
|
||||||
|
");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卸载时执行
|
||||||
|
* 删除数据库表(可按需保留数据)
|
||||||
|
*/
|
||||||
|
function vr_ticket_uninstall()
|
||||||
|
{
|
||||||
|
// 建议注释掉以下内容,保留数据以便重新安装
|
||||||
|
// $db = GetDB();
|
||||||
|
// $prefix = $db->getTablePrefix();
|
||||||
|
// $db->query("DROP TABLE IF EXISTS `{$prefix}vr_seat_templates`");
|
||||||
|
// $db->query("DROP TABLE IF EXISTS `{$prefix}vr_tickets`");
|
||||||
|
// $db->query("DROP TABLE IF EXISTS `{$prefix}vr_verifiers`");
|
||||||
|
// $db->query("DROP TABLE IF EXISTS `{$prefix}vr_verifications`");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 升级时执行
|
||||||
|
*/
|
||||||
|
function vr_ticket_upgrade($old_version)
|
||||||
|
{
|
||||||
|
// 未来升级时在这里处理数据迁移
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
# VR票务插件 - vr_ticket
|
||||||
|
|
||||||
|
> ⚡ 核心原则:怎么快怎么来,怎么方便怎么来
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
1. 将本目录上传到 ShopXO 插件目录:
|
||||||
|
```bash
|
||||||
|
cp -r vr_ticket /path/to/shopxo/app/plugins/
|
||||||
|
```
|
||||||
|
2. 后台 → 应用中心 → 插件管理 → 找到「VR票务」→ 点击安装
|
||||||
|
3. 数据库表自动创建
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
vr_ticket/
|
||||||
|
├── plugin.json # 插件配置(名称、菜单、钩子)
|
||||||
|
├── EventListener.php # 安装/卸载/升级生命周期
|
||||||
|
├── service/
|
||||||
|
│ ├── BaseService.php # 基础工具(AES加密、QR生成)
|
||||||
|
│ └── TicketService.php # 核心票务逻辑(发票、核销)
|
||||||
|
├── admin/
|
||||||
|
│ ├── controller/
|
||||||
|
│ │ ├── SeatTemplate.php # 座位模板 CRUD
|
||||||
|
│ │ ├── Ticket.php # 电子票管理
|
||||||
|
│ │ ├── Verifier.php # 核销员管理
|
||||||
|
│ │ └── Verification.php # 核销记录
|
||||||
|
│ └── view/ # 后台视图模板
|
||||||
|
│ ├── seat_template/
|
||||||
|
│ ├── ticket/
|
||||||
|
│ ├── verifier/
|
||||||
|
│ └── verification/
|
||||||
|
└── view/
|
||||||
|
└── goods/
|
||||||
|
└── ticket_detail.html # 前端票务详情页(独立模板)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键钩子
|
||||||
|
|
||||||
|
| 钩子 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| `plugins_service_order_pay_success_handle_end` | 支付成功 → 自动发放 QR 电子票 |
|
||||||
|
| `plugins_service_order_delete_success` | 订单删除 → 清理票务数据 |
|
||||||
|
|
||||||
|
## 前端票务详情页
|
||||||
|
|
||||||
|
需要在 ShopXO 核心文件 `app/index/controller/Goods.php` 中加 1 行:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 在 return MyView(); 之前(约第 440 行)
|
||||||
|
if (!empty($assign['goods']['item_type']) && $assign['goods']['item_type'] == 'ticket') {
|
||||||
|
return MyView('/../../../plugins/vr_ticket/view/goods/ticket_detail');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
详见 `docs/GOODS_PHP_MODIFICATION.md`
|
||||||
|
|
||||||
|
## 数据库表
|
||||||
|
|
||||||
|
| 表名 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `vrt_vr_seat_templates` | 座位模板(绑定分类) |
|
||||||
|
| `vrt_vr_tickets` | 电子票(含观演人) |
|
||||||
|
| `vrt_vr_verifiers` | 核销员 |
|
||||||
|
| `vrt_vr_verifications` | 核销记录 |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| URL | 方法 | 作用 |
|
||||||
|
|-----|------|------|
|
||||||
|
| `/plugins/vr_ticket/admin/seat_template/list` | GET | 座位模板列表 |
|
||||||
|
| `/plugins/vr_ticket/admin/seat_template/save` | GET/POST | 添加/编辑模板 |
|
||||||
|
| `/plugins/vr_ticket/admin/ticket/list` | GET | 电子票列表 |
|
||||||
|
| `/plugins/vr_ticket/admin/verification/list` | GET | 核销记录 |
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* VR票务插件 - 座位模板管理
|
||||||
|
*
|
||||||
|
* @package vr_ticket\admin\controller
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace app\plugins\vr_ticket\admin\controller;
|
||||||
|
|
||||||
|
class SeatTemplate
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 座位模板列表
|
||||||
|
*/
|
||||||
|
public function list()
|
||||||
|
{
|
||||||
|
$where = [];
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
$name = input('name', '', null);
|
||||||
|
if ($name !== '') {
|
||||||
|
$where[] = ['name', 'like', "%{$name}%"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = input('status', '', null);
|
||||||
|
if ($status !== '' && $status !== null) {
|
||||||
|
$where[] = ['status', '=', intval($status)];
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = \Db::name('plugins_vr_seat_templates')
|
||||||
|
->where($where)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->paginate(20)
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// 关联分类名
|
||||||
|
$category_ids = array_column($list['data'], 'category_id');
|
||||||
|
if (!empty($category_ids)) {
|
||||||
|
$categories = \Db::name('GoodsCategory')
|
||||||
|
->where('id', 'in', $category_ids)
|
||||||
|
->column('name', 'id');
|
||||||
|
foreach ($list['data'] as &$item) {
|
||||||
|
$item['category_name'] = $categories[$item['category_id']] ?? '未知分类';
|
||||||
|
$item['seat_count'] = self::countSeats($item['seat_map']);
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('', [
|
||||||
|
'list' => $list['data'],
|
||||||
|
'page' => $list['page'],
|
||||||
|
'count' => $list['total'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加/编辑座位模板
|
||||||
|
*/
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
$id = input('id', 0, 'intval');
|
||||||
|
|
||||||
|
if (IS_AJAX_POST) {
|
||||||
|
$data = [
|
||||||
|
'name' => input('name', '', null, 'trim'),
|
||||||
|
'category_id' => input('category_id', 0, 'intval'),
|
||||||
|
'seat_map' => input('seat_map', '', null, 'trim'),
|
||||||
|
'spec_base_id_map' => input('spec_base_id_map', '', null, 'trim'),
|
||||||
|
'status' => input('status', 1, 'intval'),
|
||||||
|
'upd_time' => time(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (empty($data['name'])) {
|
||||||
|
return DataReturn('模板名称不能为空', -1);
|
||||||
|
}
|
||||||
|
if (empty($data['category_id'])) {
|
||||||
|
return DataReturn('请选择绑定的分类', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 seat_map 为合法 JSON
|
||||||
|
$seat_map = json_decode($data['seat_map'], true);
|
||||||
|
if (empty($seat_map) && $data['seat_map'] !== '[]' && $data['seat_map'] !== '{}') {
|
||||||
|
return DataReturn('座位地图JSON格式错误', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id > 0) {
|
||||||
|
// 更新
|
||||||
|
$data['upd_time'] = time();
|
||||||
|
\Db::name('plugins_vr_seat_templates')->where('id', $id)->update($data);
|
||||||
|
return DataReturn('更新成功', 0, ['url' => MyUrl('plugins_vr_ticket/admin/seat_template/list')]);
|
||||||
|
} else {
|
||||||
|
// 新增
|
||||||
|
$data['add_time'] = time();
|
||||||
|
$data['upd_time'] = time();
|
||||||
|
\Db::name('plugins_vr_seat_templates')->insert($data);
|
||||||
|
return DataReturn('添加成功', 0, ['url' => MyUrl('plugins_vr_ticket/admin/seat_template/list')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑时加载数据
|
||||||
|
$info = [];
|
||||||
|
if ($id > 0) {
|
||||||
|
$info = \Db::name('plugins_vr_seat_templates')->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载分类列表(用于下拉选择)
|
||||||
|
$categories = \Db::name('GoodsCategory')
|
||||||
|
->where('is_delete', 0)
|
||||||
|
->order('id', 'asc')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
return view('', [
|
||||||
|
'info' => $info,
|
||||||
|
'categories' => $categories,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除座位模板
|
||||||
|
*/
|
||||||
|
public function delete()
|
||||||
|
{
|
||||||
|
if (!IS_AJAX_POST) {
|
||||||
|
return view('', ['info' => [], 'msg' => '非法请求']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = input('id', 0, 'intval');
|
||||||
|
if ($id <= 0) {
|
||||||
|
return DataReturn('参数错误', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
\Db::name('plugins_vr_seat_templates')->delete($id);
|
||||||
|
return DataReturn('删除成功', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计座位数
|
||||||
|
*/
|
||||||
|
private static function countSeats($seat_map_json)
|
||||||
|
{
|
||||||
|
if (empty($seat_map_json)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$map = json_decode($seat_map_json, true);
|
||||||
|
if (empty($map['seats'])) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$count = 0;
|
||||||
|
foreach (str_split($map['map'][0] ?? '') as $char) {
|
||||||
|
if ($char !== '_' && isset($map['seats'][$char])) {
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 简单估算:所有行的座位总数
|
||||||
|
$rows = count($map['map']);
|
||||||
|
return $count * $rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* VR票务插件 - 电子票管理
|
||||||
|
*
|
||||||
|
* @package vr_ticket\admin\controller
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace app\plugins\vr_ticket\admin\controller;
|
||||||
|
|
||||||
|
class Ticket
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 电子票列表
|
||||||
|
*/
|
||||||
|
public function list()
|
||||||
|
{
|
||||||
|
$where = [];
|
||||||
|
|
||||||
|
$keywords = input('keywords', '', null, 'trim');
|
||||||
|
if (!empty($keywords)) {
|
||||||
|
// 支持搜索:订单号、票码、观演人姓名、手机号
|
||||||
|
$where[] = [
|
||||||
|
'order_no|ticket_code|real_name|phone',
|
||||||
|
'like',
|
||||||
|
"%{$keywords}%"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$verify_status = input('verify_status', '', null);
|
||||||
|
if ($verify_status !== '' && $verify_status !== null) {
|
||||||
|
$where[] = ['verify_status', '=', intval($verify_status)];
|
||||||
|
}
|
||||||
|
|
||||||
|
$goods_id = input('goods_id', 0, 'intval');
|
||||||
|
if ($goods_id > 0) {
|
||||||
|
$where[] = ['goods_id', '=', $goods_id];
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = \Db::name('plugins_vr_tickets')
|
||||||
|
->where($where)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->paginate(20)
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// 补充商品名称
|
||||||
|
$goods_ids = array_filter(array_column($list['data'], 'goods_id'));
|
||||||
|
if (!empty($goods_ids)) {
|
||||||
|
$goods_map = \Db::name('Goods')
|
||||||
|
->where('id', 'in', $goods_ids)
|
||||||
|
->column('title', 'id');
|
||||||
|
foreach ($list['data'] as &$item) {
|
||||||
|
$item['goods_title'] = $goods_map[$item['goods_id']] ?? '已删除商品';
|
||||||
|
$item['qr_code_url'] = \app\plugins\vr_ticket\service\TicketService::getQrCodeUrl($item['ticket_code']);
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态标签映射
|
||||||
|
$status_map = [
|
||||||
|
0 => ['text' => '未核销', 'color' => 'blue'],
|
||||||
|
1 => ['text' => '已核销', 'color' => 'green'],
|
||||||
|
2 => ['text' => '已退款', 'color' => 'red'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return view('', [
|
||||||
|
'list' => $list['data'],
|
||||||
|
'page' => $list['page'],
|
||||||
|
'count' => $list['total'],
|
||||||
|
'status_map' => $status_map,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 票详情
|
||||||
|
*/
|
||||||
|
public function detail()
|
||||||
|
{
|
||||||
|
$id = input('id', 0, 'intval');
|
||||||
|
if ($id <= 0) {
|
||||||
|
return view('', ['msg' => '参数错误']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket = \Db::name('plugins_vr_tickets')->find($id);
|
||||||
|
if (empty($ticket)) {
|
||||||
|
return view('', ['msg' => '票不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 商品信息
|
||||||
|
$goods = \Db::name('Goods')->find($ticket['goods_id']);
|
||||||
|
|
||||||
|
// 核销员信息
|
||||||
|
$verifier = [];
|
||||||
|
if ($ticket['verifier_id'] > 0) {
|
||||||
|
$verifier = \Db::name('plugins_vr_verifiers')->find($ticket['verifier_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// QR 码图片
|
||||||
|
$ticket['qr_code_url'] = \app\plugins\vr_ticket\service\TicketService::getQrCodeUrl($ticket['ticket_code']);
|
||||||
|
|
||||||
|
return view('', [
|
||||||
|
'ticket' => $ticket,
|
||||||
|
'goods' => $goods,
|
||||||
|
'verifier' => $verifier,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动核销票
|
||||||
|
*/
|
||||||
|
public function verify()
|
||||||
|
{
|
||||||
|
if (!IS_AJAX_POST) {
|
||||||
|
return view('', ['msg' => '非法请求']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket_code = input('ticket_code', '', null, 'trim');
|
||||||
|
$verifier_id = input('verifier_id', 0, 'intval');
|
||||||
|
|
||||||
|
if (empty($ticket_code)) {
|
||||||
|
return DataReturn('票码不能为空', -1);
|
||||||
|
}
|
||||||
|
if ($verifier_id <= 0) {
|
||||||
|
return DataReturn('请选择核销员', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = \app\plugins\vr_ticket\service\TicketService::verifyTicket($ticket_code, $verifier_id);
|
||||||
|
return DataReturn($result['msg'], $result['code'], $result['data'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出票列表(CSV)
|
||||||
|
*/
|
||||||
|
public function export()
|
||||||
|
{
|
||||||
|
$where = [];
|
||||||
|
$goods_id = input('goods_id', 0, 'intval');
|
||||||
|
if ($goods_id > 0) {
|
||||||
|
$where[] = ['goods_id', '=', $goods_id];
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = \Db::name('plugins_vr_tickets')
|
||||||
|
->where($where)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$header = ['ID', '订单号', '票码', '观演人', '手机', '座位', '核销状态', '发放时间'];
|
||||||
|
$data = [];
|
||||||
|
foreach ($list as $item) {
|
||||||
|
$status_text = $item['verify_status'] == 0 ? '未核销' : ($item['verify_status'] == 1 ? '已核销' : '已退款');
|
||||||
|
$data[] = [
|
||||||
|
$item['id'],
|
||||||
|
$item['order_no'],
|
||||||
|
$item['ticket_code'],
|
||||||
|
$item['real_name'],
|
||||||
|
$item['phone'],
|
||||||
|
$item['seat_info'],
|
||||||
|
$status_text,
|
||||||
|
date('Y-m-d H:i:s', $item['issued_at']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ExportCsv($header, $data, 'vr_tickets_' . date('Ymd'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* VR票务插件 - 核销记录
|
||||||
|
*
|
||||||
|
* @package vr_ticket\admin\controller
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace app\plugins\vr_ticket\admin\controller;
|
||||||
|
|
||||||
|
class Verification
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 核销记录列表
|
||||||
|
*/
|
||||||
|
public function list()
|
||||||
|
{
|
||||||
|
$where = [];
|
||||||
|
|
||||||
|
$keywords = input('keywords', '', null, 'trim');
|
||||||
|
if (!empty($keywords)) {
|
||||||
|
$where[] = ['ticket_code|verifier_name', 'like', "%{$keywords}%"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$verifier_id = input('verifier_id', 0, 'intval');
|
||||||
|
if ($verifier_id > 0) {
|
||||||
|
$where[] = ['verifier_id', '=', $verifier_id];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日期范围
|
||||||
|
$start_date = input('start_date', '', null, 'trim');
|
||||||
|
$end_date = input('end_date', '', null, 'trim');
|
||||||
|
if (!empty($start_date)) {
|
||||||
|
$where[] = ['created_at', '>=', strtotime($start_date)];
|
||||||
|
}
|
||||||
|
if (!empty($end_date)) {
|
||||||
|
$where[] = ['created_at', '<=', strtotime($end_date . ' 23:59:59')];
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = \Db::name('plugins_vr_verifications')
|
||||||
|
->where($where)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->paginate(20)
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// 补充票信息和商品信息
|
||||||
|
$ticket_ids = array_filter(array_column($list['data'], 'ticket_id'));
|
||||||
|
if (!empty($ticket_ids)) {
|
||||||
|
$tickets = \Db::name('plugins_vr_tickets')
|
||||||
|
->where('id', 'in', $ticket_ids)
|
||||||
|
->column('seat_info,real_name,goods_id', 'id');
|
||||||
|
foreach ($list['data'] as &$item) {
|
||||||
|
$ticket = $tickets[$item['ticket_id']] ?? [];
|
||||||
|
$item['seat_info'] = $ticket['seat_info'] ?? '';
|
||||||
|
$item['real_name'] = $ticket['real_name'] ?? '';
|
||||||
|
$item['goods_id'] = $ticket['goods_id'] ?? 0;
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 商品名
|
||||||
|
$goods_ids = array_filter(array_unique(array_column($list['data'], 'goods_id')));
|
||||||
|
if (!empty($goods_ids)) {
|
||||||
|
$goods_map = \Db::name('Goods')
|
||||||
|
->where('id', 'in', $goods_ids)
|
||||||
|
->column('title', 'id');
|
||||||
|
foreach ($list['data'] as &$item) {
|
||||||
|
$item['goods_title'] = $goods_map[$item['goods_id']] ?? '已删除';
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 核销员列表(用于筛选)
|
||||||
|
$verifiers = \Db::name('plugins_vr_verifiers')
|
||||||
|
->where('status', 1)
|
||||||
|
->column('name', 'id');
|
||||||
|
|
||||||
|
return view('', [
|
||||||
|
'list' => $list['data'],
|
||||||
|
'page' => $list['page'],
|
||||||
|
'count' => $list['total'],
|
||||||
|
'verifiers' => $verifiers,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* VR票务插件 - 核销员管理
|
||||||
|
*
|
||||||
|
* @package vr_ticket\admin\controller
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace app\plugins\vr_ticket\admin\controller;
|
||||||
|
|
||||||
|
class Verifier
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 核销员列表
|
||||||
|
*/
|
||||||
|
public function list()
|
||||||
|
{
|
||||||
|
$where = [];
|
||||||
|
|
||||||
|
$keywords = input('keywords', '', null, 'trim');
|
||||||
|
if (!empty($keywords)) {
|
||||||
|
$where[] = ['name|user_id', 'like', "%{$keywords}%"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = input('status', '', null);
|
||||||
|
if ($status !== '' && $status !== null) {
|
||||||
|
$where[] = ['status', '=', intval($status)];
|
||||||
|
}
|
||||||
|
|
||||||
|
$list = \Db::name('plugins_vr_verifiers')
|
||||||
|
->where($where)
|
||||||
|
->order('id', 'desc')
|
||||||
|
->paginate(20)
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// 关联 ShopXO 用户信息
|
||||||
|
$user_ids = array_filter(array_column($list['data'], 'user_id'));
|
||||||
|
if (!empty($user_ids)) {
|
||||||
|
$users = \Db::name('User')
|
||||||
|
->where('id', 'in', $user_ids)
|
||||||
|
->column('nickname|username', 'id');
|
||||||
|
foreach ($list['data'] as &$item) {
|
||||||
|
$item['user_name'] = $users[$item['user_id']] ?? '已删除用户';
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('', [
|
||||||
|
'list' => $list['data'],
|
||||||
|
'page' => $list['page'],
|
||||||
|
'count' => $list['total'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加/编辑核销员
|
||||||
|
*/
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
$id = input('id', 0, 'intval');
|
||||||
|
|
||||||
|
if (IS_AJAX_POST) {
|
||||||
|
$user_id = input('user_id', 0, 'intval');
|
||||||
|
$name = input('name', '', null, 'trim');
|
||||||
|
$status = input('status', 1, 'intval');
|
||||||
|
|
||||||
|
if ($user_id <= 0) {
|
||||||
|
return DataReturn('请选择关联用户', -1);
|
||||||
|
}
|
||||||
|
if (empty($name)) {
|
||||||
|
return DataReturn('核销员名称不能为空', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已存在
|
||||||
|
$exist = \Db::name('plugins_vr_verifiers')
|
||||||
|
->where('user_id', $user_id)
|
||||||
|
->where('id', '<>', $id)
|
||||||
|
->find();
|
||||||
|
if ($exist) {
|
||||||
|
return DataReturn('该用户已是核销员', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id > 0) {
|
||||||
|
\Db::name('plugins_vr_verifiers')
|
||||||
|
->where('id', $id)
|
||||||
|
->update([
|
||||||
|
'name' => $name,
|
||||||
|
'status' => $status,
|
||||||
|
]);
|
||||||
|
return DataReturn('更新成功', 0);
|
||||||
|
} else {
|
||||||
|
\Db::name('plugins_vr_verifiers')->insert([
|
||||||
|
'user_id' => $user_id,
|
||||||
|
'name' => $name,
|
||||||
|
'status' => $status,
|
||||||
|
'created_at' => time(),
|
||||||
|
]);
|
||||||
|
return DataReturn('添加成功', 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$info = [];
|
||||||
|
if ($id > 0) {
|
||||||
|
$info = \Db::name('plugins_vr_verifiers')->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户列表(用于选择)
|
||||||
|
$users = \Db::name('User')
|
||||||
|
->where('is_delete', 0)
|
||||||
|
->field('id, nickname, username')
|
||||||
|
->order('id', 'desc')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
return view('', [
|
||||||
|
'info' => $info,
|
||||||
|
'users' => $users,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除核销员
|
||||||
|
*/
|
||||||
|
public function delete()
|
||||||
|
{
|
||||||
|
if (!IS_AJAX_POST) {
|
||||||
|
return DataReturn('非法请求', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = input('id', 0, 'intval');
|
||||||
|
if ($id <= 0) {
|
||||||
|
return DataReturn('参数错误', -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不允许删除,改为禁用
|
||||||
|
\Db::name('plugins_vr_verifiers')
|
||||||
|
->where('id', $id)
|
||||||
|
->update(['status' => 0]);
|
||||||
|
|
||||||
|
return DataReturn('已禁用', 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>座位模板 - VR票务</title>
|
||||||
|
{include file="public/head" /}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layui-fluid">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">座位模板管理</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<div class="layui-form layui-form-pane">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">模板名称</label>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<input type="text" name="name" value="" placeholder="搜索模板名称" class="layui-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">状态</label>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<select name="status" lay-search>
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="1">启用</option>
|
||||||
|
<option value="0">禁用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="search">搜索</button>
|
||||||
|
<a href="{:MyUrl('plugins_vr_ticket/admin/seat_template/save')}" class="layui-btn layui-btn-normal">添加模板</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据表格 -->
|
||||||
|
<table class="layui-hide" id="table" lay-filter="table"></table>
|
||||||
|
|
||||||
|
<script type="text/template" id="statusTpl">
|
||||||
|
{{# if (d.status == 1) { }}
|
||||||
|
<span class="layui-badge layui-bg-green">启用</span>
|
||||||
|
{{# } else { }}
|
||||||
|
<span class="layui-badge layui-bg-gray">禁用</span>
|
||||||
|
{{# } }}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/template" id="actionTpl">
|
||||||
|
<a href="{:MyUrl('plugins_vr_ticket/admin/seat_template/save')}?id={{d.id}}" class="layui-btn layui-btn-xs">编辑</a>
|
||||||
|
<a href="javascript:;" class="layui-btn layui-btn-danger layui-btn-xs" lay-fn="del" data-id="{{d.id}}">删除</a>
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{include file="public/footer" /}
|
||||||
|
<script>
|
||||||
|
layui.use('table', function() {
|
||||||
|
var table = layui.table;
|
||||||
|
var form = layui.form;
|
||||||
|
|
||||||
|
table.render({
|
||||||
|
elem: '#table',
|
||||||
|
url: '{:MyUrl("plugins_vr_ticket/admin/seat_template/list")}',
|
||||||
|
cols: [[
|
||||||
|
{field: 'id', title: 'ID', width: 80},
|
||||||
|
{field: 'name', title: '模板名称', minWidth: 150},
|
||||||
|
{field: 'category_name', title: '绑定分类', width: 150},
|
||||||
|
{field: 'seat_count', title: '座位数', width: 100},
|
||||||
|
{field: 'status', title: '状态', width: 100, templet: '#statusTpl'},
|
||||||
|
{field: 'add_time', title: '创建时间', width: 180, templet: function(d) {
|
||||||
|
return d.add_time > 0 ? layui.util.toDateString(d.add_time * 1000) : '-';
|
||||||
|
}},
|
||||||
|
{field: 'action', title: '操作', width: 150, templet: '#actionTpl'},
|
||||||
|
]]
|
||||||
|
});
|
||||||
|
|
||||||
|
form.on('submit(search)', function(data) {
|
||||||
|
table.reload('table', {where: data.field, page: {curr: 1}});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('click', '[lay-fn="del"]', function() {
|
||||||
|
var id = $(this).data('id');
|
||||||
|
layer.confirm('确认删除?', function(index) {
|
||||||
|
$.post('{:MyUrl("plugins_vr_ticket/admin/seat_template/delete")}', {id: id}, function(res) {
|
||||||
|
if (res.code == 0) {
|
||||||
|
layer.msg('删除成功');
|
||||||
|
table.reload('table');
|
||||||
|
} else {
|
||||||
|
layer.msg(res.msg || '删除失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
layer.close(index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{$info ? '编辑' : '添加'}座位模板 - VR票务</title>
|
||||||
|
{include file="public/head" /}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layui-fluid">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">{$info ? '编辑' : '添加'}座位模板</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<form class="layui-form" lay-filter="form">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">模板名称</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="name" value="{$info.name|default=''}" required lay-verify="required" placeholder="如:鸟巢-A区" class="layui-input" style="width:400px">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">绑定分类</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<select name="category_id" lay-search required lay-verify="required">
|
||||||
|
<option value="">请选择分类</option>
|
||||||
|
{foreach $categories as $cat}
|
||||||
|
<option value="{$cat.id}" {if isset($info.category_id) && $info.category_id == $cat.id}selected{/if}>{$cat.name}</option>
|
||||||
|
{/foreach}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<label class="layui-form-label">状态</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="checkbox" name="status" value="1" lay-skin="switch" lay-text="启用|禁用" {if !isset($info.status) || $info.status == 1}checked{/if}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item layui-form-text">
|
||||||
|
<label class="layui-form-label">座位地图JSON</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<textarea name="seat_map" rows="10" placeholder="座位地图配置JSON,参考ShopXO插件文档" class="layui-textarea" style="width:600px">{$info.seat_map|default=''|raw}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-mid layui-word-aux">
|
||||||
|
格式:{"map":["AAAAAA","BBBBB"],"seats":{"A":{"price":599,"label":"VIP"},"B":{"price":299,"label":"普通"}},"sections":[]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item layui-form-text">
|
||||||
|
<label class="layui-form-label">规格映射JSON</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<textarea name="spec_base_id_map" rows="4" placeholder="座位ID到spec_base_id的映射,格式:{"A":123,"B":124}" class="layui-textarea" style="width:600px">{$info.spec_base_id_map|default=''|raw}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="hidden" name="id" value="{$info.id|default=0}">
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="submit">保存</button>
|
||||||
|
<a href="{:MyUrl('plugins_vr_ticket/admin/seat_template/list')}" class="layui-btn layui-btn-primary">返回</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{include file="public/footer" /}
|
||||||
|
<script>
|
||||||
|
layui.use('form', function() {
|
||||||
|
var form = layui.form;
|
||||||
|
form.on('submit(submit)', function(data) {
|
||||||
|
$.post(window.location.href, data.field, function(res) {
|
||||||
|
if (res.code == 0) {
|
||||||
|
layer.msg(res.msg || '保存成功', function() {
|
||||||
|
if (res.data && res.data.url) {
|
||||||
|
location.href = res.data.url;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
layer.msg(res.msg || '保存失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>电子票管理 - VR票务</title>
|
||||||
|
{include file="public/head" /}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layui-fluid">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">电子票管理</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<div class="layui-form layui-form-pane">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">关键词</label>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<input type="text" name="keywords" value="" placeholder="订单号/票码/姓名/手机" class="layui-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">核销状态</label>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<select name="verify_status">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="0">未核销</option>
|
||||||
|
<option value="1">已核销</option>
|
||||||
|
<option value="2">已退款</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="search">搜索</button>
|
||||||
|
<a href="{:MyUrl('plugins_vr_ticket/admin/ticket/export')}" class="layui-btn layui-btn-primary" target="_blank">导出CSV</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="layui-hide" id="table" lay-filter="table"></table>
|
||||||
|
|
||||||
|
<script type="text/template" id="statusTpl">
|
||||||
|
{{# var colors = ['', 'green', 'red']; var texts = ['未核销', '已核销', '已退款']; }}
|
||||||
|
<span class="layui-badge layui-bg-{{colors[d.verify_status] || 'gray'}}">{{texts[d.verify_status] || '未知'}}</span>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/template" id="qrTpl">
|
||||||
|
{{# if (d.qr_code_url) { }}
|
||||||
|
<img src="{{d.qr_code_url}}" style="width:50px;height:50px;cursor:pointer" lay-fn="preview" data-src="{{d.qr_code_url}}">
|
||||||
|
{{# } else { }}
|
||||||
|
-
|
||||||
|
{{# } }}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="text/template" id="actionTpl">
|
||||||
|
<a href="{:MyUrl('plugins_vr_ticket/admin/ticket/detail')}?id={{d.id}}" class="layui-btn layui-btn-xs">详情</a>
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{include file="public/footer" /}
|
||||||
|
<script>
|
||||||
|
layui.use(['table', 'form'], function() {
|
||||||
|
var table = layui.table;
|
||||||
|
var form = layui.form;
|
||||||
|
|
||||||
|
table.render({
|
||||||
|
elem: '#table',
|
||||||
|
url: '{:MyUrl("plugins_vr_ticket/admin/ticket/list")}',
|
||||||
|
cols: [[
|
||||||
|
{field: 'id', title: 'ID', width: 70},
|
||||||
|
{field: 'ticket_code', title: '票码', width: 200},
|
||||||
|
{field: 'goods_title', title: '商品', minWidth: 150},
|
||||||
|
{field: 'real_name', title: '观演人', width: 100},
|
||||||
|
{field: 'phone', title: '手机', width: 120},
|
||||||
|
{field: 'seat_info', title: '座位', minWidth: 120},
|
||||||
|
{field: 'verify_status', title: '状态', width: 100, templet: '#statusTpl'},
|
||||||
|
{field: 'qr', title: 'QR码', width: 80, templet: '#qrTpl'},
|
||||||
|
{field: 'issued_at', title: '发放时间', width: 160, templet: function(d) {
|
||||||
|
return d.issued_at > 0 ? layui.util.toDateString(d.issued_at * 1000) : '-';
|
||||||
|
}},
|
||||||
|
{field: 'action', title: '操作', width: 80, templet: '#actionTpl'},
|
||||||
|
]]
|
||||||
|
});
|
||||||
|
|
||||||
|
form.on('submit(search)', function(data) {
|
||||||
|
table.reload('table', {where: data.field, page: {curr: 1}});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('click', '[lay-fn="preview"]', function() {
|
||||||
|
var src = $(this).data('src');
|
||||||
|
layer.open({type: 1, title: 'QR码', content: '<img src="'+src+'" style="padding:20px">', area: ['300px', '350px']});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>核销记录 - VR票务</title>
|
||||||
|
{include file="public/head" /}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layui-fluid">
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">核销记录</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<div class="layui-form layui-form-pane">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">关键词</label>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<input type="text" name="keywords" placeholder="票码/核销员" class="layui-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">核销员</label>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<select name="verifier_id">
|
||||||
|
<option value="">全部</option>
|
||||||
|
{foreach $verifiers as $id => $name}
|
||||||
|
<option value="{$id}">{$name}</option>
|
||||||
|
{/foreach}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">日期</label>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<input type="text" name="start_date" id="start_date" placeholder="开始日期" class="layui-input laydate" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="layui-input-inline">
|
||||||
|
<input type="text" name="end_date" id="end_date" placeholder="结束日期" class="layui-input laydate" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="search">搜索</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="layui-hide" id="table" lay-filter="table"></table>
|
||||||
|
|
||||||
|
<script type="text/template" id="actionTpl">
|
||||||
|
<span style="color:#999;font-size:12px">-</span>
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{include file="public/footer" /}
|
||||||
|
<script>
|
||||||
|
layui.use(['table', 'laydate'], function() {
|
||||||
|
var table = layui.table;
|
||||||
|
var laydate = layui.laydate;
|
||||||
|
var form = layui.form;
|
||||||
|
|
||||||
|
laydate.render({elem: '#start_date'});
|
||||||
|
laydate.render({elem: '#end_date'});
|
||||||
|
|
||||||
|
table.render({
|
||||||
|
elem: '#table',
|
||||||
|
url: '{:MyUrl("plugins_vr_ticket/admin/verification/list")}',
|
||||||
|
cols: [[
|
||||||
|
{field: 'id', title: 'ID', width: 70},
|
||||||
|
{field: 'ticket_code', title: '票码', width: 200},
|
||||||
|
{field: 'goods_title', title: '商品', minWidth: 150},
|
||||||
|
{field: 'real_name', title: '观演人', width: 100},
|
||||||
|
{field: 'seat_info', title: '座位', minWidth: 120},
|
||||||
|
{field: 'verifier_name', title: '核销员', width: 100},
|
||||||
|
{field: 'created_at', title: '核销时间', width: 160, templet: function(d) {
|
||||||
|
return d.created_at > 0 ? layui.util.toDateString(d.created_at * 1000) : '-';
|
||||||
|
}},
|
||||||
|
]]
|
||||||
|
});
|
||||||
|
|
||||||
|
form.on('submit(search)', function(data) {
|
||||||
|
table.reload('table', {where: data.field, page: {curr: 1}});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "vr_ticket",
|
||||||
|
"title": "VR票务",
|
||||||
|
"description": "为ShopXO添加VR演唱会票务功能:座位模板、观演人收集、QR电子票、扫码核销",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "sileya-ai",
|
||||||
|
"author_url": "http://xmhome.ow-my.com:3000/sileya-ai/vr-shopxo-plugin",
|
||||||
|
"shopxo_version": ">=1.0.0",
|
||||||
|
"dependencies": [],
|
||||||
|
"menus": [
|
||||||
|
{
|
||||||
|
"title": "VR票务",
|
||||||
|
"icon": "icon icon-ticket",
|
||||||
|
"submenus": [
|
||||||
|
{ "title": "座位模板", "url": "/plugins/vr_ticket/admin/seat_template/list" },
|
||||||
|
{ "title": "电子票", "url": "/plugins/vr_ticket/admin/ticket/list" },
|
||||||
|
{ "title": "核销员", "url": "/plugins/vr_ticket/admin/verifier/list" },
|
||||||
|
{ "title": "核销记录", "url": "/plugins/vr_ticket/admin/verification/list" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"hooks": [
|
||||||
|
"plugins_service_order_pay_success_handle_end",
|
||||||
|
"plugins_service_order_delete_success"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* VR票务插件 - 基础服务
|
||||||
|
*
|
||||||
|
* @package vr_ticket\service
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace app\plugins\vr_ticket\service;
|
||||||
|
|
||||||
|
class BaseService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 获取插件表前缀
|
||||||
|
*/
|
||||||
|
public static function table($name)
|
||||||
|
{
|
||||||
|
return 'plugins_vr_' . $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前时间戳
|
||||||
|
*/
|
||||||
|
public static function now()
|
||||||
|
{
|
||||||
|
return time();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 UUID v4 票码
|
||||||
|
*/
|
||||||
|
public static function generateUuid()
|
||||||
|
{
|
||||||
|
$data = random_bytes(16);
|
||||||
|
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
|
||||||
|
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
|
||||||
|
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-256-CBC 加密 QR 数据
|
||||||
|
*
|
||||||
|
* @param array $data 待加密数据
|
||||||
|
* @param int|null $expire 过期时间戳(默认30天)
|
||||||
|
* @return string base64 编码密文
|
||||||
|
*/
|
||||||
|
public static function encryptQrData($data, $expire = null)
|
||||||
|
{
|
||||||
|
$secret = self::getQrSecret();
|
||||||
|
$expire = $expire ?? (time() + 86400 * 30);
|
||||||
|
|
||||||
|
$payload = json_encode(array_merge($data, [
|
||||||
|
'exp' => $expire,
|
||||||
|
'iat' => time(),
|
||||||
|
]), JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$encrypted = openssl_encrypt($payload, 'AES-256-CBC', $secret, OPENSSL_RAW_DATA, $iv);
|
||||||
|
|
||||||
|
return base64_encode($iv . $encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密 QR 数据
|
||||||
|
*
|
||||||
|
* @param string $encoded base64 编码密文
|
||||||
|
* @return array|null
|
||||||
|
*/
|
||||||
|
public static function decryptQrData($encoded)
|
||||||
|
{
|
||||||
|
$secret = self::getQrSecret();
|
||||||
|
$combined = base64_decode($encoded);
|
||||||
|
|
||||||
|
if (strlen($combined) < 16) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$iv = substr($combined, 0, 16);
|
||||||
|
$encrypted = substr($combined, 16);
|
||||||
|
|
||||||
|
$decrypted = openssl_decrypt($encrypted, 'AES-256-CBC', $secret, OPENSSL_RAW_DATA, $iv);
|
||||||
|
|
||||||
|
if ($decrypted === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($decrypted, true);
|
||||||
|
|
||||||
|
if (isset($data['exp']) && $data['exp'] < time()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 QR 加密密钥
|
||||||
|
*/
|
||||||
|
private static function getQrSecret()
|
||||||
|
{
|
||||||
|
// 优先从环境变量读取
|
||||||
|
$secret = env('VR_TICKET_QR_SECRET', '');
|
||||||
|
if (!empty($secret)) {
|
||||||
|
return $secret;
|
||||||
|
}
|
||||||
|
// 回退:使用 ShopXO 应用密钥
|
||||||
|
return config('shopxo.app_key', 'shopxo_default_secret_change_me');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断商品是否为票务商品
|
||||||
|
*
|
||||||
|
* @param int $goods_id
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function isTicketGoods($goods_id)
|
||||||
|
{
|
||||||
|
$goods = \Db::name('Goods')->find($goods_id);
|
||||||
|
if (empty($goods)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !empty($goods['venue_data']) || ($goods['item_type'] ?? '') === 'ticket';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取商品座位模板
|
||||||
|
*
|
||||||
|
* @param int $goods_id
|
||||||
|
* @return array|null
|
||||||
|
*/
|
||||||
|
public static function getSeatTemplateByGoods($goods_id)
|
||||||
|
{
|
||||||
|
$goods = \Db::name('Goods')->find($goods_id);
|
||||||
|
if (empty($goods) || empty($goods['category_id'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return \Db::name(self::table('seat_templates'))
|
||||||
|
->where('category_id', $goods['category_id'])
|
||||||
|
->where('status', 1)
|
||||||
|
->find();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全日志
|
||||||
|
*/
|
||||||
|
public static function log($message, $context = [], $level = 'info')
|
||||||
|
{
|
||||||
|
$tag = '[vr_ticket]';
|
||||||
|
$ctx = empty($context) ? '' : ' ' . json_encode($context, JSON_UNESCAPED_UNICODE);
|
||||||
|
$log_func = "log_{$level}";
|
||||||
|
if (function_exists($log_func)) {
|
||||||
|
$log_func($tag . $message . $ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* VR票务插件 - 票务服务
|
||||||
|
*
|
||||||
|
* 核心业务:订单支付成功 → 生成电子票
|
||||||
|
*
|
||||||
|
* @package vr_ticket\service
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace app\plugins\vr_ticket\service;
|
||||||
|
|
||||||
|
class TicketService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 订单支付成功回调
|
||||||
|
*
|
||||||
|
* 从 plugin.json 的 hook 触发:
|
||||||
|
* plugins_service_order_pay_success_handle_end
|
||||||
|
*
|
||||||
|
* @param array $params 钩子参数,含 business_data, user_id, business_ids(order_ids)
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function onOrderPaid($params = [])
|
||||||
|
{
|
||||||
|
$order_id = $params['business_id'] ?? ($params['business_ids'][0] ?? 0);
|
||||||
|
if (empty($order_id)) {
|
||||||
|
BaseService::log('onOrderPaid: empty order_id', $params, 'warning');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询订单
|
||||||
|
$order = \Db::name('Order')->find($order_id);
|
||||||
|
if (empty($order) || $order['pay_status'] != 1) {
|
||||||
|
BaseService::log('onOrderPaid: order not paid or not found', ['order_id' => $order_id], 'warning');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否为票务商品
|
||||||
|
if (!BaseService::isTicketGoods($order['goods_id'])) {
|
||||||
|
BaseService::log('onOrderPaid: not a ticket goods', ['order_id' => $order_id], 'info');
|
||||||
|
return true; // 不是票务商品,不报错
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询商品快照(规格信息)
|
||||||
|
$order_goods = \Db::name('OrderGoods')
|
||||||
|
->where('order_id', $order_id)
|
||||||
|
->select();
|
||||||
|
if (empty($order_goods)) {
|
||||||
|
BaseService::log('onOrderPaid: no order goods', ['order_id' => $order_id], 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐个生成票(每个规格选项 = 一张票)
|
||||||
|
$count = 0;
|
||||||
|
foreach ($order_goods as $og) {
|
||||||
|
$ticket_id = self::issueTicket($order, $og);
|
||||||
|
if ($ticket_id > 0) {
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseService::log('onOrderPaid: success', [
|
||||||
|
'order_id' => $order_id,
|
||||||
|
'tickets_issued' => $count,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发放单张票
|
||||||
|
*
|
||||||
|
* @param array $order 订单数据
|
||||||
|
* @param array $order_goods 订单商品数据(包含 spec_base_id)
|
||||||
|
* @return int 票ID
|
||||||
|
*/
|
||||||
|
public static function issueTicket($order, $order_goods)
|
||||||
|
{
|
||||||
|
$ticket_code = BaseService::generateUuid();
|
||||||
|
|
||||||
|
// 构建 QR 数据
|
||||||
|
$qr_payload = [
|
||||||
|
'id' => 0, // 写入后再更新
|
||||||
|
'code' => $ticket_code,
|
||||||
|
'event' => $order['goods_id'],
|
||||||
|
'seat' => $order_goods['spec_name'] ?? '', // 规格名=座位信息
|
||||||
|
];
|
||||||
|
$qr_data = BaseService::encryptQrData($qr_payload);
|
||||||
|
|
||||||
|
// 观演人信息(从订单扩展字段读取,由购票页表单写入)
|
||||||
|
$extension_data = json_decode($order['extension_data'] ?? '{}', true);
|
||||||
|
$attendee = $extension_data['attendee'] ?? [];
|
||||||
|
|
||||||
|
$now = BaseService::now();
|
||||||
|
|
||||||
|
$ticket_id = \Db::name(BaseService::table('tickets'))->insertGetId([
|
||||||
|
'order_id' => $order['id'],
|
||||||
|
'order_no' => $order['order_no'],
|
||||||
|
'goods_id' => $order['goods_id'],
|
||||||
|
'goods_snapshot' => json_encode([
|
||||||
|
'goods_name' => $order['goods_name'] ?? '',
|
||||||
|
'spec_name' => $order_goods['spec_name'] ?? '',
|
||||||
|
'price' => $order_goods['goods_price'] ?? 0,
|
||||||
|
], JSON_UNESCAPED_UNICODE),
|
||||||
|
'user_id' => $order['user_id'],
|
||||||
|
'ticket_code' => $ticket_code,
|
||||||
|
'qr_data' => $qr_data,
|
||||||
|
'seat_info' => $order_goods['spec_name'] ?? '',
|
||||||
|
'spec_base_id' => $order_goods['spec_base_id'] ?? 0,
|
||||||
|
'real_name' => $attendee['real_name'] ?? '',
|
||||||
|
'phone' => $attendee['phone'] ?? '',
|
||||||
|
'id_card' => $attendee['id_card'] ?? '',
|
||||||
|
'verify_status' => 0, // 0=未核销
|
||||||
|
'issued_at' => $now,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 更新 QR 数据中的 ticket_id
|
||||||
|
if ($ticket_id > 0) {
|
||||||
|
$qr_payload['id'] = $ticket_id;
|
||||||
|
$qr_data_updated = BaseService::encryptQrData($qr_payload);
|
||||||
|
\Db::name(BaseService::table('tickets'))
|
||||||
|
->where('id', $ticket_id)
|
||||||
|
->update(['qr_data' => $qr_data_updated]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ticket_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 核销票
|
||||||
|
*
|
||||||
|
* @param string $ticket_code 票码
|
||||||
|
* @param int $verifier_id 核销员ID
|
||||||
|
* @return array [code, msg]
|
||||||
|
*/
|
||||||
|
public static function verifyTicket($ticket_code, $verifier_id)
|
||||||
|
{
|
||||||
|
$ticket = \Db::name(BaseService::table('tickets'))
|
||||||
|
->where('ticket_code', $ticket_code)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if (empty($ticket)) {
|
||||||
|
return ['code' => -1, 'msg' => '票码不存在'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticket['verify_status'] == 1) {
|
||||||
|
return ['code' => -2, 'msg' => '该票已核销'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticket['verify_status'] == 2) {
|
||||||
|
return ['code' => -3, 'msg' => '该票已退款'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = BaseService::now();
|
||||||
|
|
||||||
|
// 更新票状态
|
||||||
|
\Db::name(BaseService::table('tickets'))
|
||||||
|
->where('id', $ticket['id'])
|
||||||
|
->update([
|
||||||
|
'verify_status' => 1,
|
||||||
|
'verify_time' => $now,
|
||||||
|
'verifier_id' => $verifier_id,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 写入核销记录
|
||||||
|
$verifier = \Db::name(BaseService::table('verifiers'))
|
||||||
|
->where('id', $verifier_id)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
\Db::name(BaseService::table('verifications'))->insert([
|
||||||
|
'ticket_id' => $ticket['id'],
|
||||||
|
'ticket_code' => $ticket_code,
|
||||||
|
'verifier_id' => $verifier_id,
|
||||||
|
'verifier_name'=> $verifier['name'] ?? '',
|
||||||
|
'goods_id' => $ticket['goods_id'],
|
||||||
|
'created_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
BaseService::log('verifyTicket: success', [
|
||||||
|
'ticket_id' => $ticket['id'],
|
||||||
|
'verifier_id' => $verifier_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '核销成功',
|
||||||
|
'data' => [
|
||||||
|
'seat_info' => $ticket['seat_info'],
|
||||||
|
'real_name' => $ticket['real_name'],
|
||||||
|
'goods_name' => json_decode($ticket['goods_snapshot'] ?? '{}', true)['goods_name'] ?? '',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户所有票
|
||||||
|
*/
|
||||||
|
public static function getUserTickets($user_id, $status = null)
|
||||||
|
{
|
||||||
|
$where = ['user_id' => $user_id];
|
||||||
|
if ($status !== null) {
|
||||||
|
$where['verify_status'] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
return \Db::name(BaseService::table('tickets'))
|
||||||
|
->where($where)
|
||||||
|
->order('created_at', 'desc')
|
||||||
|
->select();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 QR 码图片 URL
|
||||||
|
*
|
||||||
|
* @param string $ticket_code
|
||||||
|
* @return string QR码图片URL
|
||||||
|
*/
|
||||||
|
public static function getQrCodeUrl($ticket_code)
|
||||||
|
{
|
||||||
|
$content = base64_encode(json_encode([
|
||||||
|
'type' => 'vr_ticket',
|
||||||
|
'code' => $ticket_code,
|
||||||
|
]));
|
||||||
|
|
||||||
|
return ROOT_URL . '?s=index/qrcode/index&content=' . urlencode($content) . '&size=8&level=H';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,431 @@
|
||||||
|
{include file="public/head" /}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* VR票务 - 票务商品详情页 */
|
||||||
|
/* 完全独立于 ShopXO 标准商品页,不加载 goods-detail 相关 CSS */
|
||||||
|
|
||||||
|
/* 页面布局 */
|
||||||
|
.vr-ticket-page { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||||
|
.vr-ticket-header { margin-bottom: 20px; }
|
||||||
|
.vr-event-title { font-size: 24px; font-weight: bold; color: #333; margin-bottom: 8px; }
|
||||||
|
.vr-event-subtitle { color: #666; font-size: 14px; }
|
||||||
|
|
||||||
|
/* 座位图区域 */
|
||||||
|
.vr-seat-section { margin-bottom: 30px; }
|
||||||
|
.vr-section-title { font-size: 18px; font-weight: bold; margin-bottom: 15px; color: #333; }
|
||||||
|
|
||||||
|
/* 座位图 */
|
||||||
|
.vr-seat-map-wrapper { background: #f8f9fa; border-radius: 8px; padding: 20px; margin-bottom: 20px; overflow-x: auto; }
|
||||||
|
.vr-stage {
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(180deg, #e8e8e8, #d0d0d0);
|
||||||
|
border-radius: 50% 50% 0 0 / 20px 20px 0 0;
|
||||||
|
padding: 15px 40px;
|
||||||
|
margin: 0 auto 25px;
|
||||||
|
max-width: 600px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
.vr-seat-rows { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||||
|
.vr-seat-row { display: flex; align-items: center; gap: 2px; }
|
||||||
|
.vr-row-label { width: 24px; text-align: center; color: #999; font-size: 12px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* 座位格子 */
|
||||||
|
.vr-seat {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #fff;
|
||||||
|
transition: all 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.vr-seat:hover { transform: scale(1.15); z-index: 1; box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
|
||||||
|
.vr-seat.selected { border: 2px solid #333; transform: scale(1.1); }
|
||||||
|
.vr-seat.sold { background: #ccc !important; cursor: not-allowed; opacity: 0.5; }
|
||||||
|
.vr-seat.sold:hover { transform: none; box-shadow: none; }
|
||||||
|
.vr-seat.aisle { background: transparent !important; cursor: default; }
|
||||||
|
.vr-seat.space { background: transparent !important; cursor: default; }
|
||||||
|
|
||||||
|
/* 座位类型图例 */
|
||||||
|
.vr-legend { display: flex; gap: 15px; flex-wrap: wrap; margin-bottom: 15px; justify-content: center; }
|
||||||
|
.vr-legend-item { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #666; }
|
||||||
|
.vr-legend-seat { width: 20px; height: 20px; border-radius: 3px; }
|
||||||
|
|
||||||
|
/* 已选座位 */
|
||||||
|
.vr-selected-seats { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
||||||
|
.vr-selected-title { font-weight: bold; margin-bottom: 10px; color: #333; }
|
||||||
|
.vr-selected-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; min-height: 30px; }
|
||||||
|
.vr-selected-item {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
background: #e8f4ff; border: 1px solid #b8d4f0;
|
||||||
|
border-radius: 4px; padding: 4px 10px; font-size: 13px;
|
||||||
|
}
|
||||||
|
.vr-selected-item .remove { color: #f56c6c; cursor: pointer; font-size: 16px; line-height: 1; }
|
||||||
|
.vr-total { font-size: 16px; font-weight: bold; color: #333; margin-top: 10px; }
|
||||||
|
|
||||||
|
/* 场次选择 */
|
||||||
|
.vr-sessions { margin-bottom: 20px; }
|
||||||
|
.vr-session-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
|
||||||
|
.vr-session-item {
|
||||||
|
border: 1px solid #ddd; border-radius: 6px; padding: 10px;
|
||||||
|
cursor: pointer; text-align: center;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.vr-session-item:hover { border-color: #409eff; }
|
||||||
|
.vr-session-item.selected { border-color: #409eff; background: #ecf5ff; }
|
||||||
|
.vr-session-item .date { font-size: 14px; font-weight: bold; color: #333; }
|
||||||
|
.vr-session-item .time { font-size: 13px; color: #666; margin-top: 4px; }
|
||||||
|
.vr-session-item .price { font-size: 14px; color: #f56c6c; font-weight: bold; margin-top: 6px; }
|
||||||
|
|
||||||
|
/* 观演人表单 */
|
||||||
|
.vr-attendee-form { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
||||||
|
.vr-form-title { font-weight: bold; margin-bottom: 15px; color: #333; }
|
||||||
|
.vr-attendee-item { background: #f8f9fa; border-radius: 6px; padding: 15px; margin-bottom: 10px; }
|
||||||
|
.vr-attendee-label { font-size: 13px; color: #666; margin-bottom: 5px; }
|
||||||
|
.vr-attendee-input { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
|
||||||
|
|
||||||
|
/* 购买栏 */
|
||||||
|
.vr-purchase-bar {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0;
|
||||||
|
background: #fff; border-top: 1px solid #e8e8e8;
|
||||||
|
padding: 12px 20px; z-index: 100;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
box-shadow: 0 -2px 10px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
.vr-purchase-info { font-size: 14px; color: #666; }
|
||||||
|
.vr-purchase-info strong { font-size: 20px; color: #f56c6c; }
|
||||||
|
.vr-purchase-btn {
|
||||||
|
background: linear-gradient(135deg, #409eff, #3b8ef8);
|
||||||
|
color: #fff; border: none; border-radius: 20px;
|
||||||
|
padding: 12px 36px; font-size: 16px; font-weight: bold;
|
||||||
|
cursor: pointer; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.vr-purchase-btn:hover { transform: scale(1.03); box-shadow: 0 4px 12px rgba(64,158,255,0.4); }
|
||||||
|
.vr-purchase-btn:disabled { background: #ccc; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||||
|
|
||||||
|
/* 商品信息侧边 */
|
||||||
|
.vr-goods-info { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
|
||||||
|
.vr-goods-photos { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 8px; margin-bottom: 15px; }
|
||||||
|
.vr-goods-photos img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- 页面内容 -->
|
||||||
|
<div class="vr-ticket-page" id="vrTicketApp">
|
||||||
|
<!-- 商品头部 -->
|
||||||
|
<div class="vr-ticket-header">
|
||||||
|
<div class="vr-event-title">{$goods.title|default='VR演唱会'}</div>
|
||||||
|
<div class="vr-event-subtitle">{$goods.simple_desc|default=''|raw}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 场次选择 -->
|
||||||
|
<div class="vr-seat-section" id="sessionSection">
|
||||||
|
<div class="vr-section-title">选择场次</div>
|
||||||
|
<div class="vr-session-grid" id="sessionGrid">
|
||||||
|
<!-- 由 JS 动态渲染 -->
|
||||||
|
<div class="vr-no-session-tip">该商品暂无场次信息</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 座位图 -->
|
||||||
|
<div class="vr-seat-section" id="seatSection" style="display:none">
|
||||||
|
<div class="vr-section-title">选择座位 <span style="font-size:13px;color:#999;font-weight:normal">(点击空座选中,再点击取消)</span></div>
|
||||||
|
<div class="vr-legend" id="seatLegend"></div>
|
||||||
|
<div class="vr-seat-map-wrapper">
|
||||||
|
<div class="vr-stage">舞 台</div>
|
||||||
|
<div class="vr-seat-rows" id="seatRows"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已选座位 -->
|
||||||
|
<div class="vr-selected-seats" id="selectedSection" style="display:none">
|
||||||
|
<div class="vr-selected-title">已选座位 <span id="selectedCount">(0)</span></div>
|
||||||
|
<div class="vr-selected-list" id="selectedList"></div>
|
||||||
|
<div class="vr-total">合计:<strong id="totalPrice">¥0</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 观演人信息 -->
|
||||||
|
<div class="vr-attendee-form" id="attendeeSection" style="display:none">
|
||||||
|
<div class="vr-form-title">观演人信息 <span style="font-size:12px;color:#999">(每张票需填写一位)</span></div>
|
||||||
|
<div id="attendeeList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 商品详情(保留) -->
|
||||||
|
{if !empty($goods.content)}
|
||||||
|
<div class="vr-seat-section">
|
||||||
|
<div class="vr-section-title">演出详情</div>
|
||||||
|
<div class="goods-detail-content">{$goods.content|raw}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- 购买栏 -->
|
||||||
|
<div class="vr-purchase-bar">
|
||||||
|
<div class="vr-purchase-info">
|
||||||
|
已选 <strong id="barCount">0</strong> 座
|
||||||
|
合计 <strong id="barPrice">¥0</strong>
|
||||||
|
</div>
|
||||||
|
<button class="vr-purchase-btn" id="purchaseBtn" disabled onclick="vrTicketApp.submit()">
|
||||||
|
提交订单
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{include file="public/footer" /}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var app = {
|
||||||
|
goodsId: {$goods.id|default=0},
|
||||||
|
seatMap: {json_decode($vr_seat_template.seat_map|default='{}', true)|raw},
|
||||||
|
specBaseIdMap: {json_decode($vr_seat_template.spec_base_id_map|default='{}', true)|raw},
|
||||||
|
selectedSeats: [], // [{row, col, char, price, label, classes}]
|
||||||
|
soldSeats: {}, // {row_col: true}
|
||||||
|
currentSession: null,
|
||||||
|
sessionSpecId: null,
|
||||||
|
requestUrl: '{:Config("shopxo.host_url")}',
|
||||||
|
userId: {:IsMobileLogin()},
|
||||||
|
|
||||||
|
init: function() {
|
||||||
|
this.renderSessions();
|
||||||
|
this.bindEvents();
|
||||||
|
this.loadSoldSeats();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 渲染场次列表(基于 ShopXO spec 数据)
|
||||||
|
renderSessions: function() {
|
||||||
|
var specData = {$goods_spec_data|json_encode|raw} || [];
|
||||||
|
var html = '';
|
||||||
|
if (specData.length > 0) {
|
||||||
|
specData.forEach(function(spec) {
|
||||||
|
html += '<div class="vr-session-item" data-spec-id="'+spec.spec_id+'" data-spec-base-id="'+spec.spec_id+'" onclick="vrTicketApp.selectSession(this)">' +
|
||||||
|
'<div class="date">'+spec.spec_name+'</div>' +
|
||||||
|
'<div class="price">¥'+spec.price+'</div></div>';
|
||||||
|
});
|
||||||
|
document.getElementById('sessionGrid').innerHTML = html;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectSession: function(el) {
|
||||||
|
// 移除其他选中
|
||||||
|
document.querySelectorAll('.vr-session-item').forEach(function(item) {
|
||||||
|
item.classList.remove('selected');
|
||||||
|
});
|
||||||
|
el.classList.add('selected');
|
||||||
|
this.currentSession = el.dataset.specId;
|
||||||
|
this.sessionSpecId = el.dataset.specBaseId;
|
||||||
|
|
||||||
|
// 显示座位图
|
||||||
|
document.getElementById('seatSection').style.display = 'block';
|
||||||
|
document.getElementById('attendeeSection').style.display = 'block';
|
||||||
|
|
||||||
|
this.renderSeatMap();
|
||||||
|
this.loadSoldSeats();
|
||||||
|
},
|
||||||
|
|
||||||
|
renderSeatMap: function() {
|
||||||
|
var map = this.seatMap;
|
||||||
|
if (!map || !map.map || map.map.length === 0) {
|
||||||
|
document.getElementById('seatRows').innerHTML = '<div style="text-align:center;color:#999;padding:40px">座位图加载失败</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染图例
|
||||||
|
var legendHtml = '';
|
||||||
|
var sections = map.sections || [];
|
||||||
|
var seats = map.seats || {};
|
||||||
|
sections.forEach(function(sec) {
|
||||||
|
var color = sec.color || '#409eff';
|
||||||
|
legendHtml += '<div class="vr-legend-item"><div class="vr-legend-seat" style="background:'+color+'"></div>'+sec.name+'</div>';
|
||||||
|
});
|
||||||
|
legendHtml += '<div class="vr-legend-item"><div class="vr-legend-seat" style="background:#ccc"></div>已售</div>';
|
||||||
|
document.getElementById('seatLegend').innerHTML = legendHtml;
|
||||||
|
|
||||||
|
// 渲染座位图
|
||||||
|
var rowsContainer = document.getElementById('seatRows');
|
||||||
|
var row_labels = map.row_labels || [];
|
||||||
|
var rowsHtml = '';
|
||||||
|
|
||||||
|
map.map.forEach(function(rowStr, rowIndex) {
|
||||||
|
var rowLabel = row_labels[rowIndex] || String.fromCharCode(65 + rowIndex);
|
||||||
|
rowsHtml += '<div class="vr-seat-row">';
|
||||||
|
rowsHtml += '<div class="vr-row-label">'+rowLabel+'</div>';
|
||||||
|
|
||||||
|
var chars = rowStr.split('');
|
||||||
|
chars.forEach(function(char, colIndex) {
|
||||||
|
if (char === '_' || char === '-') {
|
||||||
|
rowsHtml += '<div class="vr-seat space" style="width:28px;height:28px"></div>';
|
||||||
|
} else {
|
||||||
|
var seatInfo = seats[char] || {};
|
||||||
|
var color = seatInfo.color || '#409eff';
|
||||||
|
var price = seatInfo.price || 0;
|
||||||
|
var label = seatInfo.label || '';
|
||||||
|
var key = rowIndex + '_' + colIndex;
|
||||||
|
|
||||||
|
rowsHtml += '<div class="vr-seat '+(seatInfo.classes||'')+'" '+
|
||||||
|
'style="background:'+color+'" '+
|
||||||
|
'data-row="'+rowIndex+'" data-col="'+colIndex+'" '+
|
||||||
|
'data-char="'+char+'" data-price="'+price+'" '+
|
||||||
|
'data-seat-id="'+char+'" data-label="'+rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座" '+
|
||||||
|
'onclick="vrTicketApp.toggleSeat(this)"></div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
rowsHtml += '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
rowsContainer.innerHTML = rowsHtml;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSeat: function(el) {
|
||||||
|
if (el.classList.contains('sold')) return;
|
||||||
|
|
||||||
|
var row = el.dataset.row;
|
||||||
|
var col = el.dataset.col;
|
||||||
|
var key = row + '_' + col;
|
||||||
|
var seat = {
|
||||||
|
row: parseInt(row),
|
||||||
|
col: parseInt(col),
|
||||||
|
char: el.dataset.char,
|
||||||
|
price: parseFloat(el.dataset.price),
|
||||||
|
label: el.dataset.label,
|
||||||
|
seatId: el.dataset.seatId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (el.classList.contains('selected')) {
|
||||||
|
// 取消选中
|
||||||
|
el.classList.remove('selected');
|
||||||
|
this.selectedSeats = this.selectedSeats.filter(function(s) {
|
||||||
|
return s.row !== seat.row || s.col !== seat.col;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 选中
|
||||||
|
el.classList.add('selected');
|
||||||
|
this.selectedSeats.push(seat);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateSelectedUI();
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSelectedUI: function() {
|
||||||
|
var count = this.selectedSeats.length;
|
||||||
|
var total = this.selectedSeats.reduce(function(sum, s) { return sum + s.price; }, 0);
|
||||||
|
|
||||||
|
document.getElementById('selectedCount').textContent = '(' + count + ')';
|
||||||
|
document.getElementById('totalPrice').textContent = '¥' + total.toFixed(2);
|
||||||
|
document.getElementById('barCount').textContent = count;
|
||||||
|
document.getElementById('barPrice').textContent = '¥' + total.toFixed(2);
|
||||||
|
|
||||||
|
// 渲染已选列表
|
||||||
|
var listHtml = '';
|
||||||
|
this.selectedSeats.forEach(function(seat, i) {
|
||||||
|
listHtml += '<div class="vr-selected-item">' +
|
||||||
|
seat.label + ' ¥' + seat.price +
|
||||||
|
'<span class="remove" onclick="vrTicketApp.removeSeat('+i+')">×</span></div>';
|
||||||
|
});
|
||||||
|
document.getElementById('selectedList').innerHTML = listHtml;
|
||||||
|
|
||||||
|
// 渲染观演人表单
|
||||||
|
this.renderAttendeeForms();
|
||||||
|
|
||||||
|
// 更新按钮状态
|
||||||
|
document.getElementById('purchaseBtn').disabled = count === 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeSeat: function(index) {
|
||||||
|
var seat = this.selectedSeats[index];
|
||||||
|
if (seat) {
|
||||||
|
var el = document.querySelector(
|
||||||
|
'[data-row="'+seat.row+'"][data-col="'+seat.col+'"]'
|
||||||
|
);
|
||||||
|
if (el) el.classList.remove('selected');
|
||||||
|
this.selectedSeats.splice(index, 1);
|
||||||
|
this.updateSelectedUI();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderAttendeeForms: function() {
|
||||||
|
var html = '';
|
||||||
|
this.selectedSeats.forEach(function(seat, i) {
|
||||||
|
html += '<div class="vr-attendee-item">' +
|
||||||
|
'<div class="vr-attendee-label">第 '+(i+1)+' 张票 - '+seat.label+'</div>' +
|
||||||
|
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">' +
|
||||||
|
'<input type="text" class="vr-attendee-input" placeholder="真实姓名 *" data-field="real_name" data-index="'+i+'" required>' +
|
||||||
|
'<input type="tel" class="vr-attendee-input" placeholder="手机号 *" data-field="phone" data-index="'+i+'" required>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="margin-top:8px">' +
|
||||||
|
'<input type="text" class="vr-attendee-input" placeholder="身份证号(选填)" data-field="id_card" data-index="'+i+'">' +
|
||||||
|
'</div>' +
|
||||||
|
'<input type="hidden" class="vr-attendee-input" value="'+seat.label+'" data-field="seat_info" data-index="'+i+'">' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
document.getElementById('attendeeList').innerHTML = html;
|
||||||
|
},
|
||||||
|
|
||||||
|
loadSoldSeats: function() {
|
||||||
|
// TODO: 从后端加载已售座位
|
||||||
|
// $.get(this.requestUrl + '?s=plugins/vr_ticket/index/sold_seats', {
|
||||||
|
// goods_id: this.goodsId,
|
||||||
|
// spec_base_id: this.sessionSpecId
|
||||||
|
// }, function(res) {
|
||||||
|
// // 标记已售座位
|
||||||
|
// });
|
||||||
|
},
|
||||||
|
|
||||||
|
bindEvents: function() {
|
||||||
|
// 空实现,后续扩展
|
||||||
|
},
|
||||||
|
|
||||||
|
submit: function() {
|
||||||
|
if (this.selectedSeats.length === 0) {
|
||||||
|
alert('请先选择座位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.userId) {
|
||||||
|
alert('请先登录');
|
||||||
|
location.href = this.requestUrl + '?s=index/user/logininfo';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集观演人信息
|
||||||
|
var attendees = [];
|
||||||
|
var inputs = document.querySelectorAll('#attendeeList input');
|
||||||
|
var attendeeData = {};
|
||||||
|
inputs.forEach(function(input) {
|
||||||
|
var idx = input.dataset.index;
|
||||||
|
var field = input.dataset.field;
|
||||||
|
if (!attendeeData[idx]) attendeeData[idx] = {};
|
||||||
|
attendeeData[idx][field] = input.value;
|
||||||
|
});
|
||||||
|
for (var k in attendeeData) {
|
||||||
|
attendees.push(attendeeData[k]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造订单扩展数据
|
||||||
|
var extensionData = JSON.stringify({attendee: attendees, seats: this.selectedSeats});
|
||||||
|
|
||||||
|
// 跳转到 ShopXO 结算页,附加扩展数据
|
||||||
|
var goodsParams = JSON.stringify([{
|
||||||
|
goods_id: this.goodsId,
|
||||||
|
spec_base_id: this.sessionSpecId,
|
||||||
|
stock: this.selectedSeats.length,
|
||||||
|
extension_data: extensionData
|
||||||
|
}]);
|
||||||
|
|
||||||
|
var checkoutUrl = this.requestUrl + '?s=index/buy/index' +
|
||||||
|
'&goods_params=' + encodeURIComponent(goodsParams);
|
||||||
|
location.href = checkoutUrl;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.vrTicketApp = app;
|
||||||
|
app.init();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{include file="public/footer_page" /}
|
||||||
|
|
@ -63,7 +63,7 @@ if (!empty($assign['goods']['venue_data']) || !empty($assign['goods']['item_type
|
||||||
|| !empty($assign['goods']['venue_data']);
|
|| !empty($assign['goods']['venue_data']);
|
||||||
}
|
}
|
||||||
if (!empty($is_ticket) && !empty($assign['vr_is_ticket_goods'])) {
|
if (!empty($is_ticket) && !empty($assign['vr_is_ticket_goods'])) {
|
||||||
return MyView('/goods/ticket_detail');
|
return MyView('/../../../plugins/vr_ticket/view/goods/ticket_detail');
|
||||||
}
|
}
|
||||||
// === VR票务插件 END ===
|
// === VR票务插件 END ===
|
||||||
|
|
||||||
|
|
@ -74,7 +74,7 @@ return MyView();
|
||||||
|
|
||||||
## 新建票务模板文件
|
## 新建票务模板文件
|
||||||
|
|
||||||
路径:`{SHOPXO_ROOT}/app/index/view/default/goods/ticket_detail.html`
|
路径:`{SHOPXO_ROOT}/app/plugins/vr_ticket/view/goods/ticket_detail.html`
|
||||||
|
|
||||||
参考 `app/index/view/default/goods/detail.html`,重写以下部分:
|
参考 `app/index/view/default/goods/detail.html`,重写以下部分:
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue