xiunobbs 重构记录 (十五) MD编辑器与AI设置
贰先生 2小时前

# Xiuno BBS 编辑器与 AI 设置

## 1. 概述

Xiuno BBS 4.5+ 采用 AiEditor 作为富文本编辑器,集成了 AI 辅助写作功能(续写、优化),支持图片拖拽/粘贴上传、附件管理、移动端自适应工具栏等特性。

**核心组件**:
- **EditorService** — 编辑器服务类,负责资源加载、HTML 渲染、AI 配置构建
- **AiEditor** — 第三方富文本编辑器(`view/js/aieditor/index.umd.js`)
- **UploadService** — 统一上传模块,支持拖拽、粘贴、进度追踪、文件校验
- **AI 设置** — 后台全局配置 + 前台用户个人配置,双层覆盖机制

## 2. 架构总览

```
用户发帖/回帖页面 (post.htm)
    │
    ├── EditorService::getEditorAssets()  → 加载 CSS/JS 资源
    ├── EditorService::renderEditorHtml() → 渲染编辑器 HTML + 初始化脚本
    │       │
    │       ├── buildAiConfig()           → 构建前端 AI 配置对象
    │       │       ├── 读取全局配置 $conf['ai']
    │       │       ├── 读取用户配置 $user['ai_config']
    │       │       └── mergeAiConfig()   → 用户配置覆盖全局配置
    │       │
    │       ├── 初始化 AiEditor 实例
    │       ├── 绑定 UploadService(拖拽/粘贴上传)
    │       └── 同步编辑器内容到隐藏 input
    │
    └── 表单提交 → hiddenInput.value(编辑器 HTML 内容)
```

## 3. EditorService 详解

**文件位置**:`lib/EditorService.php`

### 3.1 构造函数

```php
$editorService = new EditorService($conf);
```

参数:
- `$conf` — 全局配置数组(`$conf`)

### 3.2 getEditorAssets() — 获取编辑器资源

```php
$assets = $editorService->getEditorAssets();
```

返回值:
```php
[
    'css' => [
        'view/js/aieditor/style.css',
    ],
    'js' => [
        'view/js/upload-service.js',
        'view/js/aieditor/index.umd.js',
    ],
]
```

支持通过 `editor_assets` 钩子扩展资源列表。

### 3.3 renderEditorHtml() — 渲染编辑器

```php
$html = $editorService->renderEditorHtml('message');
```

参数:
- `$textareaId` — 原 textarea 的 ID,默认 `'message'`

**渲染逻辑**:
1. 查找 `#message-editor-wrap` 容器
2. 隐藏原 `<textarea>`,创建 `#aieditor-container` 替代
3. 添加上传进度条和附件列表容器
4. 创建隐藏 `<input name="message">` 用于表单提交
5. 初始化 `UploadService` 实例(拖拽/粘贴上传)
6. 初始化 `AiEditor` 实例(含 AI 配置)
7. 绑定 `onChange` 事件同步内容到隐藏 input
8. 监听表单 `submit` 事件确保内容同步

### 3.4 工具栏配置

编辑器根据屏幕宽度自动切换工具栏:

**桌面端**(宽度 >= 768px):
```
bold, italic, underline, strikeThrough, heading, color,
link, image, ai, codeBlock, quote, orderedList, unorderedList,
align, hr, undo, redo
```

**移动端**(宽度 < 768px):
```
bold, italic, link, image, ai, code
```

## 4. UploadService 详解

**文件位置**:`view/js/upload-service.js`

替代旧版 `FileUploader` 和 `xn.upload_file`,提供统一的文件上传体验。

### 4.1 核心功能

| 功能 | 说明 |
|------|------|
| FormData 上传 | 使用 XMLHttpRequest + FormData |
| 进度追踪 | 实时显示上传百分比 |
| 拖拽上传 | 拖拽文件到编辑器区域 |
| 粘贴上传 | 粘贴剪贴板图片 |
| 图片预览 | 上传前预览图片 |
| 多文件队列 | 串行上传,避免并发问题 |
| 文件校验 | 类型检查 + 大小限制 |

### 4.2 构造函数

```javascript
var uploadSvc = new UploadService({
    uploadUrl: '/attach-create',
    csrfToken: 'xxx',
    maxImageSize: 10485760,      // 10MB
    maxFileSize: 20480000,       // 20MB
    maxVideoSize: 104857600,     // 100MB
    onProgress: function(file, percent) {},
    onComplete: function(file, response) {},
    onError: function(file, error) {},
    onPreview: function(file, dataUrl) {},
    onDragOver: function() {},
    onDragLeave: function() {}
});
```

