# vr_ticket 插件重构经验总结

> 来源：原始对话日志 `refactoring_log_vrticket_2026.md` 分块提炼
> 适用版本：vr_ticket v1.x（ShopXO 插件）

---

+++++ 原始日志第一块（行1-980）

### 轮次1：ShopXO 插件架构理解 & 基础搭建
- **核心问题**：对 ShopXO 插件目录结构不熟悉，视图路径和 URL 生成方式不明
- **解决方案**：
  - `PluginsAdminUrl('vr_ticket', 'admin', 'action')` 生成后台 URL
  - `{{:ModuleInclude('public/header')}}` 加载全局头尾
  - 视图放在 `admin/view/{module}/list.html`
- **关键教训**：
  - ShopXO 插件后台必须遵循 `public/header` + `public/footer` 包含模式，否则页面无样式
  - 路由格式：`{插件名}_{控制器}_{动作}` → URL 映射到 `admin/{plugin}/admin/{action}`

---

+++++ 原始日志第二块（行981-2272）

### 轮次2：LayUI → AmazeUI 全局前端架构重构

- **核心问题**：所有视图模板使用 LayUI 框架编写，但 ShopXO 整套后台基于 AmazeUI，导致页面无样式、无表单验证、布局完全失效

- **解决方案**：批量将 9 个视图模板从 LayUI 迁移到 ShopXO 原生 AmazeUI：

  **布局结构（所有模板统一）：**
  ```
  {{:ModuleInclude('public/header')}}
  <div class="content-right">
      <div class="content">
          [页面内容]
      </div>
  </div>
  {{:ModuleInclude('public/footer')}}
  ```

  **搜索表单**：`request-type="form"`，GET 提交到当前 action
  **保存表单**：`request-type="ajax-url" request-value="{跳转URL}"`
  **删除按钮**：`<button class="submit-delete" data-url="..." data-id="...">`
  **分页**：`{{$page|raw}}`
  **时间格式化**：`{{:date('Y-m-d H:i:s', $var)}}`

- **关键教训**：
  - **AmazeUI 必须用类名**：`am-table`/`am-table-striped`/`am-table-hover`、`am-btn`/`am-btn-default`/`am-btn-primary`/`am-btn-secondary`/`am-btn-danger`、`am-badge`/`am-badge-success`/`am-badge-danger`/`am-badge-primary`、`am-form`/`am-form-group`/`am-input-group`
  - **表单验证**：必须加 `class="am-form form-validation"`，否则无法触发 ShopXO 的 AJAX 提交
  - **字段校验提示**：`data-validation-message` 属性设置错误文案，`required` 标记必填
  - **单选/复选框**：`am-radio-inline` + `data-am-ucheck`
  - **加载状态**：`data-am-loading="{spinner:'circle-o-notch', loadingText:'...'}"`

---

### 轮次3：Vue CDN 国内阻断问题（venue/save.html）

- **核心问题**：`https://unpkg.com/vue@3/dist/vue.global.prod.js` 在中国大陆无法访问，导致场馆编辑页 Vue3 编辑器完全失效

- **解决方案**：更换 CDN 源
  ```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.staticfile.net` 或 `cdn.bootcdn.net`，禁止使用 `unpkg.com`/`cdnjs.cloudflare.com`
  - 涉及国外 CDN 的资源都需要做国内镜像替换

---

### 轮次4：venue/save.html Vue3 交互式编辑器

- **核心问题**：场馆编辑器需要复杂的座位可视化预览，包括分区颜色映射、座位预览、tooltip 等，纯 PHP 无法实现

- **解决方案**：ShopXO 混合模式 — PHP 渲染表单外壳 + Vue3 实现交互：
  - PHP 表单包含 `<input type="hidden">` 字段，`:value` 绑定 Vue computed 属性
  - Vue3 的 `computed` 属性（`zonesJson`、`seatMapRowsJson`）自动同步到隐藏字段
  - PHP 后端直接 `json_decode($_POST['zones'])` 获取数据

  **数据结构设计**：
  ```javascript
  // 分区
  zones = [{ char: 'A', name: 'VIP区', price: 899, color: '#e74c3c' }, ...]
  // 座位排布（每排字符串对应各区座位）
  seatMapRows = ['AAAAAA', 'BBBBBB', 'CCCCCC']
  // 场馆元信息
  venue = { name: '', address: '', image: '' }
  ```

  **座位预览核心逻辑**：
  - 遍历 `seatMapRows`，每个字符对应一个 zone 的 `char`
  - `getZoneColor(ch)` → 根据字符从 zones 映射取颜色
  - 座位 tooltip 显示：分区名 + 价格 + 座位号

  **Vue3 挂载注意点**：
  - `compilerOptions: { delimiters: ['[[', ']]'] }` 避免与 ShopXO 模板语法冲突
  - `v-cloak` + `[v-cloak] { display: none; }` 防止 Vue 未加载时闪烁
  - `mounted()` 中解析 PHP 注入的默认值

