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('请求失败,请重试')
+ }
}
}