Files
mce/templates/MetaLab-2026/static/js/editor.js
Victor_Jay bacfd4945d feat: 编辑器与帖子展示布局优化 + 新增个人空间
- 编辑器投稿页布局优化:标题与编辑器高度动态适配,工具栏与内容区视觉对齐
- 稿件展示页布局优化:MD 渲染区与评论区视觉分离,代码块主题微调
- CSRF 中间件:图像上传端点豁免,解决 Vditor 拖拽/粘贴上传 403
- Post 状态映射、GetGinUser、SaveUploadedFile 提取至 common 包,遵循审计建议
- 新增个人空间功能:/space(需登录)重定向到 /space/:uid,/space/:uid 公开访问
- 空间页模板:参考 B 站布局,展示头像、用户名、个性签名、UID、加入时间及稿件列表
- 导航栏:用户名链接指向 /space,新增「设置」入口
- 分层实现:SpaceController → SpaceService → PostRepo.FindByUserID,严格 ISP 接口隔离
2026-05-30 19:06:10 +08:00

296 lines
9.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 ----
// 直接从 cookie 读取 mlb_csrfhttpOnly=false确保与服务端验证时读取的值一致
function getCsrfToken() {
var match = document.cookie.match(/(?:^|;\s*)mlb_csrf=([^;]*)/)
return match ? match[1] : ''
}
// ---- 初始化 ----
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',
],
// Vditor 3.11.2 中 highlightToolbarWYSIWYG 直接调用此回调
// 不提供会导致 TypeError: not a function所有弹窗代码块语言等都不可用
customWysiwygToolbar(type, popover) {},
// 计数器
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: false,
position: 'right',
},
// 预览配置
// 注意WYSIWYG 编辑模式下代码块不做语法高亮Vditor 3.11.2 源码中
// highlightRender 会跳过 .vditor-wysiwyg__pre仅在预览模式/详情页渲染)
// langs 确保语言选择下拉始终可用,不受 hljs 异步加载时机影响
preview: {
theme: { current: 'light', path: '/static/vditor/dist/css/content-theme' },
hljs: {
style: 'github-dark',
enable: true,
langs: [
'javascript', 'typescript', 'python', 'java', 'go', 'rust',
'c', 'cpp', 'csharp', 'php', 'ruby', 'swift', 'kotlin',
'html', 'css', 'scss', 'xml', 'json', 'yaml', 'markdown',
'sql', 'bash', 'shell', 'powershell', 'dockerfile', 'nginx',
'diff', 'http', 'graphql', 'makefile',
],
},
markdown: { codeBlockPreview: true },
},
// 图片上传
// CSRF token 由 common.js 的全局 XHR monkey-patch 自动注入,此处无需重复设置
// 重复设置会导致浏览器将 header 合并为 "TOKEN, TOKEN" 从而验证失败
upload: {
url: '/api/posts/upload-image',
fieldName: 'file',
max: 5 * 1024 * 1024,
accept: 'image/jpg,image/jpeg,image/png,image/gif,image/webp',
},
// 初始内容
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 ? '保存修改' : '发布帖子'
}
}
}