- **关键教训**：
  - ShopXO 插件的 Vue 混合模式：Vue 负责交互，PHP 负责数据持久化，通过隐藏字段桥接
  - 颜色预设面板需要实现 `applyPresetColor()` 自动填充空白分区
  - 座位行追加逻辑：用 `new Array(n+1).join(ch)` 生成重复字符字符串

---

### 轮次5：AmazeUI 组件速查（高频使用）

| 功能 | AmazeUI 类 / 写法 |
|------|------------------|
| 表格 | `am-table am-table-striped am-table-hover am-text-middle` |
| 按钮 | `am-btn am-btn-default` / `-primary` / `-secondary` / `-danger` + `am-btn-xs am-radius` |
| 徽章 | `am-badge am-badge-success` / `-danger` / `-primary` |
| 表单 | `am-form form-validation`，字段 `am-form-group`，输入框 `am-radius` |
| 搜索栏 | `am-input-group am-input-group-sm am-fl so` + `am-input-group-btn` |
| 单选/复选 | `am-radio-inline` + `data-am-ucheck` |
| 下拉选择 | `chosen-select` + `data-placeholder` |
| 分页 | `{{$page\|raw}}` |
| 图标 | `am-icon-plus` / `am-icon-edit` / `am-icon-trash-o` 等 |
| 面板 | `am-panel am-panel-default` + `am-panel-bd` |
| 布局 | `am-g`（行）, `am-u-sm-12 am-u-md-4`（列）, `am-fl`/`am-fr` |
| 加载动画 | `data-am-loading="{spinner:'circle-o-notch', loadingText:'...'}"` |

---

### 轮次6：各模块视图重构要点

**venue/list.html & venue/save.html**
- 场馆列表包含座位模板快捷入口：`PluginsAdminUrl('vr_ticket', 'admin', 'SeatTemplateSave', ['id'=>$v['id']])`
- 保存页使用 Vue3 编辑器（见轮次4）

**seat_template/list.html & save.html**
- 座位模板绑定分类：关联 `vr_category` 表的 `category_id`
- seat_map 字段存 JSON：格式 `{map: ["AAAAAA"], seats: {A: {price: 599, label: "VIP"}}}`

**ticket/list.html & detail.html**
- 导出 CSV：动态构建隐藏 form，POST 提交到 `TicketExport`
- 二维码预览：`UI.Modal()` 弹窗展示大图
- 核销操作：表单提交到 `TicketVerify`，`request-type="ajax-reload"` 自动刷新

**verifier/list.html & save.html**
- 用户关联后不可修改：编辑时 `disabled` + 额外 `hidden` 字段传值

**verification/list.html**
- 日期范围筛选：使用 `WdatePicker`（ShopXO 自带日期控件）
- 核销记录只读，无编辑/删除操作

---

### 核心经验总结

1. **ShopXO 插件视图 = AmazeUI 规范 + PHP 模板语法**，禁止混用其他 UI 框架
2. **Vue3 只用于复杂交互场景**（如场馆座位编辑器），普通 CRUD 用纯 AmazeUI+jQuery
3. **CDN 必须在 `.cn` 域名或国内可用源**，`unpkg.com` 不可用
4. **PHP ↔ Vue 数据桥接**：Vue computed → hidden input → PHP `$_POST`
5. **所有列表页统一模式**：搜索栏 → 操作按钮 → `am-table` → 分页
6. **ShopXO 表单验证依赖 `form-validation` class**，没有这个 class 表单不会走 AJAX

---

+++++ 原始日志末尾块（行3643-4644）

### 轮次N+1：座位刷新不及时 / 空行误报 / 数据库索引残留 / Hook失效
- 核心问题：座位预览和排布设计器只在输入时响应，删除或失焦不刷新；空行残留后无法保存；Hook菜单未自动出现
- 解决方案：
  - 排布文本框改用 `@input` 实时触发预览
  - 后端 VenueSave 加入 `array_filter` 自动剔除空白行
  - 提供 SQL：`ALTER TABLE vrt_vr_seat_templates DROP INDEX uk_category_id;`
  - Hook.php 补全顶级菜单项缺失的 `url` 和 `id` 字段
- 关键教训：
  - ThinkPHP 6 分页对象 → `paginate()` 返回对象，`->render()` 返回分页HTML字符串，不能链式 `.toArray()` 后访问 `page` 键
  - Hook菜单项必须有 `id`、`url`、`name`、`is_show` 完整字段，否则渲染引擎报 `undefined array key`

