16 KiB
VR票务插件 — 踩坑经验文档
本文档源自 2026-04-16 一整夜的重构调试,汇集了所有关键教训。 任何接手本项目的 agent,请先阅读本文档。
原始日志:
refactoring_log_vrticket_2026.md(4644行) 提炼源:refactoring_learnings.md
🔴 P0 — 致命陷阱(必读)
1. public/footer 缺失 → 无限加载(最反直觉)
现象:列表页秒开,点击"新建/编辑"后页面永远转圈。
假线索:后端死循环、数据库慢查询、unpkg.com CDN 阻断。
真实根因:save.html 漏掉了 {{:ModuleInclude('public/footer')}}。AmazeUI 后台在页面跳转时显示全屏 Loading Spinner,关闭动画的 JS 逻辑在 footer 的库文件里。没有 footer → Spinner 永不消失。
<!-- ❌ 错误:漏 footer -->
{{:ModuleInclude('public/header')}}
<div>页面内容</div>
<!-- ✅ 正确:必须成对 -->
{{:ModuleInclude('public/header')}}
<div>页面内容</div>
{{:ModuleInclude('public/footer')}}
教训:无限加载 ≠ 后端死循环。AmazeUI Loading Spinner 遮罩是更常见的原因,优先检查 header/footer 是否完整。
2. Vue 3 [[ ]] 插值禁止用于 <textarea>
现象:Base64 大字符串场景下页面完全无响应(浏览器卡死)。
根因:<textarea>[[ compiledJsonRaw ]]</textarea> 使用双花括号插值绑定 Text Node,Vue 3 在大数据动态赋值时触发虚拟 DOM 补丁机制无限死循环。
<!-- ❌ 错误 -->
<textarea>[[ compiledJsonRaw ]]</textarea>
<!-- ✅ 正确:用隐藏 input -->
<input type="hidden" name="seat_map_raw" :value="compiledJsonRaw" />
教训:Vue 3 插值语法 [[ ]] 只用于文本节点,禁止用于 <textarea> 的 value 属性。
3. 字段名不能猜,必须查源码
现象:is_delete_time 报错,第一轮凭经验改字段名后仍然报错。
根因:GoodsCategory 表根本没有软删除字段(ShopXO 的软删除用的是 is_enable 而非 is_delete_time)。
// ❌ 错误:凭经验猜字段名
if (!empty($category['is_delete_time']))
// ✅ 正确:查 GoodsCategoryService.php 源码确认字段
if (!empty($category['is_enable']))
教训:ShopXO 部分表没有软删除字段。改字段名之前必须查对应 Service 层源码或实际表结构。
🟡 P1 — 严重问题
4. 插件视图路径:../../../plugins/插件名/view/{group}/controller/action
ShopXO 插件控制器继承 app\admin\controller\Common 后,模板引擎默认去找 app/admin/view/default/ 而非插件目录。
// ❌ 错误:引擎截断路径
return MyView('venue/list');
// ❌ 错误:路径片段顺序颠倒(admin/view/ vs view/admin/)
return MyView('../../../plugins/vr_ticket/admin/view/seat_template/list');
// ↑ admin/view/ 是错的
// 正确:view/admin/ ↓
return MyView('../../../plugins/vr_ticket/view/admin/seat_template/list');
ShopXO 插件视图目录结构(必须严格遵守):
app/plugins/vr_ticket/
├── admin/ ← 控制器目录(PHP 类),不是视图目录!
│ └── Admin.php
├── view/ ← 视图文件根目录
│ ├── admin/ ← 后台视图(group=admin)
│ │ ├── seat_template/list.html
│ │ ├── seat_template/save.html
│ │ ├── ticket/list.html
│ │ ├── verifier/list.html
│ │ └── verification/list.html
│ ├── goods/ ← C端商品页视图
│ └── venue/ ← C端场馆视图
完整路径公式:../../../plugins/vr_ticket/view/{group}/{controller}/{action}
| 场景 | group | 示例路径 |
|---|---|---|
| 后台管理 | admin |
../../../plugins/vr_ticket/view/admin/seat_template/list |
| C端前台 | goods |
../../../plugins/vr_ticket/view/goods/ticket_detail |
| C端场馆 | venue |
../../../plugins/vr_ticket/view/venue/list |
教训:插件视图放在插件根目录的 view/ 下(不是 admin/view/),路径公式 ../../../plugins/插件名/view/{group}/controller/action 中的 view/{group}/ 顺序不能颠倒。
5. Hook.php 返回值必须完整
ShopXO 的菜单渲染引擎要求 Hook 返回数组包含 id、url、name、is_show 完整字段。缺失任何一项都会导致侧边栏报错(Undefined array key url 等)。
// ❌ 错误:缺少字段
return [
'title' => '场馆管理',
'control' => 'Venue',
'action' => 'list',
];
// ✅ 正确:完整字段
return [
'id' => 'venue-list',
'name' => '场馆管理',
'url' => MyUrl('vr_ticket/admin/venue-list'),
'is_show' => 1,
'control' => 'Venue',
'action' => 'list',
];
6. CDN 国内阻断:unpkg.com 不可用
现象:Vue 3 编辑器完全失效,控制台有 [Intervention] Slow network is detected。
根因:unpkg.com 在中国大陆常发生静默阻断挂起。
<!-- ❌ 错误:国内阻断 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<!-- ✅ 正确 -->
<script src="https://cdn.staticfile.net/vue/3.x.x/vue.global.prod.js"></script>
教训:国内项目 CDN 必须用 cdn.staticfile.net 或 cdn.bootcdn.net,禁止 unpkg.com/cdnjs.cloudflare.com。
更优方案:优先使用 ShopXO 源码自带的本地文件(见第 18 条)。
7. PHP 注释块污染:未闭合 /* 导致语法错误
现象:整个 Admin 控制器报 syntax error, unexpected token "public"。
根因:调试期间遗留的未闭合 /* 注释块吞噬了后面的方法定义。
/* 这是调试代码...
// 忘记闭合,public 关键字被吞掉
public function index() { ... }
教训:隔离测试时清理调试代码;用 php -l 做语法检查。
8. ShopXO 路由 → PluginsAdminUrl() 而非硬编码
// ❌ 错误:硬编码 URL
$url = '/adminwatekc.php?s=Plugins/VrTicket/Admin/index';
// ✅ 正确
$url = PluginsAdminUrl('vr_ticket', 'admin', 'index');
9. Admin.php initialize() 每次请求执行 SHOW TABLES
现象:save.html 等页面加载极其缓慢。
根因:Admin.php 的 initialize() 构造中每次请求都在执行完整的 SHOW TABLES 和重建引用脚本,巨大 I/O 负担。
解法:引入基于时间跨度的缓存锁(Cache Lock),将高昂的表检查操作降低至 1 小时 1 次。
protected function initialize()
{
parent::initialize();
$lockKey = 'vr_ticket_init_lock';
$cache = cache($lockKey);
if ($cache === false) {
// 执行表检查和初始化
$this->checkTables();
cache($lockKey, 1, 3600); // 锁1小时
}
}
10. ThinkPHP 6 分页:->render() 而非 ->toArray() 后取 page
// ❌ 错误
$list = $model->paginate(10)->toArray();
return $list['page']; // undefined
// ✅ 正确
$list = $model->paginate(10);
return $list->render();
🟢 P2 — 重要经验
11. ShopXO 插件目录结构(正确模式)
vr_ticket/
├── Admin.php ← 根目录,继承 think\Controller(不是 admin/controller/)
├── Hook.php ← 根目录
├── config.json
├── service/
│ └── BaseService.php
└── view/ ← 不是 admin/view/,是根目录 view/
└── venue/
├── list.html
└── save.html
教训:参考 freightfee/answers 插件结构,不要用 admin/controller/ 子目录模式(会导致 strtolower+ucfirst 类名不匹配)。
12. JSON 传给 JS:用 <script type="text/json"> + textContent
<!-- ❌ 错误:模板插值,含特殊字符时 JS 解析失败 -->
<script>var data = {{$seat_map | json_encode | raw}};</script>
<!-- ✅ 正确:隔绝特殊字符 -->
<script type="text/json" id="seat-map-data">{{$seat_map | json_encode | raw}}</script>
<script>var data = JSON.parse(document.getElementById('seat-map-data').textContent);</script>
13. AmazeUI 必用类名速查
| 功能 | AmazeUI 类 |
|---|---|
| 表格 | am-table am-table-striped am-table-hover am-text-middle |
| 按钮 | am-btn am-btn-default / -primary / -danger + am-btn-xs am-radius |
| 表单验证 | class="am-form form-validation"(没有这个 class 不走 AJAX) |
| 字段校验提示 | data-validation-message 属性 |
| 徽章 | am-badge am-badge-success / -danger |
| 搜索栏 | am-input-group am-input-group-sm am-fl so |
| 分页 | {{$page|raw}} |
| 图标 | am-icon-plus / am-icon-edit / am-icon-trash-o |
14. URL 截断:Base64 编码兜底
ShopXO 框架内部有多层正则扫描"净化"资源路径,CDN URL 会被截断。
// 前端:提交前 Base64 编码
formData.seat_map = btoa(unescape(encodeURIComponent(jsonString)));
// 后端:解码还原
$seatMap = json_decode(base64_decode($_POST['seat_map']), true);
15. 搜索字段 = 列表主标题(字段一致性原则)
- 搜索条件固定为数据库可索引字段(
name列) - 列表展示为三行式层级:
大字简名→(完整场馆名称)→📍 地址 - JSON 内字段只做展示,不做搜索条件
-- ✅ 好:直接索引字段检索
WHERE name LIKE '%keyword%'
-- ❌ 坏:JSON 字段模糊查询,效率低
WHERE JSON_EXTRACT(venue_data, '$.full_name') LIKE '%keyword%'
16. char 匹配需 toUpperCase() 归一化
座位标识字符比较时,务必归一化大小写:
// ✅ 正确
const char = seatChar.toUpperCase();
const zone = zones.find(z => z.char.toUpperCase() === char);
// ❌ 错误:大小写不一致导致匹配失败
const zone = zones.find(z => z.char === seatChar);
17. ShopXO 插件静态文件双目录陷阱 → 详见 DEBUG_STATIC_FILE_SYNC.md
⚠️ 高危:插件 JS/CSS 文件在
app/(PHP runtime)和public/(Nginx webroot)各有一份副本。修改后必须同步两边并验证 MD5。详情见专项文档。
🟢 P2 — 重要经验
18. 插件模板静态文件引用:$public_host 最佳实践
背景:ticket_wallet.html 的 JsBarcode 条形码不显示,原因是 cdn.jsdelivr.net 在大陆阻断。
案例:票夹页面用 <script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.0/..."> → JsBarcode 加载失败 → typeof JsBarcode === 'undefined' → 条形码静默不渲染。
ShopXO 源码已自带 JsBarcode v3.11.5:
public/static/common/lib/JsBarcode/JsBarcode.all.min.js
根本原因一(CDN 阻断):jsdelivr.net 在大陆不可用。应优先使用 ShopXO 自带库或国内 CDN(cdn.staticfile.net)。
根本原因二($public_host 不可用):ShopXO 官方模板用 {{$public_host}} 引用静态文件,但插件控制器不继承 index/Common.php,$public_host 不会自动赋值。
// ❌ 错误:插件控制器不继承 Common,模板里 $public_host 是空的
// 模板中用 {{$public_host}}static/... → 变成 /static/...(丢域名)
// ✅ 正确:在控制器显式传递 public_host
class Index
{
public function wallet()
{
return MyView('../../../plugins/vr_ticket/view/goods/ticket_wallet', [
'user' => $user,
'public_host' => \think\facade\Config::get('shopxo.host_url'),
]);
}
}
// 模板中用 {{$public_host}}static/...
静态文件引用优先级:
- ShopXO 自带文件(如 JsBarcode):直接引用
{{$public_host}}static/common/lib/...,无需下载 - 国内 CDN:
cdn.staticfile.net/cdn.bootcdn.net(ShopXO 无自带时) - 国际 CDN:
unpkg.com/jsdelivr.net— 禁止在国内项目使用
教训:引用 JS/CSS 前先查 ShopXO 源码是否已自带(public/static/common/lib/),优先使用本地文件。
规范写法(插件模板):
- 控制器:
MyView('path', ['public_host' => \think\facade\Config::get('shopxo.host_url'), ...]) - 模板:
{{$public_host}}static/...
⚠️ 高危:插件 JS/CSS 文件在
app/(PHP runtime)和public/(Nginx webroot)各有一份副本。修改后必须同步两边并验证 MD5。详情见专项文档。
19. AdminSidebarInit 子菜单:items(复数)不是 item(单数)
症状:sidebar 显示"VR票务"顶级菜单,但子菜单项(场馆配置/座位模板/电子票等)全部丢失,控制台无报错。
根因:Hook.php 的 AdminSidebarInit 里,子菜单数组的 key 写成 item:
// ❌ 错误:ShopXO 渲染器不认识 `item`
$params[] = [
'id' => 'plugins-vr_ticket',
'name' => 'VR票务',
'item' => [ // ← 单数,ShopXO sidebar 不认
['name' => '场馆配置', 'url' => ..., 'is_show' => 1],
['name' => '座位模板', 'url' => ..., 'is_show' => 1],
]
];
ShopXO 后台 sidebar 模板(app/admin/view/default/public/menu.html)渲染逻辑:
{{foreach $left_menu as $v}}
{{if empty($v['items'])}} <!-- 无子菜单,渲染为顶级单项 -->
{{else /}} <!-- 有子菜单,渲染为可折叠父项 -->
<ul class="admin-sidebar-sub">
{{foreach $v.items as $vs}} ... {{/foreach}}
</ul>
{{/if}}
{{/foreach}}
ShopXO 渲染器检查的是 items(复数),不是 item(单数)。item 被当作普通属性忽略,所有子菜单项静默丢失。
修复:
// ✅ 正确:`items` 复数
$params[] = [
'id' => 'plugins-vr_ticket',
'name' => 'VR票务',
'items' => [ // ← 复数,ShopXO sidebar 渲染器认识
['name' => '场馆配置', 'url' => ..., 'is_show' => 1],
['name' => '座位模板', 'url' => ..., 'is_show' => 1],
]
];
调试方法:在浏览器控制台执行:
// 提取 sidebar 中所有菜单项 URL
const items = document.querySelectorAll('a.menu-item');
const vr = [];
items.forEach(a => {
const t = a.textContent.trim();
if(t.includes('VR')||t.includes('票务')||t.includes('场馆')||t.includes('座位'))
vr.push(a.href + ' => ' + t);
});
console.log(vr.join('\n'));
对比 Hook.php 中注册的所有 URL,即可发现哪些子菜单项未出现在 DOM 中。
教训:ShopXO 大量使用复数形式约定(items、menus、params),遇到"数据明明传了但不渲染"的问题时,优先检查属性名的单复数是否与渲染器约定一致。
📌 开发前检查清单
接手本插件时,逐项确认以下内容:
save.html有完整的{{:ModuleInclude('public/header')}}和{{:ModuleInclude('public/footer')}}- Vue 3 的
<textarea>没有使用[[ ]]插值绑定 value - 引用 JS/CSS 前先查 ShopXO 是否已自带(
public/static/common/lib/),优先本地文件而非 CDN - 插件模板使用
$public_host时,控制器已显式传递(不依赖框架自动赋值) - Hook.php 返回数组包含
id、url、name、is_show - Hook.php
AdminSidebarInit子菜单数组的 key 是items(复数),不是item(单数) - Admin.php 有缓存锁机制保护
initialize()免于每次请求检查表 - 改字段名之前查过 Service 层源码或实际表结构
- 插件视图路径使用
../../../plugins/vr_ticket/view/...前缀 php -l语法检查通过后再提交