### 4.3 API 方法

| 方法 | 说明 |
|------|------|
| `uploadFile(file)` | 上传 单个文件,返回 Promise |
| `uploadFiles(fileList)` | 串行上传多个文件 |
| `enableDragDrop(element)` | 启用拖拽上传 |
| `enablePaste(element)` | 启用粘贴上传 |
| `validateFile(file)` | 校验文件类型和大小 |
| `getImagePreview(file, callback)` | 获取图片预览 DataURL |
| `abort()` | 取消当前上传 |
| `destroy()` | 销毁实例,移除事件监听 |

### 4.4 静态方法

| 方法 | 说明 |
|------|------|
| `UploadService.isImageFile(file)` | 判断是否为图片文件 |
| `UploadService.isVideoFile(file)` | 判断是否为视频文件 |
| `UploadService.formatFileSize(bytes)` | 格式化文件大小(B/KB/MB/GB) |

### 4.5 上传流程

```
1. 用户拖拽/粘贴/选择文件
2. validateFile() 校验文件类型和大小
3. 创建 XMLHttpRequest + FormData
4. 设置 CSRF Token 请求头
5. 上传进度回调 onProgress
6. 服务器返回 JSON {code: 0, message: {url, orgfilename, filesize, isimage}}
7. 图片 → insertImageToEditor(),非图片 → addAttachmentItem()
8. 进度条显示完成/错误状态
```

## 5. AI 设置系统

### 5.1 配置层级

AI 配置采用**双层覆盖**机制:

```
全局配置(后台管理员设置)
    $conf['ai']  — 存储在 conf/conf.php
        ↓ 覆盖
用户配置(前台个人设置)
    $user['ai_config']  — 存储在 bbs_user 表 ai_config 字段(JSON)
```

用户配置优先级高于全局配置。`mergeAiConfig()` 方法逐字段合并,用户配置中存在的字段会覆盖全局配置。

### 5.2 后台 AI 设置

**访问路径**:后台 → 设置 → AI 设置(`/admin/?setting-ai.htm`)

**路由处理**:`admin/route/setting.php` 的 `ai` action

**配置项**:

| 配置项 | 表单字段 | 说明 |
|--------|----------|------|
| AI 服务商 | `ai_provider` | openai / deepseek / gitee / spark / wenxin / custom |
| API Key | `ai_apikey` | 密钥,password 类型输入 |
| API URL | `ai_endpoint` | API 端点地址 |
| 模型名称 | `ai_model` | 如 gpt-4o-mini、deepseek-chat |
| AI 续写 Prompt | `ai_prompt_continue` | 续写功能的自定义提示词 |
| AI 优化 Prompt | `ai_prompt_improve` | 优化功能的自定义提示词 |

**服务商预设**:

| 服务商 | 默认 Endpoint | 默认模型 |
|--------|---------------|----------|
| OpenAI | `https://api.openai.com` | `gpt-4o-mini` |
| DeepSeek | `https://api.deepseek.com` | `deepseek-chat` |
| Gitee AI | `https://ai.gitee.com/v1` | `DeepSeek-V3` |
| 讯飞星火 | `https://spark-api-open.xf-yun.com/v1` | `generalv3.5` |
| 百度文心 | `https://qianfan.baidubce.com/v2` | `ernie-4.0-8k` |
| 自定义 | (空) | (空) |

选择服务商后自动填充 Endpoint 和模型名称。

**存储格式**:

配置保存到 `conf/conf.php` 的 `$conf['ai']` 数组:

```php
'ai' => [
    'models' => [
        'openai' => [
            'apiKey' => 'sk-xxx',
            'endpoint' => 'https://api.openai.com',
            'model' => 'gpt-4o-mini',
        ],
    ],
    'bubblePanelEnable' => true,
    'bubblePanelModel' => 'openai',
    'promptContinue' => '请帮我续写以下内容...',
    'promptImprove' => '请帮我优化以下内容...',
],
```

### 5.3 前台用户 AI 设置

**访问路径**:个人中心 → AI 设置(`/user-ai_setting.htm`)

**路由处理**:`route/my.php` 的 `ai_setting` action

**配置项**:

| 配置项 | 表单字段 | 说明 |
|--------|----------|------|
| AI 服务商 | `ai_provider` | 同后台,6 种选择 |
| API Key | `ai_apikey` | 用户自己的 API Key |
| API URL | `ai_endpoint` | 用户自定义端点 |
| 模型名称 | `ai_model` | 用户自定义模型 |

**存储格式**:

配置保存到 `bbs_user` 表的 `ai_config` 字段(JSON 格式):

