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']);
|
||||
}
|
||||
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 ===
|
||||
|
||||
|
|
@ -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`,重写以下部分:
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue