diff --git a/app/plugins/vr_ticket/EventListener.php b/app/plugins/vr_ticket/EventListener.php new file mode 100644 index 0000000..751ccd4 --- /dev/null +++ b/app/plugins/vr_ticket/EventListener.php @@ -0,0 +1,125 @@ +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; +} diff --git a/app/plugins/vr_ticket/README.md b/app/plugins/vr_ticket/README.md new file mode 100644 index 0000000..3d04c9a --- /dev/null +++ b/app/plugins/vr_ticket/README.md @@ -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 | 核销记录 | diff --git a/app/plugins/vr_ticket/admin/controller/SeatTemplate.php b/app/plugins/vr_ticket/admin/controller/SeatTemplate.php new file mode 100644 index 0000000..3b41628 --- /dev/null +++ b/app/plugins/vr_ticket/admin/controller/SeatTemplate.php @@ -0,0 +1,158 @@ +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; + } +} diff --git a/app/plugins/vr_ticket/admin/controller/Ticket.php b/app/plugins/vr_ticket/admin/controller/Ticket.php new file mode 100644 index 0000000..4d7d0c8 --- /dev/null +++ b/app/plugins/vr_ticket/admin/controller/Ticket.php @@ -0,0 +1,165 @@ + 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; + } +} diff --git a/app/plugins/vr_ticket/admin/controller/Verification.php b/app/plugins/vr_ticket/admin/controller/Verification.php new file mode 100644 index 0000000..8c54bf7 --- /dev/null +++ b/app/plugins/vr_ticket/admin/controller/Verification.php @@ -0,0 +1,84 @@ + 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, + ]); + } +} diff --git a/app/plugins/vr_ticket/admin/controller/Verifier.php b/app/plugins/vr_ticket/admin/controller/Verifier.php new file mode 100644 index 0000000..9c8d110 --- /dev/null +++ b/app/plugins/vr_ticket/admin/controller/Verifier.php @@ -0,0 +1,140 @@ +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); + } +} diff --git a/app/plugins/vr_ticket/admin/view/seat_template/list.html b/app/plugins/vr_ticket/admin/view/seat_template/list.html new file mode 100644 index 0000000..41dba22 --- /dev/null +++ b/app/plugins/vr_ticket/admin/view/seat_template/list.html @@ -0,0 +1,102 @@ + + +
+ +