refactor: ByteMD 迁移至 Tiptap WYSIWYG 编辑器 + Token 刷新优化
- 移除 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
This commit is contained in:
1
go.mod
1
go.mod
@ -8,7 +8,6 @@ require (
|
|||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/yuin/goldmark v1.8.2
|
|
||||||
golang.org/x/crypto v0.52.0
|
golang.org/x/crypto v0.52.0
|
||||||
golang.org/x/image v0.41.0
|
golang.org/x/image v0.41.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
|||||||
2
go.sum
2
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/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 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
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 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
|||||||
@ -25,7 +25,7 @@ type rateLimiter interface {
|
|||||||
Clear(email, ip string)
|
Clear(email, ip string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// postUseCase PostController 对 PostService 的最小依赖(ISP:8 个方法)
|
// postUseCase PostController 对 PostService 的最小依赖(ISP:6 个方法)
|
||||||
type postUseCase interface {
|
type postUseCase interface {
|
||||||
Create(userID uint, title, body string) (*model.Post, error)
|
Create(userID uint, title, body string) (*model.Post, error)
|
||||||
GetByID(id uint) (*model.Post, error)
|
GetByID(id uint) (*model.Post, error)
|
||||||
@ -33,5 +33,4 @@ type postUseCase interface {
|
|||||||
Update(postID uint, title, body string) error
|
Update(postID uint, title, body string) error
|
||||||
Delete(postID uint) error
|
Delete(postID uint) error
|
||||||
SubmitForAudit(postID uint) error
|
SubmitForAudit(postID uint) error
|
||||||
RenderMarkdown(body string) (string, error)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -306,25 +306,6 @@ func (ctrl *PostController) ShowAPI(c *gin.Context) {
|
|||||||
common.Ok(c, post)
|
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 允许的图片扩展名
|
// allowedImageExt 允许的图片扩展名
|
||||||
var allowedImageExt = map[string]bool{
|
var allowedImageExt = map[string]bool{
|
||||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||||||
|
|||||||
@ -9,8 +9,8 @@ import (
|
|||||||
func SecurityHeaders() gin.HandlerFunc {
|
func SecurityHeaders() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Content-Security-Policy
|
// Content-Security-Policy
|
||||||
// script-src: 本站 + esm.sh CDN (ByteMD ESM 模块)
|
// script-src: 本站 + esm.sh CDN (Tiptap ESM 模块)
|
||||||
// style-src: 本站 + esm.sh (ByteMD/highlight.js CSS)
|
// style-src: 本站 + esm.sh (Tiptap CSS)
|
||||||
// connect-src: 本站 + esm.sh (source map 请求)
|
// connect-src: 本站 + esm.sh (source map 请求)
|
||||||
// img-src: 本站 + data: URI (头像裁切) + blob: (粘贴图片)
|
// img-src: 本站 + data: URI (头像裁切) + blob: (粘贴图片)
|
||||||
c.Header("Content-Security-Policy",
|
c.Header("Content-Security-Policy",
|
||||||
|
|||||||
@ -56,7 +56,6 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
postsAPI.PUT("/:id", d.postCtrl.Update)
|
postsAPI.PUT("/:id", d.postCtrl.Update)
|
||||||
postsAPI.DELETE("/:id", d.postCtrl.Delete)
|
postsAPI.DELETE("/:id", d.postCtrl.Delete)
|
||||||
postsAPI.POST("/:id/submit", d.postCtrl.Submit)
|
postsAPI.POST("/:id/submit", d.postCtrl.Submit)
|
||||||
postsAPI.POST("/preview", d.postCtrl.PreviewAPI)
|
|
||||||
postsAPI.POST("/upload-image", d.postCtrl.UploadImage)
|
postsAPI.POST("/upload-image", d.postCtrl.UploadImage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/metalab/internal/common"
|
||||||
@ -9,15 +8,16 @@ import (
|
|||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
"github.com/yuin/goldmark"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// sanitizePolicy 纵深防御:对 goldmark 输出的 HTML 再做一层消毒
|
// sanitizePolicy Tiptap 输出 HTML 的消毒策略
|
||||||
// goldmark 默认已过滤原始 HTML 和危险链接,但 bluemonday 作为第二道防线
|
// 前端 WYSIWYG 编辑器输出 HTML,由 bluemonday UGC 策略消毒
|
||||||
// UGC 策略会剥离 code/pre 的 class 属性,需要显式放行以保留 language-xxx
|
// 显式放行 code/pre 的 class 属性以保留代码块样式
|
||||||
|
// 放行 img 的 src/alt 属性以保留图片
|
||||||
var sanitizePolicy = func() *bluemonday.Policy {
|
var sanitizePolicy = func() *bluemonday.Policy {
|
||||||
p := bluemonday.UGCPolicy()
|
p := bluemonday.UGCPolicy()
|
||||||
p.AllowAttrs("class").OnElements("code", "pre")
|
p.AllowAttrs("class").OnElements("code", "pre")
|
||||||
|
p.AllowAttrs("src", "alt", "title").OnElements("img")
|
||||||
return p
|
return p
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@ -37,7 +37,6 @@ type postStore interface {
|
|||||||
type PostService struct {
|
type PostService struct {
|
||||||
repo postStore
|
repo postStore
|
||||||
ss *config.SiteSettings
|
ss *config.SiteSettings
|
||||||
md goldmark.Markdown
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPostService 构造函数
|
// NewPostService 构造函数
|
||||||
@ -45,7 +44,6 @@ func NewPostService(repo postStore, ss *config.SiteSettings) *PostService {
|
|||||||
return &PostService{
|
return &PostService{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
ss: ss,
|
ss: ss,
|
||||||
md: goldmark.New(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,27 +52,15 @@ func (s *PostService) auditEnabled() bool {
|
|||||||
return s.ss.IsAuditEnabled()
|
return s.ss.IsAuditEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderMarkdown 将 Markdown 文本渲染为安全 HTML
|
// sanitizeHTML 对 Tiptap 输出的 HTML 进行消毒
|
||||||
// goldmark(默认安全模式)→ bluemonday(纵深防御)
|
// 前端 WYSIWYG 编辑器直接输出 HTML,后端仅做安全消毒
|
||||||
func (s *PostService) renderMarkdown(body string) (string, error) {
|
func (s *PostService) sanitizeHTML(body string) string {
|
||||||
var buf bytes.Buffer
|
return sanitizePolicy.Sanitize(body)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 创建帖子
|
// Create 创建帖子
|
||||||
func (s *PostService) Create(userID uint, title, body string) (*model.Post, error) {
|
func (s *PostService) Create(userID uint, title, body string) (*model.Post, error) {
|
||||||
bodyHTML, err := s.renderMarkdown(body)
|
bodyHTML := s.sanitizeHTML(body)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
status := model.PostStatusApproved
|
status := model.PostStatusApproved
|
||||||
if s.auditEnabled() {
|
if s.auditEnabled() {
|
||||||
@ -129,14 +115,9 @@ func (s *PostService) Update(postID uint, title, body string) error {
|
|||||||
return fmt.Errorf("当前状态不允许编辑")
|
return fmt.Errorf("当前状态不允许编辑")
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyHTML, err := s.renderMarkdown(body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
post.Title = title
|
post.Title = title
|
||||||
post.Body = body
|
post.Body = body
|
||||||
post.BodyHTML = bodyHTML
|
post.BodyHTML = s.sanitizeHTML(body)
|
||||||
|
|
||||||
// rejected 状态编辑后自动重置为 draft
|
// rejected 状态编辑后自动重置为 draft
|
||||||
if post.Status == model.PostStatusRejected {
|
if post.Status == model.PostStatusRejected {
|
||||||
|
|||||||
@ -1,16 +1,114 @@
|
|||||||
{{template "layout/header.html" .}}
|
{{template "layout/header.html" .}}
|
||||||
<link rel="stylesheet" href="https://esm.sh/bytemd@1.22.0/dist/index.css">
|
|
||||||
<link rel="stylesheet" href="https://esm.sh/highlight.js@11.9.0/styles/github.css">
|
|
||||||
<style>
|
<style>
|
||||||
/* ByteMD 编辑器容器适配全高布局 */
|
/* Tiptap 编辑器容器适配全高布局 */
|
||||||
#editor-container {
|
#editor-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
height: 100%;
|
background: #fff;
|
||||||
}
|
}
|
||||||
#editor-container .bytemd {
|
|
||||||
height: 100%;
|
/* Tiptap 编辑器核心样式 */
|
||||||
|
.tiptap {
|
||||||
|
padding: 16px 24px;
|
||||||
|
outline: none;
|
||||||
|
min-height: 100%;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #24292f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 段落间距 */
|
||||||
|
.tiptap p { margin: 0.5em 0; }
|
||||||
|
|
||||||
|
/* 标题 */
|
||||||
|
.tiptap h1 { margin: 1em 0 0.5em; font-size: 2em; font-weight: 700; }
|
||||||
|
.tiptap h2 { margin: 0.8em 0 0.4em; font-size: 1.5em; font-weight: 700; }
|
||||||
|
.tiptap h3 { margin: 0.6em 0 0.3em; font-size: 1.17em; font-weight: 700; }
|
||||||
|
|
||||||
|
/* 列表 */
|
||||||
|
.tiptap ul, .tiptap ol { padding-left: 1.5em; margin: 0.5em 0; }
|
||||||
|
.tiptap li { margin: 0.2em 0; }
|
||||||
|
.tiptap ul[data-type="taskList"] { list-style: none; padding-left: 0; }
|
||||||
|
.tiptap ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 0.4em; }
|
||||||
|
.tiptap ul[data-type="taskList"] li > label { flex-shrink: 0; margin-top: 0.15em; }
|
||||||
|
|
||||||
|
/* 引用 */
|
||||||
|
.tiptap blockquote {
|
||||||
|
border-left: 3px solid #e5e7eb;
|
||||||
|
padding-left: 1em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 代码块 */
|
||||||
|
.tiptap pre {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #f3f4f6;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.tiptap pre code { background: transparent; padding: 0; color: inherit; font-size: inherit; }
|
||||||
|
.tiptap code {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-family: 'SF Mono', 'Menlo', 'Monaco', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 水平线 */
|
||||||
|
.tiptap hr { border: none; border-top: 1px solid #e5e7eb; margin: 1em 0; }
|
||||||
|
|
||||||
|
/* 图片 */
|
||||||
|
.tiptap img { max-width: 100%; height: auto; border-radius: 6px; }
|
||||||
|
|
||||||
|
/* 链接 */
|
||||||
|
.tiptap a { color: #6366f1; text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Placeholder */
|
||||||
|
.tiptap p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: #c4c4c4;
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工具栏 */
|
||||||
|
.editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.editor-toolbar button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.editor-toolbar button:hover { background: #e5e7eb; }
|
||||||
|
.editor-toolbar button.is-active { background: #1f2937; color: #fff; }
|
||||||
|
.editor-toolbar .toolbar-divider {
|
||||||
|
width: 1px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
margin: 3px 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<body>
|
<body>
|
||||||
@ -29,10 +127,33 @@
|
|||||||
maxlength="200" required autofocus>
|
maxlength="200" required autofocus>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{/* 工具栏 */}}
|
||||||
|
<div class="editor-toolbar" id="toolbar">
|
||||||
|
<button type="button" data-action="bold" title="加粗 (Ctrl+B)"><strong>B</strong></button>
|
||||||
|
<button type="button" data-action="italic" title="斜体 (Ctrl+I)"><em>I</em></button>
|
||||||
|
<button type="button" data-action="strike" title="删除线"><s>S</s></button>
|
||||||
|
<button type="button" data-action="code" title="行内代码"></></button>
|
||||||
|
<span class="toolbar-divider"></span>
|
||||||
|
<button type="button" data-action="h1" title="标题 1">H1</button>
|
||||||
|
<button type="button" data-action="h2" title="标题 2">H2</button>
|
||||||
|
<button type="button" data-action="h3" title="标题 3">H3</button>
|
||||||
|
<span class="toolbar-divider"></span>
|
||||||
|
<button type="button" data-action="bulletList" title="无序列表">•</button>
|
||||||
|
<button type="button" data-action="orderedList" title="有序列表">1.</button>
|
||||||
|
<button type="button" data-action="blockquote" title="引用">"</button>
|
||||||
|
<span class="toolbar-divider"></span>
|
||||||
|
<button type="button" data-action="codeBlock" title="代码块">{ }</button>
|
||||||
|
<button type="button" data-action="horizontalRule" title="分隔线">—</button>
|
||||||
|
<span class="toolbar-divider"></span>
|
||||||
|
<button type="button" data-action="undo" title="撤销">↩</button>
|
||||||
|
<button type="button" data-action="redo" title="重做">↪</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{/* 编辑器主体 */}}
|
{{/* 编辑器主体 */}}
|
||||||
<div class="editor-main">
|
<div class="editor-main">
|
||||||
<div class="editor-pane" id="editorPane">
|
<div class="editor-pane" id="editorPane">
|
||||||
<div id="editor-container" data-value="{{if .Post}}{{.Post.Body}}{{end}}"></div>
|
<div id="editor-container"
|
||||||
|
data-content="{{if .Post}}{{.Post.BodyHTML}}{{end}}"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -49,4 +170,6 @@
|
|||||||
|
|
||||||
{{template "layout/footer.html" .}}
|
{{template "layout/footer.html" .}}
|
||||||
|
|
||||||
|
<script src="/static/js/common.js"></script>
|
||||||
<script type="module" src="/static/js/editor.js"></script>
|
<script type="module" src="/static/js/editor.js"></script>
|
||||||
|
</template>
|
||||||
|
|||||||
@ -247,9 +247,7 @@
|
|||||||
color: #c4c4c4;
|
color: #c4c4c4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- 编辑器主体 ---- */
|
||||||
|
|
||||||
/* ---- 编辑器主体(编辑区 + 预览区) ---- */
|
|
||||||
.editor-main {
|
.editor-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -263,16 +261,6 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ByteMD 编辑器容器 */
|
|
||||||
#editor-container {
|
|
||||||
background: #fff;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#editor-container .bytemd {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 状态栏 ---- */
|
/* ---- 状态栏 ---- */
|
||||||
.editor-statusbar {
|
.editor-statusbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@ -24,11 +24,59 @@
|
|||||||
|
|
||||||
// ---- Auto-attach X-CSRF-Token to all state-changing requests ----
|
// ---- Auto-attach X-CSRF-Token to all state-changing requests ----
|
||||||
// Monkey-patch XMLHttpRequest and fetch to inject CSRF header
|
// Monkey-patch XMLHttpRequest and fetch to inject CSRF header
|
||||||
|
// Also intercept 401 responses to auto-refresh token
|
||||||
(function () {
|
(function () {
|
||||||
var token = window.MetaLab.csrfToken();
|
var SAME_ORIGIN = /^\/(?!\/)/;
|
||||||
if (!token) return;
|
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 origXHROpen = XMLHttpRequest.prototype.open;
|
||||||
var origXHRSend = XMLHttpRequest.prototype.send;
|
var origXHRSend = XMLHttpRequest.prototype.send;
|
||||||
XMLHttpRequest.prototype.open = function (method, url) {
|
XMLHttpRequest.prototype.open = function (method, url) {
|
||||||
@ -36,31 +84,100 @@
|
|||||||
this._url = url;
|
this._url = url;
|
||||||
return origXHROpen.apply(this, arguments);
|
return origXHROpen.apply(this, arguments);
|
||||||
};
|
};
|
||||||
var SAME_ORIGIN = /^\/(?!\/)/;
|
|
||||||
XMLHttpRequest.prototype.send = function () {
|
XMLHttpRequest.prototype.send = function () {
|
||||||
if (SAME_ORIGIN.test(this._url) &&
|
var xhr = this;
|
||||||
this._method && !/^(GET|HEAD|OPTIONS)$/i.test(this._method)) {
|
if (SAME_ORIGIN.test(xhr._url) &&
|
||||||
this.setRequestHeader('X-CSRF-Token', token);
|
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;
|
var origFetch = window.fetch;
|
||||||
window.fetch = function (url, options) {
|
window.fetch = function (url, options) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
var headers = options.headers || {};
|
var headers = options.headers || {};
|
||||||
var method = (options.method || 'GET').toUpperCase();
|
var method = (options.method || 'GET').toUpperCase();
|
||||||
var urlStr = (typeof url === 'string') ? url : '';
|
var urlStr = (typeof url === 'string') ? url : '';
|
||||||
|
|
||||||
if (SAME_ORIGIN.test(urlStr) && !/^(GET|HEAD|OPTIONS)$/i.test(method)) {
|
if (SAME_ORIGIN.test(urlStr) && !/^(GET|HEAD|OPTIONS)$/i.test(method)) {
|
||||||
if (headers instanceof Headers) {
|
if (headers instanceof Headers) {
|
||||||
headers.set('X-CSRF-Token', token);
|
headers.set('X-CSRF-Token', getCSRFTokenHeader());
|
||||||
} else {
|
} else {
|
||||||
headers['X-CSRF-Token'] = token;
|
headers['X-CSRF-Token'] = getCSRFTokenHeader();
|
||||||
}
|
}
|
||||||
options.headers = headers;
|
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) ----
|
// ---- Auto-refresh token (silent) ----
|
||||||
// 场景 A:页面开着时,每 10 分钟静默续期(保持 access token 不过期)
|
// 场景 A:页面开着时,每 10 分钟静默续期(保持 access token 不过期)
|
||||||
// 场景 B:关浏览器 15+ min 后回来,access token 过期但 refresh 仍有效
|
// 场景 B:关浏览器 15+ min 后回来,access token 过期但 refresh 仍有效
|
||||||
// → 页面先渲染为未登录 → 刷新成功后 reload 一次获得登录态
|
// → 页面渲染为未登录 → 刷新成功后 reload 一次获得登录态
|
||||||
|
// → 仅当页面是未登录状态(无 logoutBtn)时才触发 reload
|
||||||
|
// 场景 C:编辑器等页面发 API 请求时遇到 401 → 由 fetch/XHR patch 自动刷新后重试
|
||||||
// 不在认证页面执行刷新(登录/注册页无需续期)
|
// 不在认证页面执行刷新(登录/注册页无需续期)
|
||||||
var onAuthPage = /^\/auth\//.test(window.location.pathname);
|
var onAuthPage = /^\/auth\//.test(window.location.pathname);
|
||||||
if (!onAuthPage) {
|
if (!onAuthPage) {
|
||||||
@ -190,23 +309,22 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仅当存在"记住我"标记 Cookie 时才尝试刷新(避免无需刷新时产生 401 控制台报错)
|
// 启动定时刷新:只要用户已登录(有 logoutBtn 元素),就定期刷新
|
||||||
function hasCookie(name) {
|
if (document.getElementById('logoutBtn')) {
|
||||||
return document.cookie.split(';').some(function (c) {
|
// 页面已经是登录态,启动定时刷新即可,无需 reload
|
||||||
return c.trim().startsWith(name + '=');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasCookie('mlb_rm')) {
|
|
||||||
tryRefresh(function () {
|
tryRefresh(function () {
|
||||||
refreshTimer = setInterval(function () { tryRefresh(null); }, REFRESH_INTERVAL);
|
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)
|
} // end if (!onAuthPage)
|
||||||
|
|||||||
@ -1,19 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* MetaLab Editor — ByteMD Markdown 编辑器
|
* MetaLab Editor — Tiptap 所见即所得编辑器
|
||||||
*
|
*
|
||||||
* ByteMD 基于 CodeMirror 5(纯 textarea 兜底,不依赖 contentEditable),
|
* Tiptap 基于 ProseMirror,输出 HTML,无需 Markdown 知识即可使用。
|
||||||
* 输出 Markdown 原文,由后端 goldmark 统一渲染 HTML。
|
* 后端存储 HTML,通过 bluemonday 消毒确保安全。
|
||||||
*
|
*
|
||||||
* 安全模型:Markdown 文本 → goldmark → HTML(AST 生成,天然安全)
|
* 安全模型:Tiptap → HTML(富文本)→ 后端 bluemonday 消毒 → 存储
|
||||||
* 前端不再做 sanitize,XSS 风险由 goldmark 的 AST 输出消除。
|
|
||||||
*/
|
*/
|
||||||
import { Editor } from 'https://esm.sh/bytemd@1.22.0'
|
import { Editor } from 'https://esm.sh/@tiptap/core@3.13.0'
|
||||||
import gfm from 'https://esm.sh/@bytemd/plugin-gfm@1.22.0'
|
import StarterKit from 'https://esm.sh/@tiptap/starter-kit@3.13.0'
|
||||||
import highlight from 'https://esm.sh/@bytemd/plugin-highlight@1.22.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 editor = null
|
||||||
let currentValue = ''
|
let currentHTML = ''
|
||||||
let draftTimer = null
|
let draftTimer = null
|
||||||
|
|
||||||
// ---- 初始化 ----
|
// ---- 初始化 ----
|
||||||
@ -24,74 +25,181 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const postId = document.getElementById('postId')?.value
|
const postId = document.getElementById('postId')?.value
|
||||||
const isEdit = !!postId
|
const isEdit = !!postId
|
||||||
|
|
||||||
// 从容器 data-value 属性获取初始内容(由后端 SSR 渲染)
|
// 从容器 data-content 属性获取初始 HTML 内容(由后端 SSR 渲染)
|
||||||
const initialValue = container.dataset.value || ''
|
const initialContent = container.dataset.content || ''
|
||||||
currentValue = initialValue
|
currentHTML = initialContent
|
||||||
|
|
||||||
const plugins = [
|
|
||||||
gfm(),
|
|
||||||
highlight(),
|
|
||||||
]
|
|
||||||
|
|
||||||
editor = new Editor({
|
editor = new Editor({
|
||||||
target: container,
|
element: container,
|
||||||
props: {
|
extensions: [
|
||||||
value: initialValue,
|
StarterKit.configure({
|
||||||
plugins,
|
heading: { levels: [1, 2, 3] },
|
||||||
mode: 'split',
|
}),
|
||||||
placeholder: '在此输入正文... (Markdown)',
|
Placeholder.configure({
|
||||||
uploadImages: uploadImage,
|
placeholder: '在此输入正文...',
|
||||||
|
}),
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||||
|
}),
|
||||||
|
Image.configure({
|
||||||
|
inline: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: initialContent,
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
const html = editor.getHTML()
|
||||||
|
// 忽略空内容(<p></p>)
|
||||||
|
currentHTML = html === '<p></p>' ? '' : 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) {
|
if (!isEdit) {
|
||||||
setTimeout(() => restoreDraft(), 100)
|
setTimeout(() => restoreDraft(), 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 工具栏按钮绑定
|
||||||
|
setupToolbar()
|
||||||
|
|
||||||
// 全屏切换
|
// 全屏切换
|
||||||
document.getElementById('btnFullscreen')?.addEventListener('click', toggleFullscreen)
|
document.getElementById('btnFullscreen')?.addEventListener('click', toggleFullscreen)
|
||||||
|
|
||||||
// 表单提交
|
// 表单提交
|
||||||
document.getElementById('postForm')?.addEventListener('submit', handleSubmit)
|
document.getElementById('postForm')?.addEventListener('submit', handleSubmit)
|
||||||
|
|
||||||
// Ctrl+S 快捷键
|
// Ctrl+S / F11 快捷键
|
||||||
document.addEventListener('keydown', handleKeyboard)
|
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 csrfMeta = document.querySelector('meta[name="csrf-token"]')
|
||||||
const csrf = csrfMeta ? csrfMeta.getAttribute('content') : ''
|
const csrf = csrfMeta ? csrfMeta.getAttribute('content') : ''
|
||||||
|
|
||||||
const results = []
|
const formData = new FormData()
|
||||||
for (const file of files) {
|
formData.append('file', file)
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', file)
|
|
||||||
|
|
||||||
|
try {
|
||||||
const resp = await fetch('/api/posts/upload-image', {
|
const resp = await fetch('/api/posts/upload-image', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRF-Token': csrf },
|
headers: { 'X-CSRF-Token': csrf },
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
|
|
||||||
if (data.success || data.code === 200) {
|
if (data.success || data.code === 200) {
|
||||||
const url = data.data?.url || data.url
|
return data.data?.url || data.url || ''
|
||||||
results.push({ url, alt: file.name, title: file.name })
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 上传失败,静默忽略
|
||||||
}
|
}
|
||||||
return results
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 字数统计 ----
|
// ---- 字数统计 ----
|
||||||
@ -103,43 +211,40 @@ function updateWordCount(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- 草稿保存/恢复 ----
|
// ---- 草稿保存/恢复 ----
|
||||||
function scheduleDraft(text) {
|
function scheduleDraft(html) {
|
||||||
clearTimeout(draftTimer)
|
clearTimeout(draftTimer)
|
||||||
draftTimer = setTimeout(() => saveDraft(text), 2000)
|
draftTimer = setTimeout(() => saveDraft(html), 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveDraft(text) {
|
function saveDraft(html) {
|
||||||
const title = document.getElementById('postTitle')?.value || ''
|
const title = document.getElementById('postTitle')?.value || ''
|
||||||
if (!text.trim() && !title.trim()) return
|
if (!html.trim() && !title.trim()) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('draft_post_title', title)
|
localStorage.setItem('draft_post_title', title)
|
||||||
localStorage.setItem('draft_post_body', text)
|
localStorage.setItem('draft_post_body_html', html)
|
||||||
const statusEl = document.getElementById('draftStatus')
|
const statusEl = document.getElementById('draftStatus')
|
||||||
if (statusEl) {
|
if (statusEl) {
|
||||||
statusEl.style.display = ''
|
statusEl.style.display = ''
|
||||||
setTimeout(() => { statusEl.style.display = 'none' }, 2000)
|
setTimeout(() => { statusEl.style.display = 'none' }, 2000)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// localStorage 不可用(隐私模式等),静默忽略
|
// localStorage 不可用,静默忽略
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreDraft() {
|
function restoreDraft() {
|
||||||
try {
|
try {
|
||||||
const savedId = localStorage.getItem('draft_post_id')
|
|
||||||
if (savedId) return // 编辑模式不恢复草稿
|
|
||||||
|
|
||||||
const title = localStorage.getItem('draft_post_title')
|
const title = localStorage.getItem('draft_post_title')
|
||||||
const body = localStorage.getItem('draft_post_body')
|
const html = localStorage.getItem('draft_post_body_html')
|
||||||
|
|
||||||
if (title) {
|
if (title) {
|
||||||
document.getElementById('postTitle').value = title
|
document.getElementById('postTitle').value = title
|
||||||
}
|
}
|
||||||
if (body && editor) {
|
if (html && editor) {
|
||||||
editor.$set({ value: body })
|
editor.commands.setContent(html)
|
||||||
currentValue = body
|
currentHTML = html
|
||||||
updateWordCount(body)
|
updateWordCount(editor.getText())
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 静默忽略
|
// 静默忽略
|
||||||
@ -149,7 +254,7 @@ function restoreDraft() {
|
|||||||
function clearDraft() {
|
function clearDraft() {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem('draft_post_title')
|
localStorage.removeItem('draft_post_title')
|
||||||
localStorage.removeItem('draft_post_body')
|
localStorage.removeItem('draft_post_body_html')
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,7 +286,12 @@ async function handleSubmit(e) {
|
|||||||
const title = document.getElementById('postTitle')?.value.trim()
|
const title = document.getElementById('postTitle')?.value.trim()
|
||||||
|
|
||||||
if (!title) { alert('请输入标题'); return }
|
if (!title) { alert('请输入标题'); return }
|
||||||
if (!currentValue.trim()) { alert('请输入正文'); return }
|
|
||||||
|
// 获取编辑器 HTML 内容
|
||||||
|
const body = editor ? editor.getHTML() : ''
|
||||||
|
// 忽略空段落
|
||||||
|
const cleanBody = body === '<p></p>' ? '' : body
|
||||||
|
if (!cleanBody.trim()) { alert('请输入正文'); return }
|
||||||
|
|
||||||
const isEdit = !!postId
|
const isEdit = !!postId
|
||||||
const url = isEdit ? '/api/posts/' + postId : '/api/posts'
|
const url = isEdit ? '/api/posts/' + postId : '/api/posts'
|
||||||
@ -197,7 +307,7 @@ async function handleSubmit(e) {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-CSRF-Token': csrf,
|
'X-CSRF-Token': csrf,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ title, body: currentValue }),
|
body: JSON.stringify({ title, body: cleanBody }),
|
||||||
})
|
})
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
|
|
||||||
@ -209,6 +319,11 @@ async function handleSubmit(e) {
|
|||||||
alert(data.message || '操作失败')
|
alert(data.message || '操作失败')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('请求失败,请重试')
|
if (err && err.message === 'refresh_failed') {
|
||||||
|
alert('登录已过期,请重新登录')
|
||||||
|
window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname)
|
||||||
|
} else {
|
||||||
|
alert('请求失败,请重试')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user