298 lines
9.1 KiB
Markdown
298 lines
9.1 KiB
Markdown
# 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 永不消失。
|
||
|
||
```html
|
||
<!-- ❌ 错误:漏 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 补丁机制无限死循环。
|
||
|
||
```html
|
||
<!-- ❌ 错误 -->
|
||
<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`)。
|
||
|
||
```php
|
||
// ❌ 错误:凭经验猜字段名
|
||
if (!empty($category['is_delete_time']))
|
||
|
||
// ✅ 正确:查 GoodsCategoryService.php 源码确认字段
|
||
if (!empty($category['is_enable']))
|
||
```
|
||
|
||
**教训**:ShopXO 部分表没有软删除字段。改字段名之前必须查对应 Service 层源码或实际表结构。
|
||
|
||
---
|
||
|
||
## 🟡 P1 — 严重问题
|
||
|
||
### 4. 插件视图路径:必须用 `../../../plugins/插件名/view/...`
|
||
|
||
ShopXO 插件控制器继承 `app\admin\controller\Common` 后,模板引擎默认去找 `app/admin/view/default/` 而非插件目录。
|
||
|
||
```php
|
||
// ❌ 错误:引擎截断路径
|
||
return MyView('venue/list');
|
||
|
||
// ✅ 正确:跨模块绝对路径
|
||
return MyView('../../../plugins/vr_ticket/view/venue/list');
|
||
```
|
||
|
||
**教训**:插件视图必须放在插件根目录的 `view/` 下(不是 `admin/view/`),且调用时加 `../../../plugins/插件名/view/...` 前缀。
|
||
|
||
---
|
||
|
||
### 5. Hook.php 返回值必须完整
|
||
|
||
ShopXO 的菜单渲染引擎要求 Hook 返回数组包含 `id`、`url`、`name`、`is_show` 完整字段。缺失任何一项都会导致侧边栏报错(`Undefined array key url` 等)。
|
||
|
||
```php
|
||
// ❌ 错误:缺少字段
|
||
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` 在中国大陆常发生静默阻断挂起。
|
||
|
||
```html
|
||
<!-- ❌ 错误:国内阻断 -->
|
||
<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`。
|
||
|
||
---
|
||
|
||
### 7. PHP 注释块污染:未闭合 `/*` 导致语法错误
|
||
|
||
**现象**:整个 Admin 控制器报 `syntax error, unexpected token "public"`。
|
||
**根因**:调试期间遗留的未闭合 `/*` 注释块吞噬了后面的方法定义。
|
||
|
||
```php
|
||
/* 这是调试代码...
|
||
// 忘记闭合,public 关键字被吞掉
|
||
public function index() { ... }
|
||
```
|
||
|
||
**教训**:隔离测试时清理调试代码;用 `php -l` 做语法检查。
|
||
|
||
---
|
||
|
||
### 8. ShopXO 路由 → `PluginsAdminUrl()` 而非硬编码
|
||
|
||
```php
|
||
// ❌ 错误:硬编码 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 次。
|
||
|
||
```php
|
||
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`
|
||
|
||
```php
|
||
// ❌ 错误
|
||
$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`
|
||
|
||
```html
|
||
<!-- ❌ 错误:模板插值,含特殊字符时 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 会被截断。
|
||
|
||
```javascript
|
||
// 前端:提交前 Base64 编码
|
||
formData.seat_map = btoa(unescape(encodeURIComponent(jsonString)));
|
||
|
||
// 后端:解码还原
|
||
$seatMap = json_decode(base64_decode($_POST['seat_map']), true);
|
||
```
|
||
|
||
---
|
||
|
||
### 15. 搜索字段 = 列表主标题(字段一致性原则)
|
||
|
||
- 搜索条件固定为数据库可索引字段(`name` 列)
|
||
- 列表展示为三行式层级:`大字简名` → `(完整场馆名称)` → `📍 地址`
|
||
- JSON 内字段只做展示,不做搜索条件
|
||
|
||
```sql
|
||
-- ✅ 好:直接索引字段检索
|
||
WHERE name LIKE '%keyword%'
|
||
|
||
-- ❌ 坏:JSON 字段模糊查询,效率低
|
||
WHERE JSON_EXTRACT(venue_data, '$.full_name') LIKE '%keyword%'
|
||
```
|
||
|
||
---
|
||
|
||
### 16. char 匹配需 `toUpperCase()` 归一化
|
||
|
||
座位标识字符比较时,务必归一化大小写:
|
||
|
||
```javascript
|
||
// ✅ 正确
|
||
const char = seatChar.toUpperCase();
|
||
const zone = zones.find(z => z.char.toUpperCase() === char);
|
||
|
||
// ❌ 错误:大小写不一致导致匹配失败
|
||
const zone = zones.find(z => z.char === seatChar);
|
||
```
|
||
|
||
---
|
||
|
||
## 📌 开发前检查清单
|
||
|
||
接手本插件时,逐项确认以下内容:
|
||
|
||
- [ ] `save.html` 有完整的 `{{:ModuleInclude('public/header')}}` 和 `{{:ModuleInclude('public/footer')}}`
|
||
- [ ] Vue 3 的 `<textarea>` 没有使用 `[[ ]]` 插值绑定 value
|
||
- [ ] Vue CDN 使用 `cdn.staticfile.net` 而非 `unpkg.com`
|
||
- [ ] Hook.php 返回数组包含 `id`、`url`、`name`、`is_show`
|
||
- [ ] Admin.php 有缓存锁机制保护 `initialize()` 免于每次请求检查表
|
||
- [ ] 改字段名之前查过 Service 层源码或实际表结构
|
||
- [ ] 插件视图路径使用 `../../../plugins/vr_ticket/view/...` 前缀
|
||
- [ ] `php -l` 语法检查通过后再提交
|