```json
{
    "models": {
        "custom": {
            "apiKey": "sk-xxx",
            "endpoint": "https://api.deepseek.com",
            "model": "deepseek-v4-flash"
        }
    },
    "bubblePanelEnable": true,
    "bubblePanelModel": "custom"
}
```

### 5.4 AI 配置构建流程

`EditorService::buildAiConfig()` 负责将 PHP 配置转为前端 JS 对象:

```
1. 读取全局配置 $conf['ai']
2. 调用 getUserAiConfig() 读取当前用户配置
3. 调用 mergeAiConfig() 合并(用户覆盖全局)
4. 如果配置为空或无 models → 返回 {bubblePanelEnable:false}
5. 构建 JS 对象字符串:
   - models: {name: {apiKey, endpoint, model}}
   - bubblePanelEnable: true/false
   - bubblePanelModel: "provider_name"
   - promptContinue / promptImprove(自定义提示词)
   - bubblePanelMenus(自定义气泡菜单)
6. 特殊处理:custom 类型自动转为 openai 兼容配置
```

**custom 类型转换**:当用户选择 `custom` 服务商时,前端会将其转为 `openai` 兼容模式,将 `url` 字段映射为 `endpoint`,确保 AiEditor 能正确调用 OpenAI 兼容 API。

### 5.5 AI 编辑器功能

配置完成后,编辑器工具栏会出现 AI 按钮,选中文本后弹出气泡菜单:

| 功能 | 说明 |
|------|------|
| AI 续写 | 根据自定义 prompt 续写选中内容 |
| AI 优化 | 根据自定义 prompt 优化选中内容 |

自定义 prompt 通过 `bubblePanelMenus` 配置注入,优先使用管理员设置的 `promptContinue` / `promptImprove`,未设置则使用 AiEditor 默认 prompt。

## 6. 编辑器样式

**文件位置**:`view/js/aieditor/style.css`

### 6.1 主题支持

样式文件定义了完整的 CSS 变量体系,支持明亮(`aie-theme-light`)和暗黑(`aie-theme-dark`)两种主题:

```css
:root, :root .aie-theme-light {
    --aie-bg-color: #fff;
    --aie-border-color: #eee;
    --aie-text-color: #333;
    /* ... */
}

:root .aie-theme-dark {
    --aie-bg-color: #1e2022;
    --aie-border-color: #333;
    --aie-text-color: #ccc;
    /* ... */
}
```

### 6.2 编辑器容器样式

```css
.aieditor-container {
    border: 1px solid var(--border-color, #ddd);
    border-radius: 4px;
    min-height: 450px;
}
```

### 6.3 上传相关样式

| 类名 | 说明 |
|------|------|
| `.editor-upload-progress` | 上传进度条容器 |
| `.editor-upload-progress.active` | 上传中状态(蓝色) |
| `.editor-upload-progress.complete` | 上传完成状态(绿色) |
| `.editor-upload-progress.error` | 上传失败状态(红色) |
| `.aieditor-container.upload-drop-active` | 拖拽悬停状态(蓝色边框+阴影) |
| `.editor-attachment-list` | 附件列表 |
| `.editor-attachment-item` | 附件项(图标+名称+大小) |

## 7. 编辑器初始化流程

```
1. 页面加载 post.htm
2. PHP 端创建 EditorService 实例
3. getEditorAssets() 获取 CSS/JS 资源列表
4. 模板中加载资源文件
5. renderEditorHtml() 输出编辑器 HTML 和初始化脚本
6. 浏览器执行脚本:
   a. 检查 #aieditor-container 是否已存在(防重复初始化)
   b. 查找 #message-editor-wrap 容器
   c. 隐藏原 textarea,创建编辑器容器
   d. 创建 UploadService 实例
   e. 等待 AiEditor 构造函数可用(最多重试 50 次,每次 100ms)
   f. 构建 AI 配置(含自定义 bubblePanelMenus)
   g. 创建 AiEditor 实例
   h. 启用拖拽上传和粘贴上传
   i. 绑定 onChange 同步内容
   j. 监听表单 submit 事件
7. HTMX 导航后通过 htmx:afterSwap 事件重新初始化
```

### 7.1 防重复初始化

编辑器通过检查 `#aieditor-container` 是否已存在来防止重复初始化:

```javascript
if (document.getElementById('aieditor-container')) {
    console.warn('[Editor Debug] 编辑器已初始化,跳过重复创建');
    return;
}
```

### 7.2 AiEditor 加载等待

AiEditor 是 UMD 模块,可能延迟加载。初始化脚本会重试最多 50 次(每次 100ms,总计 5 秒)等待构造函数可用:

