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 完整记录迁移决策、架构、用法
This commit is contained in:
319
docs/vditor-migration-plan.md
Normal file
319
docs/vditor-migration-plan.md
Normal file
@ -0,0 +1,319 @@
|
||||
# Vditor 整合 + MD 存储迁移方案
|
||||
|
||||
> **状态:已实施 ✓** `2026-05-30`
|
||||
|
||||
## 决策与结果
|
||||
|
||||
| 决策 | 结论 | 结果 |
|
||||
|------|------|------|
|
||||
| 新增 API | ❌ 不新增 | 仅改 `POST /api/posts/upload-image` 响应格式 |
|
||||
| 桥接/转换层 | ❌ 不做 | API 直接返回 Vditor 原生格式,前端零适配 |
|
||||
| 存储格式 | HTML → Markdown | MD 更小、更灵活、更可移植 |
|
||||
| 向后兼容 | ❌ 不考虑 | 开发阶段,已有帖子数据量小 |
|
||||
| 依赖方式 | 本地托管 | 从 npm registry 下载 dist,5.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.js(47KB),不需要完整编辑器 -->
|
||||
<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` 自动清理)
|
||||
Reference in New Issue
Block a user