### 轮次N+2：添加/编辑场馆页面无限加载 / View路径不规范
- 核心问题：从 `admin/view/` 迁移视图文件后，添加/编辑场馆页面一直转圈；插件配置入口未显示
- 解决方案：
  - 将视图从 `admin/view/` 迁移至标准 `view/` 目录，MyView 调用改用 `../../../plugins/vr_ticket/view/venue/xxx` 绝对路径
  - 列表页添加"插件设置"按钮；在编辑页地址栏旁添加齿轮图标链接
  - Admin.php initialize() 自检逻辑加入 Cache 锁，降低每次请求的数据库 I/O
- 关键教训：
  - ShopXO 插件视图必须放在插件根目录的 `view/` 下（不是 `admin/view/`）
  - 表格被挤压到屏幕下方 = 搜索区域浮动未清除，需用 clearfix 解决

### 轮次N+3：模板文件路径导致 template not exists
- 核心问题：MyView('venue/list') 无法定位到插件 view 目录，报 `template not exists`
- 解决方案：Admin.php 中所有 MyView 调用统一改为 `MyView('../../../plugins/vr_ticket/view/venue/xxx')` 跨模块绝对路径
- 关键教训：
  - 插件控制器继承 `app\admin\controller\Common` 后，模板引擎默认去找 `app/admin/view/default/` 而非插件目录
  - 必须显式指定 `../../../plugins/插件名/view/...` 前缀引导跨模块路径解析

### 轮次N+4：添加场馆页面依然无限加载（真实根因）
- 核心问题：列表页秒开，但点击"新建/编辑"必定卡死；控制台出现 Slow network 和 Vue 开发版警告
- 根因定位：
  - 假线索：unpkg.com 被阻断 → 实为假象，主请求卡住导致后续资源全部 pending
  - 假线索：数据库自检频率 → 实为次要因素
  - **真实根因**：`save.html` 漏掉了 `{{:ModuleInclude('public/footer')}}`
  - ShopXO 后台基于 AmazeUI，页面跳转时显示全屏 Loading Spinner，关闭动画的 JS 信号存在于 `public/footer` 的库文件中；没有 footer 则 Spinner 永不消失
- 解决方案：补全 `{{:ModuleInclude('public/footer')}}`；同步检查 list.html 和 save.html 均有 footer
- 关键教训：**无限加载 ≠ 一定是后端死循环**，AmazeUI 的 Loading Spinner 遮罩也是常见原因，排查时优先检查 header/footer 是否完整

### 轮次N+5：Vue 3 textarea [[ ]] 插值导致无限渲染循环
- 核心问题：Footer 补全后仍偶发卡死（Base64 大字符串场景下更明显）
- 根因定位：Vue 3 中 `<textarea>[[ compiledJsonRaw ]]</textarea>` 使用双花括号插值绑定 Text Node，在大数据 Base64 字符串动态赋值时触发虚拟 DOM 补丁机制无限死循环，JS 主线程被锁死导致页面完全无响应
- 解决方案：将 `<textarea>` 改为隐藏 input：`<input type="hidden" name="seat_map_raw" :value="compiledJsonRaw" />`
- 关键教训：Vue 3 禁止用插值语法 `[[ ]]` 绑定 `<textarea>` 的内部文本，必须用 `:value` 或 `v-model`

### 轮次N+6：URL截断终极解决方案（Base64编码）
- 核心问题：即使绕过框架 input 过滤，ShopXO 内部仍有多层正则扫描自动"净化"资源路径，CDN URL 被截断
- 解决方案：前端提交前对场馆 JSON 进行 Base64 编码 → 后端解码还原 → 传输过程中URL只是一段加密字符，绕过所有过滤器
- 关键教训：ShopXO 框架对资源路径存在多层自动处理机制；Base64 是最稳健的兜底方案

### 轮次N+7：搜索逻辑与字段命名重构
- 核心问题：搜索基础名称但列表显示"详情展示名称"，导致操作割裂；搜索条件堆在一起；表格列错位
- 解决方案：
  - 搜索字段固定为 `name` 列（数据库可索引），不搜 JSON 内的详情展示名称
  - 列表场馆信息列改为三行式层级：
    1. **简短名称**（大字加粗，可索引）
    2. `（完整场馆名称）`（灰色小字，括号）
    3. `📍 地址`（灰色小字）
  - 表单字段命名：`基础名称` → `场馆名称(简短名称)`，`详情展示名称` → `完整场馆名称`
- 关键教训：
  - 搜索字段与列表主标题必须一一对应，数据库可索引字段优于 JSON 内字段
  - 表格列宽和对齐必须显式指定（居中/靠左），不能依赖浏览器默认行为

