From 69ba2fb01bd79419372be71c35ecb4b7057bf3bc Mon Sep 17 00:00:00 2001 From: Victor_Jay Date: Thu, 28 May 2026 11:21:53 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20ByteMD=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E8=87=B3=20Tiptap=20WYSIWYG=20=E7=BC=96=E8=BE=91=E5=99=A8=20+?= =?UTF-8?q?=20Token=20=E5=88=B7=E6=96=B0=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 ByteMD CDN 资源和 goldmark 依赖,替换为 Tiptap ESM 模块 - 编辑器从源码编辑模式改为 WYSIWYG 所见即所得 - 移除 /api/posts/preview 预览接口(Tiptap 直接输出 HTML 无需后端渲染) - 保留 UploadImage 图片上传接口和 bluemonday HTML 消毒 - 工具栏支持加粗、斜体、删除线、代码、标题、列表、引用、代码块、分割线、撤销/重做 - 支持图片粘贴/拖拽上传,Ctrl+S 快捷提交,F11 全屏,字数统计 - 优化 common.js:自动 401 拦截 Token 刷新,修复刷新死循环 - 草稿 localStorage 键名更新为 draft_post_body_html --- go.mod | 1 - go.sum | 2 - internal/controller/interfaces.go | 3 +- internal/controller/post_controller.go | 19 -- internal/middleware/security.go | 4 +- internal/router/api.go | 1 - internal/service/post_service.go | 41 +--- templates/MetaLab-2026/html/posts/new.html | 137 ++++++++++- templates/MetaLab-2026/static/css/posts.css | 14 +- templates/MetaLab-2026/static/js/common.js | 172 +++++++++++--- templates/MetaLab-2026/static/js/editor.js | 239 +++++++++++++++----- 11 files changed, 467 insertions(+), 166 deletions(-) diff --git a/go.mod b/go.mod index 345a592..e90c669 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/microcosm-cc/bluemonday v1.0.27 github.com/spf13/viper v1.21.0 - github.com/yuin/goldmark v1.8.2 golang.org/x/crypto v0.52.0 golang.org/x/image v0.41.0 gorm.io/driver/postgres v1.6.0 diff --git a/go.sum b/go.sum index 9107546..38c7e64 100644 --- a/go.sum +++ b/go.sum @@ -115,8 +115,6 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= -github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index 417442b..f272a77 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -25,7 +25,7 @@ type rateLimiter interface { Clear(email, ip string) } -// postUseCase PostController 对 PostService 的最小依赖(ISP:8 个方法) +// postUseCase PostController 对 PostService 的最小依赖(ISP:6 个方法) type postUseCase interface { Create(userID uint, title, body string) (*model.Post, error) GetByID(id uint) (*model.Post, error) @@ -33,5 +33,4 @@ type postUseCase interface { Update(postID uint, title, body string) error Delete(postID uint) error SubmitForAudit(postID uint) error - RenderMarkdown(body string) (string, error) } diff --git a/internal/controller/post_controller.go b/internal/controller/post_controller.go index 28354e0..d42e131 100644 --- a/internal/controller/post_controller.go +++ b/internal/controller/post_controller.go @@ -306,25 +306,6 @@ func (ctrl *PostController) ShowAPI(c *gin.Context) { common.Ok(c, post) } -// PreviewAPI Markdown 预览 API -func (ctrl *PostController) PreviewAPI(c *gin.Context) { - var req struct { - Body string `json:"body" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - common.Error(c, http.StatusBadRequest, "正文不能为空") - return - } - - html, err := ctrl.postService.RenderMarkdown(req.Body) - if err != nil { - common.Error(c, http.StatusInternalServerError, "渲染失败") - return - } - - common.Ok(c, gin.H{"html": html}) -} - // allowedImageExt 允许的图片扩展名 var allowedImageExt = map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, diff --git a/internal/middleware/security.go b/internal/middleware/security.go index c47965a..34204ce 100644 --- a/internal/middleware/security.go +++ b/internal/middleware/security.go @@ -9,8 +9,8 @@ import ( func SecurityHeaders() gin.HandlerFunc { return func(c *gin.Context) { // Content-Security-Policy - // script-src: 本站 + esm.sh CDN (ByteMD ESM 模块) - // style-src: 本站 + esm.sh (ByteMD/highlight.js CSS) + // script-src: 本站 + esm.sh CDN (Tiptap ESM 模块) + // style-src: 本站 + esm.sh (Tiptap CSS) // connect-src: 本站 + esm.sh (source map 请求) // img-src: 本站 + data: URI (头像裁切) + blob: (粘贴图片) c.Header("Content-Security-Policy", diff --git a/internal/router/api.go b/internal/router/api.go index 08a75bb..3336b42 100644 --- a/internal/router/api.go +++ b/internal/router/api.go @@ -56,7 +56,6 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { postsAPI.PUT("/:id", d.postCtrl.Update) postsAPI.DELETE("/:id", d.postCtrl.Delete) postsAPI.POST("/:id/submit", d.postCtrl.Submit) - postsAPI.POST("/preview", d.postCtrl.PreviewAPI) postsAPI.POST("/upload-image", d.postCtrl.UploadImage) } } diff --git a/internal/service/post_service.go b/internal/service/post_service.go index 09465cc..f4a52f4 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -1,7 +1,6 @@ package service import ( - "bytes" "fmt" "metazone.cc/metalab/internal/common" @@ -9,15 +8,16 @@ import ( "metazone.cc/metalab/internal/model" "github.com/microcosm-cc/bluemonday" - "github.com/yuin/goldmark" ) -// sanitizePolicy 纵深防御:对 goldmark 输出的 HTML 再做一层消毒 -// goldmark 默认已过滤原始 HTML 和危险链接,但 bluemonday 作为第二道防线 -// UGC 策略会剥离 code/pre 的 class 属性,需要显式放行以保留 language-xxx +// sanitizePolicy Tiptap 输出 HTML 的消毒策略 +// 前端 WYSIWYG 编辑器输出 HTML,由 bluemonday UGC 策略消毒 +// 显式放行 code/pre 的 class 属性以保留代码块样式 +// 放行 img 的 src/alt 属性以保留图片 var sanitizePolicy = func() *bluemonday.Policy { p := bluemonday.UGCPolicy() p.AllowAttrs("class").OnElements("code", "pre") + p.AllowAttrs("src", "alt", "title").OnElements("img") return p }() @@ -37,7 +37,6 @@ type postStore interface { type PostService struct { repo postStore ss *config.SiteSettings - md goldmark.Markdown } // NewPostService 构造函数 @@ -45,7 +44,6 @@ func NewPostService(repo postStore, ss *config.SiteSettings) *PostService { return &PostService{ repo: repo, ss: ss, - md: goldmark.New(), } } @@ -54,27 +52,15 @@ func (s *PostService) auditEnabled() bool { return s.ss.IsAuditEnabled() } -// renderMarkdown 将 Markdown 文本渲染为安全 HTML -// goldmark(默认安全模式)→ bluemonday(纵深防御) -func (s *PostService) renderMarkdown(body string) (string, error) { - var buf bytes.Buffer - if err := s.md.Convert([]byte(body), &buf); err != nil { - return "", fmt.Errorf("markdown render: %w", err) - } - return sanitizePolicy.Sanitize(buf.String()), nil -} - -// RenderMarkdown 对外暴露,用于预览 API -func (s *PostService) RenderMarkdown(body string) (string, error) { - return s.renderMarkdown(body) +// sanitizeHTML 对 Tiptap 输出的 HTML 进行消毒 +// 前端 WYSIWYG 编辑器直接输出 HTML,后端仅做安全消毒 +func (s *PostService) sanitizeHTML(body string) string { + return sanitizePolicy.Sanitize(body) } // Create 创建帖子 func (s *PostService) Create(userID uint, title, body string) (*model.Post, error) { - bodyHTML, err := s.renderMarkdown(body) - if err != nil { - return nil, err - } + bodyHTML := s.sanitizeHTML(body) status := model.PostStatusApproved if s.auditEnabled() { @@ -129,14 +115,9 @@ func (s *PostService) Update(postID uint, title, body string) error { return fmt.Errorf("当前状态不允许编辑") } - bodyHTML, err := s.renderMarkdown(body) - if err != nil { - return err - } - post.Title = title post.Body = body - post.BodyHTML = bodyHTML + post.BodyHTML = s.sanitizeHTML(body) // rejected 状态编辑后自动重置为 draft if post.Status == model.PostStatusRejected { diff --git a/templates/MetaLab-2026/html/posts/new.html b/templates/MetaLab-2026/html/posts/new.html index 2f4fefc..0bae38e 100644 --- a/templates/MetaLab-2026/html/posts/new.html +++ b/templates/MetaLab-2026/html/posts/new.html @@ -1,16 +1,114 @@ {{template "layout/header.html" .}} - - @@ -29,10 +127,33 @@ maxlength="200" required autofocus> + {{/* 工具栏 */}} +
+ + + + + + + + + + + + + + + + + + +
+ {{/* 编辑器主体 */}}
-
+
@@ -49,4 +170,6 @@ {{template "layout/footer.html" .}} + + diff --git a/templates/MetaLab-2026/static/css/posts.css b/templates/MetaLab-2026/static/css/posts.css index 017f5c3..ff2db39 100644 --- a/templates/MetaLab-2026/static/css/posts.css +++ b/templates/MetaLab-2026/static/css/posts.css @@ -247,9 +247,7 @@ color: #c4c4c4; } - - -/* ---- 编辑器主体(编辑区 + 预览区) ---- */ +/* ---- 编辑器主体 ---- */ .editor-main { flex: 1; display: flex; @@ -263,16 +261,6 @@ min-width: 0; } -/* ByteMD 编辑器容器 */ -#editor-container { - background: #fff; - height: 100%; -} - -#editor-container .bytemd { - height: 100%; -} - /* ---- 状态栏 ---- */ .editor-statusbar { display: flex; diff --git a/templates/MetaLab-2026/static/js/common.js b/templates/MetaLab-2026/static/js/common.js index 0d0fc0e..09232ff 100644 --- a/templates/MetaLab-2026/static/js/common.js +++ b/templates/MetaLab-2026/static/js/common.js @@ -24,11 +24,59 @@ // ---- Auto-attach X-CSRF-Token to all state-changing requests ---- // Monkey-patch XMLHttpRequest and fetch to inject CSRF header + // Also intercept 401 responses to auto-refresh token (function () { - var token = window.MetaLab.csrfToken(); - if (!token) return; + var SAME_ORIGIN = /^\/(?!\/)/; + var isRefreshing = false; + var refreshPromise = null; + var pendingRetries = []; - // Patch XMLHttpRequest (used by register.js / login.js / common.js) + function resolveCSRFToken() { + var meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute('content') : ''; + } + + function getCSRFTokenHeader() { + var t = resolveCSRFToken(); + return t || window.MetaLab.csrfToken(); + } + + // ---- Token refresh logic ---- + function doRefresh() { + if (isRefreshing) return refreshPromise; + isRefreshing = true; + refreshPromise = fetch('/api/auth/refresh', { method: 'POST' }) + .then(function (res) { + isRefreshing = false; + refreshPromise = null; + if (!res.ok) { + // Refresh failed — clear all pending retries + while (pendingRetries.length) { + var r = pendingRetries.shift(); + r.reject(new Error('refresh_failed')); + } + return Promise.reject(new Error('refresh_failed')); + } + // Retry all pending requests + while (pendingRetries.length) { + var retry = pendingRetries.shift(); + retry.retry(); + } + return true; + }) + .catch(function (err) { + isRefreshing = false; + refreshPromise = null; + while (pendingRetries.length) { + var r2 = pendingRetries.shift(); + r2.reject(err); + } + return Promise.reject(err); + }); + return refreshPromise; + } + + // ---- Patch XMLHttpRequest ---- var origXHROpen = XMLHttpRequest.prototype.open; var origXHRSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url) { @@ -36,31 +84,100 @@ this._url = url; return origXHROpen.apply(this, arguments); }; - var SAME_ORIGIN = /^\/(?!\/)/; XMLHttpRequest.prototype.send = function () { - if (SAME_ORIGIN.test(this._url) && - this._method && !/^(GET|HEAD|OPTIONS)$/i.test(this._method)) { - this.setRequestHeader('X-CSRF-Token', token); + var xhr = this; + if (SAME_ORIGIN.test(xhr._url) && + xhr._method && !/^(GET|HEAD|OPTIONS)$/i.test(xhr._method)) { + xhr.setRequestHeader('X-CSRF-Token', getCSRFTokenHeader()); } - return origXHRSend.apply(this, arguments); + + // 401 interception for XHR + var origOnReady = xhr.onreadystatechange; + xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4 && xhr.status === 401 && + xhr._url !== '/api/auth/refresh' && + !xhr._retried) { + xhr._retried = true; + // Abort the original response handling + xhr._blocked = true; + + doRefresh().then(function () { + // Re-create and re-send the request + var newXhr = new XMLHttpRequest(); + newXhr.open(xhr._method, xhr._url, true); + // Copy relevant properties + newXhr.onreadystatechange = origOnReady; + newXhr.setRequestHeader('X-CSRF-Token', getCSRFTokenHeader()); + newXhr.send(xhr._body); + }).catch(function () { + // Refresh failed, trigger original error handler + xhr._blocked = false; + if (origOnReady) origOnReady.call(xhr); + }); + } + if (!xhr._blocked && origOnReady) { + origOnReady.call(xhr); + } + }); + + // Store body for potential retry + if (xhr._method && !/^(GET|HEAD)$/i.test(xhr._method)) { + xhr._body = arguments[0]; + } + return origXHRSend.apply(xhr, arguments); }; - // Patch fetch (used by auto-refresh) + // ---- Patch fetch with 401 interceptor ---- var origFetch = window.fetch; window.fetch = function (url, options) { options = options || {}; var headers = options.headers || {}; var method = (options.method || 'GET').toUpperCase(); var urlStr = (typeof url === 'string') ? url : ''; + if (SAME_ORIGIN.test(urlStr) && !/^(GET|HEAD|OPTIONS)$/i.test(method)) { if (headers instanceof Headers) { - headers.set('X-CSRF-Token', token); + headers.set('X-CSRF-Token', getCSRFTokenHeader()); } else { - headers['X-CSRF-Token'] = token; + headers['X-CSRF-Token'] = getCSRFTokenHeader(); } options.headers = headers; } - return origFetch(url, options); + + // Don't intercept the refresh request itself + if (urlStr === '/api/auth/refresh') { + return origFetch(url, options); + } + + return origFetch(url, options).then(function (res) { + if (res.status === 401) { + // Clone the response so we can read it once for logging + var cloned = res.clone(); + + // Queue this request for retry after refresh + return new Promise(function (resolve, reject) { + pendingRetries.push({ + retry: function () { + // Re-fetch with updated CSRF token + var retryOpts = Object.assign({}, options); + var retryHeaders = retryOpts.headers || {}; + if (retryHeaders instanceof Headers) { + retryHeaders.set('X-CSRF-Token', getCSRFTokenHeader()); + } else { + retryHeaders['X-CSRF-Token'] = getCSRFTokenHeader(); + } + retryOpts.headers = retryHeaders; + resolve(origFetch(url, retryOpts)); + }, + reject: function (err) { + reject(err); + } + }); + doRefresh(); + }); + } + return res; + }); }; })(); @@ -162,7 +279,9 @@ // ---- Auto-refresh token (silent) ---- // 场景 A:页面开着时,每 10 分钟静默续期(保持 access token 不过期) // 场景 B:关浏览器 15+ min 后回来,access token 过期但 refresh 仍有效 - // → 页面先渲染为未登录 → 刷新成功后 reload 一次获得登录态 + // → 页面渲染为未登录 → 刷新成功后 reload 一次获得登录态 + // → 仅当页面是未登录状态(无 logoutBtn)时才触发 reload + // 场景 C:编辑器等页面发 API 请求时遇到 401 → 由 fetch/XHR patch 自动刷新后重试 // 不在认证页面执行刷新(登录/注册页无需续期) var onAuthPage = /^\/auth\//.test(window.location.pathname); if (!onAuthPage) { @@ -190,23 +309,22 @@ }); } - // 仅当存在"记住我"标记 Cookie 时才尝试刷新(避免无需刷新时产生 401 控制台报错) - function hasCookie(name) { - return document.cookie.split(';').some(function (c) { - return c.trim().startsWith(name + '='); - }); - } - - if (hasCookie('mlb_rm')) { + // 启动定时刷新:只要用户已登录(有 logoutBtn 元素),就定期刷新 + if (document.getElementById('logoutBtn')) { + // 页面已经是登录态,启动定时刷新即可,无需 reload tryRefresh(function () { refreshTimer = setInterval(function () { tryRefresh(null); }, REFRESH_INTERVAL); - - if (!document.getElementById('logoutBtn') && !sessionStorage.getItem('mlb_refreshed')) { - sessionStorage.setItem('mlb_refreshed', '1'); - window.location.reload(); - } }); - sessionStorage.removeItem('mlb_refreshed'); + } else { + // 场景 B:页面渲染为未登录(无 logoutBtn),但 refresh token 可能仍有效 + // 尝试刷新一次,成功后 reload 页面获取登录态 + // 使用 sessionStorage 防止死循环(reload 后再次进入此分支不会再 reload) + if (!sessionStorage.getItem('mlb_refreshed')) { + sessionStorage.setItem('mlb_refreshed', '1'); + tryRefresh(function () { + window.location.reload(); + }); + } } } // end if (!onAuthPage) diff --git a/templates/MetaLab-2026/static/js/editor.js b/templates/MetaLab-2026/static/js/editor.js index b0a9c13..727c947 100644 --- a/templates/MetaLab-2026/static/js/editor.js +++ b/templates/MetaLab-2026/static/js/editor.js @@ -1,19 +1,20 @@ /** - * MetaLab Editor — ByteMD Markdown 编辑器 + * MetaLab Editor — Tiptap 所见即所得编辑器 * - * ByteMD 基于 CodeMirror 5(纯 textarea 兜底,不依赖 contentEditable), - * 输出 Markdown 原文,由后端 goldmark 统一渲染 HTML。 + * Tiptap 基于 ProseMirror,输出 HTML,无需 Markdown 知识即可使用。 + * 后端存储 HTML,通过 bluemonday 消毒确保安全。 * - * 安全模型:Markdown 文本 → goldmark → HTML(AST 生成,天然安全) - * 前端不再做 sanitize,XSS 风险由 goldmark 的 AST 输出消除。 + * 安全模型:Tiptap → HTML(富文本)→ 后端 bluemonday 消毒 → 存储 */ -import { Editor } from 'https://esm.sh/bytemd@1.22.0' -import gfm from 'https://esm.sh/@bytemd/plugin-gfm@1.22.0' -import highlight from 'https://esm.sh/@bytemd/plugin-highlight@1.22.0' +import { Editor } from 'https://esm.sh/@tiptap/core@3.13.0' +import StarterKit from 'https://esm.sh/@tiptap/starter-kit@3.13.0' +import Placeholder from 'https://esm.sh/@tiptap/extension-placeholder@3.13.0' +import Link from 'https://esm.sh/@tiptap/extension-link@3.13.0' +import Image from 'https://esm.sh/@tiptap/extension-image@3.13.0' // ---- 状态 ---- let editor = null -let currentValue = '' +let currentHTML = '' let draftTimer = null // ---- 初始化 ---- @@ -24,74 +25,181 @@ document.addEventListener('DOMContentLoaded', () => { const postId = document.getElementById('postId')?.value const isEdit = !!postId - // 从容器 data-value 属性获取初始内容(由后端 SSR 渲染) - const initialValue = container.dataset.value || '' - currentValue = initialValue - - const plugins = [ - gfm(), - highlight(), - ] + // 从容器 data-content 属性获取初始 HTML 内容(由后端 SSR 渲染) + const initialContent = container.dataset.content || '' + currentHTML = initialContent editor = new Editor({ - target: container, - props: { - value: initialValue, - plugins, - mode: 'split', - placeholder: '在此输入正文... (Markdown)', - uploadImages: uploadImage, + element: container, + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + }), + Placeholder.configure({ + placeholder: '在此输入正文...', + }), + Link.configure({ + openOnClick: false, + HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' }, + }), + Image.configure({ + inline: true, + }), + ], + content: initialContent, + onUpdate: ({ editor }) => { + const html = editor.getHTML() + // 忽略空内容(

) + currentHTML = html === '

