This repository has been archived on 2026-06-21. You can view files and clone it, but cannot push or open issues or pull requests.
Files
MetaLab/docs/vditor-migration-plan.md
Victor_Jay 6ec12010f3 feat: Tiptap替换为Vditor + MD存储重构 + Shortcode卡片扩展
- 编辑器:Tiptap 替换为 Vditor 3.11.2(wysiwyg 模式,本地托管 5.8MB,去 CDN)
- 存储:Body 字段从 HTML 改为 Markdown,去除 BodyHTML,新增 Excerpt 列表摘要
- 安全:去除 bluemonday 依赖(MD 纯文本无 XSS 风险),go.mod 已清理
- Shortcode:新增 [zone:type:params] 扩展语法,支持活动/游戏/投票/资源卡片
- 图片上传:POST /api/posts/upload-image 直接返回 Vditor 原生响应格式
- 草稿:localStorage 键名改为 draft_post_body_md,与旧 HTML 草稿隔离
- 详情页:Vditor.method.min.js 客户端 MD → HTML 渲染,去 highlight.js CDN
- 样式:去 Tiptap 样式 ~190 行,精简工具栏 CSS,新增卡片样式
- 文档:vditor-migration-plan.md 完整记录迁移决策、架构、用法
2026-05-30 15:56:22 +08:00

