- 编辑器: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 完整记录迁移决策、架构、用法
12 KiB
12 KiB
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
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 原生响应格式:
{
"code": 0,
"msg": "",
"data": {
"errFiles": [],
"succMap": {
"微信截图_2026.png": "/uploads/posts/1_1717071234567.png"
}
}
}
ShowPage — 去掉 PostBodyHTML,MD 由前端渲染。
4. Common internal/common/response.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 初始化关键配置:
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
<!-- 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
<!-- 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(布局) - 新增:
#vditorflex 适配、.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:类型:参数]:
# 周末活动汇总
下面是我们本周的推荐活动:
[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 自动补全提示 |
如何添加新类型
internal/model/shortcode.go:注册ShortcodeType常量internal/service/shortcode_service.go:在renderPlaceholder()添加 casetemplates/.../js/shortcode.js:在cardRenderers注册渲染函数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)
注意事项
- DB Schema:GORM AutoMigrate 自动添加
excerpt列;旧的body_html列残留但不影响运行(GORM 不会 DROP,可手动清理) - 已有帖子:旧 HTML body 在 MD 预览中会显示为 HTML 源码,可手动清理或通过 SQL 迁移
- CSRF:
upload.setHeaders为函数,每次上传前动态读取最新 token,不受 token 过期影响 - 时序:Vditor 主文件先加载,lute/highlight.js/i18n 等由 Vditor 内部按需动态加载,无需手动控制
- bluemonday:已从
go.mod中移除(go mod tidy自动清理)