# 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/{group}/controller/action`

ShopXO 插件控制器继承 `app\admin\controller\Common` 后，模板引擎默认去找 `app/admin/view/default/` 而非插件目录。

```php
// ❌ 错误：引擎截断路径
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` 等）。

```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`。

**更优方案**：优先使用 ShopXO 源码自带的本地文件（见第 18 条）。

---

### 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);
```

---

### 17. ShopXO 插件静态文件双目录陷阱 → 详见 [DEBUG_STATIC_FILE_SYNC.md](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` 不会自动赋值。

```php
// ❌ 错误：插件控制器不继承 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/...
```

**静态文件引用优先级**：
1. **ShopXO 自带文件**（如 JsBarcode）：直接引用 `{{$public_host}}static/common/lib/...`，无需下载
2. **国内 CDN**：`cdn.staticfile.net` / `cdn.bootcdn.net`（ShopXO 无自带时）
3. **国际 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`：

```php
// ❌ 错误：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`）渲染逻辑：

```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` 被当作普通属性忽略，所有子菜单项静默丢失。

**修复**：

```php
// ✅ 正确：`items` 复数
$params[] = [
    'id'    => 'plugins-vr_ticket',
    'name'  => 'VR票务',
    'items' => [  // ← 复数，ShopXO sidebar 渲染器认识
        ['name' => '场馆配置', 'url' => ..., 'is_show' => 1],
        ['name' => '座位模板', 'url' => ..., 'is_show' => 1],
    ]
];
```

**调试方法**：在浏览器控制台执行：

```javascript
// 提取 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` 语法检查通过后再提交