320 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Vditor 整合 + MD 存储迁移方案
> **状态:已实施 ✓** `2026-05-30`
## 决策与结果
| 决策 | 结论 | 结果 |
|------|------|------|
| 新增 API | ❌ 不新增 | 仅改 `POST /api/posts/upload-image` 响应格式 |
| 桥接/转换层 | ❌ 不做 | API 直接返回 Vditor 原生格式,前端零适配 |
| 存储格式 | HTML → Markdown | MD 更小、更灵活、更可移植 |
| 向后兼容 | ❌ 不考虑 | 开发阶段,已有帖子数据量小 |
| 依赖方式 | 本地托管 | 从 npm registry 下载 dist5.8MB(仅必需插件) |
---
## 最终数据流
```
编辑器: Vditor(wysiwyg) → vditor.getValue() → MD 纯文本
存储: 后端直接存 MD纯文本无 XSS 风险,无需 sanitize
└─ generateExcerpt() 生成 plain text 摘要
显示: Vditor.preview() 客户端 MD → HTML + github-dark 代码主题
```
---
## 变更文件清单11 个)
| 文件 | 改动 | 详情 |
|------|------|------|
| `internal/model/post.go` | 修改 | `Body` → MD 存储,去 `BodyHTML`,加 `Excerpt`varchar 500 |
| `internal/service/post_service.go` | 重写 | 去 bluemonday`generateExcerpt()`(纯 Go regex无外部依赖 |
| `internal/controller/post_controller.go` | 修改 | `UploadImage` → Vditor 原生格式,`ShowPage` 去 BodyHTML`html/template` import |
| `internal/common/response.go` | 新增函数 | `VditorUploadOk()` — Vditor 图片上传成功响应 |
| `templates/.../posts/new.html` | 重写 | Tiptap → Vditor去除 importmap/工具栏/语言选择器 |
| `templates/.../posts/show.html` | 重写 | Vditor 客户端 MD 渲染,去 highlight.js CDN去代码标签注入脚本 |
| `templates/.../posts/index.html` | 微调 | `Body` 截断 → `Excerpt`(带降级回退) |
| `templates/.../editor.js` | 重写 | Vditor 初始化 + CSRF + upload + draft + word count + submit |
| `templates/.../posts.css` | 删减 | 去 Tiptap 样式(~150 行)+ 工具栏样式(~40 行),加 Vditor 微调 |
| `static/vditor/dist/` | 新增 | 本地 Vditor 文件5.8MB |
| `go.mod / go.sum` | 清理 | 移除 bluemonday 依赖 |
**不涉及的路由/中间件/仓储:零改动。**
---
## 各层详细实现
### 1. Model `internal/model/post.go`
```go
type Post struct {
ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"type:varchar(200);not null" json:"title"`
Body string `gorm:"type:text" json:"body"` // Markdown content
Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary
// ... 其余字段不变
}
```
### 2. Service `internal/service/post_service.go`
- 删除 `sanitizePolicy`bluemonday UGC`sanitizeHTML()`
- 新增 `generateExcerpt(md string) string`
- 按行逐条去掉 MD 语法(代码块、图片、标题 #、粗体/斜体、引用 >、列表标记等)
- 链接保留文字 `[text](url)``text`
- 按 rune 截断 300 字符,末尾加 `...`
- 纯 Go `regexp` 实现,零外部依赖
- `Create()` / `Update()` 生成 `Excerpt`MD 直存无消毒
### 3. Controller `internal/controller/post_controller.go`
**UploadImage — Vditor 原生响应格式:**
```json
{
"code": 0,
"msg": "",
"data": {
"errFiles": [],
"succMap": {
"微信截图_2026.png": "/uploads/posts/1_1717071234567.png"
}
}
}
```
**ShowPage — 去掉 `PostBodyHTML`**MD 由前端渲染。
### 4. Common `internal/common/response.go`
```go
func VditorUploadOk(c *gin.Context, succMap map[string]string) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "",
"data": gin.H{
"errFiles": []string{},
"succMap": succMap,
},
})
}
```
### 5. 编辑器 `posts/new.html` + `editor.js`
**Vditor 初始化关键配置:**
```javascript
new Vditor('vditor', {
mode: 'wysiwyg',
cdn: '/static/vditor', // 所有动态加载走本地
height: '100%',
lang: 'zh_CN',
toolbar: [
'headings', 'bold', 'italic', 'strike', '|',
'line', 'code', 'inline-code', 'link', 'quote', '|',
'list', 'ordered-list', 'check', 'outdent', 'indent', '|',
'upload', 'table', '|',
'undo', 'redo', '|',
'fullscreen', 'code-theme', '|',
'outline', 'preview', 'devtools',
],
upload: {
url: '/api/posts/upload-image',
fieldName: 'file',
max: 5 * 1024 * 1024,
accept: 'image/jpg,image/jpeg,image/png,image/gif,image/webp',
setHeaders() { // 每次请求重新读取 CSRF
const meta = document.querySelector('meta[name="csrf-token"]');
return { 'X-CSRF-Token': meta ? meta.getAttribute('content') : '' };
},
},
preview: {
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
hljs: { style: 'github-dark', enable: true },
},
})
```
**CSRF 处理**`upload.setHeaders` 为函数,每次上传前动态读取 `meta[name="csrf-token"]`,不会因 token 过期而失败。
**草稿机制**
- 每 2 秒自动保存 `title` + `md``localStorage`
- 键名:`draft_post_title` / `draft_post_body_md`(与旧 Tiptap HTML 草稿隔离)
- 新帖模式下自动恢复草稿
**表单提交**`vditor.getValue()` 获取 MD`POST /api/posts``PUT /api/posts/:id`
### 6. 详情页 `posts/show.html`
```html
<!-- MD 内容通过隐藏 textarea 安全传递给 JS -->
<textarea id="postMdContent" style="display:none">{{.Post.Body}}</textarea>
<!-- Vditor 仅需 method.min.js47KB不需要完整编辑器 -->
<script src="/static/vditor/dist/method.min.js"></script>
<script>
Vditor.preview(document.getElementById('postContent'), md, {
cdn: '/static/vditor',
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
hljs: { style: 'github-dark', enable: true },
});
</script>
```
### 7. 列表页 `posts/index.html`
```html
<!-- Excerpt 优先,降级回退 Body 截断(兼容旧数据) -->
<p class="post-card-summary">
{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}
</p>
```
### 8. 样式 `posts.css`
- 移除:`.tiptap` 及所有子选择器(~100 行、highlight.js 硬编码主题色(~35 行)、手动工具栏样式(~40 行、JS 注入代码标签 CSS
- 保留:`.editor-layout` / `.editor-header` / `.editor-main` / `.editor-pane` / `.editor-statusbar`(布局)
- 新增:`#vditor` flex 适配、`.vditor-toolbar` / `.vditor-content` 主题微调
---
## Vditor 本地文件结构
```
templates/MetaLab-2026/static/vditor/dist/
├── index.css # 43KB 编辑器 + 预览全部样式
├── index.min.js # 292KB 编辑器核心wysiwyg/sv/ir + 工具栏 + 上传)
├── method.min.js # 47KB 独立方法Vditor.preview 等,详情页用)
├── css/content-theme/
│ ├── light.css # 内容区明亮主题
│ ├── dark.css # 内容区暗色主题
│ ├── ant-design.css # Ant Design 主题
│ └── wechat.css # 微信主题
├── images/
│ ├── img-loading.svg # 上传进度指示
│ ├── logo.png # Vditor logo
│ └── emoji/ # 内置表情包b3log/octocat/doge 等)
├── js/
│ ├── highlight.js/
│ │ ├── highlight.min.js # 1.4MB 代码高亮核心
│ │ ├── third-languages.js # 额外语言支持
│ │ └── styles/*.min.css # 60+ 代码主题
│ ├── lute/
│ │ └── lute.min.js # 3.9MB WASM MD 解析器Vditor 核心依赖)
│ ├── i18n/
│ │ └── zh_CN.js # 8KB 中文语言包
│ └── icons/
│ ├── material.js # Material Design 图标
│ └── ant.js # Ant Design 图标
```
**已剔除的插件**(节省 16MB+
| 插件 | 大小 | 功能 | 对技术论坛无用 |
|------|------|------|---------------|
| mathjax | 6.5MB | LaTeX 数学公式 | ❌ |
| mermaid | 2.6MB | 流程图/时序图 | ❌ |
| graphviz | 2.0MB | 图谱渲染 | ❌ |
| katex | 1.5MB | 数学公式引擎 | ❌ |
| echarts | 1.0MB | 图表渲染 | ❌ |
| markmap | 836KB | 思维导图 | ❌ |
| abcjs | 356KB | 五线谱 | ❌ |
| plantuml | 32KB | PlantUML | ❌ |
| flowchart.js / smiles-drawer | ~400KB | 流程图 / 化学分子式 | ❌ |
---
## Shortcode 扩展(`[zone:type:params]`
### 语法
在 Markdown 正文中嵌入特殊卡片,语法为 `[zone:类型:参数]`
```markdown
# 周末活动汇总
下面是我们本周的推荐活动:
[zone:event:summer2026]
更多内容请关注官方动态...
```
### 已注册类型
| 语法 | 渲染结果 | 状态 |
|------|---------|------|
| `[zone:event:活动ID]` | 活动卡片(🎪 图标 + ID + 跳转链接) | ✅ 已实现(占位) |
| `[zone:game:游戏slug]` | 游戏卡片(🎮 图标 + slug + 跳转链接) | ✅ 已实现(占位) |
| `[zone:poll:投票ID]` | 投票组件 | ⏳ 预留 |
| `[zone:resource:资源ID]` | 资源推荐卡片 | ⏳ 预留 |
### 架构
```
MD body "[zone:event:summer2026]"
ShortcodeService.Process() ← 后端:正则替换 [zone:...] → 占位 <div>
VDitor.preview() ← 前端MD → HTML占位 div 原样保留
renderShortcodes() ← 前端 shortcode.js扫描 .zone-card渲染 UI 卡片
```
### 文件清单
| 文件 | 职责 |
|------|------|
| `internal/model/shortcode.go` | 类型常量 + 正则 + DTO |
| `internal/service/shortcode_service.go` | 解析器 + 占位 HTML 生成器 |
| `internal/controller/post_controller.go` | ShowPage/ShowAPI 调用 Process() |
| `internal/router/deps_extra.go` | DI 注入 ShortcodeService |
| `templates/.../js/shortcode.js` | 前端卡片渲染器 |
| `templates/.../html/posts/show.html` | 引入 shortcode.js + 调用 renderShortcodes() |
| `templates/.../static/css/posts.css` | 卡片样式 |
| `templates/.../static/js/editor.js` | hint 自动补全提示 |
### 如何添加新类型
1. `internal/model/shortcode.go`:注册 `ShortcodeType` 常量
2. `internal/service/shortcode_service.go`:在 `renderPlaceholder()` 添加 case
3. `templates/.../js/shortcode.js`:在 `cardRenderers` 注册渲染函数
4. `templates/.../static/js/editor.js`:在 `hint.extend` 添加补全提示
### 注意事项
- 未注册或格式错误的 shortcode **不报错**,原文保留,避免破坏用户内容
- 占位 div 结构为 `<div class="zone-card" data-zone-type="..." data-zone-id="...">`
- 编辑器输入 `[zone:` 时自动弹出补全列表Vditor hint.extend
- 后端 API 就绪后,将 `shortcode.js` 中的静态卡片替换为 `fetch()` 动态数据即可
---
## 不涉及的部分
- `internal/router/api.go` — 路由不变(`POST /api/posts/upload-image` 端点不变)
- `internal/router/frontend.go` — SSR 页面路由不变
- `internal/repository/post_repo.go` — 仓储不变Model 字段变更由 GORM 自动映射)
- `internal/middleware/` — CSRF / Auth 中间件不变
- `internal/config/` — 配置不变
- `cmd/server/main.go``Static()` 映射不变(`/static``templates/.../static`
---
## 注意事项
1. **DB Schema**GORM AutoMigrate 自动添加 `excerpt` 列;旧的 `body_html` 列残留但不影响运行GORM 不会 DROP可手动清理
2. **已有帖子**:旧 HTML body 在 MD 预览中会显示为 HTML 源码,可手动清理或通过 SQL 迁移
3. **CSRF**`upload.setHeaders` 为函数,每次上传前动态读取最新 token不受 token 过期影响
4. **时序**Vditor 主文件先加载lute/highlight.js/i18n 等由 Vditor 内部按需动态加载,无需手动控制
5. **bluemonday**:已从 `go.mod` 中移除(`go mod tidy` 自动清理)