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/templates/MetaLab-2026/static/js/editor.js
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

280 lines
8.4 KiB
JavaScript
Raw 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.

/**
* MetaLab Editor — Vditor Markdown 所见即所得编辑器
*
* Vditor 原生支持 MD + WYSIWYG后端存储 Markdown。
* 图片上传复用现有 /api/posts/upload-image后端已返回 Vditor 原生响应格式。
* 安全模型:前端输出 MD纯文本→ 后端直接存储 → 详情页客户端 Vditor 渲染为 HTML。
*
* Shortcode 扩展语法([zone:type:params]
* 输入 [zone: 触发自动补全,选择类型后自动生成对应语法。
* 支持的 shortcode 详情见 /docs/vditor-migration-plan.md
*
* Vditor 全局对象由 /static/vditor/dist/index.min.js 提供。
*/
let vditor = null
let draftTimer = null
let submitting = false
// ---- CSRF Token ----
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]')
return meta ? meta.getAttribute('content') : ''
}
// ---- 初始化 ----
document.addEventListener('DOMContentLoaded', () => {
const container = document.getElementById('vditor')
if (!container) return
const postId = document.getElementById('postId')?.value
const isEdit = !!postId
// 获取初始内容:编辑模式下从后端传入,新帖模式尝试恢复草稿
let initialMD = ''
if (isEdit) {
// 编辑模式:从后端 JSON 嵌入的 Post.BodyMD中获取
// 由 Go 模板 {{.Post.Body}} 嵌入到 hidden 字段或 script 标签
initialMD = ''
}
// 从隐藏字段获取编辑内容Go 模板传入)
const editBodyEl = document.getElementById('editBody')
if (editBodyEl) {
initialMD = editBodyEl.value
}
vditor = new Vditor('vditor', {
// 核心配置
mode: 'wysiwyg',
cdn: '/static/vditor',
height: '100%',
lang: 'zh_CN',
placeholder: '在此输入正文...',
// 工具栏(精简,适合技术社区)
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',
],
// 计数器
counter: {
enable: true,
type: 'text',
},
// Shortcode 自动补全提示
// 输入 [zone: 后触发,列出所有已注册的 shortcode 类型
hint: {
extend: [
{
key: '[zone:event:',
value: '[zone:event:活动ID]',
},
{
key: '[zone:game:',
value: '[zone:game:游戏Slug]',
},
{
key: '[zone:poll:',
value: '[zone:poll:投票ID]',
},
{
key: '[zone:resource:',
value: '[zone:resource:资源ID]',
},
],
},
// 大纲
outline: {
enable: true,
position: 'right',
},
// 预览配置
preview: {
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
hljs: { style: 'github-dark', enable: true },
markdown: { codeBlockPreview: true },
},
// 图片上传
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 token
setHeaders() {
return { 'X-CSRF-Token': getCsrfToken() }
},
},
// 初始内容
value: initialMD,
// 初始化完成
after() {
// 新帖模式下恢复草稿
if (!isEdit) {
setTimeout(() => restoreDraft(), 100)
}
// 更新字数
updateWordCount()
},
// 内容变化
input(value) {
scheduleDraft(value)
updateWordCount()
},
})
// 表单提交
document.getElementById('postForm')?.addEventListener('submit', handleSubmit)
// 快捷键 Ctrl+S
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
document.getElementById('postForm')?.dispatchEvent(new Event('submit'))
}
})
// 全屏快捷键 F11
document.addEventListener('keydown', (e) => {
if (e.key === 'F11') {
e.preventDefault()
const layout = document.querySelector('.editor-layout')
if (layout) layout.classList.toggle('fullscreen')
}
})
})
// ---- 字数统计 ----
function updateWordCount() {
const el = document.getElementById('wordCount')
if (!el || !vditor) return
// Vditor 自带计数器已渲染到 .vditor-counter我们同步到自定义的状态栏
const counterEl = document.querySelector('.vditor-counter')
if (counterEl) {
el.textContent = counterEl.textContent
}
}
// ---- 草稿保存/恢复 ----
function scheduleDraft(md) {
clearTimeout(draftTimer)
draftTimer = setTimeout(() => saveDraft(md), 2000)
}
function saveDraft(md) {
const title = document.getElementById('postTitle')?.value || ''
if (!md.trim() && !title.trim()) return
try {
localStorage.setItem('draft_post_title', title)
localStorage.setItem('draft_post_body_md', md)
const statusEl = document.getElementById('draftStatus')
if (statusEl) {
statusEl.style.display = ''
setTimeout(() => { statusEl.style.display = 'none' }, 2000)
}
} catch (e) {
// localStorage 不可用
}
}
function restoreDraft() {
try {
const title = localStorage.getItem('draft_post_title')
const md = localStorage.getItem('draft_post_body_md')
if (title) {
document.getElementById('postTitle').value = title
}
if (md && vditor) {
vditor.setValue(md)
updateWordCount()
}
} catch (e) {
// 静默忽略
}
}
function clearDraft() {
try {
localStorage.removeItem('draft_post_title')
localStorage.removeItem('draft_post_body_md')
} catch (e) { /* ignore */ }
}
// ---- 表单提交 ----
async function handleSubmit(e) {
e.preventDefault()
if (submitting) return
submitting = true
const submitBtn = document.querySelector('.btn-submit')
if (submitBtn) {
submitBtn.disabled = true
submitBtn.textContent = '提交中...'
}
try {
const postId = document.getElementById('postId')?.value
const title = document.getElementById('postTitle')?.value.trim()
if (!title) { alert('请输入标题'); return }
// 获取 Vditor Markdown 内容
const body = vditor ? vditor.getValue() : ''
if (!body.trim()) { alert('请输入正文'); return }
const isEdit = !!postId
const url = isEdit ? '/api/posts/' + postId : '/api/posts'
const method = isEdit ? 'PUT' : 'POST'
const resp = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken(),
},
body: JSON.stringify({ title, body }),
})
const data = await resp.json()
if (data.success || data.code === 200) {
clearDraft()
const newPostId = data.data?.id || data.data?.ID || postId
window.location.href = newPostId ? '/posts/' + newPostId : '/posts'
} else {
alert(data.message || '操作失败')
}
} catch (err) {
if (err && err.message === 'refresh_failed') {
alert('登录已过期,请重新登录')
window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname)
} else {
alert('请求失败,请重试')
}
} finally {
submitting = false
if (submitBtn) {
submitBtn.disabled = false
const isEdit = !!document.getElementById('postId')?.value
submitBtn.textContent = isEdit ? '保存修改' : '发布帖子'
}
}
}