refactor: 彻底移除 /posts/new 旧编辑器,统一迁移至 /studio/write

- 删除 /posts/new 路由,不再保留任何兼容层
- /posts/:id/edit 改为 301 跳转 /studio/write?id=:id
- 删除 posts/new.html 模板和 editor.js
- 移除 PostController.EditPage/Create/Update/Delete 孤儿方法
- 移除 /api/posts POST/PUT/DELETE 孤儿 API(保留 submit 和 upload-image)
- posts/show.html 编辑链接指向 /studio/write
This commit is contained in:
2026-05-31 15:35:20 +08:00
parent 7e50c892ad
commit 3f4a079c78
6 changed files with 4 additions and 514 deletions

View File

@ -135,147 +135,6 @@ func (ctrl *PostController) ShowPage(c *gin.Context) {
}))
}
// EditPage 编辑页面
func (ctrl *PostController) EditPage(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
"ExtraCSS": "/static/css/posts.css",
}))
return
}
post, err := ctrl.postService.GetByID(uint(id))
if err != nil || post.Status == model.PostStatusPending || post.IsLocked {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到或无法编辑",
"ExtraCSS": "/static/css/posts.css",
}))
return
}
uid, role, _ := common.GetGinUser(c)
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
"ExtraCSS": "/static/css/posts.css",
}))
return
}
// 已通过帖子有待审修订时,编辑页展示待审内容
if post.PendingBody != "" {
post.Title = post.PendingTitle
post.Body = post.PendingBody
}
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
"Title": "编辑帖子",
"Post": post,
"ExtraCSS": "/static/css/posts.css",
}))
}
// Create API 发帖
func (ctrl *PostController) Create(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
var req model.PostCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
return
}
post, err := ctrl.postService.Create(uid, req.Title, req.Body)
if err != nil {
common.Error(c, http.StatusInternalServerError, "发帖失败")
return
}
common.OkWithMessage(c, post, "发布成功")
}
// Update API 编辑帖子
func (ctrl *PostController) Update(c *gin.Context) {
uid, role, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
var req model.PostUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
return
}
if _, ok := ctrl.getPostAndCheckAccess(uint(id), c, uid, role); !ok {
return
}
if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil {
if errors.Is(err, common.ErrPostCannotEdit) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "编辑失败")
}
return
}
common.OkMessage(c, "编辑成功")
}
// Delete API 删除帖子
func (ctrl *PostController) Delete(c *gin.Context) {
uid, role, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
return
}
if _, ok := ctrl.getPostAndCheckAccess(uint(id), c, uid, role); !ok {
return
}
if err := ctrl.postService.Delete(uint(id)); err != nil {
common.Error(c, http.StatusInternalServerError, "删除失败")
return
}
common.OkMessage(c, "删除成功")
}
// getPostAndCheckAccess 获取帖子并检查当前用户权限
// 返回 (*model.Post, true) 表示通过;返回 (nil, false) 表示已写入错误响应
func (ctrl *PostController) getPostAndCheckAccess(id uint, c *gin.Context, uid uint, role string) (*model.Post, bool) {
post, err := ctrl.postService.GetByID(id)
if err != nil {
common.Error(c, http.StatusNotFound, "帖子不存在")
return nil, false
}
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
common.Error(c, http.StatusForbidden, "无权操作此帖子")
return nil, false
}
return post, true
}
// Submit API 提交审核
func (ctrl *PostController) Submit(c *gin.Context) {

View File

@ -57,9 +57,6 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
postsAPI.Use(d.authMdw.Required())
postsAPI.Use(middleware.CSRF(cfg))
{
postsAPI.POST("", d.postCtrl.Create)
postsAPI.PUT("/:id", d.postCtrl.Update)
postsAPI.DELETE("/:id", d.postCtrl.Delete)
postsAPI.POST("/:id/submit", d.postCtrl.Submit)
postsAPI.POST("/upload-image", d.postCtrl.UploadImage)
}

View File

@ -40,11 +40,10 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// 帖子页面
pages.GET("/posts", d.postCtrl.ListPage)
pages.GET("/posts/new", d.authMdw.Required(), func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/studio/write")
})
pages.GET("/posts/:id", d.postCtrl.ShowPage)
pages.GET("/posts/:id/edit", d.authMdw.Required(), d.postCtrl.EditPage)
pages.GET("/posts/:id/edit", d.authMdw.Required(), func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/studio/write?id="+c.Param("id"))
})
// 创作中心(需登录)
studioPages := pages.Group("/studio")