' ? '' : html + updateWordCount(editor.getText()) + scheduleDraft(currentHTML) }, }) - // 监听内容变化,同步 currentValue - editor.$on('change', (e) => { - currentValue = e.detail.value - updateWordCount(currentValue) - scheduleDraft(currentValue) - }) - // 初始字数统计 - updateWordCount(initialValue) + updateWordCount(editor.getText()) - // 恢复草稿(延迟确保 ByteMD 完全初始化) + // 恢复草稿(延迟确保编辑器完全初始化) if (!isEdit) { setTimeout(() => restoreDraft(), 100) } + // 工具栏按钮绑定 + setupToolbar() + // 全屏切换 document.getElementById('btnFullscreen')?.addEventListener('click', toggleFullscreen) // 表单提交 document.getElementById('postForm')?.addEventListener('submit', handleSubmit) - // Ctrl+S 快捷键 + // Ctrl+S / F11 快捷键 document.addEventListener('keydown', handleKeyboard) }) -// ---- 图片上传 ---- -async function uploadImage(files) { +// ---- 工具栏 ---- +function setupToolbar() { + const toolbar = document.getElementById('toolbar') + if (!toolbar) return + + const actionMap = { + bold: () => editor.chain().focus().toggleBold().run(), + italic: () => editor.chain().focus().toggleItalic().run(), + strike: () => editor.chain().focus().toggleStrike().run(), + code: () => editor.chain().focus().toggleCode().run(), + h1: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), + h2: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), + h3: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), + bulletList: () => editor.chain().focus().toggleBulletList().run(), + orderedList: () => editor.chain().focus().toggleOrderedList().run(), + blockquote: () => editor.chain().focus().toggleBlockquote().run(), + codeBlock: () => editor.chain().focus().toggleCodeBlock().run(), + horizontalRule: () => editor.chain().focus().setHorizontalRule().run(), + undo: () => editor.chain().focus().undo().run(), + redo: () => editor.chain().focus().redo().run(), + } + + const activeCheck = { + bold: () => editor.isActive('bold'), + italic: () => editor.isActive('italic'), + strike: () => editor.isActive('strike'), + code: () => editor.isActive('code'), + h1: () => editor.isActive('heading', { level: 1 }), + h2: () => editor.isActive('heading', { level: 2 }), + h3: () => editor.isActive('heading', { level: 3 }), + bulletList: () => editor.isActive('bulletList'), + orderedList: () => editor.isActive('orderedList'), + blockquote: () => editor.isActive('blockquote'), + codeBlock: () => editor.isActive('codeBlock'), + } + + function updateActive() { + toolbar.querySelectorAll('[data-action]').forEach(btn => { + const action = btn.dataset.action + const check = activeCheck[action] + btn.classList.toggle('is-active', check ? check() : false) + }) + } + + toolbar.addEventListener('click', (e) => { + const btn = e.target.closest('[data-action]') + if (!btn) return + const action = btn.dataset.action + if (actionMap[action]) { + actionMap[action]() + updateActive() + } + }) + + editor.on('selectionUpdate', updateActive) + editor.on('update', updateActive) +} + +// ---- 图片上传(拖拽/粘贴) ---- +// Tiptap 默认支持图片拖入,但我们需要上传到服务器并替换 src +// 通过监听 editor 的 drop/paste 事件处理图片 +document.addEventListener('DOMContentLoaded', () => { + // 延迟绑定,等 editor 初始化 + setTimeout(() => { + const editorEl = document.querySelector('#editor-container .tiptap') + if (!editorEl) return + + // 粘贴图片 + editorEl.addEventListener('paste', async (e) => { + const items = e.clipboardData?.items + if (!items) return + for (const item of items) { + if (item.type.startsWith('image/')) { + e.preventDefault() + const file = item.getAsFile() + const url = await uploadSingleImage(file) + if (url) { + editor.chain().focus().setImage({ src: url }).run() + } + break + } + } + }) + + // 拖入图片 + editorEl.addEventListener('drop', async (e) => { + const files = e.dataTransfer?.files + if (!files || files.length === 0) return + const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/')) + if (imageFiles.length === 0) return + + e.preventDefault() + for (const file of imageFiles) { + const url = await uploadSingleImage(file) + if (url) { + editor.chain().focus().setImage({ src: url }).run() + } + } + }) + }, 200) +}) + +async function uploadSingleImage(file) { const csrfMeta = document.querySelector('meta[name="csrf-token"]') const csrf = csrfMeta ? csrfMeta.getAttribute('content') : '' - const results = [] - for (const file of files) { - const formData = new FormData() - formData.append('file', file) + const formData = new FormData() + formData.append('file', file) + try { const resp = await fetch('/api/posts/upload-image', { method: 'POST', headers: { 'X-CSRF-Token': csrf }, body: formData, }) const data = await resp.json() - if (data.success || data.code === 200) { - const url = data.data?.url || data.url - results.push({ url, alt: file.name, title: file.name }) + return data.data?.url || data.url || '' } + } catch (e) { + // 上传失败,静默忽略 } - return results + return '' } // ---- 字数统计 ---- @@ -103,43 +211,40 @@ function updateWordCount(text) { } // ---- 草稿保存/恢复 ---- -function scheduleDraft(text) { +function scheduleDraft(html) { clearTimeout(draftTimer) - draftTimer = setTimeout(() => saveDraft(text), 2000) + draftTimer = setTimeout(() => saveDraft(html), 2000) } -function saveDraft(text) { +function saveDraft(html) { const title = document.getElementById('postTitle')?.value || '' - if (!text.trim() && !title.trim()) return + if (!html.trim() && !title.trim()) return try { localStorage.setItem('draft_post_title', title) - localStorage.setItem('draft_post_body', text) + localStorage.setItem('draft_post_body_html', html) const statusEl = document.getElementById('draftStatus') if (statusEl) { statusEl.style.display = '' setTimeout(() => { statusEl.style.display = 'none' }, 2000) } } catch (e) { - // localStorage 不可用(隐私模式等),静默忽略 + // localStorage 不可用,静默忽略 } } function restoreDraft() { try { - const savedId = localStorage.getItem('draft_post_id') - if (savedId) return // 编辑模式不恢复草稿 - const title = localStorage.getItem('draft_post_title') - const body = localStorage.getItem('draft_post_body') + const html = localStorage.getItem('draft_post_body_html') if (title) { document.getElementById('postTitle').value = title } - if (body && editor) { - editor.$set({ value: body }) - currentValue = body - updateWordCount(body) + if (html && editor) { + editor.commands.setContent(html) + currentHTML = html + updateWordCount(editor.getText()) } } catch (e) { // 静默忽略 @@ -149,7 +254,7 @@ function restoreDraft() { function clearDraft() { try { localStorage.removeItem('draft_post_title') - localStorage.removeItem('draft_post_body') + localStorage.removeItem('draft_post_body_html') } catch (e) { /* ignore */ } } @@ -181,7 +286,12 @@ async function handleSubmit(e) { const title = document.getElementById('postTitle')?.value.trim() if (!title) { alert('请输入标题'); return } - if (!currentValue.trim()) { alert('请输入正文'); return } + + // 获取编辑器 HTML 内容 + const body = editor ? editor.getHTML() : '' + // 忽略空段落 + const cleanBody = body === '

' ? '' : body + if (!cleanBody.trim()) { alert('请输入正文'); return } const isEdit = !!postId const url = isEdit ? '/api/posts/' + postId : '/api/posts' @@ -197,7 +307,7 @@ async function handleSubmit(e) { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf, }, - body: JSON.stringify({ title, body: currentValue }), + body: JSON.stringify({ title, body: cleanBody }), }) const data = await resp.json() @@ -209,6 +319,11 @@ async function handleSubmit(e) { alert(data.message || '操作失败') } } catch (err) { - alert('请求失败,请重试') + if (err && err.message === 'refresh_failed') { + alert('登录已过期,请重新登录') + window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname) + } else { + alert('请求失败,请重试') + } } }