```javascript
if (initAiEditor._retries < 50) {
    initAiEditor._retries++;
    setTimeout(initAiEditor, 100);
    return;
}
```

### 7.3 HTMX 兼容

编辑器监听 `htmx:afterSwap` 事件,在 HTMX 内容替换后自动重新初始化:

```javascript
document.body.addEventListener('htmx:afterSwap', function(evt) {
    if (evt.detail.target.querySelector('#aieditor-container')) {
        setTimeout(safeInit, 100);
    }
});
```

## 8. 内容同步机制

编辑器使用隐藏 `<input>` 替代原 `<textarea>` 进行表单提交:

```
原 textarea (#message) → 隐藏,name 改为 _message_old
新建 hidden input (#message, name=message) → 存储编辑器 HTML 内容
```

**同步时机**:
1. `onChange` 回调 — 编辑器内容变化时实时同步
2. 表单 `submit` 事件 — 提交前强制同步一次
3. 初始化后 200ms — 同步初始内容

## 9. 图片上传处理

### 9.1 编辑器内置上传

AiEditor 自带图片上传功能,通过 `image.uploader` 自定义上传逻辑:

```javascript
image: {
    uploadUrl: '/attach-create',
    uploadHeaders: {'X-CSRF-Token': 'xxx'},
    uploader: function(file, uploadUrl, headers) {
        // XMLHttpRequest 上传,返回 Promise
        // 成功: resolve({errorCode: 0, data: {src: url, alt: filename}})
        // 失败: reject(errorMessage)
    }
}
```

### 9.2 UploadService 上传

拖拽和粘贴上传使用 `UploadService`:

- 图片文件 → `insertImageToEditor()` 插入编辑器
- 非图片文件 → `addAttachmentItem()` 添加到附件列表

### 9.3 上传进度条

```
上传中: 蓝色进度条,宽度随百分比增长
上传完成: 绿色进度条,1.5s 后自动隐藏
上传失败: 红色进度条,2s 后自动隐藏
```

## 10. 数据库字段

### bbs_user.ai_config

| 字段 | 类型 | 说明 |
|------|------|------|
| ai_config | text | 用户 AI 配置(JSON 格式),存储在 bbs_user 表 |

JSON 结构:
```json
{
    "models": {
        "<provider>": {
            "apiKey": "sk-xxx",
            "endpoint": "https://api.xxx.com",
            "model": "model-name"
        }
    },
    "bubblePanelEnable": true,
    "bubblePanelModel": "<provider>"
}
```

## 11. 文件清单

| 文件路径 | 说明 |
|----------|------|
| `lib/EditorService.php` | 编辑器服务类 |
| `view/js/aieditor/index.umd.js` | AiEditor 核心库 |
| `view/js/aieditor/style.css` | 编辑器样式(含明暗主题) |
| `view/js/upload-service.js` | 统一上传模块 |
| `admin/view/htm/setting_ai.htm` | 后台 AI 设置页面模板 |
| `admin/route/setting.php` | 后台设置路由(含 AI 设置处理) |
| `view/htm/user_ai_setting.htm` | 前台用户 AI 设置页面模板 |
| `route/my.php` | 前台个人中心路由(含 AI 设置处理) |
| `view/htm/post.htm` | 发帖/回帖页面(编辑器使用入口) |
| `index.inc.php` | 全局引入 EditorService |

## 12. 常见问题

### Q: 编辑器不显示?

检查:
1. `#message-editor-wrap` 容器是否存在
2. AiEditor JS 文件是否正常加载
3. 浏览器控制台是否有 `[Editor Debug]` 日志
4. `tmp/` 缓存是否已清理

### Q: AI 功能不可用?

检查:
1. 后台 AI 设置是否已配置 API Key
2. API URL 是否正确可达
3. 模型名称是否与服务商匹配
4. 用户个人 AI 设置是否覆盖了全局配置
5. 浏览器控制台查看 `[Editor Debug] AI 配置` 输出

### Q: 图片上传失败?

检查:
1. 上传目录 `upload/` 是否有写权限
2. 文件大小是否超过限制(图片 10MB / 文件 20MB / 视频 100MB)
3. 文件类型是否在允许列表中
4. CSRF Token 是否正确传递

### Q: 拖拽上传不生效?

检查:
1. `UploadService` 是否已正确初始化
2. `enableDragDrop(container)` 是否已调用
3. 拖拽目标元素是否正确

### Q: HTMX 导航后编辑器消失?

编辑器已内置 `htmx:afterSwap` 事件监听,会自动重新初始化。如果仍不生效,检查替换内容中是否包含 `#aieditor-container`。
最新回复 (0)
全部楼主
返回