View File

@ -1,70 +0,0 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
<div class="editor-layout">
<form id="postForm" class="editor-form">
<input type="hidden" id="postId" value="{{if .Post}}{{.Post.ID}}{{end}}">
{{/* 编辑模式下传入已有的 MD 内容 */}}
{{if .Post}}
<textarea id="editBody" style="display:none">{{.Post.Body}}</textarea>
{{end}}
{{/* 标题区域 */}}
<div class="editor-header">
<input type="text" id="postTitle" class="editor-title-input"
value="{{if .Post}}{{.Post.Title}}{{end}}"
placeholder="输入文章标题..."
maxlength="200" required autofocus>
</div>
{{/* Vditor 编辑器容器(工具栏+编辑区由 Vditor 自动生成) */}}
<div class="editor-main">
<div class="editor-pane">
<div id="vditor"></div>
</div>
<aside class="editor-sidebar">
<div class="editor-sidebar-section">
<h4>快捷语法</h4>
<ul class="tips-list">
<li><code>#</code> <code>##</code> <code>###</code> 标题</li>
<li><code>**粗体**</code> <code>*斜体*</code></li>
<li><code>`行内代码`</code></li>
<li><code>```</code> 代码块</li>
<li><code>&gt;</code> 引用</li>
<li><code>-</code> 无序列表</li>
<li><code>[文字](链接)</code></li>
<li><code>![图片](链接)</code></li>
</ul>
</div>
<div class="editor-sidebar-section">
<h4>发布提示</h4>
<ul class="tips-list">
<li>标题控制在 50 字以内</li>
<li>正文清晰分段,便于阅读</li>
<li>代码使用代码块包裹</li>
<li>Ctrl+S 快速保存草稿</li>
</ul>
</div>
</aside>
</div>
{{/* 状态栏 */}}
<div class="editor-statusbar">
<span class="status-item" id="wordCount">0 字</span>
<span class="status-item status-draft" id="draftStatus" style="display:none">草稿已保存</span>
<span class="status-spacer"></span>
<button type="submit" class="btn btn-primary btn-submit">{{if .Post}}保存修改{{else}}发布帖子{{end}}</button>
</div>
</form>
</div>
{{template "layout/footer.html" .}}
<link rel="stylesheet" href="/static/vditor/dist/index.css?v={{assetV "/static/vditor/dist/index.css"}}">
<script src="/static/vditor/dist/index.min.js?v={{assetV "/static/vditor/dist/index.min.js"}}"></script>
<script src="/static/js/common.js?v={{assetV "/static/js/common.js"}}"></script>
<script type="module" src="/static/js/editor.js?v={{assetV "/static/js/editor.js"}}"></script>
</body>
</html>

View File

@ -34,7 +34,7 @@
{{if .IsLoggedIn}}
<div class="post-actions">
{{if and $.Post (or (eq $.Post.UserID $.UID) $.CanAccessAdmin) (ne $.Post.Status "pending") (not $.Post.IsLocked)}}
<a href="/posts/{{$.Post.ID}}/edit" class="btn btn-secondary">编辑</a>
<a href="/studio/write?id={{$.Post.ID}}" class="btn btn-secondary">编辑</a>
{{end}}
{{if and $.Post (eq $.Post.UserID $.UID) (or (eq $.Post.Status "draft") (eq $.Post.Status "rejected"))}}
<button class="btn btn-primary" id="submitAuditBtn" data-id="{{$.Post.ID}}">提交审核</button>

View File

@ -1,295 +0,0 @@
/**
* 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 ? '保存修改' : '发布帖子'
}
}
}