# 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` 自动清理）
