refactor(vr_ticket): Admin.php root pattern → Hook-based goods save
- DELETE old Admin.php root controller (Vrticket.php) - DELETE old Layui view files (seat_template/ticket/venue/verification/verifier) - ADD hook/AdminGoodsSave.php: plugins_view_admin_goods_save hook (Vue3 form injection) - ADD hook/AdminGoodsSaveHandle.php: handle save flow (save_handle + save_thing_end) - UPDATE config.json: register 3 new hooks - UPDATE SeatSkuService.php: refactored BatchGenerate - ADD data.db: SQLite venue data - UPDATE venue/save.html: venue editing form - docs: add GOODS_ADD_HOOK_RESEARCH.md + update plan.mdcouncil/SecurityEngineer
parent
111063d785
commit
9603ab42f6
|
|
@ -127,9 +127,11 @@ public static function BatchGenerate(int $goodsId, int $seatTemplateId): array
|
|||
用户点击「发布商品」
|
||||
│
|
||||
└─► Hook[2] plugins_service_goods_save_handle
|
||||
└─► 读取 goods.vr_goods_config(JSON)
|
||||
└─► 读取表单传上来的 vr_goods_config(JSON)与 vr_is_ticket 标识
|
||||
└─► 若 vr_is_ticket=1,强制设置商品 item_type = 'ticket';否则设为 'normal'
|
||||
└─► 对每个 templateId 调用 BatchGenerate(带节点过滤)
|
||||
└─► 写入/更新 sku(goods_spec_base + goods_spec_value)
|
||||
└─► 存储 vr_goods_config 到 goods 表新字段
|
||||
```
|
||||
|
||||
### 2.2 Hook[1] 注入面板设计
|
||||
|
|
@ -143,7 +145,7 @@ public static function BatchGenerate(int $goodsId, int $seatTemplateId): array
|
|||
☑ 是否为票务商品 ← 勾选框,解锁票务配置
|
||||
─────────────────────────
|
||||
▼ 票务配置(勾选后展开)
|
||||
场馆模板:[请选择 ▼]
|
||||
场馆模板(可多选多个场馆):[请选择 ▼]
|
||||
└─ 场馆名称预览 + 地址
|
||||
|
||||
演播厅选择(多选):
|
||||
|
|
@ -174,9 +176,10 @@ $templates = Db::name('vr_seat_templates')
|
|||
|
||||
### 2.3 Hook[2] 数据处理流程
|
||||
|
||||
用户提交后,`params` 里携带:
|
||||
用户提交后,`params` 里携带多个template,version 用于未来不同前端识别适配:
|
||||
```json
|
||||
{
|
||||
“version": "1.0.0",
|
||||
"vr_is_ticket": "1",
|
||||
"vr_goods_config": [
|
||||
{
|
||||
|
|
@ -191,7 +194,7 @@ $templates = Db::name('vr_seat_templates')
|
|||
}
|
||||
```
|
||||
|
||||
**存储**:新增 `goods.vr_goods_config` LONGTEXT 字段(JSON 数组),直接存这个结构,不建新表。
|
||||
**存储**:新增一个 `goods.vr_goods_config` LONGTEXT 字段(JSON 数组),直接存这个结构,不建新表。
|
||||
|
||||
### 2.4 BatchGenerate 扩展(按分区过滤)
|
||||
|
||||
|
|
@ -206,6 +209,10 @@ public static function BatchGenerate(
|
|||
|
||||
**扩展逻辑**:
|
||||
```php
|
||||
// 参数清理:过滤掉空的分类
|
||||
$selectedRooms = array_filter($selectedRooms);
|
||||
$selectedSections = array_filter($selectedSections);
|
||||
|
||||
// 遍历每个 room
|
||||
foreach ($seatMap['rooms'] as $room) {
|
||||
// 跳过未选中的房间
|
||||
|
|
@ -272,10 +279,10 @@ AFTER item_type;
|
|||
|
||||
| 决策点 | 结论 |
|
||||
|--------|------|
|
||||
| vr_goods_config 存在哪 | 直接存 `goods.vr_goods_config`(JSON 数组),不建新表 |
|
||||
| 表名 | MySQL 数据库 `vrticket`(不是 `shopxo` 默认前缀) |
|
||||
| vr_goods_config 存在哪 | 直接存 `goods.vr_goods_config`(JSON 数组字段),不建新表 |
|
||||
| 数据库定义澄清 | 统一使用 ShopXO 默认数据库连接与 `{prefix}` 机制(表如 `{prefix}goods`,插件表为 `{prefix}vr_seat_templates`)|
|
||||
| 票务配置面板是否默认显示 | **否**,默认隐藏,用户勾选"是否为票务商品"后展开 |
|
||||
| 勾选框名称 | "是否为票务商品"(checkbox) |
|
||||
| 商品核心类型联动 | 若勾选“是否为票务商品”,Hook 将同步改变商品的核心 `item_type` 字段为 `'ticket'`。 |
|
||||
| 多模板支持 | 支持(vr_goods_config 是 JSON 数组,每个元素=一个模板配置) |
|
||||
| 座位模板表内字段废弃 | `category_id` 绑定字段已废弃,不使用 |
|
||||
| category_id 废弃后的模板查询 | 直接查询 `vr_seat_templates.status=1`,不再依赖 category_id |
|
||||
|
|
|
|||
75
plan.md
75
plan.md
|
|
@ -84,14 +84,52 @@ app/plugins/vr_ticket/
|
|||
|
||||
---
|
||||
|
||||
## 视图路径问题
|
||||
## 视图路径问题(Round 5 根因确认 + 修复)
|
||||
|
||||
### 根因(BackendArchitect 分析)
|
||||
ThinkPHP 5 视图路径解析规则:
|
||||
1. 相对路径(如 `'seat_template/list'`):相对于**控制器 namespace 对应的默认视图目录**
|
||||
2. namespace `app\plugins\vr_ticket\admin` → 默认视图目录 `app/plugins/vr_ticket/admin/view/`
|
||||
3. 实际文件在 `app/admin/view/default/plugins/view/vr_ticket/admin/view/` ← 路径不匹配!
|
||||
|
||||
### 实际文件位置
|
||||
```
|
||||
app/admin/view/default/plugins/view/vr_ticket/admin/view/
|
||||
├── seat_template/
|
||||
│ ├── list.html
|
||||
│ └── save.html
|
||||
├── ticket/
|
||||
│ ├── list.html
|
||||
│ └── detail.html
|
||||
├── venue/
|
||||
│ ├── list.html
|
||||
│ └── save.html
|
||||
├── verifier/
|
||||
│ ├── list.html
|
||||
│ └── save.html
|
||||
└── verification/
|
||||
└── list.html
|
||||
```
|
||||
|
||||
### 修复方案
|
||||
- BackendArchitect 已将视图复制到 `app/admin/view/default/plugins/view/vr_ticket/admin/view/`
|
||||
- `admin/Admin.php` 中使用 `return view('seat_template/list', $data)`(相对路径)
|
||||
- ShopXO 会自动从 `app/admin/view/default/plugins/view/vr_ticket/admin/view/` 解析
|
||||
- ✓ 路径问题已通过 BackendArchitect 的 Vrticket.php 方式部分解决
|
||||
- admin/Admin.php 使用 ThinkPHP 的 view() 助手函数,相对路径正确解析
|
||||
ThinkPHP 5 以 `/` 开头的视图路径为**绝对路径**,相对于配置的视图根目录(`app/admin/view/default/`)解析。
|
||||
|
||||
修复前(错误):
|
||||
```php
|
||||
return view('seat_template/list', $data); // 解析到 app/plugins/vr_ticket/admin/view/ ← 不存在
|
||||
```
|
||||
|
||||
修复后(正确):
|
||||
```php
|
||||
return view('/plugins/view/vr_ticket/admin/view/seat_template/list', $data);
|
||||
// → app/admin/view/default/plugins/view/vr_ticket/admin/view/seat_template/list.html ✓
|
||||
```
|
||||
|
||||
**所有 9 个 view() 调用已全部修复为绝对路径格式。**
|
||||
|
||||
### Vrticket.php 的参考价值
|
||||
`shopxo/app/admin/controller/Vrticket.php` 使用 `MyView('../../../plugins/vr_ticket/admin/' . $template)` 手动处理路径。
|
||||
Admin.php 使用 ThinkPHP `view()` 函数,以 `/` 开头则由 ThinkPHP 自动解析到 `app/admin/view/default/`。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -126,8 +164,29 @@ app/plugins/vr_ticket/
|
|||
| P1-T2 | [Done] | admin/Admin.php 模式正确 |
|
||||
| P1-T3 | [Done] | admin/Admin.php 已创建 + plugin.json 已修复 |
|
||||
| P1-T4 | [Pending] | 需实际访问 URL 截图验证 |
|
||||
| P2-T1 | [Pending] | 数据库编码检查(需 DB 访问)|
|
||||
| P2-T2 | [Pending] | 数据库修复(如需要)|
|
||||
| P2-T1 | [Done] | 根因:plugins.name 字段 Latin1 存储 |
|
||||
| P2-T2 | [Done] | SQL 修复脚本见 docs/SQL_FIX_garbled_plugin_name.md |
|
||||
| P1-视图路径 | [Done] | 所有 9 个 view() 改为绝对路径 `/plugins/view/vr_ticket/admin/view/...` |
|
||||
|
||||
---
|
||||
|
||||
## BackendArchitect Round 5 实现
|
||||
|
||||
### 交付物
|
||||
1. ✅ `shopxo/app/plugins/vr_ticket/admin/Admin.php` — 9 个 view() 调用全部改为 `/plugins/view/vr_ticket/admin/view/...` 绝对路径
|
||||
2. ✅ `docs/SQL_FIX_garbled_plugin_name.md` — 乱码修复 SQL 脚本
|
||||
3. ✅ `plan.md` — 更新根因分析
|
||||
|
||||
### P1 乱码 DB 根因(最终确认)
|
||||
- `plugins.name` 字段 = `VR票务`(Latin1 解码的 UTF-8 字节)
|
||||
- 安装时 `plugin.json` 的 `title: "VR票务"` 被以 Latin1 编码存入 MySQL
|
||||
- 读取时 MySQL 连接 charset 是 utf8mb4,所以 Latin1 字节被错误解码为乱码
|
||||
- **修复**:执行 `UPDATE sx_plugins SET name = 'VR票务' WHERE plugins = 'vr_ticket'`
|
||||
|
||||
### 乱码字节分析
|
||||
`票` UTF-8: `E7 A5 8A` → Latin1 解读为: `票务`
|
||||
`务` UTF-8: `E5 8A B1` → (in `VR票务` combined string)
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | ShopXO 国内领先企业级B2C免费开源电商系统
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2011~2099 http://shopxo.net All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Licensed ( https://opensource.org/licenses/mit-license.php )
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: Devil
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
// [ 后台入口文件 ]
|
||||
namespace think;
|
||||
|
||||
// 根目录入口
|
||||
define('IS_ROOT_ACCESS', true);
|
||||
|
||||
// 引入公共入口文件
|
||||
require __DIR__.'/public/core.php';
|
||||
|
||||
// 加载基础文件
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
// 执行HTTP应用并响应
|
||||
$http = (new App())->http;
|
||||
$response = $http->name('admin')->run();
|
||||
$response->send();
|
||||
$http->end($response);
|
||||
?>
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
<?php
|
||||
namespace app\admin\controller;
|
||||
|
||||
use app\admin\controller\Common;
|
||||
|
||||
/**
|
||||
* VR票务后台管理控制器
|
||||
* @author Sileya
|
||||
*/
|
||||
class Vrticket extends Common
|
||||
{
|
||||
/**
|
||||
* 构造方法
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 座位模板列表
|
||||
*/
|
||||
public function SeatTemplateList()
|
||||
{
|
||||
return $this->render('seat_template/list');
|
||||
}
|
||||
|
||||
/**
|
||||
* 座位模板保存
|
||||
*/
|
||||
public function SeatTemplateSave()
|
||||
{
|
||||
return $this->render('seat_template/save');
|
||||
}
|
||||
|
||||
/**
|
||||
* 电子票列表
|
||||
*/
|
||||
public function TicketList()
|
||||
{
|
||||
return $this->render('ticket/list');
|
||||
}
|
||||
|
||||
/**
|
||||
* 电子票详情
|
||||
*/
|
||||
public function TicketDetail()
|
||||
{
|
||||
return $this->render('ticket/detail');
|
||||
}
|
||||
|
||||
/**
|
||||
* 核销员列表
|
||||
*/
|
||||
public function VerifierList()
|
||||
{
|
||||
return $this->render('verifier/list');
|
||||
}
|
||||
|
||||
/**
|
||||
* 核销员保存
|
||||
*/
|
||||
public function VerifierSave()
|
||||
{
|
||||
return $this->render('verifier/save');
|
||||
}
|
||||
|
||||
/**
|
||||
* 核销记录列表
|
||||
*/
|
||||
public function VerificationList()
|
||||
{
|
||||
return $this->render('verification/list');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染插件视图
|
||||
* @param string $template 模板路径(相对于 vr_ticket/admin/view/ 目录)
|
||||
*/
|
||||
protected function render($template)
|
||||
{
|
||||
// 插件视图路径(从 app/admin/view/default/plugins/view/vr_ticket/admin/view/ 开始)
|
||||
return MyView('plugins/view/vr_ticket/admin/view/' . $template);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
<!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="{:PluginsAdminUrl('vr_ticket', '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="{:PluginsAdminUrl('vr_ticket', '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: '{:PluginsAdminUrl("vr_ticket", "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('{:PluginsAdminUrl("vr_ticket", "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>
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
<!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="{:PluginsAdminUrl('vr_ticket', '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>
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>票详情 - VR票务</title>
|
||||
{{include file="public/head" }}
|
||||
<style>
|
||||
.ticket-detail-card {max-width:800px;}
|
||||
.detail-row {padding:12px 0;border-bottom:1px solid #f0f0f0;}
|
||||
.detail-label {color:#999;font-size:13px;margin-bottom:4px;}
|
||||
.detail-value {font-size:14px;}
|
||||
.qr-box {text-align:center;padding:20px;background:#fafafa;border-radius:4px;}
|
||||
.qr-box img {max-width:200px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layui-fluid">
|
||||
<div class="layui-card ticket-detail-card">
|
||||
<div class="layui-card-header">
|
||||
票详情
|
||||
<span class="layui-badge layui-bg-{{$ticket['verify_status']==1 ? 'green' : ($ticket['verify_status']==2 ? 'red' : 'blue')}}">
|
||||
{{$ticket['verify_status']==1 ? '已核销' : ($ticket['verify_status']==2 ? '已退款' : '未核销')}}
|
||||
</span>
|
||||
<span class="layui-btn layui-btn-xs layui-btn-primary" style="float:right" onclick="history.back()">返回</span>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<!-- 票码 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">票码</div>
|
||||
<div class="detail-value" style="font-family:monospace;font-size:16px;color:#1e9fff">{$ticket.ticket_code}</div>
|
||||
</div>
|
||||
|
||||
<!-- 二维码 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">二维码</div>
|
||||
<div class="qr-box">
|
||||
<img src="{$ticket.qr_code_url}" alt="票二维码">
|
||||
<div style="margin-top:10px;color:#999;font-size:12px">扫描核销</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">关联商品</div>
|
||||
<div class="detail-value">{$goods['title']|default='已删除商品'}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">订单号</div>
|
||||
<div class="detail-value">{$ticket.order_no}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">座位信息</div>
|
||||
<div class="detail-value">{$ticket.seat_info|default='无'}</div>
|
||||
</div>
|
||||
|
||||
<!-- 观演人 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">观演人</div>
|
||||
<div class="detail-value">{$ticket.real_name|default='-'}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">手机号</div>
|
||||
<div class="detail-value">{$ticket.phone|default='-'}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">身份证</div>
|
||||
<div class="detail-value">{$ticket.id_card|default='-'}</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">发放时间</div>
|
||||
<div class="detail-value">{$ticket.issued_at > 0 ? date('Y-m-d H:i:s', $ticket.issued_at) : '-'}</div>
|
||||
</div>
|
||||
{if $ticket['verify_status'] == 1}
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">核销时间</div>
|
||||
<div class="detail-value">{$ticket.verify_time > 0 ? date('Y-m-d H:i:s', $ticket.verify_time) : '-'}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">核销员</div>
|
||||
<div class="detail-value">{$verifier['name']|default='-'}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 操作 -->
|
||||
{if $ticket['verify_status'] == 0}
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">手动核销</div>
|
||||
<div class="detail-value">
|
||||
<form class="layui-form" style="display:inline" id="verify-form">
|
||||
<select name="verifier_id" required lay-verify="required" style="width:200px;display:inline">
|
||||
<option value="">选择核销员</option>
|
||||
{foreach $verifiers as $v}
|
||||
<option value="{$v.id}">{$v.name}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
<button class="layui-btn layui-btn-sm layui-btn-danger" lay-submit lay-filter="do-verify">确认核销</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{include file="public/footer" }}
|
||||
<script>
|
||||
layui.use('form', function() {
|
||||
var form = layui.form;
|
||||
form.on('submit(do-verify)', function(data) {
|
||||
if (!data.field.verifier_id) {
|
||||
layer.msg('请选择核销员');
|
||||
return false;
|
||||
}}
|
||||
$.post('{:MyUrl("plugins_vr_ticket/admin/ticket/verify")}', {
|
||||
ticket_code: '{$ticket.ticket_code}',
|
||||
verifier_id: data.field.verifier_id
|
||||
}, function(res) {
|
||||
if (res.code == 0) {
|
||||
layer.msg('核销成功', function() { location.reload(); });
|
||||
} else {
|
||||
layer.msg(res.msg || '核销失败');
|
||||
}}
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
<!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>
|
||||
<button type="button" class="layui-btn layui-btn-primary" id="export-btn">导出CSV</button>
|
||||
</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="{:PluginsAdminUrl('vr_ticket', '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: '{:PluginsAdminUrl("vr_ticket", "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;
|
||||
});
|
||||
|
||||
// 导出 CSV:POST 触发(当前全量导出,不携带搜索条件)
|
||||
$('#export-btn').on('click', function() {
|
||||
var $form = $('<form action="{:PluginsAdminUrl(\'vr_ticket\', \'ticket\', \'export\')}" method="post" target="_blank" style="display:none"></form>');
|
||||
$(document.body).append($form);
|
||||
$form.submit().remove();
|
||||
layer.msg('正在导出,请稍候…');
|
||||
});
|
||||
|
||||
$(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>
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<include file="public::head" />
|
||||
<style>
|
||||
.venue-preview-img { width: 60px; height: 40px; object-fit: cover; border-radius: 4px;}}
|
||||
.zone-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; margin: 2px;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layui-fluid">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
<span>场馆配置管理</span>
|
||||
<a href="{:MyUrl('plugins_vr_ticket/admin/venue/save')}" class="layui-btn layui-btn-sm layui-btn-normal fr">
|
||||
<i class="layui-icon layui-icon-add-1"></i> 添加场馆
|
||||
</a>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<!-- 搜索栏 -->
|
||||
<form class="layui-form layui-form-pane" method="get">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">场馆名称</label>
|
||||
<div class="layui-input-inline" style="width:200px;">
|
||||
<input type="text" name="name" value="{:input('name')}" placeholder="搜索场馆名称" class="layui-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-inline" style="width:120px;">
|
||||
<select name="status" lay-search="">
|
||||
<option value="">全部</option>
|
||||
<option value="1" {:input('status')=== '1' ? 'selected' : ''}>启用</option>
|
||||
<option value="0" {:input('status')=== '0' ? 'selected' : ''}>禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<button type="submit" class="layui-btn layui-btn-primary"><i class="layui-icon layui-icon-search"></i> 搜索</button>
|
||||
<a href="{:MyUrl('plugins_vr_ticket/admin/venue/list')}" class="layui-btn layui-btn-primary">重置</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<table class="layui-table" lay-skin="line">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>场馆名称</th>
|
||||
<th>场馆地址</th>
|
||||
<th>分区数</th>
|
||||
<th>座位数</th>
|
||||
<th>绑定分类</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<notempty name="list">
|
||||
<foreach name="list" item="vo">
|
||||
<tr>
|
||||
<td>{$vo.id}</td>
|
||||
<td>{$vo.venue_name}</td>
|
||||
<td class="layui-elip" style="max-width:200px;">{$vo.venue_address|default='—'}</td>
|
||||
<td><span class="layui-badge">{$vo.zone_count}</span></td>
|
||||
<td><span class="layui-badge layui-bg-blue">{$vo.seat_count}</span></td>
|
||||
<td>{$vo.category_name|default='—'}</td>
|
||||
<td>
|
||||
<eq name="vo.status" value="1">
|
||||
<span class="layui-badge layui-bg-green">启用</span>
|
||||
<else/>
|
||||
<span class="layui-badge layui-bg-gray">禁用</span>
|
||||
</eq>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{:MyUrl('plugins_vr_ticket/admin/venue/save', ['id'=>$vo['id']])}" class="layui-btn layui-btn-xs">编辑</a>
|
||||
<a href="{:MyUrl('plugins_vr_ticket/admin/seat_template/save', ['id'=>$vo['id']])}" class="layui-btn layui-btn-xs layui-btn-primary">座位模板</a>
|
||||
<a class="layui-btn layui-btn-xs layui-btn-danger js-delete" data-id="{$vo.id}">删除</a>
|
||||
</td>
|
||||
</tr>
|
||||
</foreach>
|
||||
<else/>
|
||||
<tr><td colspan="8" class="layui-text-center">暂无数据</td></tr>
|
||||
</notempty>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div>{$page|raw}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<include file="public::foot" />
|
||||
<script>
|
||||
$('.js-delete').on('click', function() {
|
||||
var id = $(this).data('id');
|
||||
layer.confirm('确认删除该场馆?', function(index) {
|
||||
$.post('{:MyUrl("plugins_vr_ticket/admin/venue/delete")}', {id: id}, function(res) {
|
||||
if (res.code === 0) {
|
||||
layer.msg(res.msg, {icon: 1});
|
||||
location.reload();
|
||||
} else {
|
||||
layer.msg(res.msg, {icon: 2});
|
||||
}}
|
||||
});
|
||||
layer.close(index);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,453 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{if isset($info['id'])}编辑{else}添加{/if}场馆</title>
|
||||
<link rel="stylesheet" href="__STATIC__/layui/css/layui.css">
|
||||
<style>
|
||||
[v-cloak] { display: none;}}
|
||||
.venue-editor { margin-top: 20px;}}
|
||||
.venue-editor .section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0 10px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 2px solid #009688;
|
||||
color: #009688;
|
||||
}}
|
||||
/* 座位预览 */
|
||||
.seat-preview-wrap {
|
||||
margin: 15px 0;
|
||||
padding: 15px;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 4px;
|
||||
min-height: 80px;
|
||||
}}
|
||||
.seat-preview-wrap .preview-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}}
|
||||
.seat-preview-wrap .preview-row:last-child { margin-bottom: 0;}}
|
||||
.seat-preview-wrap .seat-cell {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
border-radius: 3px;
|
||||
cursor: default;
|
||||
position: relative;
|
||||
transition: transform 0.1s;
|
||||
}}
|
||||
.seat-preview-wrap .seat-cell:hover {
|
||||
transform: scale(1.15);
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}}
|
||||
.seat-preview-wrap .seat-cell .seat-tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 38px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0,0,0,0.85);
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}}
|
||||
.seat-preview-wrap .seat-cell:hover .seat-tooltip { display: block;}}
|
||||
.seat-preview-wrap .seat-cell .seat-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 4px solid transparent;
|
||||
border-top-color: rgba(0,0,0,0.85);
|
||||
}}
|
||||
.seat-preview-stats {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}}
|
||||
.seat-preview-stats span {
|
||||
margin-right: 16px;
|
||||
}}
|
||||
/* 分区配置 */
|
||||
.zone-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e2e2e2;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
.zone-row .zone-char { width: 60px;}}
|
||||
.zone-row .zone-name { flex: 1;}}
|
||||
.zone-row .zone-price { width: 100px;}}
|
||||
.zone-row .zone-color { width: 50px; height: 36px; padding: 2px; border: 1px solid #ccc; border-radius: 3px; cursor: pointer;}}
|
||||
/* 座位排布 */
|
||||
.seat-map-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}}
|
||||
.seat-map-row label {
|
||||
width: 70px;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
}}
|
||||
.seat-map-row input[type="text"] {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
letter-spacing: 2px;
|
||||
}}
|
||||
/* 颜色预设 */
|
||||
.color-presets {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}}
|
||||
.color-presets .preset-swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #ccc;
|
||||
transition: transform 0.1s;
|
||||
}}
|
||||
.color-presets .preset-swatch:hover { transform: scale(1.2);}}
|
||||
/* 工具栏按钮 */
|
||||
.toolbar { margin: 10px 0;}}
|
||||
/* 隐藏字段同步占位提示 */
|
||||
.sync-hint {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="layui-fluid" style="padding:20px;">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">
|
||||
{if isset($info['id'])}编辑{else}添加{/if}场馆
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" method="POST" lay-filter="venue-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=''}" class="layui-input"
|
||||
lay-verify="required" placeholder="请输入场馆名称">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
Vue3 交互式场馆编辑器
|
||||
============================================================ -->
|
||||
<div id="venue-editor" v-cloak>
|
||||
|
||||
<div class="section-title">票务配置</div>
|
||||
|
||||
<!-- 场馆票务名 & 地址 -->
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">场馆名(票务用)</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" v-model="venue.name" class="layui-input" placeholder="票务系统展示的场馆名称">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">场馆地址</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" v-model="venue.address" class="layui-input" placeholder="场馆详细地址">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">场馆图片</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" v-model="venue.image" class="layui-input" placeholder="场馆图片URL(可选)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
实时座位预览
|
||||
============================================================ -->
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">座位预览</label>
|
||||
<div class="layui-input-block">
|
||||
<div class="seat-preview-wrap">
|
||||
<template v-for="(rowStr, rowIdx) in seatMapRows" :key="rowIdx">
|
||||
<div class="preview-row" v-if="rowStr.trim() !== ''">
|
||||
<template v-for="(ch, colIdx) in rowStr.trim()" :key="rowIdx + '_' + colIdx">
|
||||
<div class="seat-cell"
|
||||
:style="{ backgroundColor: getZoneColor(ch) }"
|
||||
:title="getSeatTooltip(rowIdx, colIdx, ch)">
|
||||
{{ ch }}{{ colIdx + 1 }}
|
||||
<div class="seat-tooltip">{{ getSeatTooltip(rowIdx, colIdx, ch) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="totalSeats === 0" style="color:#999;font-size:13px;text-align:center;padding:10px 0;">
|
||||
暂无座位,请添加分区和排布
|
||||
</div>
|
||||
<div class="seat-preview-stats" v-if="totalSeats > 0">
|
||||
<span>总座位数:<strong>{{ totalSeats }}</strong></span>
|
||||
<span>总排数:<strong>{{ seatMapRows.filter(r => r.trim()).length }}</strong></span>
|
||||
<span>分区数:<strong>{{ activeZones.length }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
分区配置
|
||||
============================================================ -->
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">分区配置</label>
|
||||
<div class="layui-input-block">
|
||||
<div v-for="(zone, idx) in zones" :key="idx" class="zone-row">
|
||||
<input type="text" v-model="zone.char" maxlength="1"
|
||||
class="layui-input zone-char"
|
||||
placeholder="字符"
|
||||
@input="onZoneChange">
|
||||
<input type="text" v-model="zone.name"
|
||||
class="layui-input zone-name"
|
||||
placeholder="分区名称,如 VIP区">
|
||||
<input type="number" v-model="zone.price"
|
||||
class="layui-input zone-price"
|
||||
placeholder="价格" min="0">
|
||||
<input type="color" v-model="zone.color"
|
||||
class="zone-color"
|
||||
@input="onZoneChange">
|
||||
<button type="button" class="layui-btn layui-btn-xs layui-btn-danger"
|
||||
@click="removeZone(idx)">删除</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="layui-btn layui-btn-sm" @click="addZone">
|
||||
<i class="layui-icon layui-icon-add-1"></i> 添加分区
|
||||
</button>
|
||||
</div>
|
||||
<div class="color-presets">
|
||||
<span style="font-size:12px;color:#888;margin-right:4px;">预设色:</span>
|
||||
<template v-for="c in colorPresets" :key="c">
|
||||
<div class="preset-swatch"
|
||||
:style="{ backgroundColor: c }"
|
||||
:title="c"
|
||||
@click="applyPresetColor(c)"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
座位排布
|
||||
============================================================ -->
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">座位排布</label>
|
||||
<div class="layui-input-block">
|
||||
<div v-for="(row, idx) in seatMapRows" :key="idx" class="seat-map-row">
|
||||
<label>第 {{ idx + 1 }} 排:</label>
|
||||
<input type="text"
|
||||
:value="row"
|
||||
@input="updateSeatMapRow(idx, $event)"
|
||||
placeholder="输入座位字符,如 AAAAAA">
|
||||
<button type="button" class="layui-btn layui-btn-xs layui-btn-danger"
|
||||
@click="removeSeatRow(idx)">删除</button>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button type="button" class="layui-btn layui-btn-sm" @click="addSeatRow">
|
||||
<i class="layui-icon layui-icon-add-1"></i> 添加排
|
||||
</button>
|
||||
<span class="sync-hint">每排字符对应上方分区,例:ABABAB 表示交替座位</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- / Vue3 编辑器 -->
|
||||
|
||||
<!-- 隐藏字段:提交给 PHP -->
|
||||
<input type="hidden" name="zones" :value="zonesJson">
|
||||
<input type="hidden" name="seat_map_rows" :value="seatMapRowsJson">
|
||||
<input type="hidden" name="venue_json" :value="venueJson">
|
||||
|
||||
<div class="layui-form-item" style="margin-top:20px;">
|
||||
<div class="layui-input-block">
|
||||
<button type="submit" class="layui-btn" lay-submit lay-filter="venue-submit">
|
||||
提交
|
||||
</button>
|
||||
<a href="{:PluginsAdminUrl('vr_ticket/admin/venue/index')}" class="layui-btn layui-btn-primary">
|
||||
返回
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="__STATIC__/layui/layui.js"></script>
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
// ============================================================
|
||||
// Vue3 编辑器
|
||||
// ============================================================
|
||||
var COLOR_PRESETS = [
|
||||
'#e74c3c', '#3498db', '#2ecc71', '#9b59b6',
|
||||
'#f39c12', '#1abc9c', '#34495e', '#e67e22',
|
||||
'#16a085', '#8e44ad', '#27ae60', '#c0392b'
|
||||
];
|
||||
|
||||
var DEFAULT_ZONE = { char: '', name: '', price: 0, color: '#3498db' };
|
||||
|
||||
var app = Vue.createApp({
|
||||
data: function() {
|
||||
return {
|
||||
venue: { name: '', address: '', image: '' },
|
||||
zones: [
|
||||
{ char: 'A', name: 'VIP区', price: 899, color: '#e74c3c' },
|
||||
{ char: 'B', name: '看台区', price: 599, color: '#3498db'}}
|
||||
],
|
||||
seatMapRows: ['AAAAAA', 'BBBBBB', 'CCCCCC'],
|
||||
colorPresets: COLOR_PRESETS
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
activeZones: function() {
|
||||
var map = {};
|
||||
this.zones.forEach(function(z) {
|
||||
if (z.char.trim()) map[z.char.trim().toUpperCase()] = z;
|
||||
});
|
||||
return map;
|
||||
},
|
||||
zonesJson: function() {
|
||||
return JSON.stringify(this.zones);
|
||||
},
|
||||
seatMapRowsJson: function() {
|
||||
return JSON.stringify(this.seatMapRows);
|
||||
},
|
||||
venueJson: function() {
|
||||
return JSON.stringify(this.venue);
|
||||
},
|
||||
totalSeats: function() {
|
||||
var total = 0;
|
||||
this.seatMapRows.forEach(function(row) {
|
||||
if (row && row.trim) total += row.trim().length;
|
||||
});
|
||||
return total;
|
||||
}}
|
||||
},
|
||||
methods: {
|
||||
getZoneColor: function(ch) {
|
||||
var z = this.activeZones[ch.toUpperCase()];
|
||||
return z ? z.color : '#cccccc';
|
||||
},
|
||||
getZone: function(ch) {
|
||||
return this.activeZones[ch.toUpperCase()] || null;
|
||||
},
|
||||
getSeatTooltip: function(rowIdx, colIdx, ch) {
|
||||
var zone = this.getZone(ch);
|
||||
if (!zone) return ch + (colIdx + 1);
|
||||
return zone.name + ' · ¥' + zone.price + ' · ' + ch + (colIdx + 1);
|
||||
},
|
||||
addZone: function() {
|
||||
this.zones.push(Vue.util.extend({}, DEFAULT_ZONE));
|
||||
},
|
||||
removeZone: function(idx) {
|
||||
this.zones.splice(idx, 1);
|
||||
},
|
||||
applyPresetColor: function(color) {
|
||||
// 把颜色应用到最后一个未填写颜色的 zone
|
||||
var lastEmpty = -1;
|
||||
for (var i = 0; i < this.zones.length; i++) {
|
||||
if (!this.zones[i].color || this.zones[i].color === '#cccccc') {
|
||||
lastEmpty = i;
|
||||
}}
|
||||
}}
|
||||
if (lastEmpty >= 0) {
|
||||
this.zones[lastEmpty].color = color;
|
||||
} else {
|
||||
this.zones[this.zones.length - 1].color = color;
|
||||
}}
|
||||
},
|
||||
addSeatRow: function() {
|
||||
var lastRow = this.seatMapRows.length > 0
|
||||
? this.seatMapRows[this.seatMapRows.length - 1]
|
||||
: 'A';
|
||||
var ch = lastRow.trim().split('')[0] || 'A';
|
||||
var count = 6;
|
||||
this.seatMapRows.push(new Array(count + 1).join(ch));
|
||||
},
|
||||
removeSeatRow: function(idx) {
|
||||
this.seatMapRows.splice(idx, 1);
|
||||
},
|
||||
updateSeatMapRow: function(idx, event) {
|
||||
this.seatMapRows[idx] = event.target.value.toUpperCase();
|
||||
},
|
||||
onZoneChange: function() {
|
||||
// 强制 Vue 重新渲染预览(响应式已处理,这里可扩展自定义校验)
|
||||
}}
|
||||
},
|
||||
mounted: function() {
|
||||
// 编辑时回填
|
||||
var zonesRaw = '{$info.zones_json|raw}' || '[]';
|
||||
var mapRaw = '{$info.map_json|raw}' || '[]';
|
||||
var venueRaw = '{$info.venue_json|raw}' || '{}';
|
||||
|
||||
if (zonesRaw && zonesRaw !== '[]') {
|
||||
try { this.zones = JSON.parse(zonesRaw); } catch(e) {}
|
||||
}}
|
||||
if (mapRaw && mapRaw !== '[]') {
|
||||
try { this.seatMapRows = JSON.parse(mapRaw); } catch(e) {}
|
||||
}}
|
||||
if (venueRaw && venueRaw !== '{}') {
|
||||
try { this.venue = JSON.parse(venueRaw); } catch(e) {}
|
||||
}}
|
||||
}}
|
||||
});
|
||||
|
||||
app.mount('#venue-editor');
|
||||
|
||||
// ============================================================
|
||||
// layui 初始化
|
||||
// ============================================================
|
||||
layui.use(['form', 'layer'], function(){
|
||||
var form = layui.form;
|
||||
var layer = layui.layer;
|
||||
|
||||
form.on('submit(venue-submit)', function(data){
|
||||
// 数据已通过隐藏字段序列化,此处不做额外处理
|
||||
return true;
|
||||
});
|
||||
|
||||
form.render();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
<!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: '{:PluginsAdminUrl("vr_ticket", "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>
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
<!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="姓名/用户ID" 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/verifier/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/verifier/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/verifier/list")}',
|
||||
cols: [[
|
||||
{field: 'id', title: 'ID', width: 80},
|
||||
{field: 'name', title: '核销员名称', minWidth: 120},
|
||||
{field: 'user_id', title: '用户ID', width: 100},
|
||||
{field: 'user_name', title: '关联用户', minWidth: 150},
|
||||
{field: 'status', title: '状态', width: 100, templet: '#statusTpl'},
|
||||
{field: 'created_at', title: '创建时间', width: 180, templet: function(d) {
|
||||
return d.created_at > 0 ? layui.util.toDateString(d.created_at * 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/verifier/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>
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
<!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">
|
||||
<select name="user_id" lay-search required lay-verify="required" {if isset($info.id) && $info.id > 0}disabled{/if}>
|
||||
<option value="">请选择用户</option>
|
||||
{foreach $users as $u}
|
||||
<option value="{$u.id}" {if isset($info.user_id) && $info.user_id == $u.id}selected{/if}>
|
||||
{$u.nickname|default=$u.username|default='用户'}{$u.username ? ' / '.$u.username : ''} (ID:{$u.id})
|
||||
</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
{if isset($info.id) && $info.id > 0}
|
||||
<input type="hidden" name="user_id" value="{$info.user_id}">
|
||||
<div class="layui-form-mid layui-word-aux">用户关联后不可修改</div>
|
||||
{/if}
|
||||
</div>
|
||||
<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="如:张三(检票员)" class="layui-input" style="width:400px">
|
||||
</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">
|
||||
<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/verifier/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() {
|
||||
location.href = '{:MyUrl("plugins_vr_ticket/admin/verifier/list")}';
|
||||
});
|
||||
} else {
|
||||
layer.msg(res.msg || '保存失败');
|
||||
}}
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -26,6 +26,15 @@
|
|||
],
|
||||
"plugins_service_order_delete_success": [
|
||||
"app\\plugins\\vr_ticket\\Hook"
|
||||
],
|
||||
"plugins_view_admin_goods_save": [
|
||||
"app\\plugins\\vr_ticket\\hook\\AdminGoodsSave"
|
||||
],
|
||||
"plugins_service_goods_save_handle": [
|
||||
"app\\plugins\\vr_ticket\\hook\\AdminGoodsSaveHandle"
|
||||
],
|
||||
"plugins_service_goods_save_thing_end": [
|
||||
"app\\plugins\\vr_ticket\\hook\\AdminGoodsSaveHandle"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
<?php
|
||||
namespace app\plugins\vr_ticket\hook;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
class AdminGoodsSave
|
||||
{
|
||||
public function handle($params = [])
|
||||
{
|
||||
$data = $params['data'] ?? [];
|
||||
$isTicket = ($data['item_type'] ?? '') === 'ticket' ? 1 : 0;
|
||||
|
||||
// 解析原有配置
|
||||
$vrGoodsConfig = [];
|
||||
$rawConfig = $data['vr_goods_config'] ?? '';
|
||||
if (!empty($rawConfig)) {
|
||||
$parsed = json_decode($rawConfig, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
if (is_string($parsed)) {
|
||||
$parsed = json_decode($parsed, true);
|
||||
}
|
||||
if (is_array($parsed)) {
|
||||
$vrGoodsConfig = $parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查询模板
|
||||
$templates = Db::name(self::table('seat_templates'))
|
||||
->where('status', 1)
|
||||
->field('id, name, seat_map')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$templateData = [];
|
||||
foreach ($templates as $t) {
|
||||
$seatMap = json_decode($t['seat_map'] ?? '{}', true);
|
||||
// 补全缺失的 room.id(老格式 seat_map 里没有 id 字段)
|
||||
if (!empty($seatMap['rooms'])) {
|
||||
foreach ($seatMap['rooms'] as $rIdx => &$room) {
|
||||
if (empty($room['id'])) {
|
||||
$room['id'] = 'room_' . $rIdx;
|
||||
}
|
||||
}
|
||||
unset($room);
|
||||
}
|
||||
$t['seat_map'] = $seatMap;
|
||||
$templateData[] = $t;
|
||||
}
|
||||
|
||||
$initData = [
|
||||
'isTicket' => $isTicket,
|
||||
'vrGoodsConfig' => $vrGoodsConfig,
|
||||
'templates' => $templateData,
|
||||
];
|
||||
$jsonInitData = base64_encode(json_encode($initData, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$html = <<<EOF
|
||||
<div id="vr-ticket-plugin-app" style="margin: 20px 0; border: 1px solid #ebedf0; padding: 15px; border-radius: 4px; background: #fafafa;">
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<div class="am-form-group">
|
||||
<label>票务商品设置 <span class="am-form-group-label-tips">(使用插件覆盖基础多规格体系)</span></label>
|
||||
<div>
|
||||
<label class="am-checkbox-inline">
|
||||
<input type="checkbox" v-model="isTicket" />
|
||||
<span style="margin-left:5px;">设为在线选座的票务商品(勾选后将开启场馆节点配置)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="isTicket" style="margin-top:20px; border-top:1px solid #eee; padding-top:15px;" v-cloak>
|
||||
<div class="am-form-group">
|
||||
<label>请选择场馆模板(可多选)</label>
|
||||
<div style="margin-top:10px;">
|
||||
<label v-for="t in templates" :key="t.id" class="am-checkbox-inline" style="margin-right:20px;">
|
||||
<input type="checkbox" :value="t.id" v-model="selectedTemplateIds" @change="onTemplateChange" />
|
||||
{{ t.name }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="templates.length === 0" style="color:#999;font-size:12px;">暂无启用的场馆模板。</div>
|
||||
</div>
|
||||
|
||||
<div v-for="config in configs" :key="config.template_id" style="margin-top: 15px; background: #fff; padding: 15px; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<p style="font-weight:bold;margin-bottom:10px;">► 场馆配置:{{ getTemplateName(config.template_id) }}</p>
|
||||
|
||||
<!-- 场次管理 -->
|
||||
<div class="am-form-group" style="background: #fffbf0; padding: 12px; border: 1px solid #f5e79e; border-radius: 4px; margin-bottom:15px;">
|
||||
<label style="font-weight:bold;">
|
||||
<span style="color:#d2322d; margin-right:4px;">*</span>场次时段设置
|
||||
<span style="color:#999; font-size:12px; font-weight:normal; margin-left:8px;">(至少设置一个场次)</span>
|
||||
</label>
|
||||
<div style="margin-top:8px;">
|
||||
<div v-for="(session, idx) in config.sessions" :key="idx"
|
||||
style="display:flex; align-items:center; gap:8px; margin-bottom:6px;">
|
||||
<input type="time" v-model="session.start"
|
||||
@change="validateSession(session)"
|
||||
class="am-form-field"
|
||||
style="width:110px; display:inline-block;" />
|
||||
<span style="color:#666;">至</span>
|
||||
<input type="time" v-model="session.end"
|
||||
@change="validateSession(session)"
|
||||
class="am-form-field"
|
||||
style="width:110px; display:inline-block;" />
|
||||
<button type="button" class="am-btn am-btn-danger am-btn-xs"
|
||||
@click="removeSession(config, idx)">删除</button>
|
||||
<span v-if="session._error" style="color:red; font-size:12px;">{{ session._error }}</span>
|
||||
</div>
|
||||
<button type="button" class="am-btn am-btn-default am-btn-xs"
|
||||
style="margin-top:4px;" @click="addSession(config)">+ 添加场次</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="am-form-group">
|
||||
<label>演播厅选择</label>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:15px; margin-top:5px;">
|
||||
<label v-for="room in getRooms(config.template_id)" :key="room.id" class="am-checkbox-inline">
|
||||
<input type="checkbox" :value="room.id" v-model="config.selected_rooms" />
|
||||
<span style="margin-left:5px;">{{ room.name }}</span>
|
||||
</label>
|
||||
<span v-if="getRooms(config.template_id).length === 0" style="color:#999;font-size:12px;">该场馆内无放映室/演播厅数据</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="am-form-group" v-if="config.selected_rooms.length > 0">
|
||||
<label>分区选择 (仅限已选的演播厅)</label>
|
||||
<div v-for="roomId in config.selected_rooms" :key="roomId" style="margin-left:20px; margin-top:10px;">
|
||||
<p style="color:#666; font-size:13px; margin-bottom:5px;">• {{ getRoomName(config.template_id, roomId) }}</p>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:10px;">
|
||||
<label v-for="sec in getSections(config.template_id, roomId)" :key="sec.char" class="am-checkbox-inline">
|
||||
<input type="checkbox" :value="sec.char" v-model="config.selected_sections[roomId]" />
|
||||
<span style="display:inline-block; width:12px; height:12px; margin:0 5px; vertical-align:middle;"
|
||||
:style="{backgroundColor: sec.color || '#ccc'}"></span>
|
||||
{{ sec.name }} ({{ sec.char }}) - ¥{{ sec.price }}
|
||||
</label>
|
||||
<span v-if="getSections(config.template_id, roomId).length === 0" style="color:#999;font-size:12px;">该放映室无分区数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="vr_is_ticket" :value="isTicket ? 1 : 0" />
|
||||
<input type="hidden" name="vr_goods_config_base64" :value="outputBase64" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[v-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const AppData = JSON.parse(decodeURIComponent(escape(atob('{$jsonInitData}'))));
|
||||
const { createApp, ref, computed, watch } = Vue;
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const isTicket = ref(AppData.isTicket === 1);
|
||||
const templates = ref(AppData.templates || []);
|
||||
const configs = ref([]);
|
||||
const selectedTemplateIds = ref([]);
|
||||
|
||||
// 辅助函数移至顶部,确保初始化时可用
|
||||
const getTemplateName = (tid) => {
|
||||
const t = templates.value.find(x => x.id === tid);
|
||||
return t ? t.name : 'Unknown';
|
||||
};
|
||||
|
||||
const getRooms = (tid) => {
|
||||
const t = templates.value.find(x => x.id === tid);
|
||||
return (t && t.seat_map && t.seat_map.rooms) ? t.seat_map.rooms : [];
|
||||
};
|
||||
|
||||
const getRoomName = (tid, roomId) => {
|
||||
const r = getRooms(tid).find(x => x.id === roomId);
|
||||
return r ? r.name : 'Unknown Room';
|
||||
};
|
||||
|
||||
const getSections = (tid, roomId) => {
|
||||
const r = getRooms(tid).find(x => x.id === roomId);
|
||||
if (!r) return [];
|
||||
if (r.sections && r.sections.length > 0) {
|
||||
return r.sections.filter(s => s.char && s.char !== '_' && s.char !== '-');
|
||||
}
|
||||
return Object.entries(r.seats || {})
|
||||
.filter(([char]) => char !== '_' && char !== '-')
|
||||
.map(([char, info]) => ({
|
||||
char,
|
||||
name: info.name || info.label || char,
|
||||
price: parseFloat(info.price) || 0,
|
||||
color: info.color || '#ccc',
|
||||
}));
|
||||
};
|
||||
|
||||
const defaultSessions = () => [{ start: '08:00', end: '23:59' }];
|
||||
|
||||
// 还原已保存的配置并清洗历史脏数据
|
||||
if (AppData.vrGoodsConfig && Array.isArray(AppData.vrGoodsConfig)) {
|
||||
configs.value = AppData.vrGoodsConfig.map(c => {
|
||||
// 确保 sessions 结构正确
|
||||
if (!c.sessions || c.sessions.length === 0) {
|
||||
c.sessions = defaultSessions();
|
||||
}
|
||||
if (!c.selected_sections) c.selected_sections = {};
|
||||
|
||||
// 【核心清洗】过滤非法 room ID
|
||||
const validRooms = getRooms(c.template_id);
|
||||
const validRoomIds = validRooms.map(r => String(r.id));
|
||||
c.selected_rooms = (c.selected_rooms || [])
|
||||
.map(rid => String(rid))
|
||||
.filter(rid => rid && rid !== 'null' && rid !== 'undefined' && validRoomIds.includes(rid));
|
||||
|
||||
// 清理无效分区键
|
||||
const newSections = {};
|
||||
c.selected_rooms.forEach(rid => {
|
||||
if (c.selected_sections[rid]) {
|
||||
newSections[rid] = c.selected_sections[rid];
|
||||
}
|
||||
});
|
||||
c.selected_sections = newSections;
|
||||
|
||||
return c;
|
||||
});
|
||||
selectedTemplateIds.value = configs.value.map(c => c.template_id);
|
||||
}
|
||||
|
||||
const outputBase64 = computed(() => {
|
||||
const clean = configs.value.map(c => {
|
||||
const activeSections = {};
|
||||
(c.selected_rooms || []).forEach(rid => {
|
||||
if (c.selected_sections[rid]) {
|
||||
activeSections[rid] = c.selected_sections[rid];
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...c,
|
||||
selected_sections: activeSections,
|
||||
sessions: (c.sessions || []).map(s => ({ start: s.start, end: s.end }))
|
||||
};
|
||||
});
|
||||
return btoa(unescape(encodeURIComponent(JSON.stringify(clean))));
|
||||
});
|
||||
|
||||
const onTemplateChange = () => {
|
||||
const oldConfigs = [...configs.value];
|
||||
configs.value = selectedTemplateIds.value.map(tid => {
|
||||
const existing = oldConfigs.find(c => c.template_id === tid);
|
||||
if (existing) return existing;
|
||||
return {
|
||||
template_id: tid,
|
||||
selected_rooms: [],
|
||||
selected_sections: {},
|
||||
sessions: defaultSessions(),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const addSession = (config) => {
|
||||
config.sessions.push({ start: '08:00', end: '23:59' });
|
||||
};
|
||||
|
||||
const removeSession = (config, idx) => {
|
||||
if (config.sessions.length <= 1) {
|
||||
alert('至少需要保留一个场次时段');
|
||||
return;
|
||||
}
|
||||
config.sessions.splice(idx, 1);
|
||||
};
|
||||
|
||||
const validateSession = (session) => {
|
||||
if (session.start && session.end && session.start >= session.end) {
|
||||
session._error = '结束时间必须晚于开始时间';
|
||||
} else {
|
||||
delete session._error;
|
||||
}
|
||||
};
|
||||
|
||||
watch(configs, (newConfigs) => {
|
||||
newConfigs.forEach(conf => {
|
||||
if (!conf.selected_sections) conf.selected_sections = {};
|
||||
(conf.selected_rooms || []).forEach(roomId => {
|
||||
if (roomId && !conf.selected_sections[roomId]) {
|
||||
conf.selected_sections[roomId] = [];
|
||||
}
|
||||
});
|
||||
});
|
||||
}, { deep: true });
|
||||
|
||||
return {
|
||||
isTicket, templates, configs, selectedTemplateIds, outputBase64,
|
||||
onTemplateChange, addSession, removeSession, validateSession,
|
||||
getTemplateName, getRooms, getRoomName, getSections,
|
||||
};
|
||||
}
|
||||
}).mount('#vr-ticket-plugin-app');
|
||||
</script>
|
||||
EOF;
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private static function table($name)
|
||||
{
|
||||
return 'vr_' . $name;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
namespace app\plugins\vr_ticket\hook;
|
||||
|
||||
use think\facade\Db;
|
||||
use app\plugins\vr_ticket\service\SeatSkuService;
|
||||
|
||||
class AdminGoodsSaveHandle
|
||||
{
|
||||
/**
|
||||
* 商品保存钩子(同时响应 save_handle 和 save_thing_end 两个时机)
|
||||
*/
|
||||
public function handle($params = [])
|
||||
{
|
||||
$hookName = $params['hook_name'] ?? '';
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// 时机 1:plugins_service_goods_save_handle(事务前,修改待保存数据)
|
||||
// - 把 vr_goods_config base64 解码写入 $data
|
||||
// - 设置 item_type
|
||||
// - 强制 is_exist_many_spec = 1
|
||||
// ──────────────────────────────────────────────────────
|
||||
if ($hookName === 'plugins_service_goods_save_handle') {
|
||||
$postParams = $params['params'] ?? [];
|
||||
|
||||
if (isset($postParams['vr_is_ticket']) && $postParams['vr_is_ticket'] == 1) {
|
||||
$params['data']['item_type'] = 'ticket';
|
||||
$params['data']['is_exist_many_spec'] = 1;
|
||||
|
||||
$base64Config = $postParams['vr_goods_config_base64'] ?? '';
|
||||
if (!empty($base64Config)) {
|
||||
$jsonStr = base64_decode($base64Config);
|
||||
if ($jsonStr !== false) {
|
||||
$params['data']['vr_goods_config'] = $jsonStr;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$params['data']['item_type'] = 'normal';
|
||||
$params['data']['vr_goods_config'] = '';
|
||||
}
|
||||
|
||||
return ['code' => 0];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// 时机 2:plugins_service_goods_save_thing_end(事务内,goods 已落表)
|
||||
// 关键:此时 GoodsSpecificationsInsert + GoodsSaveBaseUpdate
|
||||
// 已经执行完毕(它们处理的是表单原生规格数据)。
|
||||
//
|
||||
// 对于票务商品,我们需要:
|
||||
// a) 删除原生流程产生的所有 spec 数据(表单垃圾)
|
||||
// b) 用 BatchGenerate 重新生成 VR 座位级 SKU
|
||||
// c) 回写 GoodsSpecType.value(让后台正确展示)
|
||||
// d) 重新计算 goods 表的 price/inventory
|
||||
// ──────────────────────────────────────────────────────
|
||||
if ($hookName === 'plugins_service_goods_save_thing_end') {
|
||||
$data = $params['data'] ?? [];
|
||||
$goodsId = $params['goods_id'] ?? 0;
|
||||
|
||||
if ($goodsId > 0 && ($data['item_type'] ?? '') === 'ticket') {
|
||||
$rawConfig = $data['vr_goods_config'] ?? '';
|
||||
if (!empty($rawConfig)) {
|
||||
$configs = json_decode($rawConfig, true);
|
||||
|
||||
if (is_array($configs) && !empty($configs)) {
|
||||
// a) 清空原生规格数据 —— 避免列偏移
|
||||
Db::name('GoodsSpecType')->where('goods_id', $goodsId)->delete();
|
||||
Db::name('GoodsSpecBase')->where('goods_id', $goodsId)->delete();
|
||||
Db::name('GoodsSpecValue')->where('goods_id', $goodsId)->delete();
|
||||
|
||||
// b) 逐模板生成 VR SKU(ensureAndFillVrSpecTypes 在内部调用,type.value 同步写入)
|
||||
foreach ($configs as $config) {
|
||||
$templateId = intval($config['template_id'] ?? 0);
|
||||
$selectedRooms = $config['selected_rooms'] ?? [];
|
||||
$selectedSections = $config['selected_sections'] ?? [];
|
||||
$sessions = $config['sessions'] ?? [];
|
||||
|
||||
if ($templateId > 0) {
|
||||
$res = SeatSkuService::BatchGenerate(
|
||||
$goodsId, $templateId,
|
||||
$selectedRooms, $selectedSections, $sessions
|
||||
);
|
||||
if ($res['code'] !== 0) {
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// c) 重新计算 goods.price / goods.inventory
|
||||
SeatSkuService::refreshGoodsBase($goodsId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['code' => 0];
|
||||
}
|
||||
|
||||
return ['code' => 0];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,33 +2,54 @@
|
|||
/**
|
||||
* VR票务插件 - 座位 SKU 服务
|
||||
*
|
||||
* 核心业务:批量生成座位级 SKU(spec_base + spec_value)
|
||||
* 旁路 GoodsSpecificationsInsert(),直接 SQL INSERT
|
||||
* ShopXO 规格表结构:
|
||||
* GoodsSpecType : id, goods_id, name, value(JSON数组), add_time
|
||||
* GoodsSpecBase : id, goods_id, price, original_price, inventory, ...
|
||||
* GoodsSpecValue : id, goods_id, goods_spec_base_id, value, md5_key, add_time
|
||||
*
|
||||
* 列对应关系由 GoodsEditSpecifications 决定:
|
||||
* 它把每个 GoodsSpecValue.value 逐个在 GoodsSpecType.value(JSON) 的 name 字段中搜索,
|
||||
* 匹配成功才显示在对应列。
|
||||
* 因此:GoodsSpecType.value JSON 里必须包含我们写入的所有 value 字符串。
|
||||
*
|
||||
* @package vr_ticket\service
|
||||
*/
|
||||
|
||||
namespace app\pluginsr_ticket\service;
|
||||
namespace app\plugins\vr_ticket\service;
|
||||
|
||||
use think\facade\Db;
|
||||
|
||||
class SeatSkuService extends BaseService
|
||||
{
|
||||
/** @var int 分批处理每批条数 */
|
||||
const BATCH_SIZE = 500;
|
||||
const BATCH_SIZE = 200;
|
||||
|
||||
/**
|
||||
* VR 规格维度名(顺序固定)
|
||||
*/
|
||||
const SPEC_DIMS = ['$vr-场馆', '$vr-分区', '$vr-座位号', '$vr-场次'];
|
||||
|
||||
/**
|
||||
* 批量生成座位级 SKU
|
||||
*
|
||||
* @param int $goodsId
|
||||
* @param int $seatTemplateId
|
||||
* @param array $selectedRooms 要生成的厅 id 列表,空=全部
|
||||
* @param array $selectedSections 按 roomId 为 key 的分区 char 数组,空=全部
|
||||
* @param array $sessions 场次数组 e.g. [["start"=>"08:00","end"=>"23:59"]]
|
||||
*/
|
||||
public static function BatchGenerate(int $goodsId, int $seatTemplateId): array
|
||||
{
|
||||
$goodsId = intval($goodsId);
|
||||
$seatTemplateId = intval($seatTemplateId);
|
||||
|
||||
public static function BatchGenerate(
|
||||
int $goodsId,
|
||||
int $seatTemplateId,
|
||||
array $selectedRooms = [],
|
||||
array $selectedSections = [],
|
||||
array $sessions = []
|
||||
): array {
|
||||
if ($goodsId <= 0 || $seatTemplateId <= 0) {
|
||||
return ['code' => -1, 'msg' => '参数错误:goodsId 或 seatTemplateId 无效'];
|
||||
}
|
||||
|
||||
// 1. 加载座位模板
|
||||
$template = hinkacade\Db::name(self::table('seat_templates'))
|
||||
$template = Db::name(self::table('seat_templates'))
|
||||
->where('id', $seatTemplateId)
|
||||
->find();
|
||||
if (empty($template)) {
|
||||
|
|
@ -37,207 +58,294 @@ class SeatSkuService extends BaseService
|
|||
|
||||
// 2. 解析 seat_map
|
||||
$seatMap = json_decode($template['seat_map'] ?? '{}', true);
|
||||
|
||||
$rooms = $seatMap['rooms'] ?? [];
|
||||
$rooms = $seatMap['rooms'] ?? [];
|
||||
if (empty($rooms)) {
|
||||
// 向下兼容旧结构
|
||||
if (!empty($seatMap['map'])) {
|
||||
$rooms = [
|
||||
[
|
||||
'id' => 'room_default',
|
||||
'name' => '默认放映室',
|
||||
'map' => $seatMap['map'],
|
||||
'seats' => $seatMap['seats'] ?? [],
|
||||
'sections' => $seatMap['sections'] ?? []
|
||||
]
|
||||
];
|
||||
} else {
|
||||
return ['code' => -3, 'msg' => '座位模板 seat_map 数据无效'];
|
||||
return ['code' => -3, 'msg' => '座位模板 seat_map 无效(rooms 为空)'];
|
||||
}
|
||||
|
||||
// 使用模板表的短名称
|
||||
$venueName = $template['name'] ?? '未命名场馆';
|
||||
|
||||
// 3. 场次处理(默认兜底)
|
||||
if (empty($sessions)) {
|
||||
$sessions = [['start' => '08:00', 'end' => '23:59']];
|
||||
}
|
||||
$sessionStrings = [];
|
||||
foreach ($sessions as $s) {
|
||||
$start = is_array($s) ? ($s['start'] ?? '08:00') : '08:00';
|
||||
$end = is_array($s) ? ($s['end'] ?? '23:59') : '23:59';
|
||||
$sessionStr = "{$start}-{$end}";
|
||||
if (!in_array($sessionStr, $sessionStrings)) {
|
||||
$sessionStrings[] = $sessionStr;
|
||||
}
|
||||
}
|
||||
|
||||
$venueName = $seatMap['venue']['name'] ?? $template['name'] ?? '未命名场馆';
|
||||
$specTypeIds = self::ensureVrSpecTypes($goodsId, $venueName);
|
||||
if ($specTypeIds['code'] !== 0) {
|
||||
return $specTypeIds;
|
||||
}
|
||||
$typeVenue = $specTypeIds['data']['$vr-场馆'];
|
||||
$typeZone = $specTypeIds['data']['$vr-分区'];
|
||||
$typeTime = $specTypeIds['data']['$vr-时段']; // 时段留作可选,前端默认传“不限时段”
|
||||
$typeSeat = $specTypeIds['data']['$vr-座位号'];
|
||||
// 4. 确保 4 个 VR spec type 维度存在,同时收集要写入的所有唯一 value
|
||||
// 我们先遍历座位图,收集全部规格值,再一次性写入 type.value JSON
|
||||
$selectedRooms = array_values(array_filter($selectedRooms));
|
||||
$selectedSections = array_filter($selectedSections);
|
||||
|
||||
// 按维度收集唯一值(用 有序列表 + 去重)
|
||||
$dimUniqueValues = [
|
||||
'$vr-场馆' => [],
|
||||
'$vr-分区' => [],
|
||||
'$vr-座位号' => [],
|
||||
'$vr-场次' => [],
|
||||
];
|
||||
|
||||
// 5. 遍历地图,收集所有座位信息
|
||||
$seatsToInsert = [];
|
||||
// 遍历 rooms
|
||||
foreach ($rooms as $room) {
|
||||
$roomId = $room['id'] ?? 'room_default';
|
||||
foreach ($rooms as $rIdx => $room) {
|
||||
// 与前端 PHP 预处理保持一致:id 缺失时用 'room_{index}'
|
||||
$roomId = !empty($room['id']) ? $room['id'] : ('room_' . $rIdx);
|
||||
$roomName = $room['name'] ?? '默认放映室';
|
||||
|
||||
$sectionPrices = [];
|
||||
foreach (($room['sections'] ?? []) as $section) {
|
||||
$sectionPrices[$section['name'] ?? ''] = floatval($section['price'] ?? 0);
|
||||
|
||||
if (!empty($selectedRooms) && !in_array($roomId, $selectedRooms)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$map = $room['map'] ?? [];
|
||||
|
||||
// char → price 映射
|
||||
$sectionPrices = [];
|
||||
foreach (($room['sections'] ?? []) as $sec) {
|
||||
if (!empty($sec['char'])) {
|
||||
$sectionPrices[$sec['char']] = floatval($sec['price'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$map = $room['map'] ?? [];
|
||||
$seatsData = $room['seats'] ?? [];
|
||||
|
||||
|
||||
foreach ($map as $rowIndex => $rowStr) {
|
||||
$rowLabel = chr(65 + $rowIndex);
|
||||
$chars = mb_str_split($rowStr);
|
||||
$chars = mb_str_split($rowStr);
|
||||
|
||||
foreach ($chars as $colIndex => $char) {
|
||||
if ($char === '_' || $char === '-' || !isset($seatsData[$char])) {
|
||||
continue;
|
||||
}
|
||||
$seatInfo = $seatsData[$char];
|
||||
$zoneName = $seatInfo['label'] ?? ($seatInfo['zone'] ?? ($seatInfo['section'] ?? '默认区'));
|
||||
|
||||
$seatPrice = floatval($seatInfo['price'] ?? 0);
|
||||
if ($seatPrice == 0 && isset($sectionPrices[$zoneName])) {
|
||||
$seatPrice = $sectionPrices[$zoneName];
|
||||
|
||||
if (!empty($selectedSections[$roomId])
|
||||
&& !in_array($char, $selectedSections[$roomId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 价格三级 fallback
|
||||
$seatInfo = $seatsData[$char];
|
||||
$seatPrice = floatval($seatInfo['price'] ?? 0);
|
||||
if ($seatPrice == 0 && isset($sectionPrices[$char])) {
|
||||
$seatPrice = $sectionPrices[$char];
|
||||
}
|
||||
if ($seatPrice == 0) {
|
||||
foreach (($room['sections'] ?? []) as $sec) {
|
||||
if (($sec['char'] ?? '') === $char) {
|
||||
$seatPrice = floatval($sec['price'] ?? 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$col = $colIndex + 1;
|
||||
|
||||
// 维度值字符串(确保非空)
|
||||
$val_venue = $venueName;
|
||||
$val_section = $venueName . '-' . $roomName . '-' . $char;
|
||||
$val_seat = $venueName . '-' . $roomName . '-' . $char . '-' . $rowLabel . $col;
|
||||
|
||||
foreach ($sessionStrings as $sessionStr) {
|
||||
$seatId = $roomId . '_' . $rowLabel . '_' . $col . '_' . md5($sessionStr);
|
||||
|
||||
$seatsToInsert[$seatId] = [
|
||||
'price' => $seatPrice,
|
||||
'spec_values' => [
|
||||
$val_venue,
|
||||
$val_section,
|
||||
$val_seat,
|
||||
$sessionStr,
|
||||
],
|
||||
];
|
||||
|
||||
// 收集唯一维度值(保持首次出现顺序)
|
||||
if (!in_array($val_venue, $dimUniqueValues['$vr-场馆'])) {
|
||||
$dimUniqueValues['$vr-场馆'][] = $val_venue;
|
||||
}
|
||||
if (!in_array($val_section, $dimUniqueValues['$vr-分区'])) {
|
||||
$dimUniqueValues['$vr-分区'][] = $val_section;
|
||||
}
|
||||
if (!in_array($val_seat, $dimUniqueValues['$vr-座位号'])) {
|
||||
$dimUniqueValues['$vr-座位号'][] = $val_seat;
|
||||
}
|
||||
if (!in_array($sessionStr, $dimUniqueValues['$vr-场次'])) {
|
||||
$dimUniqueValues['$vr-场次'][] = $sessionStr;
|
||||
}
|
||||
}
|
||||
|
||||
$seatId = $roomName . '_' . $rowLabel . '_' . ($colIndex + 1);
|
||||
$seatDisplayName = $roomName . ' ' . $zoneName . ' ' . $rowLabel . ($colIndex + 1);
|
||||
|
||||
$seatsToInsert[$seatId] = [
|
||||
'room' => $roomName,
|
||||
'zone' => $zoneName,
|
||||
'row' => $rowIndex,
|
||||
'col' => $colIndex,
|
||||
'char' => $char,
|
||||
'label' => $seatDisplayName,
|
||||
'price' => $seatPrice
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (empty($seatsToInsert)) {
|
||||
return ['code' => -3, 'msg' => '无有效座位可生成'];
|
||||
return ['code' => -4, 'msg' => '无有效座位可生成'];
|
||||
}
|
||||
|
||||
// 查询已有 SKU
|
||||
$existingBases = hinkacade\Db::name('GoodsSpecBase')
|
||||
->where('goods_id', $goodsId)
|
||||
->column('id', 'id');
|
||||
$existingValues = [];
|
||||
if (!empty($existingBases)) {
|
||||
$valueRows = hinkacade\Db::name('GoodsSpecValue')
|
||||
->where('goods_id', $goodsId)
|
||||
->where('goods_spec_base_id', 'in', array_keys($existingBases))
|
||||
->where('type', $typeSeat)
|
||||
->column('value', 'goods_spec_base_id');
|
||||
foreach ($valueRows as $baseId => $val) {
|
||||
$existingValues[$val] = $baseId;
|
||||
}
|
||||
}
|
||||
// 6. 保证 4 个 VR spec type 存在,并把已收集的 value 写入 type.value JSON
|
||||
// 这样 GoodsEditSpecifications 的 name 匹配才能命中每条 GoodsSpecValue
|
||||
self::ensureAndFillVrSpecTypes($goodsId, $dimUniqueValues);
|
||||
|
||||
$now = time();
|
||||
// 7. 写入 GoodsSpecBase + GoodsSpecValue
|
||||
$now = time();
|
||||
$generatedCount = 0;
|
||||
$specBaseIdMap = [];
|
||||
$valueBatch = [];
|
||||
|
||||
hinkacade\Db::startTrans();
|
||||
try {
|
||||
$baseBatch = [];
|
||||
$valueBatch = [];
|
||||
foreach ($seatsToInsert as $seatId => $s) {
|
||||
if (isset($existingValues[$s['label']])) {
|
||||
$baseId = $existingValues[$s['label']];
|
||||
$specBaseIdMap[$seatId] = [
|
||||
'spec_base_id' => $baseId,
|
||||
'room' => $s['room'],
|
||||
'zone' => $s['zone'],
|
||||
'row' => $s['row'],
|
||||
'col' => $s['col']
|
||||
];
|
||||
continue; // 已经存在,查出来返回映射即可
|
||||
}
|
||||
foreach ($seatsToInsert as $seatId => $s) {
|
||||
$baseId = Db::name('GoodsSpecBase')->insertGetId([
|
||||
'goods_id' => $goodsId,
|
||||
'price' => $s['price'],
|
||||
'original_price' => 0,
|
||||
'inventory' => 1,
|
||||
'buy_min_number' => 1,
|
||||
'buy_max_number' => 1,
|
||||
'weight' => 0,
|
||||
'volume' => 0,
|
||||
'coding' => '',
|
||||
'barcode' => '',
|
||||
'add_time' => $now,
|
||||
]);
|
||||
|
||||
$baseId = hinkacade\Db::name('GoodsSpecBase')->insertGetId([
|
||||
'goods_id' => $goodsId,
|
||||
'price' => $s['price'],
|
||||
'inventory' => 1,
|
||||
'weight' => 0,
|
||||
'volume' => 0,
|
||||
'coding' => '',
|
||||
'barcode' => '',
|
||||
'add_time' => $now,
|
||||
]);
|
||||
if (!$baseId) {
|
||||
throw new \Exception("GoodsSpecBase 写入失败 (seat: {$seatId})");
|
||||
}
|
||||
|
||||
$valueBatch[] = ['goods_id' => $goodsId, 'goods_spec_base_id' => $baseId, 'type' => $typeVenue, 'value' => $venueName, 'add_time' => $now];
|
||||
$valueBatch[] = ['goods_id' => $goodsId, 'goods_spec_base_id' => $baseId, 'type' => $typeZone, 'value' => $s['room'] . '-' . $s['zone'], 'add_time' => $now];
|
||||
$valueBatch[] = ['goods_id' => $goodsId, 'goods_spec_base_id' => $baseId, 'type' => $typeTime, 'value' => '不限时段', 'add_time' => $now];
|
||||
$valueBatch[] = ['goods_id' => $goodsId, 'goods_spec_base_id' => $baseId, 'type' => $typeSeat, 'value' => $s['label'], 'add_time' => $now];
|
||||
|
||||
$specBaseIdMap[$seatId] = [
|
||||
'spec_base_id' => $baseId,
|
||||
'room' => $s['room'],
|
||||
'zone' => $s['zone'],
|
||||
'row' => $s['row'],
|
||||
'col' => $s['col']
|
||||
// 4 条 GoodsSpecValue,每条对应一个维度
|
||||
foreach ($s['spec_values'] as $specVal) {
|
||||
$valueBatch[] = [
|
||||
'goods_id' => $goodsId,
|
||||
'goods_spec_base_id' => $baseId,
|
||||
'value' => (string)$specVal,
|
||||
'md5_key' => md5((string)$specVal),
|
||||
'add_time' => $now,
|
||||
];
|
||||
|
||||
$generatedCount++;
|
||||
|
||||
if (count($valueBatch) >= self::BATCH_SIZE) {
|
||||
hinkacade\Db::name('GoodsSpecValue')->insertAll($valueBatch);
|
||||
$valueBatch = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($valueBatch)) {
|
||||
hinkacade\Db::name('GoodsSpecValue')->insertAll($valueBatch);
|
||||
$generatedCount++;
|
||||
|
||||
if (count($valueBatch) >= self::BATCH_SIZE) {
|
||||
Db::name('GoodsSpecValue')->insertAll($valueBatch);
|
||||
$valueBatch = [];
|
||||
}
|
||||
|
||||
hinkacade\Db::commit();
|
||||
|
||||
return [
|
||||
'code' => 0,
|
||||
'msg' => '生成成功',
|
||||
'data' => [
|
||||
'total' => count($seatsToInsert),
|
||||
'generated' => $generatedCount,
|
||||
'spec_base_id_map' => $specBaseIdMap,
|
||||
]
|
||||
];
|
||||
|
||||
} catch (\Exception $e) {
|
||||
hinkacade\Db::rollback();
|
||||
return ['code' => -99, 'msg' => '事务异常:' . $e->getMessage()];
|
||||
}
|
||||
|
||||
if (!empty($valueBatch)) {
|
||||
Db::name('GoodsSpecValue')->insertAll($valueBatch);
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 0,
|
||||
'msg' => '生成成功',
|
||||
'data' => [
|
||||
'total' => count($seatsToInsert),
|
||||
'generated' => $generatedCount,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private static function ensureVrSpecTypes(int $goodsId, string $venueName): array
|
||||
/**
|
||||
* 幂等确保 4 个 VR 维度存在,并把本次收集的所有唯一值合并写入 type.value JSON
|
||||
*
|
||||
* 关键:GoodsEditSpecifications 通过 type.value JSON 里的 name 做匹配,
|
||||
* 所以每条 GoodsSpecValue.value 都必须在对应 type.value 的某个 name 里找到。
|
||||
*
|
||||
* @param int $goodsId
|
||||
* @param array $dimUniqueValues ['$vr-场馆' => [...], '$vr-分区' => [...], ...]
|
||||
*/
|
||||
public static function ensureAndFillVrSpecTypes(int $goodsId, array $dimUniqueValues = []): void
|
||||
{
|
||||
$types = ['$vr-场馆', '$vr-分区', '$vr-时段', '$vr-座位号'];
|
||||
$typeIds = [];
|
||||
$now = time();
|
||||
|
||||
$existing = hinkacade\Db::name('GoodsSpecType')
|
||||
// 读取已存在的 VR 维度(按顺序)
|
||||
$existing = Db::name('GoodsSpecType')
|
||||
->where('goods_id', $goodsId)
|
||||
->where('name', 'in', $types)
|
||||
->column('id', 'name');
|
||||
->whereIn('name', self::SPEC_DIMS)
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($types as $name) {
|
||||
if (isset($existing[$name])) {
|
||||
$typeIds[$name] = $existing[$name];
|
||||
$existingByName = array_column($existing, null, 'name');
|
||||
|
||||
foreach (self::SPEC_DIMS as $dimName) {
|
||||
// 构建该维度的 value JSON(把所有唯一值合并,含已有值)
|
||||
$newItems = [];
|
||||
$existingItems = [];
|
||||
if (isset($existingByName[$dimName])) {
|
||||
$existingItems = json_decode($existingByName[$dimName]['value'] ?? '[]', true);
|
||||
if (!is_array($existingItems)) $existingItems = [];
|
||||
}
|
||||
|
||||
// 把现有 JSON 中的 name 提取出来
|
||||
$existingNames = array_column($existingItems, 'name');
|
||||
|
||||
// 合并本次新增的值
|
||||
$toAdd = $dimUniqueValues[$dimName] ?? [];
|
||||
foreach ($toAdd as $val) {
|
||||
if (!in_array($val, $existingNames)) {
|
||||
$existingItems[] = ['name' => (string)$val, 'images' => ''];
|
||||
$existingNames[] = $val;
|
||||
}
|
||||
}
|
||||
|
||||
$valueJson = json_encode($existingItems, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
if (isset($existingByName[$dimName])) {
|
||||
// 更新已有维度的 value JSON
|
||||
Db::name('GoodsSpecType')
|
||||
->where('id', $existingByName[$dimName]['id'])
|
||||
->update(['value' => $valueJson]);
|
||||
} else {
|
||||
$val = '';
|
||||
if ($name === '$vr-场馆') $val = $venueName;
|
||||
if ($name === '$vr-时段') $val = '不限时段';
|
||||
|
||||
$id = hinkacade\Db::name('GoodsSpecType')->insertGetId([
|
||||
// 插入缺失维度(保持 SPEC_DIMS 顺序,ID 递增)
|
||||
Db::name('GoodsSpecType')->insert([
|
||||
'goods_id' => $goodsId,
|
||||
'name' => $name,
|
||||
'value' => $val,
|
||||
'name' => $dimName,
|
||||
'value' => $valueJson,
|
||||
'add_time' => $now,
|
||||
]);
|
||||
if (!$id) {
|
||||
return ['code' => -10, 'msg' => "写入规格类型 {$name} 失败"];
|
||||
}
|
||||
$typeIds[$name] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['code' => 0, 'data' => $typeIds];
|
||||
/**
|
||||
* 重新计算商品基础信息(价格区间、总库存)
|
||||
*/
|
||||
public static function refreshGoodsBase(int $goodsId): array
|
||||
{
|
||||
$bases = Db::name('GoodsSpecBase')
|
||||
->where('goods_id', $goodsId)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if (empty($bases)) {
|
||||
return ['code' => -1, 'msg' => '无 GoodsSpecBase'];
|
||||
}
|
||||
|
||||
$prices = array_column($bases, 'price');
|
||||
$minPrice = min($prices);
|
||||
$maxPrice = max($prices);
|
||||
$inventory = array_sum(array_column($bases, 'inventory'));
|
||||
|
||||
$priceDisplay = ($minPrice != $maxPrice && $maxPrice > 0)
|
||||
? $minPrice . '-' . $maxPrice : $minPrice;
|
||||
|
||||
Db::name('Goods')->where('id', $goodsId)->update([
|
||||
'min_price' => $minPrice,
|
||||
'max_price' => $maxPrice,
|
||||
'price' => $priceDisplay,
|
||||
'min_original_price' => 0,
|
||||
'max_original_price' => 0,
|
||||
'original_price' => 0,
|
||||
'inventory' => $inventory,
|
||||
'is_exist_many_spec' => 1,
|
||||
'buy_min_number' => 1,
|
||||
'buy_max_number' => 1,
|
||||
'upd_time' => time(),
|
||||
]);
|
||||
|
||||
return ['code' => 0];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,12 +140,17 @@
|
|||
<div class="am-input-group">
|
||||
<input type="text" v-model="venue.address" placeholder="请输入场馆地址" class="am-radius" />
|
||||
<span class="am-input-group-btn">
|
||||
<button class="am-btn am-btn-default am-radius" type="button" @click="openAmap"><i class="am-icon-map-marker"></i> 地图选点</button>
|
||||
<button class="am-btn am-btn-default am-radius" type="button" @click="openAmap"><i class="am-icon-map-marker"></i> 解析坐标</button>
|
||||
<a href="{{:PluginsAdminUrl('vr_ticket', 'admin', 'Setup')}}" target="_blank" class="am-btn am-btn-default am-radius" title="配置高德 API"><i class="am-icon-cog"></i></a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="am-text-xs am-text-grey am-margin-top-xs" v-if="venue.location.lng">
|
||||
已选坐标: 经度 [[ venue.location.lng ]], 纬度 [[ venue.location.lat ]]
|
||||
<div class="am-margin-top-sm" v-if="venue.location.lng">
|
||||
<div class="am-text-xs am-text-grey am-margin-bottom-xs">
|
||||
<i class="am-icon-check-circle am-text-success"></i> 已确立坐标: 经度 [[ venue.location.lng ]], 纬度 [[ venue.location.lat ]]
|
||||
</div>
|
||||
<div class="am-padding-xs am-radius" style="border: 1px solid #e0e0e0; display: inline-block; background: #fafafa;" v-if="staticMapUrl">
|
||||
<img :src="staticMapUrl" alt="地图定位预览" style="width: 500px; height: 250px; object-fit: cover; border-radius: 2px; display: block;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -419,22 +424,51 @@
|
|||
const apiKey = vrConfig.amap_api_key;
|
||||
if (!apiKey) {
|
||||
alert('请先在 [插件设置] 中配置高德 Web 端 API Key,即可开启地图选点功能。');
|
||||
// 演示目的:如果没有 key,仍然随机更新一下坐标展示预览效果
|
||||
venue.value.location = {
|
||||
lng: (116.3 + Math.random() * 0.1).toFixed(6),
|
||||
lat: (39.9 + Math.random() * 0.1).toFixed(6)
|
||||
};
|
||||
return;
|
||||
}
|
||||
const addr = venue.value.address;
|
||||
if (!addr || addr.trim() === '') {
|
||||
alert('请先在左侧输入场馆详细地址,再进行坐标智能解析。');
|
||||
return;
|
||||
}
|
||||
|
||||
alert('检测到 API Key:' + apiKey + '\n地图拾取窗口正在集成中... 目前已正确识别您的 Key 授权。');
|
||||
// 暂时随机生成坐标用于预览效果
|
||||
venue.value.location = {
|
||||
lng: (116.3 + Math.random() * 0.1).toFixed(6),
|
||||
lat: (39.9 + Math.random() * 0.1).toFixed(6)
|
||||
};
|
||||
fetch(`https://restapi.amap.com/v3/geocode/geo?address=${encodeURIComponent(addr.trim())}&key=${apiKey}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === '1' && data.geocodes && data.geocodes.length > 0) {
|
||||
const loc = data.geocodes[0].location;
|
||||
if (loc) {
|
||||
const parts = loc.split(',');
|
||||
venue.value.location = {
|
||||
lng: parts[0],
|
||||
lat: parts[1]
|
||||
};
|
||||
alert(`解析成功!\n标准地址: ${data.geocodes[0].formatted_address}\n经度: ${parts[0]}, 纬度: ${parts[1]}`);
|
||||
} else {
|
||||
alert('未能解析到准确的坐标,请尝试细化地址。');
|
||||
}
|
||||
} else {
|
||||
alert('地址解析失败:' + (data.info || '未知错误,请检查 API Key 权限或地址格式。'));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('高德API请求错误:', err);
|
||||
alert('网络请求异常,无法访问高德 API,请检查网络设置或稍后重试。');
|
||||
});
|
||||
};
|
||||
|
||||
// 高德静态地图 URL
|
||||
const staticMapUrl = computed(() => {
|
||||
const apiKey = vrConfig.amap_api_key;
|
||||
if (!apiKey || !venue.value.location.lng || !venue.value.location.lat) {
|
||||
return '';
|
||||
}
|
||||
const lng = venue.value.location.lng;
|
||||
const lat = venue.value.location.lat;
|
||||
// zoom=15,尺寸 500x250,中心点 location,标点 markers
|
||||
return `https://restapi.amap.com/v3/staticmap?location=${lng},${lat}&zoom=15&size=500*250&markers=mid,0xFF0000,A:${lng},${lat}&key=${apiKey}`;
|
||||
});
|
||||
|
||||
// 序列化
|
||||
const compiledJsonRaw = computed(() => {
|
||||
// 过滤掉空的房间名称等逻辑可以在此执行
|
||||
|
|
@ -474,6 +508,7 @@
|
|||
getSeatTooltip,
|
||||
addImage,
|
||||
openAmap,
|
||||
staticMapUrl,
|
||||
compiledJsonRaw
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
# ShopXO 评测环境清理计划 — Council Round 3
|
||||
|
||||
## 状态:✅ 清理完成,验证完成
|
||||
|
||||
**分支同步**:SecurityEngineer branch 已 fast-forward merge master (`9620524`),BackendArchitect 已完成验证。
|
||||
|
||||
### 清理结果
|
||||
|
||||
所有安全风险已清理,worktree 已恢复至干净状态:
|
||||
|
||||
| 文件 | 原风险 | 执行结果 |
|
||||
|------|--------|---------|
|
||||
| `app/admin/controller/Common.php` | debug 日志 | ✅ 已 checkout 恢复 |
|
||||
| `app/admin/controller/Plugins.php` | debug 日志 + 权限逻辑重写 | ✅ 已 checkout 恢复 |
|
||||
| `app/common.php` | debug 日志 | ✅ 已 checkout 恢复 |
|
||||
| `app/service/AdminPowerService.php` | admin_id==1 强制刷新 | ✅ 已 checkout 恢复 |
|
||||
| `app/service/PluginsService.php` | mode change | ✅ 已 checkout 恢复 |
|
||||
| `config/shopxo.php` | `is_develop: true` | ✅ 已 checkout 恢复(`is_develop: false`) |
|
||||
| `public/core.php` | 末尾换行符 | ✅ 已 checkout 恢复 |
|
||||
| `adminwatekc.php` | 后台入口副本 | ✅ 已删除 |
|
||||
| `debug_power.php` | 调试脚本 | ✅ 已删除 |
|
||||
| `test_admin.php` | 调试脚本 | ✅ 已删除 |
|
||||
| `public/adminwatekc.php` | 后台入口副本 | ✅ 已删除 |
|
||||
| `public/test_admin.php` | 调试脚本 | ✅ 已删除 |
|
||||
| `app/admin/view/default/plugins_admin/` | 调试视图目录 | ✅ 已删除 |
|
||||
| `app/admin/view/default/pluginsadmin/` | 调试视图目录 | ✅ 已 checkout 恢复(原始 ShopXO 文件) |
|
||||
| `app/plugins/vr_ticket/` | 插件代码 | ✅ 已 commit(15 files, 652433a) |
|
||||
|
||||
**最终 git status**: 仅 `?? .worktrees/` 未追踪(框架元数据目录,无需处理)
|
||||
|
||||
### 安全风险清理确认
|
||||
|
||||
- ✅ **`is_develop: false`** — 不再泄露 stack trace / 配置信息
|
||||
- ✅ **无 `file_put_contents()` debug 日志** — 三处 debug 日志已清除
|
||||
- ✅ **权限检查逻辑已恢复** — Plugins.php 和 AdminPowerService.php 原始代码
|
||||
- ✅ **调试脚本已删除** — 无后台入口副本或调试脚本残留
|
||||
- ✅ **vr_ticket 插件已 commit** — 插件代码现在受版本控制
|
||||
|
||||
### BackendArchitect 技术验证(Round 3)
|
||||
|
||||
从 master 抽取验证:
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| `app/common.php` 无 debug `file_put_contents` | ✅ 已清理(仅保留原始 ShopXO 缓存写入代码) |
|
||||
| `config/shopxo.php` `is_develop: false` | ✅ 已恢复 |
|
||||
| `app/admin/controller/Common.php` 无 debug 日志 | ✅ 已清理 |
|
||||
| `app/admin/controller/Plugins.php` 无 debug 日志 | ✅ 已清理 |
|
||||
| Goods.php `item_type` 判断逻辑(4747d92) | ✅ 第 139 行存在,修改完整保留 |
|
||||
| vr_ticket 插件 commit(652433a) | ✅ 15 files tracked |
|
||||
| shopxo-web HTTP 200 | ✅ 端口 10000 正常响应 |
|
||||
| shopxo-php 运行中 | ✅ 端口 9000 正常运行 |
|
||||
| `pluginsadmin/` 目录原始状态 | ⚠️ 被删除后已恢复原始 ShopXO 文件 |
|
||||
|
||||
### Docker 状态
|
||||
|
||||
- `shopxo-web`(端口 10000):✅ 运行中,返回 HTTP 200
|
||||
- `shopxo-php`(端口 9000):✅ 运行中
|
||||
- `shopxo-mysql`(端口 10001):✅ 运行中,DB 完全隔离
|
||||
- vr_ticket 插件:DB 启用(is_enable=1),代码在 `app/plugins/vr_ticket/`(已 commit)
|
||||
|
||||
### 剩余任务
|
||||
|
||||
- [x] **[Done: council/SecurityEngineer]** Round 1-2: 安全清理 + vr_ticket commit
|
||||
- [x] **[Done: council/SecurityEngineer]** Round 3: 分支同步 master (`9620524`)
|
||||
- [x] **[Done: council/BackendArchitect]** Round 3: 验证清理结果 + 技术评估
|
||||
- [ ] **[Pending: DevOps]** 可选:Docker 重启 + OPcache 清除(容器已正常运行,可跳过)
|
||||
|
||||
### 备份状态
|
||||
|
||||
- ✅ vr_ticket 插件:`/tmp/vr_ticket_backup/`(备用)
|
||||
- ✅ vr_ticket 插件:已 commit `652433a`(主副本)
|
||||
- ✅ Goods.php:`4747d92`(无需处理)
|
||||
- ✅ DB:完全隔离,无需备份
|
||||
|
||||
## 关键结论
|
||||
|
||||
1. **Plan A 已完全执行**:所有 debug 代码已清理,无遗留风险
|
||||
2. **Plan B 无需执行**:Plan A 已覆盖所有清理需求,无降级必要
|
||||
3. **Docker 服务正常运行**:无需额外重启或配置
|
||||
4. **vr_ticket 插件受 Git 控制**:无需额外备份
|
||||
5. **Goods.php 修改已 commit**:修改完整保留
|
||||
|
||||
## 备注
|
||||
|
||||
- ShopXO 主分支为 `master`
|
||||
- 所有 Agent worktree 均基于 `master`
|
||||
- `shopxo-web` 当前返回 HTTP 200,服务正常运行
|
||||
- 评测环境清理完成,可以启动新一轮调试会话
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | ShopXO 国内领先企业级B2C免费开源电商系统
|
||||
// +----------------------------------------------------------------------
|
||||
// | Copyright (c) 2011~2099 http://shopxo.net All rights reserved.
|
||||
// +----------------------------------------------------------------------
|
||||
// | Licensed ( https://opensource.org/licenses/mit-license.php )
|
||||
// +----------------------------------------------------------------------
|
||||
// | Author: Devil
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
// [ 后台入口文件 ]
|
||||
namespace think;
|
||||
|
||||
// 引入公共入口文件
|
||||
require __DIR__.'/core.php';
|
||||
|
||||
// 加载基础文件
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
// 执行HTTP应用并响应
|
||||
$http = (new App())->http;
|
||||
$response = $http->name('admin')->run();
|
||||
$response->send();
|
||||
$http->end($response);
|
||||
?>
|
||||
Loading…
Reference in New Issue