欢迎来到 Xiuno BBS

Xiuno BBS 重构记录贴(三)Bootstrap 5 UI+ Tabler Icons +JS

# Xiuno BBS 4.5+ Bootstrap 5 UI+ Tabler Icons +JS

本文档记录 Xiuno BBS 从旧版(Bootstrap 3 + jQuery + 多图标库)升级到新版(Bootstrap 5.3 + htmx + Alpine.js + Tabler Icons)的完整迁移路径,供开发者在升级插件、定制主题或排查兼容问题时参考。

---

## 目录

- [第一章:升级总览](#第一章升级总览)
- [第二章:Bootstrap 5 升级与适配](#第二章bootstrap-5-升级与适配)
- [第三章:htmx + Alpine.js 协作规范](#第三章htmx--alpinejs-协作规范)
- [第四章:图标库迁移](#第四章图标库迁移)
- [第五章:旧插件兼容与升级](#第五章旧插件兼容与升级)
- [第六章:数据流与状态管理](#第六章数据流与状态管理)
- [第七章:迁移检查清单与排错](#第七章迁移检查清单与排错)
- [第八章:关键文件路径速查](#第八章关键文件路径速查)

---

# 第一章:升级总览

## 1.1 技术栈版本清单

以下为当前系统所使用的前端库及其版本、路径和作用:

| 库名称 | 版本 | 路径 | 作用 |
|--------|------|------|------|
| Bootstrap | 5.3.7 | `view/vendor/bootstrap/css/bootstrap.min.css` + `view/vendor/bootstrap/js/bootstrap.bundle.min.js` | UI 框架,提供布局、组件、工具类 |
| htmx | 2.0.10 | `view/vendor/htmx/htmx.min.js` | 声明式 AJAX 交互,服务端渲染 HTML 片段 |
| idiomorph | — | `view/vendor/idiomorph/idiomorph-ext.min.js` | htmx 扩展,提供 morph DOM 交换算法 |
| htmx-ext-alpine-morph | — | `view/vendor/htmx-ext-alpine-morph/alpine-morph.js` | htmx 扩展,桥接 Alpine.js morph 与 htmx swap |
| Alpine.js Morph 插件 | 3.x | `view/vendor/alpinejs-morph/cdn.min.js` | Alpine.js 官方 morph 插件,支持 DOM 差异补丁 |
| Alpine.js | 3.x | `view/vendor/alpinejs/cdn.min.js` | 轻量前端响应式框架,管理 UI 状态 |
| Tabler Icons | 3.31.0 | `view/vendor/tabler-icons/tabler-icons.min.css` | 图标字体库,统一图标风格 |
| jQuery | 3.1.0 | `view/js/jquery-3.1.0.js` | 旧代码兼容保留,新代码禁止使用 |
| xiuno-modern.js | — | `view/js/xiuno-modern.js` | 原生 JS 兼容层,提供选择器、AJAX、DOM、toast 等 API |

> **说明**:idiomorph、htmx-ext-alpine-morph、Alpine.js Morph 插件均无内嵌版本号标注,以仓库最新发布版为准。Alpine.js 3.x 的具体小版本号在压缩文件中不可直接读取。

## 1.2 升级前后对比表

| 维度 | 旧版 | 新版 |
|------|------|------|
| **UI 框架** | Bootstrap 3.x | Bootstrap 5.3.7 |
| **图标库** | Font Awesome / Glyphicons 等多套 | Tabler Icons 3.31.0(统一) |
| **JS 框架** | jQuery 3.1.0(全局依赖) | Alpine.js 3.x(局部状态) + htmx 2.0.10(服务端交互) |
| **AJAX 方式** | `$.ajax()` / `$.post()` / `$.get()` | htmx 声明式属性(`hx-get` / `hx-post`)+ `XN.post()` 兼容层 |
| **表单提交** | jQuery 序列化 + AJAX | htmx `hx-post` 或原生 `fetch()` + `FormData` |
| **状态管理** | jQuery DOM 操作 / 全局变量 | Alpine.js `x-data` 局部状态 + `Alpine.data()` 复用组件 |
| **主题系统** | CSS 覆盖 / 无暗色模式 | `data-bs-theme` 暗色模式 + `data-theme` 自定义主题色 |
| **DOM 更新** | jQuery `.html()` / `.append()` | htmx swap + idiomorph morph + Alpine morph |
| **页面导航** | 全页刷新 | htmx `hx-boost` 增强导航,局部 swap |
| **组件 API** | jQuery 插件式(`$('#modal').modal('show')`) | 原生 `new bootstrap.Modal(el).show()` |

## 1.3 升级核心理念

### 渐进式迁移

本次升级采用渐进式策略,不要求一次性重写所有代码:

1. **旧代码可继续运行**:jQuery 3.1.0 保留加载,旧插件中使用 `$` 的代码无需立即修改
2. **新代码使用新栈**:新增页面、组件必须使用 htmx + Alpine.js + 原生 JS,禁止引入新的 jQuery 依赖
3. **兼容层桥接**:`xiuno-modern.js` 提供了 `XN.post()`、`XN.toast()` 等与旧 `xn.js` 对等的 API,方便逐步替换

### jQuery 保留兼容

```javascript
// 旧代码 — 仍然可用,不强制删除
$.post(url, data, function(code, msg) { ... });

// 新代码 — 使用兼容层
XN.post(url, data, function(code, msg) { ... });

// 新代码 — 使用 htmx 声明式
// <button hx-post="/api/action" hx-target="#result">提交</button>
```

### 新代码禁止 jQuery

所有新增的插件、页面、组件必须遵守以下规则:

- 使用 `XN.xxx()` 或原生 JS,不依赖 jQuery
- 服务端交互使用 htmx 属性或 `XN.post()` / `fetch()`
- UI 状态使用 Alpine.js `x-data`,禁止 `Alpine.store()` 和 `$store`
- DOM 操作使用原生 API(`document.querySelector`、`el.classList` 等)

---

# 第二章:Bootstrap 5 升级与适配

## 2.1 CSS 类名变更对照表

Bootstrap 5 对大量类名进行了重命名和调整,以下是迁移中常见的类名变更:

### 布局与网格

| 旧类名(Bootstrap 3) | 新类名(Bootstrap 5) | 说明 |
|------------------------|----------------------|------|
| `col-xs-*` | `col-*` | 超小断点不再需要 `xs` 后缀,直接使用 `col-1` ~ `col-12` |
| `col-sm-*` / `col-md-*` / `col-lg-*` | 保持不变 | 响应式断点类名未变 |
| `col-xs-offset-*` | `offset-*` | 偏移类名同步简化 |
| `row`(默认有负 margin) | `row` + `g-*` / `gx-*` / `gy-*` | gutter 改用 `g-*` 系列控制,不再使用负 margin hack |

### 排版与内容

| 旧类名(Bootstrap 3) | 新类名(Bootstrap 5) | 说明 |
|------------------------|----------------------|------|
| `pull-left` | `float-start` | 浮动方向改用逻辑属性命名 |
| `pull-right` | `float-end` | 浮动方向改用逻辑属性命名 |
| `text-left` | `text-start` | 文本对齐改用逻辑属性命名 |
| `text-right` | `text-end` | 文本对齐改用逻辑属性命名 |
| `text-justify` | 移除 | 不再提供,需自定义 CSS |

### 组件

| 旧类名(Bootstrap 3) | 新类名(Bootstrap 5) | 说明 |
|------------------------|----------------------|------|
| `label` | `badge` | 标签组件更名为徽章 |
| `panel` / `panel-heading` / `panel-body` / `panel-footer` | `card` / `card-header` / `card-body` / `card-footer` | 面板组件被卡片组件替代 |
| `well` | `card` + 自定义内边距 | Well 组件移除,用 card 替代 |
| `thumbnail` | `card` + `img-fluid` | 缩略图组件移除 |
| `pager` | `pagination` | 分页器合并到分页组件 |
| `nav-justified` | `nav-fill` / `nav-justified` | `nav-fill` 等宽分配,`nav-justified` 保留 |
| `carousel-item` 内 `item` | `carousel-item` | 轮播项不再需要额外的 `item` 类 |
| `list-inline` 子项 `list-inline-item` | 保持不变 | 无变化 |

### 图片与媒体

| 旧类名(Bootstrap 3) | 新类名(Bootstrap 5) | 说明 |
|------------------------|----------------------|------|
| `img-responsive` | `img-fluid` | 响应式图片更名 |
| `img-circle` | `rounded-circle` | 圆形图片改用圆角工具类 |
| `img-rounded` | `rounded` | 圆角图片改用圆角工具类 |
| `img-thumbnail` | `img-thumbnail` | 保持不变 |
| `media` / `media-body` | Flex 工具类(`d-flex` + `flex-fill`) | 媒体对象组件移除,改用 flex 布局 |

### 显示与隐藏

| 旧类名(Bootstrap 3) | 新类名(Bootstrap 5) | 说明 |
|------------------------|----------------------|------|
| `hidden-xs` | `d-none d-sm-block` | 隐藏/显示改用 `d-*` 工具类组合 |
| `hidden-sm` | `d-sm-none d-md-block` | 按断点组合 |
| `hidden-md` | `d-md-none d-lg-block` | 按断点组合 |
| `hidden-lg` | `d-lg-none d-xl-block` | 按断点组合 |
| `visible-xs` | `d-block d-sm-none` | 反向组合 |
| `visible-sm` | `d-none d-sm-block d-md-none` | 反向组合 |
| `visible-md` | `d-none d-md-block d-lg-none` | 反向组合 |
| `visible-lg` | `d-none d-lg-block d-xl-none` | 反向组合 |
| `hidden` | `d-none` | 通用隐藏 |
| `show` | `d-block`(或移除 `d-none`) | 通用显示 |
| `invisible` | `invisible` | 保持不变(仅影响可见性,不脱离布局) |

### 表单

| 旧类名(Bootstrap 3) | 新类名(Bootstrap 5) | 说明 |
|------------------------|----------------------|------|
| `form-group` | `mb-3` | 表单组移除,改用间距工具类 |
| `form-control-lg` | `form-control-lg` | 保持不变 |
| `form-control-sm` | `form-control-sm` | 保持不变 |
| `custom-select` | `form-select` | 自定义选择框更名 |
| `custom-file` | `form-control`(type="file") | 自定义文件输入移除 |
| `custom-control custom-checkbox` | `form-check` + `form-check-input` | 自定义控件改为标准 form-check |
| `custom-switch` | `form-check form-switch` | 开关控件更名 |

### 其他工具类

| 旧类名(Bootstrap 3) | 新类名(Bootstrap 5) | 说明 |
|------------------------|----------------------|------|
| `ml-*` | `ms-*` | margin-left 改用逻辑属性 start |
| `mr-*` | `me-*` | margin-right 改用逻辑属性 end |
| `pl-*` | `ps-*` | padding-left 改用逻辑属性 start |
| `pr-*` | `pe-*` | padding-right 改用逻辑属性 end |
| `close` | `btn-close` | 关闭按钮更名,结构变化 |
| `embed-responsive` | `ratio` | 嵌入式响应改为比例工具类 |
| `border-*`(单边) | `border-top` / `border-end` / `border-bottom` / `border-start` | 边框方向改用逻辑属性 |

## 2.2 组件 API 变化

### Modal(模态框)

Bootstrap 5 移除了 jQuery 插件式 API,改用原生 JavaScript 构造函数:

```javascript
// 旧版(Bootstrap 3 + jQuery)
$('#myModal').modal('show');
$('#myModal').modal('hide');
$('#myModal').on('hidden.bs.modal', function() { ... });

// 新版(Bootstrap 5)
var modalEl = document.getElementById('myModal');
var modal = new bootstrap.Modal(modalEl);
modal.show();
modal.hide();

// 获取已有实例
var instance = bootstrap.Modal.getInstance(modalEl);
if (instance) instance.hide();

// 事件监听
modalEl.addEventListener('hidden.bs.modal', function() { ... });
```

### Tooltip(工具提示)

```javascript
// 旧版
$('#element').tooltip({ title: '提示文本' });
$('#element').tooltip('show');

// 新版
var tooltipEl = document.getElementById('element');
var tooltip = new bootstrap.Tooltip(tooltipEl, { title: '提示文本' });
tooltip.show();
```

### Popover(弹出框)

```javascript
// 旧版
$('#element').popover({ content: '内容', trigger: 'hover' });

// 新版
var popoverEl = document.getElementById('element');
var popover = new bootstrap.Popover(popoverEl, { content: '内容', trigger: 'hover' });
popover.show();
```

### data 属性前缀变更

Bootstrap 5 的所有 data 属性统一添加 `bs` 前缀,避免与其他库冲突:

| 旧属性 | 新属性 | 说明 |
|--------|--------|------|
| `data-toggle` | `data-bs-toggle` | 触发器 |
| `data-dismiss` | `data-bs-dismiss` | 关闭 |
| `data-target` | `data-bs-target` | 目标元素 |
| `data-spy` | `data-bs-spy` | 滚动监听 |
| `data-ride` | `data-bs-ride` | 自动播放 |
| `data-slide` | `data-bs-slide` | 轮播方向 |
| `data-slide-to` | `data-bs-slide-to` | 轮播跳转 |
| `data-offset` | `data-bs-offset` | 偏移量 |
| `data-placement` | `data-bs-placement` | 定位方向 |
| `data-delay` | `data-bs-delay` | 延迟 |

实际项目中的使用示例(来自 `footer.inc.htm`):

```html
<!-- 关闭按钮使用 data-bs-dismiss -->
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
```

## 2.3 主题系统

### 暗色模式(data-bs-theme)

Bootstrap 5.3+ 原生支持暗色模式,通过 `data-bs-theme` 属性控制:

```html
<!-- 亮色模式(默认) -->
<html data-bs-theme="light">

<!-- 暗色模式 -->
<html data-bs-theme="dark">
```

项目中的实现方式(来自 `header.inc.htm`):

```javascript
// 根据用户偏好或系统设置初始化主题
(function(){
    var theme = localStorage.getItem('theme') ||
        (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
    document.documentElement.setAttribute('data-bs-theme', theme);
})();
```

暗色模式下的 CSS 变量覆盖(来自 `bootstrap-bbs.css`):

```css
[data-bs-theme="dark"] {
    --bbs-main: #e0e0e0;
    --bbs-sub: #888;
    --bbs-contrast: #1b1b1b;
    --bbs-card-bg: #1e1e1e;
    --bbs-body-bg: #1b1b1b;
    --bbs-input-bg: #2a2a2a;
    --bbs-dividing-line: #2a2a2a;
    --bbs-card-border: #2a2a2a;
    /* ... 更多暗色变量 */
}
```

### 自定义主题色(data-theme)

项目在 Bootstrap 原生主题基础上,通过 `data-theme` 属性实现了多色主题切换:

```javascript
// 初始化主题色
var themeColor = localStorage.getItem('theme-color') || 'blue';
document.documentElement.setAttribute('data-theme', themeColor);
```

当前支持的主题色(来自 `bootstrap-bbs.css`):

| 主题名 | data-theme 值 | 主色(--bbs-brand) | 背景色(--bbs-body-bg) |
|--------|---------------|---------------------|------------------------|
| 蓝色(默认) | `blue` | `#2563eb` | `#f1f2f5` |
| 绿色 | `green` | `#16a34a` | `#f4f7f5` |
| 紫色 | `purple` | `#9333ea` | `#f9f8fa` |
| 红色 | `red` | `#dc2626` | `#fbf6f5` |
| 橙色 | `orange` | `#ea580c` | `#fbf6f5` |
| 自定义 | `custom` | 用户自定义 | `#f8f8fa` |

每个主题色通过 CSS 变量覆盖实现,包含完整的 50~950 色阶:

```css
[data-theme="blue"] {
    --bbs-brand: #2563eb;
    --bbs-brand-hover: #1d4ed8;
    --bbs-brand-light: #60a5fa;
    --bbs-brand-50: #eff6ff;
    --bbs-brand-100: #dbeafe;
    /* ... 50~950 完整色阶 */
    --bbs-hover: rgba(37, 99, 235, .08);
    --bbs-body-bg: #f1f2f5;
    --bs-primary-rgb: 37, 99, 235;
}
```

## 2.4 自定义 CSS 文件关键覆盖项

`view/css/bootstrap-bbs.css` 是项目的核心自定义样式文件,通过 CSS 变量覆盖和组件样式扩展实现品牌化定制。以下为关键覆盖项:

### CSS 变量体系

文件在 `:root` 中定义了完整的 BBS 变量体系,覆盖 Bootstrap 默认值:

```css
:root {
    /* 品牌色 */
    --bbs-brand: #2563eb;
    --bbs-brand-hover: #1d4ed8;

    /* 文字色 */
    --bbs-main: #333;          /* 主文字 */
    --bbs-sub: #939393;        /* 辅助文字 */
    --bbs-contrast: #fff;      /* 对比色 */

    /* 交互色 */
    --bbs-hover: rgba(37, 99, 235, .08);  /* 悬停背景 */
    --bbs-alink: #eb7350;      /* 链接色 */
    --bbs-special: #e14123;    /* 特殊标记 */

    /* 功能色 */
    --bbs-error: #dc3545;
    --bbs-success: #198754;
    --bbs-warning: #ffc107;
    --bbs-info: #0dcaf0;

    /* 背景色 */
    --bbs-card-bg: var(--bbs-contrast);
    --bbs-body-bg: #f8f8fa;
    --bbs-input-bg: var(--bbs-gray-6);

    /* 圆角 */
    --bbs-radius-sm: 0.25rem;
    --bbs-radius-md: 0.5rem;
    --bbs-radius-lg: 0.75rem;
    --bbs-radius-pill: 50rem;

    /* 阴影 */
    --bbs-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
    --bbs-shadow-md: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);

    /* 动画 */
    --bbs-transition: all 0.2s ease;
}
```

### Bootstrap 变量覆盖

文件直接覆盖了 Bootstrap 的 CSS 变量,使品牌色生效于所有 Bootstrap 组件:

```css
:root {
    --bs-primary: var(--bbs-brand);
    --bs-primary-rgb: 37, 99, 235;
    --bs-link-color: var(--bbs-brand);
    --bs-link-hover-color: var(--bbs-brand-hover);
    --bs-btn-primary-bg: var(--bbs-brand);
    --bs-btn-primary-border-color: var(--bbs-brand);
    --bs-btn-primary-hover-bg: var(--bbs-brand-hover);
    --bs-nav-pills-link-active-bg: var(--bs-primary);
    --bs-pagination-active-bg: var(--bs-primary);
    --bs-dropdown-link-active-bg: var(--bs-primary);
    --bs-form-check-bg-checked: var(--bs-primary);
    --bs-link-decoration: none;
    --bs-link-hover-decoration: none;
}
```

### 组件样式覆盖

| 组件 | 覆盖内容 | 说明 |
|------|---------|------|
| `html, body` | `height: 100%; display: flex; flex-direction: column;` | 粘性页脚布局 |
| `body` | `background-color: var(--bbs-body-bg); font-size: 14px;` | 全局背景和字号 |
| `.card` | `padding: 12px; border: none; background-color: var(--bbs-card-bg);` | 去除边框,统一内边距 |
| `.card .card-body` | `padding: 0;` | 卡片体内边距归零 |
| `.card-body` | `padding: 12px;` | 通用卡片体内边距 |
| `.btn` | `transition: all 0.15s ease;` | 按钮过渡动画 |
| `.btn:hover` | `transform: translateY(-1px);` | 悬停微上移 |
| `.btn:active` | `transform: scale(0.97);` | 点击缩放反馈 |
| `.dropdown-menu` | `animation: fadeIn 0.15s ease; background-color: var(--bbs-card-bg);` | 下拉菜单淡入动画 |
| `.nav-tabs` | `border: none;` | 去除标签页默认边框 |
| `.nav-tabs .nav-link` | `border: none; background-color: transparent!important;` | 标签页链接无边框 |
| `a` | `text-decoration: none; transition: color 0.15s ease;` | 全局链接无下划线 |
| `.pagination .page-link` | `border-radius: var(--bbs-radius-md);` | 分页圆角 |
| `.form-check-input:checked` | `background-color: var(--bbs-brand);` | 复选框选中色 |

### 自定义组件

文件还定义了项目特有的组件样式:

- **导航栏**(`.navbar-bbs`):带阴影和底部边框
- **底部导航**(`.bottom-nav`):移动端固定底部导航栏,支持 safe-area
- **侧边栏卡片**(`.sidebar-card`):无边框侧边栏样式
- **帖子列表**(`.threadlist`、`.thread-item`):悬停高亮、无底部边框
- **时间线**(`.timeline-*`):微博模式卡片样式
- **瀑布流**(`.masonry-*`):多列瀑布流布局
- **头像**(`.avatar-xs/sm/md/lg/xl`):多尺寸圆形头像
- **Toast 通知**(`.toast-msg`):右上角滑入通知
- **加载动画**(`.loading-spinner`):CSS 旋转加载指示器
- **上传系统**(`.upload-*`):拖拽上传、进度条、预览网格

## 2.5 网格系统变化

### Flex 布局

Bootstrap 5 的网格系统底层从 `float` 改为 `flexbox`,主要影响:

1. **行内对齐**:可直接使用 `align-items-*` 和 `justify-content-*` 控制行内对齐
2. **列排序**:`col-*-offset-*` 仍可用,但推荐使用 `order-*` 进行排序
3. **嵌套行**:嵌套 `row` 不再需要额外处理,flex 自动处理

### Gutter 类名变化

Bootstrap 5 将 gutter(沟槽)从负 margin + padding 改为专用类名:

| 旧方式(Bootstrap 3) | 新方式(Bootstrap 5) | 说明 |
|------------------------|----------------------|------|
| 在 `row` 上设置 `margin-left: -15px; margin-right: -15px;` | `g-0` ~ `g-5` | 水平和垂直沟槽 |
| 无 | `gx-0` ~ `gx-5` | 仅水平沟槽 |
| 无 | `gy-0` ~ `gy-5` | 仅垂直沟槽 |
| 响应式无 | `g-sm-*` / `g-md-*` / `g-lg-*` | 响应式沟槽 |

```html
<!-- 旧版:通过负 margin 和 padding 控制 -->
<div class="row">
    <div class="col-md-6" style="padding: 0 15px;">内容</div>
</div>

<!-- 新版:使用 gutter 类 -->
<div class="row g-3">
    <div class="col-md-6">内容</div>
</div>

<!-- 仅控制水平沟槽 -->
<div class="row gx-4 gy-0">
    <div class="col-6">内容</div>
</div>
```

### 断点变化

| 断点 | Bootstrap 3 | Bootstrap 5 | 说明 |
|------|-------------|-------------|------|
| 超小 | `< 768px`(`col-xs-*`) | `< 576px`(`col-*`) | 新增 576px 断点,xs 不再需要后缀 |
| 小 | `≥ 768px`(`col-sm-*`) | `≥ 576px`(`col-sm-*`) | 断点值下移 |
| 中 | `≥ 992px`(`col-md-*`) | `≥ 768px`(`col-md-*`) | 断点值下移 |
| 大 | `≥ 1200px`(`col-lg-*`) | `≥ 992px`(`col-lg-*`) | 断点值下移 |
| 超大 | 无 | `≥ 1200px`(`col-xl-*`) | 新增断点 |
| 超超大 | 无 | `≥ 1400px`(`col-xxl-*`) | 新增断点 |

> **迁移注意**:如果旧代码中使用了 `col-md-*` 来适配 768px 以上,在 Bootstrap 5 中应改为 `col-sm-*`,因为 `md` 断点已变为 768px。建议逐一检查响应式断点是否仍符合设计意图。

---

# 第三章:htmx + Alpine.js 协作规范

本章详细阐述 Xiuno BBS 4.5+ 前端架构中 htmx 与 Alpine.js 的协作方式,包括 jQuery 到 `xiuno-modern.js` 兼容层的迁移、htmx 2.x 和 Alpine.js 3.x 的集成配置、脚本加载顺序以及七条核心原则。

## 3.1 jQuery → xiuno-modern.js 兼容层

`xiuno-modern.js`(路径:`view/js/xiuno-modern.js`)是原生 JS 兼容层,提供与 jQuery 对等的 API,新代码必须使用 `XN.xxx()` 替代 `$`。旧代码(xiuno.js / bbs.js / form.js)仍依赖 jQuery,保持不变。

### API 对照表

| jQuery 用法 | XN 兼容层用法 | 说明 |
|------------|-------------|------|
| `$(selector)` | `XN.$(selector)` | 单元素选择,返回 Element 或 null;传入 Element 原样返回 |
| `$(selector)` (多元素) | `XN.$$(selector)` | 多元素选择,返回 Array;支持字符串、NodeList、Array |
| `$.ajax({url, method, data, success})` | `XN.ajax(method, url, data, options)` | Promise 风格 AJAX,返回 `{code, message, data}` |
| `$.post(url, data, callback)` | `XN.post(url, data, callback)` | POST 请求,callback(code, msg),code=0 表示成功 |
| `$.get(url, callback)` | `XN.get(url, callback)` | GET 请求,callback(code, msg) |
| `$.xpost(url, data, callback)` | `XN.post(url, data, callback)` | 注意:旧 API code=1 成功,新 API code=0 成功 |
| `$.xget(url, callback)` | `XN.get(url, callback)` | 同上,注意 code 值差异 |
| `$(el).addClass('cls')` | `XN.addClass(el, 'cls')` | 支持多类名(空格分隔),el 可传选择器字符串 |
| `$(el).removeClass('cls')` | `XN.removeClass(el, 'cls')` | 支持多类名(空格分隔) |
| `$(el).toggleClass('cls')` | `XN.toggleClass(el, 'cls')` | 切换单个类名 |
| `$(el).hasClass('cls')` | `XN.hasClass(el, 'cls')` | 返回 boolean |
| `$(el).show()` | `XN.show(el)` | 设置 `style.display = ''` |
| `$(el).hide()` | `XN.hide(el)` | 设置 `style.display = 'none'` |
| `$(el).toggle()` | `XN.toggle(el)` | 切换 display |
| `$(el).html()` / `$(el).html(content)` | `XN.html(el)` / `XN.html(el, content)` | 读取或设置 innerHTML |
| `$(el).text()` / `$(el).text(content)` | `XN.text(el)` / `XN.text(el, content)` | 读取或设置 textContent |
| `$(el).val()` / `$(el).val(value)` | `XN.val(el)` / `XN.val(el, value)` | 读取或设置表单值 |
| `$(el).attr('name')` / `$(el).attr('name', val)` | `XN.attr(el, 'name')` / `XN.attr(el, 'name', val)` | 读取或设置属性 |
| `$(el).removeAttr('name')` | `XN.removeAttr(el, 'name')` | 移除属性 |
| `$(el).on('click', handler)` | `XN.on(el, 'click', handler)` | 事件绑定,el 可传选择器 |
| `$(parent).on('click', '.child', handler)` | `XN.on(el, 'click', '.child', handler)` | 事件委托,第三个参数为选择器 |
| `$(el).off('click', handler)` | `XN.off(el, 'click', handler)` | 移除事件监听 |
| `$(document).ready(fn)` | `XN.ready(fn)` | DOM Ready,已加载时立即执行 |
| `$(form).serialize()` | `XN.serialize(form)` | 返回 Object(非字符串),同名键自动合并为数组 |
| `$(form).submit()` + `$.post()` | `XN.submit(form, url, callback)` | 自动提取 FormData + CSRF token,POST 提交 |
| `$.cookie(name, value, time)` | `XN.cookie(name, value, time, path)` | Cookie 读写;value=null 删除;time 单位秒 |
| `$.alert(msg)` | `XN.toast(msg, 'danger')` | Toast 提示替代弹窗 |
| `xn.url(route)` | `XN.url(route)` | URL 生成,支持 url_rewrite_on 四种模式 |
| `window.location.href = url` | `XN.redirect(url, delay)` | 延迟重定向,delay 单位秒;url 为空则刷新 |
| `localStorage.getItem/setItem` | `XN.storage.get/set(key, value)` | 自动 JSON 序列化/反序列化 |
| `localStorage.removeItem` | `XN.storage.remove(key)` | 删除存储项 |
| — | `XN.toast(message, type, duration)` | Bootstrap 5 Toast 集成,type: success/danger/warning/info |
| — | `XN.escapeHtml(s)` | HTML 转义,防 XSS |
| — | `XN.intval(s)` | 整数转换,NaN 返回 0 |
| — | `XN.empty(v)` | 空值检测,支持 null/undefined/''/'0'/[]/{}/false |
| — | `XN.htmx.post(url, data, target, swap)` | htmx.ajax POST 封装,默认 target='#main' |
| — | `XN.htmx.get(url, target, swap)` | htmx.ajax GET 封装,默认 target='#main' |

### XN.ajax() 的 Promise 风格用法和错误码约定

`XN.ajax()` 是底层 AJAX 方法,返回 Promise,所有其他请求方法(`XN.get`、`XN.post`)均基于它封装:

```javascript
// 基本用法
XN.ajax('POST', 'thread-like-1-2', {key: 'value'}, {timeout: 10000})
    .then(function(result) {
        // result = {code: 0, message: '...', data: {...}}
        console.log(result.code);    // 0 表示成功
        console.log(result.message); // 服务端返回的消息
        console.log(result.data);    // 服务端返回的完整 JSON
    })
    .catch(function(err) {
        console.log(err.code);       // 错误码
        console.log(err.message);    // 错误信息
    });
```

**错误码约定**:

| 错误码 | 含义 | 来源 |
|--------|------|------|
| `0` | 成功 | 服务端返回 `json.code == 0` |
| `> 0` | 业务错误 | 服务端返回 `json.code != 0`,message 为错误描述 |
| `-HTTP状态码` | HTTP 错误 | 如 `-404`、`-500`,响应状态码 >= 400 |
| `-100` | 服务端响应为空 | `response.text()` 返回空字符串 |
| `-101` | 响应非 JSON | JSON.parse 失败,message 为原始文本 |
| `-1001` | 请求超时 | AbortController 触发,默认 30 秒 |

**options 参数**:

```javascript
XN.ajax(method, url, data, {
    timeout: 30000,       // 超时时间(毫秒),默认 30000
    headers: {            // 自定义请求头,默认包含 X-Requested-With: XMLHttpRequest
        'X-Custom-Header': 'value'
    }
});
```

### XN.post() 自动携带 CSRF token 机制

`XN.post()` 本身不自动附加 CSRF token,但系统通过以下两种机制确保 POST 请求携带 token:

**机制一:XMLHttpRequest 全局拦截**(`footer.inc.htm`)

```javascript
// footer.inc.htm 中的全局拦截器
var token = document.querySelector('meta[name="csrf-token"]');
if(token) token = token.getAttribute('content');
if(token && typeof XMLHttpRequest !== 'undefined') {
    var origSend = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.send = function(data) {
        this.setRequestHeader('X-CSRF-Token', token);
        return origSend.apply(this, arguments);
    };
}
```

此拦截器自动为所有 XMLHttpRequest 请求添加 `X-CSRF-Token` 请求头。

**机制二:XN.submit() 表单提交**

```javascript
// XN.submit() 自动从表单中提取 CSRF token
XN.submit(form, url, callback);
// 内部逻辑:
// var csrfToken = form.querySelector('input[name="csrf_token"]');
// if (csrfToken && !fd.has('csrf_token')) {
//     fd.set('csrf_token', csrfToken.value);
// }
```

**手动获取 CSRF token**(使用原生 `fetch()` 时):

```javascript
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
if(csrfMeta) {
    data.csrf_token = csrfMeta.getAttribute('content');
}
```

### XN.toast() 与 Bootstrap 5 Toast 集成

`XN.toast()` 封装了 Bootstrap 5 的 Toast 组件,自动创建容器和元素:

```javascript
// 基本用法
XN.toast('操作成功');                    // 默认 info 类型,3 秒后消失
XN.toast('保存成功', 'success');         // 绿色提示
XN.toast('操作失败', 'danger');          // 红色提示
XN.toast('请注意', 'warning', 5000);     // 黄色提示,5 秒后消失

// 类型与颜色映射
// success → bg-success(绿色)
// danger  → bg-danger(红色)
// warning → bg-warning text-dark(黄色)
// info    → bg-primary(蓝色,默认)
```

**实现原理**:

1. 检查 `.toast-container` 是否存在,不存在则创建(`position-fixed top-0 end-0 p-3`,z-index: 9999)
2. 创建 `.toast` 元素,设置类型对应的背景色
3. 使用 `new bootstrap.Toast(toast, {delay: duration})` 初始化并显示
4. 监听 `hidden.bs.toast` 事件自动移除 DOM 元素
5. 消息内容通过 `XN.escapeHtml()` 转义,防止 XSS

## 3.2 htmx 2.x 集成

### body 标签配置详解

来自 `view/htm/header.inc.htm` 第 81 行:

```html
<body hx-boost="true"
      hx-target="#body"
      hx-select="#body"
      hx-swap="innerHTML"
      hx-ext="idiomorph, alpine-morph">
```

| 属性 | 值 | 作用 |
|------|-----|------|
| `hx-boost` | `"true"` | 全局启用链接和表单的 htmx 增强,普通 `<a href>` 和 `<form>` 自动变为 htmx AJAX 请求 |
| `hx-target` | `"#body"` | 默认将响应内容注入 `id="body"` 的元素(即 `<main id="body">`) |
| `hx-select` | `"#body"` | 从服务端返回的完整 HTML 中只提取 `#body` 部分的内容 |
| `hx-swap` | `"innerHTML"` | 默认交换策略为替换目标元素的内部 HTML(被 main 标签覆盖) |
| `hx-ext` | `"idiomorph, alpine-morph"` | 全局启用 idiomorph 和 alpine-morph 扩展 |

### main 标签的 morph 交换覆盖

来自 `view/htm/header.inc.htm` 第 87 行:

```html
<main id="body" hx-swap="morph:innerHTML">
```

- **`id="body"`**:与 body 标签上的 `hx-target` / `hx-select` 对应
- **`hx-swap="morph:innerHTML"`**:覆盖 body 标签上默认的 `innerHTML` 交换策略,使用 idiomorph 的 morph 算法进行智能 DOM diff,**保留 Alpine.js 组件状态**

**工作流程**:当用户通过 `hx-boost` 导航时,body 标签声明 `hx-swap="innerHTML"` 作为默认策略,但 `<main id="body">` 覆盖为 `hx-swap="morph:innerHTML"`,服务端返回完整 HTML 后,`hx-select="#body"` 提取 `<main>` 内容,morph 算法对比新旧内容只更新差异部分,导航栏等不变区域保持不变。

### htmx:beforeSwap 全局处理器

来自 `view/htm/header.inc.htm` 第 61~67 行:

```javascript
// 允许 HTMX 在 4xx/5xx 响应时也执行 swap,确保错误页面能正确显示
document.addEventListener('htmx:beforeSwap', function(evt) {
    if(evt.detail.xhr && evt.detail.xhr.status >= 400) {
        evt.detail.shouldSwap = true;
        evt.detail.isError = false;
    }
});
```

**作用**:htmx 默认在收到 4xx/5xx 响应时不执行 swap,此处理器强制让错误响应也执行交换,使 404/403/500 等错误页面能正确渲染到 `<main>` 区域中,而非静默失败。

### 常用 htmx 属性速查表

| 属性 | 作用 | 示例 |
|------|------|------|
| `hx-get` | 发送 GET 请求 | `hx-get="/api/data"` |
| `hx-post` | 发送 POST 请求 | `hx-post="/api/submit"` |
| `hx-put` | 发送 PUT 请求 | `hx-put="/api/update"` |
| `hx-delete` | 发送 DELETE 请求 | `hx-delete="/api/delete"` |
| `hx-target` | 指定响应内容的目标元素 | `hx-target="#result"` |
| `hx-select` | 从响应中提取指定部分 | `hx-select="#content"` |
| `hx-swap` | 指定交换策略 | `hx-swap="morph:innerHTML"` |
| `hx-boost` | 增强链接和表单为 AJAX | `hx-boost="true"` / `hx-boost="false"` |
| `hx-trigger` | 指定触发条件 | `hx-trigger="click"` / `hx-trigger="keyup delay:300ms"` |
| `hx-include` | 包含额外表单数据 | `hx-include="#extra-data"` |
| `hx-params` | 过滤请求参数 | `hx-params="name,email"` / `hx-params="none"` |
| `hx-confirm` | 提交前显示确认对话框 | `hx-confirm="确定删除?"` |
| `hx-indicator` | 指定加载指示器元素 | `hx-indicator="#spinner"` |
| `hx-disable` | 禁用 htmx 处理 | `<div hx-disable>...</div>` |
| `hx-ext` | 启用扩展 | `hx-ext="idiomorph, alpine-morph"` |
| `hx-vals` | 添加额外请求参数(JSON) | `hx-vals='{"key":"value"}'` |
| `hx-headers` | 添加额外请求头(JSON) | `hx-headers='{"X-Custom":"val"}'` |
| `hx-push-url` | 是否更新浏览器 URL | `hx-push-url="true"` / `hx-push-url="false"` |
| `hx-replace-url` | 替换浏览器 URL(不写历史) | `hx-replace-url="true"` |

## 3.3 Alpine.js 3.x 集成

### 已注册 Alpine.data 组件清单

所有组件均在 `view/htm/footer.inc.htm` 中通过 `alpine:init` 事件注册:

| 组件名 | 参数 | 初始数据来源 | 后端路由 | 功能 |
|--------|------|-------------|----------|------|
| `likeBtn(pid, tid)` | `pid` 帖子 ID,`tid` 主题 ID | `window.__likesData[pid]` → `{liked, count}` | `thread-like-{tid}-{pid}` (POST) | 点赞/取消点赞 |
| `favBtn(tid)` | `tid` 主题 ID | `window.__favoritesData[tid]` → `{favorited, count}` | `thread-favorite-{tid}` (POST) | 收藏/取消收藏 |
| `followBtn(followUid)` | `followUid` 被关注用户 ID | `window.__followData[followUid]` → `boolean` | `user-follow-{followUid}` (POST) | 关注/取消关注用户 |
| `forumFollowBtn(fid, initFollowed)` | `fid` 版块 ID,`initFollowed` 初始关注状态 | 参数直接传入(非全局变量) | `forum-follow` / `forum-unfollow` (POST) | 关注/取消关注版块 |

### 组件注册位置

组件注册代码位于 `view/htm/footer.inc.htm` 第 56~151 行,在 `alpine:init` 事件回调中:

```javascript
document.addEventListener('alpine:init', function() {
    Alpine.data('likeBtn', function(pid, tid) { /* ... */ });
    Alpine.data('favBtn', function(tid) { /* ... */ });
    Alpine.data('followBtn', function(followUid) { /* ... */ });
    Alpine.data('forumFollowBtn', function(fid, initFollowed) { /* ... */ });
});
```

**为什么必须在 `alpine:init` 中注册**:`Alpine.data()` 必须在 Alpine.js 初始化之前调用,`alpine:init` 事件在 Alpine.js 开始初始化时触发,是注册组件的安全时机。在 Alpine.js 加载后直接调用 `Alpine.data()` 可能因 Alpine 尚未完全初始化而失败。

### 全局变量注入模式

| 全局变量 | 键类型 | 值结构 | 注入位置 | 用途 |
|----------|--------|--------|----------|------|
| `window.__likesData` | `pid`(帖子 ID) | `{liked: boolean, count: number}` | `view/htm/thread.htm` 页面内 `<script>` 块 | 点赞初始状态 |
| `window.__favoritesData` | `tid`(主题 ID) | `{favorited: boolean, count: number}` | `view/htm/thread.htm` 页面内 `<script>` 块 | 收藏初始状态 |
| `window.__followData` | `uid`(用户 ID) | `boolean` | `view/htm/thread.htm` 页面内 `<script>` 块 | 关注初始状态 |

**安全读取模式**:组件初始化时使用短路降级,确保全局变量未定义时安全运行:

```javascript
var initData = (window.__likesData && window.__likesData[pid]) || {liked: false, count: 0};
var initData = (window.__favoritesData && window.__favoritesData[tid]) || {favorited: false, count: 0};
var initFollowed = (window.__followData && window.__followData[followUid]) || false;
```

## 3.4 脚本加载顺序(严格固定)

脚本加载顺序是系统正常运行的**前提条件**,任何调整都可能导致功能异常。

### 5 层加载顺序表格

| 顺序 | 脚本 | 路径 | 加载位置 | 作用 | 依赖 |
|------|------|------|----------|------|------|
| ① | htmx 2.0.x | `view/vendor/htmx/htmx.min.js` | `header.inc.htm` | htmx 核心引擎,声明式 AJAX 交互 | 无 |
| ② | idiomorph | `view/vendor/idiomorph/idiomorph-ext.min.js` | `header.inc.htm` | htmx 扩展,提供 DOM morph 算法 | htmx |
| ③ | alpine-morph | `view/vendor/htmx-ext-alpine-morph/alpine-morph.js` | `header.inc.htm` | htmx 扩展,桥接 idiomorph 与 Alpine.js,使 morph 操作保留 Alpine 状态 | htmx + idiomorph |
| ④ | Alpine morph 插件 | `view/vendor/alpinejs-morph/cdn.min.js` | `footer.inc.htm` | Alpine.js 官方 morph 插件,提供 `Alpine.morph()` API | Alpine.js |
| ⑤ | Alpine.js 3.x | `view/vendor/alpinejs/cdn.min.js` | `footer.inc.htm` | Alpine.js 核心,管理 UI 响应式状态 | 无(但 morph 插件需在其之前) |

### 每层的作用和依赖关系

**① htmx**:核心引擎,提供 `hx-get`/`hx-post`/`hx-boost` 等声明式属性和 AJAX 交换机制。必须最先加载,后续扩展均依赖它。

**② idiomorph**:htmx 扩展,注册为 `idiomorph` 扩展名,提供 DOM morph 算法。依赖 htmx 的扩展注册机制,必须在 htmx 之后加载。

**③ alpine-morph**:htmx 扩展,注册为 `alpine-morph` 扩展名,桥接 idiomorph 的 morph 操作与 Alpine.js 的状态保留。作为 htmx 扩展在 header 中注册即可,htmx 会在 Alpine.js 初始化后需要时调用它。

**④ Alpine morph 插件**:Alpine.js 官方插件,提供 `Alpine.morph()` API。此 API 被 alpine-morph 扩展(③)在运行时调用,因此必须在 Alpine.js 核心(⑤)之前加载,以确保 `Alpine.morph` 在 Alpine 初始化时可用。

**⑤ Alpine.js 核心**:最后加载。Alpine.js 初始化时会扫描 DOM 中所有 `x-data` 属性并创建响应式组件。最后加载确保所有扩展和插件已就绪,避免初始化时序问题。

### 为什么不能调整顺序

1. **①→②→③**:idiomorph 和 alpine-morph 都是 htmx 扩展,必须通过 `htmx.defineExtension()` 注册,这依赖 htmx 已加载
2. **③在 header、④⑤在 footer**:alpine-morph 作为 htmx 扩展需要在 htmx 处理请求前注册,而 Alpine.js 核心放在页面底部确保 DOM 已解析完毕
3. **④在⑤之前**:Alpine morph 插件需要在 Alpine.js 初始化前注册,否则 `Alpine.morph()` 不可用,alpine-morph 扩展运行时会报错
4. **⑤最后加载**:Alpine.js 初始化时会触发 `alpine:init` 事件(用于注册 `Alpine.data` 组件),然后触发 `alpine:initialized` 事件并扫描 DOM。如果提前加载,DOM 可能未就绪或组件未注册

## 3.5 htmx + Alpine.js 七条核心原则

以下七条原则是 htmx + Alpine.js 协作开发必须遵守的规范。详细说明和代码示例请参考 `doc/htmx-alpine-guide.md` 第三章。

### 原则 1:职责分离

htmx 管服务端交互(`hx-get`、`hx-post`、`hx-boost`),Alpine.js 管前端 UI 状态(`x-show`、`x-bind`、`x-on:click`)。不得混用——不要用 Alpine 的 `x-init` 发 AJAX 请求,也不要用 htmx 的 `hx-on:click` 控制纯前端弹窗。混用会导致职责不清、调试困难。

### 原则 2:禁止全局 $store

禁止任何形式的 `Alpine.store()` 和 `$store.xxx`。所有 Alpine 数据必须定义在局部 `x-data` 内。全局 store 在 htmx morph 交换时会产生状态同步问题——morph 可能替换了 DOM 节点但 store 中的状态已过时,导致 UI 与数据不一致。局部 `x-data` 随 DOM 节点创建/销毁,天然与 morph 兼容。

### 原则 3:自包含原则

通过 htmx 动态加载的任何 HTML 片段,必须自带完整的 `x-data` 定义,片段不得依赖父级作用域中的 Alpine 数据。htmx morph 交换可能重新创建 DOM 节点,Alpine.js 的作用域树会随之重建,依赖父级作用域的片段在 morph 后可能找不到预期的数据引用。

### 原则 4:必须使用 morph 交换

需要保留状态的容器使用 `hx-swap="morph:innerHTML"` 或 `hx-swap="morph:outerHTML"`。普通的 `innerHTML` 交换会完全替换目标元素的子节点,导致所有 Alpine.js 组件状态丢失。morph 算法对比新旧 DOM 树,只更新变化的部分,保留未变化的节点及其 Alpine 状态。

### 原则 5:时序冲突用 $nextTick

当 Alpine 状态更新与 htmx 请求同时发生时,必须将 Alpine 状态更新包裹在 `$nextTick()` 中。Alpine.js 的状态更新是同步的,但 htmx 的请求是异步的,直接在 htmx 回调中更新 Alpine 状态可能产生时序冲突。`$nextTick` 确保状态更新在下一个渲染周期执行。

### 原则 6:Alpine.data 复用规范

使用 `Alpine.data()` 定义可复用组件时,必须在 `alpine:init` 事件中注册。优先使用内联 `x-data`,除非多处复用同一逻辑。当前已注册组件:`likeBtn(pid, tid)`、`favBtn(tid)`、`followBtn(uid)`、`forumFollowBtn(fid, initFollowed)`。

### 原则 7:不确定时优先 morph + $nextTick

遇到不确定的时序或状态保留问题,优先使用 morph 交换和 `$nextTick`,不要引入全局状态作为解决方案。用 `Alpine.store` 保存状态来解决 morph 后状态丢失是错误方向,正确做法是使用 `hx-swap="morph:innerHTML"` 保留状态。

> **详细说明**:七条原则的完整代码示例、正反对比和排错指南,请参考 [`doc/htmx-alpine-guide.md`](htmx-alpine-guide.md) 第三章。

---

# 第四章:图标库迁移

Xiuno BBS 4.5+ 将图标库从旧版多套混用(Font Awesome / Bootstrap Icons / Glyphicons)统一迁移到 [Tabler Icons](https://tabler.io/icons) 3.31.0。本章记录迁移映射、使用规范和项目中实际使用的图标清单。

## 4.1 图标库迁移映射表

以下为旧图标库到 Tabler Icons 的映射关系,涵盖项目中常见的图标场景:

| 旧图标库 | 旧类名 | Tabler Icons 类名 | 用途 |
|---------|--------|------------------|------|
| Font Awesome | `fa fa-search` | `ti ti-search` | 搜索 |
| Font Awesome | `fa fa-user` | `ti ti-user` | 用户 |
| Font Awesome | `fa fa-heart` | `ti ti-heart` | 点赞(空心) |
| Font Awesome | `fa fa-heart`(实心) | `ti ti-heart-filled` | 点赞(实心) |
| Font Awesome | `fa fa-star` | `ti ti-star` | 收藏/评分(空心) |
| Font Awesome | `fa fa-star`(实心) | `ti ti-star-filled` | 收藏/评分(实心) |
| Font Awesome | `fa fa-home` | `ti ti-home` | 首页 |
| Font Awesome | `fa fa-cog` / `fa fa-gear` | `ti ti-settings` | 设置 |
| Font Awesome | `fa fa-envelope` | `ti ti-mail` | 消息/邮件 |
| Font Awesome | `fa fa-bell` | `ti ti-bell` | 通知 |
| Font Awesome | `fa fa-trash` / `fa fa-trash-o` | `ti ti-trash` | 删除 |
| Font Awesome | `fa fa-pencil` / `fa fa-edit` | `ti ti-pencil` | 编辑 |
| Font Awesome | `fa fa-times` / `fa fa-close` | `ti ti-x` | 关闭 |
| Font Awesome | `fa fa-bars` | `ti ti-list` | 菜单 |
| Font Awesome | `fa fa-arrow-right` | `ti ti-arrow-right` | 箭头右 |
| Font Awesome | `fa fa-arrow-left` | `ti ti-arrow-left` | 箭头左 |
| Font Awesome | `fa fa-share` | `ti ti-share` | 分享 |
| Font Awesome | `fa fa-bookmark` | `ti ti-star` | 收藏(用星形替代) |
| Font Awesome | `fa fa-thumbs-up` | `ti ti-thumb-up` | 点赞(拇指) |
| Font Awesome | `fa fa-thumbs-o-up` | `ti ti-thumb-up` | 点赞(拇指空心) |
| Font Awesome | `fa fa-user-plus` | `ti ti-user-plus` | 关注/添加用户 |
| Font Awesome | `fa fa-user-minus` | `ti ti-user-minus` | 取消关注 |
| Font Awesome | `fa fa-lock` | `ti ti-lock` | 锁定/关闭帖子 |
| Font Awesome | `fa fa-unlock` | `ti ti-lock-open` | 解锁帖子 |
| Font Awesome | `fa fa-paperclip` | `ti ti-paperclip` | 附件 |
| Font Awesome | `fa fa-download` | `ti ti-download` | 下载 |
| Font Awesome | `fa fa-upload` / `fa fa-cloud-upload` | `ti ti-cloud-upload` | 上传 |
| Font Awesome | `fa fa-eye` | `ti ti-eye` | 浏览量/查看 |
| Font Awesome | `fa fa-clock-o` | `ti ti-clock` | 时间 |
| Font Awesome | `fa fa-tag` / `fa fa-tags` | `ti ti-tag` | 标签 |
| Font Awesome | `fa fa-bullhorn` | `ti ti-megaphone` | 公告 |
| Font Awesome | `fa fa-flag` | `ti ti-flag` | 置顶/标记 |
| Font Awesome | `fa fa-sign-out` | `ti ti-logout` | 退出登录 |
| Font Awesome | `fa fa-check` | `ti ti-check` | 确认/成功 |
| Font Awesome | `fa fa-plus` | `ti ti-plus` | 新增 |
| Font Awesome | `fa fa-refresh` / `fa fa-repeat` | `ti ti-refresh` | 刷新 |
| Font Awesome | `fa fa-copy` | `ti ti-clipboard` | 复制 |
| Font Awesome | `fa fa-file` | `ti ti-file` | 文件 |
| Font Awesome | `fa fa-key` | `ti ti-key` | 密码/密钥 |
| Font Awesome | `fa fa-shield` | `ti ti-shield` | 管理员/安全 |
| Bootstrap Icons | `bi-search` | `ti ti-search` | 搜索 |
| Bootstrap Icons | `bi-person` | `ti ti-user` | 用户 |
| Bootstrap Icons | `bi-person-plus` | `ti ti-user-plus` | 添加用户 |
| Bootstrap Icons | `bi-heart` | `ti ti-heart` | 心形 |
| Bootstrap Icons | `bi-star` | `ti ti-star` | 星形 |
| Bootstrap Icons | `bi-trash` | `ti ti-trash` | 删除 |
| Bootstrap Icons | `bi-pencil` | `ti ti-pencil` | 编辑 |
| Bootstrap Icons | `bi-bell` | `ti ti-bell` | 通知 |
| Bootstrap Icons | `bi-gear` | `ti ti-settings` | 设置 |
| Bootstrap Icons | `bi-house` | `ti ti-home` | 首页 |
| Bootstrap Icons | `bi-lock` | `ti ti-lock` | 锁定 |
| Bootstrap Icons | `bi-eye` | `ti ti-eye` | 查看 |
| Glyphicons | `glyphicon glyphicon-search` | `ti ti-search` | 搜索 |
| Glyphicons | `glyphicon glyphicon-user` | `ti ti-user` | 用户 |
| Glyphicons | `glyphicon glyphicon-heart` | `ti ti-heart` | 心形 |
| Glyphicons | `glyphicon glyphicon-star` | `ti ti-star` | 星形 |
| Glyphicons | `glyphicon glyphicon-home` | `ti ti-home` | 首页 |
| Glyphicons | `glyphicon glyphicon-cog` | `ti ti-settings` | 设置 |
| Glyphicons | `glyphicon glyphicon-trash` | `ti ti-trash` | 删除 |
| Glyphicons | `glyphicon glyphicon-pencil` | `ti ti-pencil` | 编辑 |
| Glyphicons | `glyphicon glyphicon-remove` | `ti ti-x` | 关闭 |
| Glyphicons | `glyphicon glyphicon-lock` | `ti ti-lock` | 锁定 |
| Glyphicons | `glyphicon glyphicon-eye-open` | `ti ti-eye` | 查看 |
| Glyphicons | `glyphicon glyphicon-move` | `ti ti-category-2` | 移动帖子 |
| Glyphicons | `glyphicon glyphicon-thumbs-up` | `ti ti-thumb-up` | 点赞 |
| Glyphicons | `glyphicon glyphicon-bullhorn` | `ti ti-megaphone` | 公告 |
| Glyphicons | `glyphicon glyphicon-paperclip` | `ti ti-paperclip` | 附件 |
| Glyphicons | `glyphicon glyphicon-flag` | `ti ti-flag` | 标记 |
| Glyphicons | `glyphicon glyphicon-bell` | `ti ti-bell` | 通知 |
| 旧 Xiuno 图标 | `icon icon-thumbs-o-up` | `ti ti-thumb-up` | 点赞(空心拇指) |
| 旧 Xiuno 图标 | `icon icon-thumbs-up` | `ti ti-thumb-up-filled` | 点赞(实心拇指) |
| 旧 Xiuno 图标 | `icon-search` | `ti ti-search` | 搜索 |

> **说明**:Tabler Icons 的实心变体通过添加 `-filled` 后缀实现,如 `ti-heart`(空心)→ `ti-heart-filled`(实心)。这与 Font Awesome 的 `-o` 后缀(空心)和 Bootstrap Icons 的 `-fill` 后缀规则不同,迁移时需注意。

## 4.2 使用规范

### 引用方式

Tabler Icons 采用字体图标方式,通过 `<i>` 标签 + CSS 类名引用:

```html
<!-- 基本用法 -->
<i class="ti ti-search"></i>

<!-- 带文字 -->
<button class="btn btn-primary"><i class="ti ti-plus"></i> 新建</button>

<!-- 与 Bootstrap 按钮配合 -->
<button class="btn btn-sm btn-outline-danger"><i class="ti ti-trash"></i> 删除</button>

<!-- 与导航链接配合 -->
<a class="nav-link" href="/forum"><i class="ti ti-message"></i> 版块</a>

<!-- 与下拉菜单项配合 -->
<a class="dropdown-item" href="/settings"><i class="ti ti-settings"></i> 设置</a>
```

### 与 Alpine.js 动态 class 配合

Tabler Icons 的空心/实心变体非常适合与 Alpine.js 的 `:class` 动态绑定配合使用:

```html
<!-- 点赞按钮:空心/实心切换 -->
<span x-data="likeBtn(pid, tid)" @click="toggle()">
    <i class="ti" :class="liked ? 'ti-heart-filled text-danger' : 'ti-heart'"></i>
    <span x-text="count"></span>
</span>

<!-- 收藏按钮:空心/实心切换 -->
<span x-data="favBtn(tid)" @click="toggle()">
    <i class="ti" :class="favorited ? 'ti-star-filled text-warning' : 'ti-star'"></i>
    <span x-text="count"></span>
</span>

<!-- 关注按钮:图标切换 -->
<button x-data="followBtn(uid)" @click="toggle()" :class="followed ? 'btn-outline-secondary' : 'btn-primary'">
    <i class="ti me-1" :class="followed ? 'ti-user-minus' : 'ti-user-plus'"></i>
    <span x-text="followed ? '已关注' : '关注'"></span>
</button>
```

> **关键点**:动态 class 绑定时,`ti` 基础类必须始终存在(提供字体和基础样式),只切换 `ti-xxx` 图标名部分。

### 图标大小控制

Tabler Icons 继承父元素的 `font-size`,可通过以下方式控制大小:

```html
<!-- 方式 1:Bootstrap fs-* 工具类 -->
<i class="ti ti-lock fs-4"></i>

<!-- 方式 2:内联 style -->
<i class="ti ti-eye" style="font-size:0.65rem"></i>

<!-- 方式 3:父元素 font-size -->
<span style="font-size:1.25rem"><i class="ti ti-message-circle"></i></span>

<!-- 方式 4:Bootstrap display-* 排版类(超大图标) -->
<i class="ti ti-chart-bar display-4 text-body-tertiary"></i>
```

### 禁止混用多套图标库

新代码**严禁**使用以下前缀的图标类名:

| 禁止前缀 | 来源 | 说明 |
|---------|------|------|
| `fa-` / `fa fa-` / `fas fa-` / `far fa-` | Font Awesome | 已移除,统一用 `ti ti-` |
| `bi-` / `bi bi-` | Bootstrap Icons | 已移除,统一用 `ti ti-` |
| `glyphicon glyphicon-` | Glyphicons | Bootstrap 3 内置,已移除 |
| `icon icon-` | 旧 Xiuno 自定义 | 已移除,统一用 `ti ti-` |

```html
<!-- ❌ 禁止:使用旧图标库 -->
<i class="fa fa-heart"></i>
<i class="bi bi-trash"></i>
<i class="glyphicon glyphicon-search"></i>
<i class="icon icon-thumbs-o-up"></i>

<!-- ✅ 正确:统一使用 Tabler Icons -->
<i class="ti ti-heart"></i>
<i class="ti ti-trash"></i>
<i class="ti ti-search"></i>
<i class="ti ti-thumb-up"></i>
```

### 图标选择器

后台管理页面提供了 Tabler Icons 图标选择器(`view/js/tabler-icon-picker.js`),用于版块图标和用户组图标的设置。在后台「版块管理」和「用户组管理」中,点击图标预览即可打开选择器弹窗,搜索并选择合适的图标。

## 4.3 项目中实际使用的图标清单

以下为从项目代码(`view/`、`admin/`、`plugin/` 目录)中提取的所有 `ti ti-xxx` 图标使用统计,按出现次数降序排列:

| 图标类名 | 出现次数 | 主要用途 |
|---------|---------|---------|
| `ti ti-trash` | 22 | 删除按钮(帖子、用户、版块、附件等) |
| `ti ti-pencil` | 22 | 编辑按钮(帖子、资料、版块、用户组等) |
| `ti ti-lock` | 22 | 帖子锁定/关闭、密码输入框、权限设置 |
| `ti ti-message-circle` | 17 | 评论数/帖子数、版块默认图标 |
| `ti ti-check` | 17 | 确认/成功标记、验证码验证通过 |
| `ti ti-search` | 12 | 搜索按钮(导航栏、后台、插件) |
| `ti ti-refresh` | 12 | 刷新/重试(验证码、升级、插件扫描) |
| `ti ti-medal` | 12 | 排行榜奖牌(金/银/铜) |
| `ti ti-user` | 10 | 用户相关(登录、个人主页、用户组图标) |
| `ti ti-star` | 10 | 收藏/评分/积分、用户组图标 |
| `ti ti-coin` | 10 | 积分/金币(积分插件) |
| `ti ti-arrow-left` | 9 | 返回按钮 |
| `ti ti-plus` | 8 | 新增按钮(版块、用户组、导航项、友情链接) |
| `ti ti-heart` | 8 | 点赞(空心)、版块关注 |
| `ti ti-bell-off` | 8 | 无通知占位图标 |
| `ti ti-arrows-horizontal` | 8 | 滑块验证码拖拽指示 |
| `ti ti-message` | 7 | 导航栏版块链接图标 |
| `ti ti-key` | 7 | 密码/密钥/邀请码 |
| `ti ti-clipboard` | 7 | 复制按钮(代码块、API 调试) |
| `ti ti-x` | 6 | 关闭/失败标记、侧边栏关闭 |
| `ti ti-star-filled` | 6 | 收藏(实心)、精华帖标记、默认主题标记 |
| `ti ti-gift` | 6 | 邀请注册奖励、签到奖励 |
| `ti ti-calendar-check` | 6 | 签到日历、签到按钮 |
| `ti ti-trophy` | 5 | 排行榜标题、签到排名 |
| `ti ti-settings` | 5 | 设置按钮(个人设置、系统设置) |
| `ti ti-paperclip` | 5 | 附件标记(帖子列表、后台统计) |
| `ti ti-mail` | 5 | 邮件/消息 |
| `ti ti-list` | 5 | 菜单/列表(导航栏移动端菜单) |
| `ti ti-info-circle` | 5 | 信息提示 |
| `ti ti-circle-dot` | 5 | 版块/用户组默认图标占位 |
| `ti ti-circle-check` | 5 | 成功标记(升级完成、验证通过) |
| `ti ti-chart-bar` | 5 | 数据统计/图表 |
| `ti ti-bell` | 5 | 通知铃铛、公告操作按钮 |
| `ti ti-users` | 4 | 用户列表、活跃用户、团队 |
| `ti ti-pin-filled` | 4 | 置顶帖子标记 |
| `ti ti-megaphone` | 4 | 公告设置/取消 |
| `ti ti-list-check` | 4 | 清单/统计列表 |
| `ti ti-home` | 4 | 首页导航 |
| `ti ti-eye` | 4 | 浏览量 |
| `ti ti-arrow-right` | 4 | 箭头右(升级步骤、面包屑) |
| `ti ti-shield-check` | 3 | 安全扫描、验证码设置 |
| `ti ti-palette` | 3 | 主题色设置 |
| `ti ti-message-2` | 3 | 消息变体(搜索页帖子标签) |
| `ti ti-logout` | 3 | 退出登录 |
| `ti ti-link` | 3 | 友情链接 |
| `ti ti-grid-dots` | 3 | 视图切换(网格视图) |
| `ti ti-folder` | 3 | 版块分类文件夹 |
| `ti ti-flame` | 3 | 热门/热门帖子 |
| `ti ti-cloud-upload` | 3 | 上传 |
| `ti ti-clock` | 3 | 时间/历史记录 |
| `ti ti-alert-triangle` | 3 | 警告提示 |

> **统计说明**:以上数据来自 `view/`、`admin/`、`plugin/` 目录下所有 `.htm`、`.js`、`.php` 文件,包含模板中的静态引用和 JavaScript 动态生成的图标。出现次数反映图标在代码中的引用频次,同一图标在同一模板的循环体中按实际出现次数计算。

---

# 第六章:数据流与状态管理

## 6.1 服务端数据注入链路

Xiuno BBS 4.5+ 的前端交互组件(点赞、收藏、关注)需要服务端初始状态来正确渲染。整个数据注入链路如下:

```
PHP 模板变量 → window.__xxxData 全局变量 → footer.inc.htm alpine:init → Alpine.data 组件读取 → 响应式 DOM 更新
```

### 三组全局变量

| 全局变量 | 键类型 | 值结构 | 用途 |
|----------|--------|--------|------|
| `window.__likesData` | `pid`(帖子 ID) | `{liked: boolean, count: number}` | 点赞初始状态 |
| `window.__favoritesData` | `tid`(主题 ID) | `{favorited: boolean, count: number}` | 收藏初始状态 |
| `window.__followData` | `uid`(用户 ID) | `boolean` | 关注初始状态 |

### thread.htm 中的注入代码

在 `view/htm/thread.htm` 中,PHP 模板在页面渲染时将服务端数据注入为 JavaScript 全局变量:

```html
<script>
window.__likesData = {};
window.__likesData[<?php echo $thread['firstpid'];?>] = {liked: <?php echo !empty($thread['is_liked']) ? 'true' : 'false';?>, count: <?php echo intval($first['likes']);?>};
<?php if(!empty($postlist)) { foreach($postlist as $_p) { ?>
window.__likesData[<?php echo $_p['pid'];?>] = {liked: <?php echo !empty($_p['is_liked']) ? 'true' : 'false';?>, count: <?php echo intval($_p['likes']);?>};
<?php }} ?>
<?php if(!empty($reply_map)) { foreach($reply_map as $_replies) { foreach($_replies as $_r) { ?>
window.__likesData[<?php echo $_r['pid'];?>] = {liked: <?php echo !empty($_r['is_liked']) ? 'true' : 'false';?>, count: <?php echo intval($_r['likes']);?>};
<?php }}} ?>

window.__favoritesData = {};
window.__favoritesData[<?php echo $tid;?>] = {favorited: <?php echo !empty($thread['is_favorited']) ? 'true' : 'false';?>, count: <?php echo intval($thread['favorites']);?>};

window.__followData = {};
<?php if(!empty($uid) && !empty($thread['uid']) && $uid != $thread['uid']) { ?>
window.__followData[<?php echo $thread['uid'];?>] = <?php echo user_follow_read($uid, $thread['uid']) ? 'true' : 'false';?>;
<?php } ?>
</script>
```

**关键点**:

1. `window.__likesData` 需要遍历三个层级:首帖(firstpid)、一级评论(postlist)、二级回复(reply_map)
2. `window.__favoritesData` 只需注入当前主题的收藏状态
3. `window.__followData` 只在当前用户已登录且非自身帖子时注入
4. 这段脚本位于 `header.inc.htm` include 之后、页面内容之前,确保在 Alpine.js 初始化前数据已就绪

### footer.inc.htm 中的组件读取

在 `view/htm/footer.inc.htm` 中,`alpine:init` 事件回调内注册 Alpine.data 组件时,从全局变量读取初始状态:

```javascript
document.addEventListener('alpine:init', function() {
    // 点赞按钮组件
    Alpine.data('likeBtn', function(pid, tid) {
        var initData = (window.__likesData && window.__likesData[pid]) || {liked: false, count: 0};
        return {
            liked: initData.liked || false,
            count: initData.count || 0,
            toggle: function() { /* ... */ }
        };
    });

    // 收藏按钮组件
    Alpine.data('favBtn', function(tid) {
        var initData = (window.__favoritesData && window.__favoritesData[tid]) || {favorited: false, count: 0};
        return {
            favorited: initData.favorited || false,
            count: initData.count || 0,
            toggle: function() { /* ... */ }
        };
    });

    // 关注用户按钮组件
    Alpine.data('followBtn', function(followUid) {
        var initFollowed = (window.__followData && window.__followData[followUid]) || false;
        return {
            followed: initFollowed,
            toggle: function() { /* ... */ }
        };
    });

    // 版块关注按钮组件(初始状态通过参数直接传入)
    Alpine.data('forumFollowBtn', function(fid, initFollowed) {
        return {
            followed: initFollowed || false,
            toggle: function() { /* ... */ }
        };
    });
});
```

**安全读取模式**:`(window.__likesData && window.__likesData[pid]) || {liked: false, count: 0}` 确保即使全局变量未定义或对应键不存在,组件也能安全降级到默认值。

### 完整数据流示意

```
┌──────────────────────────────────────────────────────────────────┐
│  PHP 服务端                                                      │
│  $thread['is_liked'] / $first['likes'] / $_p['is_liked'] / ...  │
└───────────────────────────┬──────────────────────────────────────┘
                            │ PHP echo 输出
                            ▼
┌──────────────────────────────────────────────────────────────────┐
│  HTML <script> 块(thread.htm 页面内)                            │
│  window.__likesData[123] = {liked: true, count: 5};             │
│  window.__favoritesData[456] = {favorited: false, count: 0};    │
│  window.__followData[789] = true;                                │
└───────────────────────────┬──────────────────────────────────────┘
                            │ 页面加载,JS 执行
                            ▼
┌──────────────────────────────────────────────────────────────────┐
│  footer.inc.htm — alpine:init 事件                               │
│  Alpine.data('likeBtn', function(pid, tid) {                     │
│      var initData = window.__likesData[pid] || {...};            │
│      return { liked: initData.liked, count: initData.count };    │
│  });                                                             │
└───────────────────────────┬──────────────────────────────────────┘
                            │ Alpine.js 初始化组件
                            ▼
┌──────────────────────────────────────────────────────────────────┐
│  DOM 模板(thread.htm / post_list.inc.htm)                      │
│  <span x-data="likeBtn(123, 456)" @click="toggle()">            │
│      <i :class="liked ? 'ti-heart-filled text-danger' : ...">   │
│      <span x-text="count"></span>                                │
│  </span>                                                         │
└──────────────────────────────────────────────────────────────────┘
```

## 6.2 Alpine.store 的废弃原因

### 旧版架构:Alpine.store 全局状态

在早期迁移阶段,项目使用 `Alpine.store()` 管理点赞、收藏、关注的全局状态:

```javascript
// 旧版方式(已废弃)
Alpine.store('likes', {
    data: {},
    toggle: function(pid, tid) { /* AJAX + 更新 data[pid] */ }
});
Alpine.store('favorites', {
    data: {},
    toggle: function(tid) { /* AJAX + 更新 data[tid] */ }
});
Alpine.store('follows', {
    data: {},
    toggle: function(uid) { /* AJAX + 更新 data[uid] */ }
});
```

模板中使用 `$store` 访问:

```html
<!-- 旧版模板写法(已废弃) -->
<span :class="$store.likes.data[pid]?.liked ? 'ti-heart-filled' : 'ti-heart'"></span>
<span x-text="$store.likes.data[pid]?.count"></span>
```

### 废弃原因

**问题一:htmx morph 交换后 store 状态与 DOM 不同步**

当 htmx boost 导航到新页面时,morph 交换会更新 `<main id="body">` 内的 DOM,但 `Alpine.store` 是全局单例,不会随 DOM 交换而重置。这导致:

- 旧页面的 store 数据残留(如旧帖子的点赞状态)
- 新页面的模板引用 `$store.likes.data[newPid]` 找不到数据,返回 `undefined`
- 用户看到空白或错误状态

**问题二:多个 alpine:init 监听器重复注册**

旧版架构中,`thread.htm` 页面模板和 `footer.inc.htm` 都注册了 `alpine:init` 事件监听器来初始化 store。当 htmx 导航后重新加载页面时,可能出现:

- store 被重复注册(`Alpine.store()` 调用报错或覆盖旧数据)
- 数据合并逻辑复杂(需要 `Object.assign()` 合并新旧数据)
- 时序不确定(哪个 `alpine:init` 先执行?)

**问题三:安全访问链冗长**

为了防止 store 未定义时模板报错,所有 `$store` 引用都需要可选链和降级:

```html
<!-- 旧版:冗长的安全访问 -->
($store.likes?.data || {})[pid]?.liked
($store.favorites?.data || {})[tid]?.favorited
($store.follows?.data || {})[uid]
```

这种写法既不直观,也容易遗漏。

### 替代方案:Alpine.data 局部组件 + window.__xxxData 预注入

新版架构完全废弃 `Alpine.store`,改用 `Alpine.data()` 注册局部组件,每个组件实例自包含状态:

```javascript
// 新版方式(当前使用)
Alpine.data('likeBtn', function(pid, tid) {
    var initData = (window.__likesData && window.__likesData[pid]) || {liked: false, count: 0};
    return {
        liked: initData.liked || false,
        count: initData.count || 0,
        toggle: function() { /* ... */ }
    };
});
```

模板中使用组件属性:

```html
<!-- 新版模板写法(当前使用) -->
<span x-data="likeBtn(123, 456)" @click="toggle()">
    <i :class="liked ? 'ti-heart-filled text-danger' : 'ti-heart'"></i>
    <span x-text="count"></span>
</span>
```

### 迁移路径

| 旧版写法 | 新版写法 | 说明 |
|----------|----------|------|
| `$store.likes.data[pid]?.liked` | `likeBtn(pid, tid)` 组件的 `liked` 属性 | 从全局 store 读取改为组件局部状态 |
| `$store.favorites.data[tid]?.favorited` | `favBtn(tid)` 组件的 `favorited` 属性 | 同上 |
| `$store.follows.data[uid]` | `followBtn(uid)` 组件的 `followed` 属性 | 同上 |
| `@click="$store.likes.toggle(pid, tid)"` | `@click="toggle()"` | 方法从 store 调用改为组件方法 |
| `Alpine.store('likes', {...})` | `Alpine.data('likeBtn', function(pid, tid) {...})` | 全局单例改为工厂函数 |
| `window.__likesData` → `Object.assign(store.data, ...)` | `window.__likesData` → 组件初始化时直接读取 | 数据注入方式不变,消费方式改变 |

## 6.3 morph 交换保留状态的原理

### idiomorph 算法流程

idiomorph 是 htmx 官方的 morph DOM 交换扩展,其核心算法流程如下:

```
1. 接收:旧 DOM 树(当前页面)+ 新 DOM 树(服务端返回的 HTML 解析结果)
2. 匹配:通过 id 属性和元素位置建立新旧节点对应关系
3. Diff:对比对应节点的标签名、属性、文本内容、子节点差异
4. Patch:只更新变化的部分(属性修改、文本替换、节点增删),保留未变化的节点
5. 结果:Alpine.js 绑定的 DOM 节点如果未被替换,其响应式状态自然保留
```

**id 属性对 morph 的重要性**:

idiomorph 优先通过 `id` 属性匹配新旧节点。有稳定 `id` 的元素在 morph 时能被精确追踪,避免被误删重建:

```html
<!-- ✅ 有唯一 id,morph 能精确匹配 -->
<div id="post-123" x-data="likeBtn(123, 456)">
    <span x-text="count"></span>
</div>

<!-- ❌ 没有 id,morph 只能靠位置匹配,可能误判 -->
<div x-data="likeBtn(123, 456)">
    <span x-text="count"></span>
</div>
```

### alpine-morph 桥接

`alpine-morph` 扩展(`view/vendor/htmx-ext-alpine-morph/alpine-morph.js`)是连接 idiomorph 和 Alpine.js 的桥梁。当 morph 操作需要替换一个 Alpine 组件节点时:

1. **保存状态**:在移除旧节点前,读取其 Alpine 组件的 `x-data` 值
2. **执行 morph**:idiomorph 完成新旧 DOM 的 diff 和 patch
3. **恢复状态**:如果新 DOM 中有对应的节点(通过 id 匹配),将保存的状态恢复到新节点
4. **结果**:组件的 `liked`、`count` 等响应式属性不会因 morph 操作而重置

### 交换策略选择

项目中有两种主要的 htmx 交换场景:

**全局导航(页面切换)**

```html
<!-- body 标签配置 -->
<body hx-boost="true" hx-target="#body" hx-select="#body" hx-swap="innerHTML"
      hx-ext="idiomorph, alpine-morph">

<!-- main 标签覆盖交换策略 -->
<main id="body" hx-swap="morph:innerHTML">
```

当用户通过 `hx-boost` 导航到新页面时:
1. body 标签声明 `hx-swap="innerHTML"` 作为默认策略
2. `<main id="body">` 覆盖为 `hx-swap="morph:innerHTML"`
3. 服务端返回完整 HTML,`hx-select="#body"` 提取 `<main>` 内容
4. morph 算法对比新旧 `<main>` 内容,只更新差异部分
5. 导航栏、侧边栏等不变区域保持不变,其 Alpine 状态保留

**局部更新(组件级交互)**

```html
<!-- 有 Alpine 组件的区域,必须使用 morph -->
<div id="post-actions-123" hx-swap="morph:innerHTML">
    <span x-data="likeBtn(123, 456)" @click="toggle()">...</span>
</div>

<!-- 无 Alpine 组件的纯内容区域,可使用 innerHTML -->
<div id="static-content" hx-swap="innerHTML">
    <p>纯文本内容,无需保留状态</p>
</div>
```

**决策树**:

```
需要更新 DOM?
├── 目标区域包含 Alpine 组件?
│   ├── 是 → 使用 morph:innerHTML 或 morph:outerHTML
│   └── 否 → 使用 innerHTML(更简单,性能略好)
└── 不需要 → 不用 htmx,用纯 Alpine 状态切换
```

---

# 第七章:迁移检查清单与排错

## 7.1 模板迁移检查清单

从 Bootstrap 3 迁移模板到 Bootstrap 5 时,逐项检查以下内容:

- [ ] **CSS 类名:面板→卡片**:`panel` → `card`、`panel-heading` → `card-header`、`panel-body` → `card-body`、`panel-footer` → `card-footer`
- [ ] **CSS 类名:标签→徽章**:`label` → `badge`
- [ ] **CSS 类名:浮动方向**:`pull-right` → `float-end`、`pull-left` → `float-start`
- [ ] **CSS 类名:间距方向**:`ml-*` → `ms-*`、`mr-*` → `me-*`、`pl-*` → `ps-*`、`pr-*` → `pe-*`
- [ ] **CSS 类名:文本对齐**:`text-left` → `text-start`、`text-right` → `text-end`
- [ ] **CSS 类名:图片**:`img-responsive` → `img-fluid`、`img-circle` → `rounded-circle`
- [ ] **CSS 类名:媒体对象**:`media` / `media-body` → `d-flex` + `flex-fill`
- [ ] **CSS 类名:表单**:`form-group` → `mb-3`、`custom-select` → `form-select`
- [ ] **图标类名**:`fa-xxx` / `bi-xxx` / `glyphicon-xxx` → `ti ti-xxx`(Tabler Icons)
- [ ] **data 属性前缀**:`data-dismiss` → `data-bs-dismiss`、`data-toggle` → `data-bs-toggle`、`data-target` → `data-bs-target`
- [ ] **关闭按钮**:`<button class="close">` → `<button class="btn-close">`
- [ ] **CSRF token**:所有 POST 表单包含 `<?php echo CsrfService::input(); ?>`
- [ ] **XSS 防护**:用户输出使用 `<?php echo esc_html($var); ?>`
- [ ] **网格类名**:`col-xs-*` → `col-*`
- [ ] **隐藏/显示类名**:`hidden-xs` → `d-none d-sm-block`、`hidden` → `d-none`、`show` → 移除 `d-none`
- [ ] **沟槽类名**:`row` 默认 gutter 改用 `g-*` / `gx-*` / `gy-*` 控制

## 7.2 JavaScript 迁移检查清单

从 jQuery 迁移 JavaScript 逻辑时,逐项检查以下内容:

- [ ] **jQuery AJAX**:`$.ajax()` / `$.post()` / `$.get()` → `XN.post()` / `XN.get()` / `XN.ajax()` 或 htmx `hx-post` / `hx-get`
- [ ] **DOM 选择器**:`$(selector)` → `XN.$(selector)` / `XN.$$(selector)` 或 `document.querySelector()` / `document.querySelectorAll()`
- [ ] **DOM 操作**:`$(el).addClass()` → `XN.addClass()` 或 `el.classList.add()`;`$(el).show()` / `.hide()` → `x-show` 或 `XN.show()` / `XN.hide()`
- [ ] **事件绑定**:`$(el).on('click', handler)` → `XN.on()` 或 Alpine `x-on:click`
- [ ] **表单提交**:`$(form).serialize()` + `$.post()` → htmx `hx-post` 或 `XN.submit()`
- [ ] **Alpine 组件自包含**:htmx 动态加载的片段自带完整 `x-data`,不依赖父级作用域
- [ ] **morph 交换策略**:有 Alpine 组件的区域使用 `hx-swap="morph:innerHTML"`
- [ ] **$nextTick 处理时序**:Alpine 状态更新与 htmx 请求同时发生时,用 `$nextTick()` 包裹
- [ ] **登录检查**:需要登录的操作先检查 `window.uid`,未登录(`uid === 0`)则跳转 `XN.url('user-login')`
- [ ] **Modal API**:`$('#modal').modal('show')` → `new bootstrap.Modal(el).show()`
- [ ] **Toast 提示**:`$.alert()` / `showToast()` → `XN.toast(message, type)`
- [ ] **URL 生成**:`xn.url()` → `XN.url()`
- [ ] **禁止 Alpine.store**:不使用 `Alpine.store()` 和 `$store`,改用 `Alpine.data()` 局部组件

## 7.3 常见错误与解决方案

### 1. Alpine 状态在 htmx 交换后丢失

**症状**:点击链接导航后,之前点赞/收藏的状态被重置。

**原因**:交换策略不是 morph,`innerHTML` 会完全替换目标元素的子节点,导致 Alpine 组件被销毁重建。

**解决**:

```html
<!-- 检查 main 标签是否有 morph 交换 -->
<main id="body" hx-swap="morph:innerHTML">  <!-- ✅ 正确 -->

<main id="body" hx-swap="innerHTML">  <!-- ❌ 状态会丢失 -->
```

同时确认 body 标签声明了 `hx-ext="idiomorph, alpine-morph"`。

### 2. htmx 加载的片段中 Alpine 不生效

**症状**:通过 htmx 加载的 HTML 片段中,`x-data` / `x-show` 等 Alpine 指令不工作。

**原因**:片段依赖了父级作用域中的 Alpine 数据,或片段缺少自包含的 `x-data`。

**解决**:

```html
<!-- ❌ 错误:片段依赖父级 x-data -->
<div x-data="{ showReply: false }">
    <div id="reply-area">
        <!-- htmx 加载的片段试图使用父级的 showReply -->
        <div x-show="showReply">回复表单</div>  <!-- 不工作 -->
    </div>
</div>

<!-- ✅ 正确:片段自带完整 x-data -->
<div id="reply-area">
    <div x-data="{ showForm: true }" x-show="showForm">回复表单</div>
</div>
```

### 3. CSRF Token 失效

**症状**:POST 请求返回 403 或 CSRF 验证失败。

**原因**:页面长时间未刷新,CSRF token 过期;或表单中缺少 CSRF token。

**排查**:

```javascript
// 检查 meta 标签中的 token
console.log(document.querySelector('meta[name="csrf-token"]')?.content);
```

**解决**:

1. `XN.post()` 自动从 `<meta name="csrf-token">` 读取 token 并附加到请求
2. 表单中必须包含 `<?php echo CsrfService::input(); ?>`
3. 使用原生 `fetch()` 时需手动读取并附加 token:

```javascript
var csrfToken = document.querySelector('meta[name="csrf-token"]');
if(csrfToken) data.csrf_token = csrfToken.getAttribute('content');
```

### 4. 旧图标不显示

**症状**:页面中出现空白图标或方框,控制台无报错。

**原因**:使用了 Font Awesome / Bootstrap Icons / Glyphicons 的类名,但项目已统一为 Tabler Icons。

**解决**:替换图标类名:

| 旧图标 | 新图标 | 说明 |
|--------|--------|------|
| `fa fa-heart` / `bi-heart` | `ti ti-heart` | 心形 |
| `fa fa-star` / `bi-star` | `ti ti-star` | 星形 |
| `fa fa-user-plus` / `bi-person-plus` | `ti ti-user-plus` | 添加用户 |
| `fa fa-trash` / `bi-trash` | `ti ti-trash` | 删除 |
| `fa fa-pencil` / `bi-pencil` | `ti ti-pencil` | 编辑 |
| `fa fa-search` / `bi-search` | `ti ti-search` | 搜索 |
| `glyphicon glyphicon-move` | `ti ti-category-2` | 移动 |

### 5. Bootstrap 3 组件样式丢失

**症状**:面板、标签、Well 等组件没有样式,显示为普通文本。

**原因**:Bootstrap 5 移除了部分 Bootstrap 3 组件类名。

**解决**:参照第二章 2.1 节的 CSS 类名变更对照表,逐一替换。常见问题:

- `panel` → `card`
- `label` → `badge`
- `well` → `card` + 自定义内边距
- `img-responsive` → `img-fluid`

### 6. Modal 无法弹出

**症状**:点击按钮后模态框不出现,控制台报 `$(...).modal is not a function`。

**原因**:Bootstrap 5 移除了 jQuery 插件式 API。

**解决**:

```javascript
// ❌ 旧版
$('#myModal').modal('show');

// ✅ 新版
var modalEl = document.getElementById('myModal');
var modal = new bootstrap.Modal(modalEl);
modal.show();

// 获取已有实例
var instance = bootstrap.Modal.getInstance(modalEl);
if(instance) instance.hide();
```

同时检查触发按钮的 `data-toggle` 是否已改为 `data-bs-toggle`:

```html
<!-- ❌ 旧版 -->
<button data-toggle="modal" data-target="#myModal">

<!-- ✅ 新版 -->
<button data-bs-toggle="modal" data-bs-target="#myModal">
```

### 7. htmx boost 拦截了外部链接

**症状**:点击外部链接或特殊链接时,页面出现异常加载或 404。

**原因**:`hx-boost="true"` 全局启用后,所有 `<a href>` 链接都会被 htmx 拦截为 AJAX 请求。

**解决**:

```html
<!-- 方式 1:对特定链接禁用 boost -->
<a href="/external-link" hx-boost="false" target="_blank">外部链接</a>

<!-- 方式 2:对整个区域禁用 htmx -->
<div hx-disable>
    <a href="/some-link">不被 boost 的链接</a>
</div>
```

项目中的实际用法(来自 `thread.htm`):

```html
<a href="<?php echo url('thread-create-'.$fid);?>" hx-boost="false">发帖</a>
<a href="<?php echo url("post-create-$tid");?>" hx-boost="false">高级回复</a>
```

### 8. 修改模板后页面未更新

**症状**:修改了模板文件,但浏览器中仍显示旧内容。

**原因**:Xiuno BBS 的模板编译缓存存储在 `tmp/` 目录,修改模板后需要清理缓存。

**解决**:

```bash
# 清理 tmp 目录下的编译缓存
rm -rf tmp/*.php
```

或在后台手动清理缓存。修改 CSS 后还需要浏览器硬刷新(Ctrl+F5 / Cmd+Shift+R)。

### 9. Alpine 组件参数传递错误

**症状**:组件接收到的参数不是预期的值,或控制台报 `Uncaught ReferenceError`。

**原因**:参数类型或格式不正确。

**解决**:

```html
<!-- ❌ 字符串参数没有引号 -->
<div x-data="likeBtn(abc, 123)">  <!-- abc 会被当作变量 -->

<!-- ✅ 正确传递数值参数 -->
<div x-data="likeBtn(<?php echo $post['pid']; ?>, <?php echo $post['tid']; ?>)">

<!-- ❌ 布尔值用了字符串 -->
<div x-data="forumFollowBtn(1, 'false')">  <!-- 'false' 是 truthy 字符串 -->

<!-- ✅ 正确传递布尔值 -->
<div x-data="forumFollowBtn(<?php echo $fid; ?>, <?php echo $followed ? 'true' : 'false'; ?>)">
```

### 10. htmx:afterSwap 后 Alpine 初始化失败

**症状**:htmx 交换内容后,新插入的 Alpine 组件不工作。

**原因**:alpine-morph 扩展未正确加载或配置。

**解决**:

1. 确认 body 标签有 `hx-ext="idiomorph, alpine-morph"`
2. 确认脚本加载顺序正确(参见 htmx-alpine-guide.md 第 2 节)
3. 如需手动初始化,在 `htmx:afterSettle` 事件中调用 `Alpine.initTree()`

---

# 第八章:关键文件路径速查

| 分类 | 文件 | 路径 | 说明 |
|------|------|------|------|
| **前端核心** | htmx | `view/vendor/htmx/htmx.min.js` | htmx 2.0.x 核心,声明式 AJAX 交互 |
| **前端核心** | idiomorph | `view/vendor/idiomorph/idiomorph-ext.min.js` | htmx 扩展,提供 morph DOM 交换算法 |
| **前端核心** | alpine-morph | `view/vendor/htmx-ext-alpine-morph/alpine-morph.js` | htmx 扩展,桥接 Alpine.js morph 与 htmx swap |
| **前端核心** | Alpine morph 插件 | `view/vendor/alpinejs-morph/cdn.min.js` | Alpine.js 官方 morph 插件 |
| **前端核心** | Alpine.js | `view/vendor/alpinejs/cdn.min.js` | Alpine.js 3.x 核心,最后加载 |
| **CSS** | Bootstrap CSS | `view/vendor/bootstrap/css/bootstrap.min.css` | Bootstrap 5.3.7 样式 |
| **CSS** | Tabler Icons CSS | `view/vendor/tabler-icons/tabler-icons.min.css` | Tabler Icons 3.31.0 图标字体 |
| **CSS** | 自定义 CSS | `view/css/bootstrap-bbs.css` | BBS 品牌样式覆盖、主题变量、自定义组件 |
| **CSS** | 通知样式 | `view/css/notice.css` | 通知系统样式 |
| **CSS** | Prism 代码高亮 | `view/vendor/prismjs/themes/prism-tomorrow.min.css` | 代码块高亮主题 |
| **JS 兼容层** | xiuno-modern.js | `view/js/xiuno-modern.js` | 原生 JS 兼容层,XN.post / XN.toast / XN.url 等 API |
| **JS 兼容层** | xiuno.js | `view/js/xiuno.js` | 旧版 JS 核心库,依赖 jQuery,showToast 等 |
| **JS 兼容层** | jQuery | `view/js/jquery-3.7.1.min.js` | 旧代码兼容保留,新代码禁止使用 |
| **JS 兼容层** | bbs.js | `view/js/bbs.js` | BBS 业务逻辑,依赖 jQuery |
| **JS 兼容层** | form.js | `view/js/form.js` | 表单处理,依赖 jQuery |
| **JS 兼容层** | async.js | `view/js/async.js` | 异步加载,依赖 jQuery |
| **JS 兼容层** | bootstrap-plugin.js | `view/js/bootstrap-plugin.js` | Bootstrap 插件适配,showToast 等 |
| **JS 兼容层** | upload-service.js | `view/js/upload-service.js` | 上传服务 |
| **JS 兼容层** | color-utils.js | `view/js/color-utils.js` | 主题色工具函数 |
| **JS 兼容层** | md5.js | `view/js/md5.js` | MD5 哈希(密码提交) |
| **JS 兼容层** | tabler-icon-picker.js | `view/js/tabler-icon-picker.js` | Tabler Icons 选择器 |
| **JS 兼容层** | Prism.js | `view/vendor/prismjs/prism.min.js` | 代码高亮核心 |
| **JS 兼容层** | Prism autoloader | `view/vendor/prismjs/plugins/prism-autoloader.min.js` | 代码高亮语言自动加载 |
| **JS 兼容层** | Chart.js | `view/vendor/chartjs/chart.umd.min.js` | 图表库 |
| **JS 兼容层** | Bootstrap JS | `view/vendor/bootstrap/js/bootstrap.bundle.min.js` | Bootstrap 5 JS 组件(含 Popper) |
| **前台模板** | 页面头部 | `view/htm/header.inc.htm` | htmx + 扩展加载、body/main 配置、主题初始化 |
| **前台模板** | 页面底部 | `view/htm/footer.inc.htm` | Alpine.js 加载、Alpine.data 组件注册、全局函数 |
| **前台模板** | 顶部导航 | `view/htm/header_nav.inc.htm` | 导航栏、搜索框、用户菜单、通知 |
| **前台模板** | 底部导航 | `view/htm/footer_nav.inc.htm` | 移动端底部导航栏 |
| **前台模板** | 左侧边栏 | `view/htm/sidebar_left.inc.htm` | 板块导航 |
| **前台模板** | 右侧边栏 | `view/htm/sidebar_right.inc.htm` | 站点信息、活跃用户、热门主题 |
| **前台模板** | 首页 | `view/htm/index.htm` | 论坛首页 |
| **前台模板** | 帖子详情 | `view/htm/thread.htm` | 帖子详情页(数据注入 + 侧边栏 + 快速回复) |
| **前台模板** | 评论列表 | `view/htm/post_list.inc.htm` | 一级评论 + 二级回复列表 |
| **前台模板** | 帖子列表 | `view/htm/thread_list.inc.htm` | 标准帖子列表 |
| **前台模板** | 时间线列表 | `view/htm/thread_list_timeline.inc.htm` | 微博模式列表 |
| **前台模板** | 瀑布流列表 | `view/htm/thread_list_masonry.inc.htm` | 瀑布流模式列表 |
| **前台模板** | 管理操作 | `view/htm/thread_list_mod.inc.htm` | 批量管理复选框和操作按钮 |
| **前台模板** | 发帖/编辑 | `view/htm/post.htm` | 发帖和编辑帖子页面 |
| **前台模板** | 版块页 | `view/htm/forum.htm` | 版块详情页 |
| **前台模板** | 用户主页 | `view/htm/user.htm` | 用户主页入口 |
| **前台模板** | 用户资料 | `view/htm/user.template.htm` | 用户资料模板 |
| **前台模板** | 用户通用模板 | `view/htm/user.common.template.htm` | 用户主页标签页模板 |
| **前台模板** | 登录 | `view/htm/user_login.htm` | 登录页 |
| **前台模板** | 注册 | `view/htm/user_create.htm` | 注册页 |
| **前台模板** | 找回密码 | `view/htm/user_resetpw.htm` | 重设密码页 |
| **前台模板** | 搜索 | `view/htm/search.htm` | 搜索结果页 |
| **前台模板** | 排行榜 | `view/htm/leaderboard.htm` | 排行榜页 |
| **前台模板** | 个人设置 | `view/htm/my.common.template.htm` | 个人设置标签页模板 |
| **前台模板** | 修改密码 | `view/htm/my_password.htm` | 修改密码/邮箱页 |
| **前台模板** | 通知列表 | `view/htm/my_notify.htm` | 系统通知列表 |
| **前台模板** | 错误页面 | `view/htm/error.htm` | 404/403/500 错误页 |
| **前台模板** | 主题切换 | `view/htm/theme.htm` | 主题色切换页 |
| **前台模板** | 管理弹窗 | `view/htm/mod_delete.htm` ~ `mod_top.htm` 等 | 管理操作弹窗模板 |
| **后台模板** | 后台模板目录 | `admin/view/htm/` | 管理后台页面模板 |
| **插件目录** | 插件根目录 | `plugin/` | 所有插件存放目录 |
| **插件目录** | 通知插件 | `plugin/huux_notice/` | 通知系统插件 |
| **插件目录** | 积分插件 | `plugin/xo_credits/` | 积分/金币/付费主题插件 |
| **插件目录** | 验证码插件 | `plugin/xo_vcode/` | 滑块验证码插件 |
| **缓存目录** | 模板缓存 | `tmp/` | 模板编译缓存,修改模板后需清理 |
| **配置文件** | 默认配置 | `conf/conf.default.php` | 站点默认配置 |
| **配置文件** | 附件配置 | `conf/attach.conf.php` | 附件相关配置 |
| **配置文件** | 邮件配置 | `conf/smtp.conf.php` | SMTP 邮件配置 |
| **文档目录** | 文档根目录 | `doc/` | 项目文档 |
| **文档目录** | htmx+Alpine 指南 | `doc/htmx-alpine-guide.md` | htmx + Alpine.js 开发指南 |
| **文档目录** | 升级指南 | `doc/upgrade-guide.md` | 本文档 |

---

# 第五章:旧插件兼容与升级

Xiuno BBS 拥有 60+ 个社区插件,绝大部分基于旧技术栈(jQuery + Bootstrap 3 + 旧图标库)开发。系统升级后采用渐进式兼容策略:jQuery 保留加载,旧插件可继续运行,新插件必须使用新栈。

## 5.1 兼容策略

### jQuery 3.1.0 保留加载

系统升级后,jQuery 3.1.0 仍然在 `footer.inc.htm` 中加载,所有旧插件中使用 `$` / `jQuery` 的代码无需任何修改即可正常运行。

当前脚本加载顺序(来自 `view/htm/footer.inc.htm`):

```
1. bbs.js(语言包)
2. jquery-3.1.0.js
3. bootstrap.bundle.min.js(Bootstrap 5)
4. xiuno.js(依赖 jQuery,提供 $.xpost / $.xget / $.alert 等)
5. bootstrap-plugin.js
6. async.js
7. form.js
8. xiuno-modern.js(XN 兼容层,不依赖 jQuery)
9. md5.js
10. bbs.js / upload-service.js
11. Alpine.data 组件注册(alpine:init 事件)
12. alpinejs-morph/cdn.min.js
13. alpinejs/cdn.min.js(最后加载)
```

### xiuno.js 旧 API 保留

`xiuno.js` 仍提供以下旧 API,旧插件可继续使用:

| 旧 API | 说明 | 新替代 |
|--------|------|--------|
| `$.xpost(url, data, callback)` | AJAX POST 请求 | `XN.post(url, data, callback)` |
| `$.xget(url, callback)` | AJAX GET 请求 | `XN.get(url, callback)` |
| `$.alert(msg)` | 弹窗提示 | `XN.toast(msg, 'danger')` |
| `$.confirm(title, callback, options)` | 确认弹窗 | 原生 `confirm()` 或 Alpine.js 弹窗 |
| `$.each_sync(arr, callback)` | 串行遍历 | `for...of` + `async/await` |
| `xn.upload_file()` | 文件上传 | `fetch()` + `FormData` |
| `xn.url()` | URL 生成 | `XN.url()` |
| `xn.intval()` | 整数转换 | `XN.intval()` |
| `xn.json_encode()` | JSON 编码 | `JSON.stringify()` |

### 新旧代码共存规则

```javascript
// ✅ 旧插件 — 继续使用 $,无需改动
$.xpost(url, {pid: pid}, function(code, msg) {
    if (code == 1) { /* ... */ }
});

// ✅ 新插件 — 使用 XN 兼容层
XN.post(url, {pid: pid}, function(code, msg) {
    if (code == 0) { /* ... */ }
});

// ✅ 新插件 — 使用 htmx 声明式
// <button hx-post="/action" hx-target="#result" hx-swap="morph:innerHTML">

// ❌ 禁止 — 新代码引入新的 jQuery 依赖
// 新插件/新页面不得使用 $ 或 jQuery
```

> **注意**:旧 API 回调中 `code == 1` 表示成功,新 `XN.post()` 回调中 `code == 0` 表示成功。迁移时务必注意此差异。

## 5.2 旧插件典型问题

升级后旧插件可能遇到以下问题:

### 问题 1:图标不显示

**原因**:旧插件使用 Font Awesome(`fa-`)、Bootstrap Icons(`bi-`)或 Glyphicons(`glyphicon-`)类名,新系统统一使用 Tabler Icons(`ti ti-xxx`)。

```html
<!-- 旧代码:图标不显示 -->
<i class="icon icon-thumbs-o-up"></i>
<i class="fa fa-heart"></i>
<i class="glyphicon glyphicon-search"></i>

<!-- 修复:替换为 Tabler Icons -->
<i class="ti ti-thumb-up"></i>
<i class="ti ti-heart"></i>
<i class="ti ti-search"></i>
```

### 问题 2:Bootstrap 3 组件类名失效

**原因**:Bootstrap 5 移除了大量 BS3 组件类名,旧模板中的 `panel`、`well`、`label` 等不再生效。

```html
<!-- 旧代码:样式丢失 -->
<div class="panel panel-default">
    <div class="panel-heading">标题</div>
    <div class="panel-body">内容</div>
</div>
<span class="label label-primary">标签</span>

<!-- 修复:替换为 BS5 类名 -->
<div class="card">
    <div class="card-header">标题</div>
    <div class="card-body">内容</div>
</div>
<span class="badge bg-primary">标签</span>
```

### 问题 3:Modal / Tooltip 初始化方式不兼容

**原因**:Bootstrap 5 的 jQuery 插件式 API(`$('#modal').modal('show')`)仍可工作(因 jQuery 保留加载),但 data 属性必须加 `bs-` 前缀。

```html
<!-- 旧代码:data 属性不生效 -->
<button data-toggle="modal" data-target="#myModal">打开</button>
<button data-dismiss="modal">关闭</button>

<!-- 修复:添加 bs- 前缀 -->
<button data-bs-toggle="modal" data-bs-target="#myModal">打开</button>
<button data-bs-dismiss="modal">关闭</button>
```

### 问题 4:htmx boost 拦截了插件内的链接点击

**原因**:`<main id="body">` 启用了 `hx-boost`,插件内通过 `<a href="...">` 发起的导航会被 htmx 拦截为 AJAX 请求,而非全页刷新。

```html
<!-- 旧代码:链接被 htmx 拦截 -->
<a href="<?php echo url('user-login');?>">登录</a>

<!-- 修复方案 1:添加 hx-boost="false" 禁用增强 -->
<a href="<?php echo url('user-login');?>" hx-boost="false">登录</a>

<!-- 修复方案 2:JS 中使用 window.location 替代链接点击 -->
<script>
window.location.href = XN.url('user-login');
</script>
```

### 问题 5:CSRF token 获取方式变化

**原因**:新系统通过 `<meta name="csrf-token">` 标签提供 CSRF token,旧插件可能从表单隐藏域或 `xn.data()` 获取。

```javascript
// 旧代码:从表单或全局变量获取
var csrf_token = $('input[name="csrf_token"]').val();
// 或
var csrf_token = xn.data('csrf_token');

// 兼容写法:优先从 meta 标签获取
function getCsrfToken() {
    var meta = document.querySelector('meta[name="csrf-token"]');
    if (meta) return meta.getAttribute('content');
    var input = document.querySelector('input[name="csrf_token"]');
    if (input) return input.value;
    return '';
}
```

> **说明**:`XMLHttpRequest.prototype.send` 已在 `footer.inc.htm` 中被拦截,自动注入 `X-CSRF-Token` 请求头,因此使用 `XN.post()` 或 `fetch()` 时无需手动添加 CSRF token 到请求体(但服务端仍需从 `csrf_token` 字段或 header 中校验)。

### 问题 6:全局事件冲突

**原因**:htmx 的 `htmx:afterRequest`、`htmx:afterSwap` 等事件与旧插件的 `$.ajax` 回调可能产生时序冲突,导致 DOM 更新后旧插件的事件监听丢失。

```javascript
// 旧插件:$.ajax 回调中操作 DOM
$.xpost(url, data, function(code, msg) {
    $('#result').html(msg);  // htmx swap 可能同时更新同一区域
});

// 兼容方案:监听 htmx:afterSwap 重新绑定事件
document.addEventListener('htmx:afterSwap', function(e) {
    // 重新初始化插件组件
    if (e.detail.target.querySelector('.my-plugin-container')) {
        initMyPlugin();
    }
});
```

## 5.3 插件升级三级分类

根据插件的 jQuery 依赖程度和前端复杂度,将插件升级分为 A、B、C 三个等级。

### A级:零改动兼容

**判断标准**:

- 插件仅包含后端 PHP 逻辑(路由、模型、Hook 处理)
- 无前端模板文件(`view/htm/` 或 `hook/*.htm`)
- 无 JavaScript 代码
- 或仅有纯服务端渲染的 HTML,无客户端交互

**示例插件**:

| 插件类型 | 说明 |
|---------|------|
| 纯后端功能插件 | 如 SEO 优化、邮件通知、数据统计等 |
| 仅修改 PHP 逻辑的 Hook 插件 | 如 `model_post_create_end.php`、`model_thread_delete_start.php` |
| 无模板的 API 类插件 | 仅提供后端接口,前端由其他插件调用 |

**操作**:无需任何改动,直接在新系统中运行。

---

### B级:模板微调

**判断标准**:

- 插件有前端模板文件(`hook/*.htm` 或 `view/htm/*.htm`)
- JavaScript 逻辑简单(仅表单提交、链接跳转等)
- 不依赖复杂的 jQuery 插件或自定义 UI 组件
- 核心交互可通过 CSS 类名替换和属性修改解决

**需要做的改动**:

1. **图标替换**:将旧图标类名替换为 Tabler Icons
2. **CSS 类名更新**:将 BS3 类名替换为 BS5 类名
3. **data 属性加 `bs-` 前缀**:`data-toggle` → `data-bs-toggle` 等
4. **间距类名更新**:`ml-*` → `ms-*`、`mr-*` → `me-*` 等
5. **隐藏/显示类名更新**:`hidden-xs` → `d-none d-sm-block` 等

**操作步骤清单**:

1. 在插件目录中搜索所有 `.htm` 文件
2. 全局替换图标类名(参考第四章图标库迁移对照表)
3. 全局替换 BS3 → BS5 类名(参考第二章 2.1 节对照表)
4. 全局替换 data 属性(添加 `bs-` 前缀)
5. 全局替换间距类名(`ml-` → `ms-`、`mr-` → `me-`、`pl-` → `ps-`、`pr-` → `pe-`)
6. 清理 `tmp/` 缓存,硬刷新测试
7. 检查暗色模式下的显示效果

**示例:xn_search 搜索插件**

搜索插件的前端代码仅包含一个表单提交和导航链接,属于典型的 B 级插件。

修改前(`hook/header_nav_user_start.htm`):

```html
<li class="nav-item hidden-lg">
    <a class="nav-link" href="<?php echo url('search');?>"><i class="icon-search"></i> <?php echo lang('search');?></a>
</li>
```

修改后:

```html
<li class="nav-item d-lg-none">
    <a class="nav-link" href="<?php echo url('search');?>"><i class="ti ti-search"></i> <?php echo lang('search');?></a>
</li>
```

修改前(`hook/index_js.htm`):

```javascript
jsearch_form = $('#search_form');
jsearch_form.on('submit', function() {
    var keyword = jsearch_form.find('input[name="keyword"]').val();
    var url = xn.url('search-'+xn.urlencode(keyword));
    window.location = url;
    return false;
});
```

修改后(可选,jQuery 版本仍可工作):

```javascript
var searchForm = document.getElementById('search_form');
if (searchForm) {
    searchForm.addEventListener('submit', function(e) {
        e.preventDefault();
        var keyword = this.querySelector('input[name="keyword"]').value;
        var url = XN.url('search-' + encodeURIComponent(keyword));
        window.location.href = url;
    });
}
```

**示例:xn_tag 标签插件**

标签插件的前端模板使用了 BS3 的 `badge` 类名和 jQuery 事件绑定。

修改前(`hook/thread_list_inc_subject_after.htm`):

```html
<a href="..." class="badge badge-pill badge-secondary">标签名</a>
```

修改后:

```html
<a href="..." class="badge rounded-pill bg-secondary">标签名</a>
```

---

### C级:JS 重写

**判断标准**:

- 插件前端交互复杂(编辑器、拖拽上传、实时预览等)
- 深度依赖 jQuery API(`$.fn`、`$.Deferred`、`$.each_sync`、jQuery 事件委托等)
- 依赖第三方 jQuery 插件(如 UMEditor、ColorPicker 等)
- 需要 DOM 状态管理(点赞计数、收藏状态切换等)

**需要做的改动**:

1. **jQuery → XN API / 原生 JS**:将 `$.xpost()` 替换为 `XN.post()`,`$.ajax()` 替换为 `fetch()` 或 `XN.ajax()`
2. **Alpine.js 状态管理**:将 jQuery DOM 操作改为 Alpine.js 响应式数据绑定
3. **hx-swap 策略**:将手动 DOM 更新改为 htmx 声明式交换
4. **自包含原则**:通过 htmx 动态加载的 HTML 片段必须自带完整的 `x-data` 定义
5. **Alpine.data 注册**:复用组件在 `alpine:init` 事件中注册

**操作步骤清单**:

1. 分析插件的 jQuery 依赖点(AJAX 调用、DOM 操作、事件绑定、动画)
2. 设计 Alpine.js 数据模型(状态变量 + 操作方法)
3. 将 `$.xpost()` / `$.ajax()` 替换为 `XN.post()` 或 `fetch()`
4. 将 jQuery DOM 操作(`.addClass()`、`.html()`、`.text()`)替换为 Alpine.js 绑定(`:class`、`x-text`、`x-html`)
5. 将 jQuery 事件绑定(`.on('click', ...)`)替换为 `@click` 或 `x-on:click`
6. 在 `alpine:init` 事件中注册 `Alpine.data()` 组件
7. 确保 htmx 动态加载的片段自包含 `x-data`
8. 清理 `tmp/` 缓存,测试所有交互场景
9. 测试暗色模式兼容性

**示例:haya_post_like 点赞插件**

这是典型的 C 级插件,深度依赖 jQuery 进行 AJAX 请求和 DOM 状态切换。

修改前(`hook/thread_js.htm`):

```javascript
$(document).on('click', '.js-haya-post-like-post-add', function() {
    var thiz = $(this);
    thiz.removeClass('js-haya-post-like-post-add');
    var pid = thiz.attr("data-pid");
    var url = '<?php echo url("post-post_like-create");?>';
    $.xpost(url, {'pid': pid}, function(code, msg){
        if (code == 1) {
            thiz.removeClass('js-haya-post-like-post-add')
                .addClass('js-haya-post-like-post-del')
                .addClass('haya-post-like-loved')
                .attr('title', '已点赞');
            thiz.find(".icon").removeClass('icon-thumbs-o-up')
                .addClass('icon-thumbs-up');
            thiz.find(".haya-post-like-post-user-count").text(msg.count);
        } else {
            thiz.addClass('js-haya-post-like-post-add');
            $.alert(msg);
        }
    });
});
```

修改后(Alpine.js + XN API):

```javascript
// 在 alpine:init 事件中注册组件
document.addEventListener('alpine:init', function() {
    Alpine.data('postLikeBtn', function(pid, initLiked, initCount) {
        return {
            liked: initLiked || false,
            count: initCount || 0,
            toggle: function() {
                if (!window.uid || window.uid === 0) {
                    window.location.href = XN.url('user-login');
                    return;
                }
                var self = this;
                var url = self.liked
                    ? '<?php echo url("post-post_like-delete");?>'
                    : '<?php echo url("post-post_like-create");?>';
                XN.post(url, {pid: pid}, function(code, msg) {
                    if (code == 0) {
                        self.liked = !self.liked;
                        self.count = self.liked ? self.count + 1 : self.count - 1;
                    } else {
                        if (typeof XN.toast === 'function') XN.toast(msg || '操作失败', 'danger');
                    }
                });
            }
        };
    });
});
```

修改前(`hook/post_list_inc_create_date_after.htm`):

```html
<span class="haya-post-like ml-2">
    <a href="javascript:;" class="text-muted js-haya-post-like-post-add" data-pid="<?php echo $_post['pid'];?>">
        <i class="icon icon-thumbs-o-up"></i>
        <span class="haya-post-like-post-user-count"><?php echo intval($_post['likes']); ?></span>
    </a>
</span>
```

修改后:

```html
<span class="haya-post-like ms-2" x-data="postLikeBtn(<?php echo $_post['pid'];?>, <?php echo $haya_post_like_check ? 'true' : 'false';?>, <?php echo intval($_post['likes']);?>)">
    <a href="javascript:;" class="text-muted" :class="{'text-danger': liked}" @click="toggle()">
        <i class="ti" :class="liked ? 'ti-thumb-up-filled' : 'ti-thumb-up'"></i>
        <span x-text="count"></span>
    </a>
</span>
```

**关键迁移对照**:

| 旧模式 | 新模式 | 说明 |
|--------|--------|------|
| `$.xpost(url, data, fn)` | `XN.post(url, data, fn)` | 注意 code 值差异:旧 1=成功,新 0=成功 |
| `$(document).on('click', '.btn', fn)` | `@click="method()"` | Alpine.js 事件绑定 |
| `$(this).addClass('x').removeClass('y')` | `:class="{'x': condition, 'y': !condition}"` | Alpine.js 动态类名 |
| `$(this).find('.count').text(n)` | `x-text="count"` | Alpine.js 文本绑定 |
| `$(this).attr('disabled', 'disabled')` | `:disabled="loading"` | Alpine.js 属性绑定 |
| `$.alert(msg)` | `XN.toast(msg, 'danger')` | Toast 提示替代弹窗 |
| `$.confirm(title, fn)` | 原生 `confirm()` 或 Alpine.js 弹窗 | 确认交互 |
| `$.each_sync(arr, fn)` | `for...of` + `async/await` | 串行遍历 |

**示例:xn_umeditor 编辑器插件**

UMEditor 是深度依赖 jQuery 的富文本编辑器,属于最复杂的 C 级插件。其核心文件 `umeditor-bbs.js` 大量使用 `$()`、`$.eduibutton()`、`$.each_sync()`、`$.alert()` 等 API。

此类插件的升级策略:

1. **短期**:保持 jQuery 依赖,仅修复模板中的 BS3 类名和图标(降级为 B 级处理)
2. **长期**:替换为基于原生 JS 的现代编辑器(如 TipTap、Quill 等),完全去除 jQuery 依赖

短期修复示例(仅处理模板):

```html
<!-- 修改前 -->
<div class="form-group">
    <label>内容</label>
    <script type="text/plain" id="message" class="form-control"></script>
</div>

<!-- 修改后 -->
<div class="mb-3">
    <label class="form-label">内容</label>
    <script type="text/plain" id="message" class="form-control"></script>
</div>
```

## 5.4 新插件开发规范

### 核心规则

1. **禁止 jQuery**:新插件必须使用 `XN.xxx()` 或原生 JS,不得依赖 `$` 或 `jQuery`
2. **UI 框架**:使用 Bootstrap 5.3+ 类名和组件
3. **图标库**:使用 Tabler Icons(`ti ti-xxx`),禁止引入其他图标库
4. **交互组件**:使用 Alpine.js,组件通过 `Alpine.data()` 在 `alpine:init` 事件中注册
5. **POST 请求**:必须包含 `CsrfService::input()` 或从 `<meta name="csrf-token">` 获取 token
6. **XSS 防护**:输出用户内容时使用 `esc_html()`,前端使用 `XN.escapeHtml()`
7. **自包含原则**:htmx 动态加载的 HTML 片段必须自带完整的 `x-data` 定义,不得依赖父级作用域
8. **禁止全局 Store**:不得使用 `Alpine.store()` 和 `$store`,所有数据定义在局部 `x-data` 内

### 文件结构规范

```
plugin/xo_example/
├── conf.json              # 插件配置
├── install.php            # 安装脚本
├── unstall.php            # 卸载脚本
├── setting.php            # 后台设置
├── model/                 # 后端模型
│   └── example.func.php
├── route/                 # 后端路由(可选)
│   └── example.php
├── hook/                  # Hook 注入点
│   ├── header_js.htm      # CSS/JS 引入
│   ├── thread_js.htm      # 页面级 JS
│   └── *.php              # 后端 Hook
├── view/htm/              # 前端模板
│   └── example.htm
└── static/                # 静态资源(或 js/、css/)
    └── js/
        └── example.js     # 前端交互(原生 JS)
```

### JavaScript 编写规范

```javascript
/**
 * 插件前端交互
 * 原生 JS + Alpine.js,无 jQuery 依赖
 */
(function() {
    'use strict';

    // ========== 工具函数 ==========

    // 获取 CSRF token
    function getCsrfToken() {
        var meta = document.querySelector('meta[name="csrf-token"]');
        if (meta) return meta.getAttribute('content');
        var input = document.querySelector('input[name="csrf_token"]');
        if (input) return input.value;
        return '';
    }

    // ========== API 调用 ==========

    function myAction(param, csrfToken, onSuccess, onError) {
        XN.post('my-plugin-action', {
            param: param,
            csrf_token: csrfToken
        }, function(code, msg) {
            if (code === 0) {
                if (onSuccess) onSuccess(msg);
            } else {
                if (onError) onError(msg || '操作失败');
            }
        });
    }

    // ========== Alpine.js 组件注册 ==========

    document.addEventListener('alpine:init', function() {
        Alpine.data('myComponent', function(initParam) {
            return {
                loading: false,
                param: initParam,
                doAction: function() {
                    var self = this;
                    self.loading = true;
                    myAction(self.param, getCsrfToken(), function(msg) {
                        self.loading = false;
                        XN.toast('操作成功', 'success');
                    }, function(msg) {
                        self.loading = false;
                        XN.toast(msg, 'danger');
                    });
                }
            };
        });
    });

    // ========== htmx 动态加载后重新初始化 ==========

    document.addEventListener('htmx:afterSwap', function(e) {
        var containers = e.detail.target.querySelectorAll('.my-plugin-container');
        containers.forEach(function(c) {
            // 重新初始化组件
        });
    });

    // ========== 暴露全局方法(兼容非 Alpine 场景) ==========

    window.MyPlugin = {
        action: myAction
    };
})();
```

### 模板编写规范

```html
<!-- 自包含的 Alpine.js 组件片段 -->
<div class="card mb-3" x-data="myComponent('<?php echo $param;?>')">
    <div class="card-body">
        <h5 class="card-title">
            <i class="ti ti-star me-1"></i>插件标题
        </h5>
        <p class="card-text" x-text="param"></p>
        <button class="btn btn-primary btn-sm"
                :disabled="loading"
                @click="doAction()">
            <i class="ti ti-check me-1" x-show="!loading"></i>
            <span class="spinner-border spinner-border-sm me-1" x-show="loading"></span>
            <span x-text="loading ? '处理中...' : '执行操作'"></span>
        </button>
    </div>
</div>
```

### Hook 注入规范

插件通过 Hook 机制注入 HTML 片段到页面指定位置。注入的片段需遵循自包含原则:

```html
<!-- hook/thread_js.htm — JS 注入 -->
<script>
// 使用 IIFE 避免全局污染
(function() {
    'use strict';

    // 初始化逻辑
    function init() { /* ... */ }

    // DOM Ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

    // htmx 动态加载后重新初始化
    document.addEventListener('htmx:afterSwap', function(e) {
        if (e.detail.target.querySelector('.my-plugin-el')) {
            init();
        }
    });
})();
</script>

<!-- hook/header_js.htm — CSS 引入 -->
<link rel="stylesheet" href="plugin/xo_example/static/css/example.css">

<!-- hook/thread_postlist_before.htm — HTML 注入(自包含) -->
<div class="card mb-3" x-data="myComponent('initValue')">
    <!-- 组件内容,自带 x-data -->
</div>
```

### 参考范例:xo_vcode 插件

`xo_vcode` 是新版插件的典型范例,特点如下:

- **原生 JS**:使用 `XMLHttpRequest` 和 `document.querySelector`,无 jQuery 依赖
- **Bootstrap 5**:使用 BS5 类名和组件
- **Tabler Icons**:图标使用 `ti ti-arrows-horizontal`、`ti ti-check`、`ti ti-x`
- **CSRF 防护**:从 `<meta name="csrf-token">` 获取 token
- **htmx 兼容**:监听 `htmx:afterSwap` 事件重新初始化组件
- **IIFE 封装**:使用 `(function() { ... })()` 避免全局污染
- **初始化标记**:通过 `dataset.initialized` 防止重复初始化

关键代码片段:

```javascript
// 防止重复初始化
function initCaptcha(container) {
    var wrap = container.querySelector('.xo-vcode-wrap');
    if (!wrap || wrap.dataset.initialized === '1') return;
    wrap.dataset.initialized = '1';
    // ...
}

// htmx 动态加载后重新初始化
document.body.addEventListener('htmx:afterSwap', function(evt) {
    var containers = evt.detail.target.querySelectorAll('.xo-vcode-container');
    containers.forEach(function(c) {
        initCaptcha(c);
    });
});
```

### 参考范例:xo_credits 积分插件

`xo_credits` 展示了 Alpine.js 组件注册的最佳实践:

- **Alpine.data 注册**:在 `alpine:init` 事件中注册 `payThreadBtn` 和 `transferDialog` 组件
- **fetch API**:使用原生 `fetch()` 进行网络请求
- **CSRF 防护**:POST 请求包含 `csrf_token` 参数
- **全局暴露**:同时暴露 `window.XoCredits` 兼容非 Alpine 场景

```javascript
document.addEventListener('alpine:init', function() {
    Alpine.data('payThreadBtn', function(tid, csrfToken) {
        return {
            loading: false,
            pay: function() {
                var self = this;
                self.loading = true;
                payThread(tid, csrfToken, function(d) {
                    self.loading = false;
                    window.location.reload();
                }, function(msg) {
                    self.loading = false;
                    alert(msg);
                });
            }
        };
    });
});
```
0 0 0
复制成功