### 综合关键教训
- **ShopXO 插件视图**：必须放在 `plugins/插件名/view/`，使用 `../../../plugins/插件名/view/...` 路径前缀
- **后台页面必须包含 header + footer**：缺少 footer = Loading Spinner 永不消失
- **Vue 3 textarea 绑定**：禁止 `[[ ]]` 插值，必须用 `:value` 或 `v-model`
- **Hook菜单项**：必须包含 `id`、`url`、`name`、`is_show` 完整字段
- **URL截断**：Base64 编码提交是最稳健的绕过方案
- **搜索与显示一致性**：搜索字段 = 列表主标题，JSON字段只做展示
- **表格对齐**：列宽和 text-align 必须显式声明

+++++ 原始日志第四块（行2611-3642）

### 轮次1：7项用户需求评审与多放映室架构重构
- 核心问题：用户提出 7 项场馆编辑器体验需求，核心是支持"场馆→多放映室→独立分区/座位"的三级嵌套结构
- 解决方案：
  - 重构 seat_map JSON 结构：从旧的扁平 {map, seats, sections} 升级为 {venue: {...}, rooms: [{id, name, map, sections, seats}]} 嵌套结构
  - VenueSave() 后端方法改为接收前端 Vue 组装好的完整 JSON，移除了对旧版 map 字段的直接处理
  - SeatSkuService::BatchGenerate() 增加向下兼容逻辑：旧数据 map 字段自动包装为 rooms[0]
- 关键教训：
  - 数据结构变更需要同步修改前后端：后端解析层、前端组装层、存储层三者缺一不可
  - 新旧数据结构兼容要做好，否则旧数据无法读取

### 轮次2：Vue3 场馆编辑器全量重写
- 核心问题：需要在前端实现多放映室 Tab 切换、分区配置、文本排布设计器、实时座位预览等功能
- 解决方案：
  - 使用 Vue 3 CDN + createApp + Composition API
  - delimiters: ['[[', ']]'] 避免与 ShopXO 模板引擎 {{}} 冲突
  - 放映室 tabs → 当前放映室面板 → 分区表格 + 文本排布 textarea → 座位预览 的面板结构
  - compiledJsonRaw computed 负责在提交前将 Vue state 编译为完整 JSON，注入到隐藏表单字段
  - 高德地图暂时 stub（alert 提示 + 随机坐标预览）
- 关键教训：
  - ShopXO 插件视图使用 {{}} 模板语法，Vue 3 模板必须换用 [[ ]] 分隔符，否则冲突
  - Vue 的 computed 可直接 return 给模板，无需手动 watch
  - v-for + v-if 混用时需用 <template> 包装

### 轮次3：Vue 渲染页面 SyntaxError Bug（两次）
- 核心问题：用户报告"添加场馆"页面只剩"返回 添加场馆"，控制台报 SyntaxError: Invalid or unexpected token
- 第一次修复：
  - 原因：后端 seat_map JSON 通过模板直接插到 JS 模板字符串时，含换行/特殊字符导致 JS 解析失败
  - 修复方案：改用 <script type="text/json"> 隐藏块 + textContent DOM 提取，隔绝特殊字符
- 第二次修复（1305行）：
  - 原因：Python 生成 JS 代码时，字符串字面量 split('\\n') 和 join('\\n') 中出现了物理换行，自动生成器错误导致字符串被折行，JS 不允许在单引号字符串内直接换行
  - 修复方法：用 grep -n "'\\$" 定位行尾单引号，定位到被折断的字符串语句
- 关键教训：
  - ShopXO 插件页面传输 JSON 给 JS 时，用 <script type="text/json"> + textContent 比模板插值更健壮
  - 自动生成代码时注意 JS 字符串字面量不能被物理换行分割
  - 浏览器缓存可能导致修复后仍显示旧版本，强制刷新（Cmd+Shift+R）后才能验证

### 轮次4：分区配置与座位预览实时联动
- 核心问题：分区价格修改后座位预览颜色变化但价格 tooltip 未实时更新
- 解决方案：
  - getSeatTooltip(char, rowIdx, colIdx) 每次调用时从 currentRoom.sections 动态查找对应分区的当前价格
  - currentSeatRows 和 currentTotalSeats 均为 computed，依赖 currentRoom.value.map 变化时自动重新计算
  - 座位标识字符强制 toUpperCase()，保证 char 匹配不区分大小写
- 关键教训：
  - Vue 3 Composition API 中 computed 依赖链要清晰：tooltip 价格应直接从 sections 数组实时读取，而非某处缓存
  - char 匹配要考虑大小写归一化
