diff --git a/internal/controller/post_controller.go b/internal/controller/post_controller.go index 92485dc..ef3b406 100644 --- a/internal/controller/post_controller.go +++ b/internal/controller/post_controller.go @@ -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) { diff --git a/internal/router/api.go b/internal/router/api.go index d5688ee..c9cc151 100644 --- a/internal/router/api.go +++ b/internal/router/api.go @@ -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) } diff --git a/internal/router/frontend.go b/internal/router/frontend.go index fb18f81..acc53a5 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -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") diff --git a/templates/MetaLab-2026/html/posts/new.html b/templates/MetaLab-2026/html/posts/new.html deleted file mode 100644 index 904c16c..0000000 --- a/templates/MetaLab-2026/html/posts/new.html +++ /dev/null @@ -1,70 +0,0 @@ -{{template "layout/header.html" .}} - - -{{template "layout/nav.html" .}} - -
-
- - {{/* 编辑模式下传入已有的 MD 内容 */}} - {{if .Post}} - - {{end}} - - {{/* 标题区域 */}} -
- -
- - {{/* Vditor 编辑器容器(工具栏+编辑区由 Vditor 自动生成) */}} -
-
-
-
- -
- - {{/* 状态栏 */}} -
- 0 字 - - - -
-
-
- -{{template "layout/footer.html" .}} - - - - - - - diff --git a/templates/MetaLab-2026/html/posts/show.html b/templates/MetaLab-2026/html/posts/show.html index f16a9f9..dfc7996 100644 --- a/templates/MetaLab-2026/html/posts/show.html +++ b/templates/MetaLab-2026/html/posts/show.html @@ -34,7 +34,7 @@ {{if .IsLoggedIn}}
{{if and $.Post (or (eq $.Post.UserID $.UID) $.CanAccessAdmin) (ne $.Post.Status "pending") (not $.Post.IsLocked)}} - 编辑 + 编辑 {{end}} {{if and $.Post (eq $.Post.UserID $.UID) (or (eq $.Post.Status "draft") (eq $.Post.Status "rejected"))}} diff --git a/templates/MetaLab-2026/static/js/editor.js b/templates/MetaLab-2026/static/js/editor.js deleted file mode 100644 index 79db4a8..0000000 --- a/templates/MetaLab-2026/static/js/editor.js +++ /dev/null @@ -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_csrf(httpOnly=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.Body(MD)中获取 - // 由 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 ? '保存修改' : '发布帖子' - } - } -}