From 7659aee4686ce8c77daba0a800013b9ca207c8f8 Mon Sep 17 00:00:00 2001 From: Victor_Jay Date: Sat, 30 May 2026 19:55:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=EF=BC=88/studio=EF=BC=89=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=95=B0=E6=8D=AE=E6=A6=82=E8=A7=88=E3=80=81=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E7=AE=A1=E7=90=86=E3=80=81=E8=8D=89=E7=A8=BF=E7=AE=B1?= =?UTF-8?q?=E3=80=81=E5=86=99=E6=96=87=E7=AB=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 StudioController,依赖 studioUseCase 接口,遵循 ISP 接口隔离 - PostRepo 新增 FindByUserIDAndStatus 方法,按用户+状态分页查询 - PostService 新增 ListByUser、GetOverview 及 StudioOverview 统计 - /studio 页面路由(概览/管理/草稿箱/写文章/数据分析)+ /api/studio API - 复用 Vditor 编辑器,studio-editor.js 对接 /api/studio/posts 端点 - 新增 studio.css(B站风格三栏布局+统计卡片+状态筛选tabs) - 模板引擎注册 add/subtract 函数,分页模板中直接使用 --- internal/controller/interfaces.go | 13 + internal/controller/studio_controller.go | 368 ++++++++++ internal/repository/post_repo.go | 7 + internal/router/api.go | 13 + internal/router/deps_core.go | 1 + internal/router/deps_extra.go | 2 + internal/router/frontend.go | 11 + internal/service/post_service.go | 37 + internal/service/repository.go | 3 +- internal/theme/loader.go | 6 +- .../MetaLab-2026/html/studio/analytics.html | 72 ++ .../MetaLab-2026/html/studio/drafts.html | 105 +++ .../MetaLab-2026/html/studio/overview.html | 95 +++ templates/MetaLab-2026/html/studio/posts.html | 105 +++ .../MetaLab-2026/html/studio/sidebar.html | 25 + templates/MetaLab-2026/html/studio/write.html | 72 ++ templates/MetaLab-2026/static/css/studio.css | 639 ++++++++++++++++++ .../MetaLab-2026/static/js/studio-editor.js | 209 ++++++ 18 files changed, 1781 insertions(+), 2 deletions(-) create mode 100644 internal/controller/studio_controller.go create mode 100644 templates/MetaLab-2026/html/studio/analytics.html create mode 100644 templates/MetaLab-2026/html/studio/drafts.html create mode 100644 templates/MetaLab-2026/html/studio/overview.html create mode 100644 templates/MetaLab-2026/html/studio/posts.html create mode 100644 templates/MetaLab-2026/html/studio/sidebar.html create mode 100644 templates/MetaLab-2026/html/studio/write.html create mode 100644 templates/MetaLab-2026/static/css/studio.css create mode 100644 templates/MetaLab-2026/static/js/studio-editor.js diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index df00d8d..c9fcbce 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -3,6 +3,7 @@ package controller import ( "metazone.cc/metalab/internal/middleware" "metazone.cc/metalab/internal/model" + "metazone.cc/metalab/internal/service" ) // authUseCase AuthController 对 AuthService 的最小依赖(ISP:4 个方法) @@ -36,6 +37,18 @@ type postUseCase interface { IsPostAccessible(userID uint, role string, postUserID uint) bool } +// studioUseCase StudioController 对 PostService 的最小依赖(ISP:8 个方法) +type studioUseCase interface { + Create(userID uint, title, body string) (*model.Post, error) + GetByID(id uint) (*model.Post, error) + Update(postID uint, title, body string) error + Delete(postID uint) error + SubmitForAudit(postID uint) error + ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error) + GetOverview(userID uint) (*service.StudioOverview, error) + IsPostAccessible(userID uint, role string, postUserID uint) bool +} + // spaceUseCase SpaceController 对 SpaceService 的最小依赖(ISP:2 个方法) type spaceUseCase interface { GetSpaceUser(uid uint) (*model.User, error) diff --git a/internal/controller/studio_controller.go b/internal/controller/studio_controller.go new file mode 100644 index 0000000..ea61354 --- /dev/null +++ b/internal/controller/studio_controller.go @@ -0,0 +1,368 @@ +package controller + +import ( + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "github.com/gin-gonic/gin" +) + +// StudioController 创作中心控制器(SSR 页面 + API) +type StudioController struct { + postService studioUseCase +} + +// NewStudioController 构造函数 +func NewStudioController(ps studioUseCase) *StudioController { + return &StudioController{postService: ps} +} + +// ============================== +// SSR 页面方法 +// ============================== + +// OverviewPage 创作中心首页(数据概览) +func (ctrl *StudioController) OverviewPage(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + c.Redirect(http.StatusFound, "/auth/login") + return + } + + overview, err := ctrl.postService.GetOverview(uid) + if err != nil { + c.HTML(http.StatusOK, "studio/overview.html", common.BuildPageData(c, gin.H{ + "Title": "创作中心", + "Error": "加载数据失败", + "ActiveTab": "overview", + "ExtraCSS": "/static/css/studio.css", + })) + return + } + + c.HTML(http.StatusOK, "studio/overview.html", common.BuildPageData(c, gin.H{ + "Title": "创作中心", + "Overview": overview, + "StatusNames": common.PostStatusDisplayNames, + "ActiveTab": "overview", + "ExtraCSS": "/static/css/studio.css", + })) +} + +// PostsPage 内容管理页 +func (ctrl *StudioController) PostsPage(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + c.Redirect(http.StatusFound, "/auth/login") + return + } + + status := c.Query("status") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + posts, total, err := ctrl.postService.ListByUser(uid, status, page, pageSize) + if err != nil { + c.HTML(http.StatusOK, "studio/posts.html", common.BuildPageData(c, gin.H{ + "Title": "内容管理 - 创作中心", + "Error": "加载失败", + "ActiveTab": "posts", + "ExtraCSS": "/static/css/studio.css", + })) + return + } + + overview, _ := ctrl.postService.GetOverview(uid) + + c.HTML(http.StatusOK, "studio/posts.html", common.BuildPageData(c, gin.H{ + "Title": "内容管理 - 创作中心", + "Posts": posts, + "Total": total, + "Page": page, + "PageSize": pageSize, + "TotalPages": common.PageCount(total, pageSize), + "CurrentStatus": status, + "Overview": overview, + "StatusNames": common.PostStatusDisplayNames, + "ActiveTab": "posts", + "ExtraCSS": "/static/css/studio.css", + })) +} + +// DraftsPage 草稿箱页面 +func (ctrl *StudioController) DraftsPage(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + c.Redirect(http.StatusFound, "/auth/login") + return + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + posts, total, err := ctrl.postService.ListByUser(uid, model.PostStatusDraft, page, pageSize) + if err != nil { + c.HTML(http.StatusOK, "studio/drafts.html", common.BuildPageData(c, gin.H{ + "Title": "草稿箱 - 创作中心", + "Error": "加载失败", + "ActiveTab": "drafts", + "ExtraCSS": "/static/css/studio.css", + })) + return + } + + c.HTML(http.StatusOK, "studio/drafts.html", common.BuildPageData(c, gin.H{ + "Title": "草稿箱 - 创作中心", + "Posts": posts, + "Total": total, + "Page": page, + "PageSize": pageSize, + "TotalPages": common.PageCount(total, pageSize), + "StatusNames": common.PostStatusDisplayNames, + "ActiveTab": "drafts", + "ExtraCSS": "/static/css/studio.css", + })) +} + +// WritePage 写文章页面 +func (ctrl *StudioController) WritePage(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + c.Redirect(http.StatusFound, "/auth/login") + return + } + + // 支持编辑模式:传入 post_id 参数 + postIDStr := c.Query("id") + if postIDStr != "" { + id, err := strconv.ParseUint(postIDStr, 10, 64) + if err == nil { + post, err := ctrl.postService.GetByID(uint(id)) + if err == nil { + _, 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": "未找到", + })) + return + } + c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{ + "Title": "编辑文章 - 创作中心", + "Post": post, + "ActiveTab": "write", + "ExtraCSS": "/static/css/studio.css", + })) + return + } + } + } + + c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{ + "Title": "写文章 - 创作中心", + "ActiveTab": "write", + "ExtraCSS": "/static/css/studio.css", + })) +} + +// AnalyticsPage 数据分析页面 +func (ctrl *StudioController) AnalyticsPage(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + c.Redirect(http.StatusFound, "/auth/login") + return + } + + overview, err := ctrl.postService.GetOverview(uid) + if err != nil { + c.HTML(http.StatusOK, "studio/analytics.html", common.BuildPageData(c, gin.H{ + "Title": "数据分析 - 创作中心", + "Error": "加载失败", + "ActiveTab": "analytics", + "ExtraCSS": "/static/css/studio.css", + })) + return + } + + c.HTML(http.StatusOK, "studio/analytics.html", common.BuildPageData(c, gin.H{ + "Title": "数据分析 - 创作中心", + "Overview": overview, + "ActiveTab": "analytics", + "ExtraCSS": "/static/css/studio.css", + })) +} + +// ============================== +// API 方法 +// ============================== + +// OverviewAPI 创作中心数据概览 +func (ctrl *StudioController) OverviewAPI(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + overview, err := ctrl.postService.GetOverview(uid) + if err != nil { + common.Error(c, http.StatusInternalServerError, "获取数据失败") + return + } + + common.Ok(c, overview) +} + +// ListPostsAPI 我的帖子列表(支持状态筛选) +func (ctrl *StudioController) ListPostsAPI(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + status := c.Query("status") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + posts, total, err := ctrl.postService.ListByUser(uid, status, page, pageSize) + if err != nil { + common.Error(c, http.StatusInternalServerError, "获取列表失败") + return + } + + common.Ok(c, gin.H{ + "items": posts, + "total": total, + "page": page, + "page_size": pageSize, + "total_pages": common.PageCount(total, pageSize), + }) +} + +// Create 创建/发布帖子 +func (ctrl *StudioController) 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 编辑帖子 +func (ctrl *StudioController) 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 + } + + post, getErr := ctrl.postService.GetByID(uint(id)) + if getErr != nil { + common.Error(c, http.StatusNotFound, "帖子不存在") + return + } + if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) { + common.Error(c, http.StatusForbidden, "无权操作此帖子") + return + } + + if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil { + common.Error(c, http.StatusInternalServerError, "编辑失败") + return + } + + common.OkMessage(c, "编辑成功") +} + +// Delete 删除帖子 +func (ctrl *StudioController) 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 + } + + post, getErr := ctrl.postService.GetByID(uint(id)) + if getErr != nil { + common.Error(c, http.StatusNotFound, "帖子不存在") + return + } + if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) { + common.Error(c, http.StatusForbidden, "无权操作此帖子") + return + } + + if err := ctrl.postService.Delete(uint(id)); err != nil { + common.Error(c, http.StatusInternalServerError, "删除失败") + return + } + + common.OkMessage(c, "删除成功") +} + +// Submit 提交审核 +func (ctrl *StudioController) Submit(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 + } + + post, getErr := ctrl.postService.GetByID(uint(id)) + if getErr != nil { + common.Error(c, http.StatusNotFound, "帖子不存在") + return + } + if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) { + common.Error(c, http.StatusForbidden, "无权操作此帖子") + return + } + + if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil { + common.Error(c, http.StatusBadRequest, err.Error()) + return + } + + common.OkMessage(c, "已提交审核") +} diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index f80d54e..60ac148 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -81,6 +81,13 @@ func (r *PostRepo) FindByUserID(userID uint, offset, limit int) ([]model.Post, i return r.pageResults(query, offset, limit) } +// FindByUserIDAndStatus 分页查询某用户指定状态的帖子(空 status=全状态) +func (r *PostRepo) FindByUserIDAndStatus(userID uint, status string, offset, limit int) ([]model.Post, int64, error) { + query := r.buildQuery("", status). + Where("posts.user_id = ?", userID) + return r.pageResults(query, offset, limit) +} + // pageResults 执行 Count + Offset/Limit + ORDER BY func (r *PostRepo) pageResults(query *gorm.DB, offset, limit int) ([]model.Post, int64, error) { var total int64 diff --git a/internal/router/api.go b/internal/router/api.go index 3336b42..b94ca17 100644 --- a/internal/router/api.go +++ b/internal/router/api.go @@ -58,4 +58,17 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { postsAPI.POST("/:id/submit", d.postCtrl.Submit) postsAPI.POST("/upload-image", d.postCtrl.UploadImage) } + + // --- 创作中心 API(需登录) --- + studioAPI := r.Group("/api/studio") + studioAPI.Use(d.authMdw.Required()) + studioAPI.Use(middleware.CSRF(cfg)) + { + studioAPI.GET("/overview", d.studioCtrl.OverviewAPI) + studioAPI.GET("/posts", d.studioCtrl.ListPostsAPI) + studioAPI.POST("/posts", d.studioCtrl.Create) + studioAPI.PUT("/posts/:id", d.studioCtrl.Update) + studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete) + studioAPI.POST("/posts/:id/submit", d.studioCtrl.Submit) + } } diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index 8bda433..5262d54 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -18,6 +18,7 @@ type dependencies struct { messageCtrl *controller.MessageController postCtrl *controller.PostController spaceCtrl *controller.SpaceController + studioCtrl *controller.StudioController adminPostCtrl *adminCtrl.AdminPostController adminCtrl *adminCtrl.AdminController auditCtrl *adminCtrl.AuditController diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index ca82d21..5757a2a 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -35,6 +35,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting shortcodeSvc := service.NewShortcodeService() postCtrl := controller.NewPostController(postService, shortcodeSvc) adminPostCtrl := adminCtrl.NewAdminPostController(postService) + studioCtrl := controller.NewStudioController(postService) // 用户空间 spaceService := service.NewSpaceService(userRepo, postRepo) @@ -43,6 +44,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting return &dependencies{ authCtrl: authCtrl, authMdw: authMdw, settingsCtrl: settingsCtrl, messageCtrl: messageController, postCtrl: postCtrl, spaceCtrl: spaceCtrl, + studioCtrl: studioCtrl, adminPostCtrl: adminPostCtrl, adminCtrl: adminController, auditCtrl: auditController, siteSettingCtrl: siteSettingController, tokenCtrl: authCtrl, diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 48cb4c5..a033a97 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -43,6 +43,17 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { pages.GET("/posts/new", d.authMdw.Required(), d.postCtrl.NewPage) pages.GET("/posts/:id", d.postCtrl.ShowPage) pages.GET("/posts/:id/edit", d.authMdw.Required(), d.postCtrl.EditPage) + + // 创作中心(需登录) + studioPages := pages.Group("/studio") + studioPages.Use(d.authMdw.Required()) + { + studioPages.GET("", d.studioCtrl.OverviewPage) + studioPages.GET("/posts", d.studioCtrl.PostsPage) + studioPages.GET("/drafts", d.studioCtrl.DraftsPage) + studioPages.GET("/write", d.studioCtrl.WritePage) + studioPages.GET("/analytics", d.studioCtrl.AnalyticsPage) + } } // 认证页面(已登录自动跳走) diff --git a/internal/service/post_service.go b/internal/service/post_service.go index 6c45eaa..6638003 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -169,6 +169,43 @@ func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]m }) } +// ListByUser 查询某用户指定状态的帖子(空 status=全状态) +func (s *PostService) ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error) { + return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) { + return s.repo.FindByUserIDAndStatus(userID, status, offset, limit) + }) +} + +// StudioOverview 创作中心数据概览 +type StudioOverview struct { + TotalPosts int64 `json:"total_posts"` + DraftCount int64 `json:"draft_count"` + PendingCount int64 `json:"pending_count"` + ApprovedCount int64 `json:"approved_count"` + RejectedCount int64 `json:"rejected_count"` +} + +// GetOverview 获取创作中心数据概览 +func (s *PostService) GetOverview(userID uint) (*StudioOverview, error) { + // 获取各状态文章数量(分页传 1 条只取 total) + _, totalPosts, err := s.repo.FindByUserIDAndStatus(userID, "", 0, 1) + if err != nil { + return nil, err + } + _, draftCount, _ := s.repo.FindByUserIDAndStatus(userID, model.PostStatusDraft, 0, 1) + _, pendingCount, _ := s.repo.FindByUserIDAndStatus(userID, model.PostStatusPending, 0, 1) + _, approvedCount, _ := s.repo.FindByUserIDAndStatus(userID, model.PostStatusApproved, 0, 1) + _, rejectedCount, _ := s.repo.FindByUserIDAndStatus(userID, model.PostStatusRejected, 0, 1) + + return &StudioOverview{ + TotalPosts: totalPosts, + DraftCount: draftCount, + PendingCount: pendingCount, + ApprovedCount: approvedCount, + RejectedCount: rejectedCount, + }, nil +} + // Update 编辑帖子(权限在 controller 层检查) func (s *PostService) Update(postID uint, title, body string) error { post, err := s.findPost(postID) diff --git a/internal/service/repository.go b/internal/service/repository.go index 00194df..4e5809b 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -31,13 +31,14 @@ type userAdminStore interface { IncrementTokenVersion(userID uint) error } -// postStore PostService 所需的最小仓储接口(ISP:8 个方法) +// postStore PostService 所需的最小仓储接口(ISP:9 个方法) type postStore interface { Create(post *model.Post) error FindByID(id uint) (*model.Post, error) FindByIDWithAuthor(id uint) (*model.Post, error) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) + FindByUserIDAndStatus(userID uint, status string, offset, limit int) ([]model.Post, int64, error) Update(post *model.Post) error SoftDelete(id uint) error Restore(id uint) error diff --git a/internal/theme/loader.go b/internal/theme/loader.go index 510c82b..bdc9b04 100644 --- a/internal/theme/loader.go +++ b/internal/theme/loader.go @@ -16,7 +16,11 @@ type TemplateRoot struct { // LoadTemplates 加载多个根目录的 .html 模板到同一模板集 // 模板名 = prefix + 相对于 root.Dir 的相对路径 func LoadTemplates(roots ...TemplateRoot) (*template.Template, error) { - t := template.New("") + funcMap := template.FuncMap{ + "add": func(a, b int) int { return a + b }, + "subtract": func(a, b int) int { return a - b }, + } + t := template.New("").Funcs(funcMap) for _, root := range roots { dir := filepath.Clean(root.Dir) + string(os.PathSeparator) err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { diff --git a/templates/MetaLab-2026/html/studio/analytics.html b/templates/MetaLab-2026/html/studio/analytics.html new file mode 100644 index 0000000..0c3f102 --- /dev/null +++ b/templates/MetaLab-2026/html/studio/analytics.html @@ -0,0 +1,72 @@ +{{template "layout/header.html" .}} + + +{{template "layout/nav.html" .}} + +
+ {{template "studio/sidebar.html" .}} + +
+
+

数据分析

+ 了解你的创作表现 +
+ + {{if .Error}} +
{{.Error}}
+ {{else}} +
+
+
📝
+
+ {{.Overview.TotalPosts}} + 文章总数 +
+
+
+
+
+ {{.Overview.ApprovedCount}} + 已发布 +
+
+
+
📄
+
+ {{.Overview.DraftCount}} + 草稿 +
+
+
+ +
+

状态分布

+
+ {{if gt .Overview.ApprovedCount 0}} +
已发布 {{.Overview.ApprovedCount}}
+ {{end}} + {{if gt .Overview.DraftCount 0}} +
草稿 {{.Overview.DraftCount}}
+ {{end}} + {{if gt .Overview.PendingCount 0}} +
审核中 {{.Overview.PendingCount}}
+ {{end}} + {{if gt .Overview.RejectedCount 0}} +
已退回 {{.Overview.RejectedCount}}
+ {{end}} +
+
+ +
+
+

📈 更详细的阅读趋势、互动数据将在后续版本中推出

+
+
+ {{end}} +
+
+ +{{template "layout/footer.html" .}} + + + diff --git a/templates/MetaLab-2026/html/studio/drafts.html b/templates/MetaLab-2026/html/studio/drafts.html new file mode 100644 index 0000000..5c36091 --- /dev/null +++ b/templates/MetaLab-2026/html/studio/drafts.html @@ -0,0 +1,105 @@ +{{template "layout/header.html" .}} + + +{{template "layout/nav.html" .}} + +
+ {{template "studio/sidebar.html" .}} + +
+
+

草稿箱

+ 共 {{.Total}} 篇草稿 +
+ + {{if .Error}} +
{{.Error}}
+ {{else}} + {{if .Posts}} +
+ {{range .Posts}} +
+
+ {{if .Title}}{{.Title}}{{else}}无标题{{end}} +
+ 草稿 + {{.CreatedAt.Format "2006-01-02 15:04"}} +
+
+
+ 继续编辑 + + +
+
+ {{end}} +
+ + {{if gt .TotalPages 1}} + + {{end}} + + {{else}} +
+

✨ 草稿箱是空的

+ 开始写作 +
+ {{end}} + {{end}} +
+
+ + + +{{template "layout/footer.html" .}} + + + diff --git a/templates/MetaLab-2026/html/studio/overview.html b/templates/MetaLab-2026/html/studio/overview.html new file mode 100644 index 0000000..c9507de --- /dev/null +++ b/templates/MetaLab-2026/html/studio/overview.html @@ -0,0 +1,95 @@ +{{template "layout/header.html" .}} + + +{{template "layout/nav.html" .}} + +
+ {{template "studio/sidebar.html" .}} + +
+
+

创作中心

+ 数据概览 +
+ + {{if .Error}} +
{{.Error}}
+ {{else}} + +
+
+
📝
+
+ {{.Overview.TotalPosts}} + 文章总数 +
+
+
+
+
+ {{.Overview.ApprovedCount}} + 已发布 +
+
+
+
+
+ {{.Overview.PendingCount}} + 审核中 +
+
+
+
📄
+
+ {{.Overview.DraftCount}} + 草稿 +
+
+
+
↩️
+
+ {{.Overview.RejectedCount}} + 已退回 +
+
+
+ + + {{if gt .Overview.TotalPosts 0}} +
+

内容分布

+
+ {{if gt .Overview.ApprovedCount 0}} +
已发布 {{.Overview.ApprovedCount}}
+ {{end}} + {{if gt .Overview.DraftCount 0}} +
草稿 {{.Overview.DraftCount}}
+ {{end}} + {{if gt .Overview.PendingCount 0}} +
审核中 {{.Overview.PendingCount}}
+ {{end}} + {{if gt .Overview.RejectedCount 0}} +
已退回 {{.Overview.RejectedCount}}
+ {{end}} +
+
+ {{end}} + + + + {{end}} +
+
+ +{{template "layout/footer.html" .}} + + + diff --git a/templates/MetaLab-2026/html/studio/posts.html b/templates/MetaLab-2026/html/studio/posts.html new file mode 100644 index 0000000..8af82dd --- /dev/null +++ b/templates/MetaLab-2026/html/studio/posts.html @@ -0,0 +1,105 @@ +{{template "layout/header.html" .}} + + +{{template "layout/nav.html" .}} + +
+ {{template "studio/sidebar.html" .}} + +
+
+

内容管理

+ {{if .Overview}} +
+ 已发布 {{.Overview.ApprovedCount}} + 审核中 {{.Overview.PendingCount}} + 草稿 {{.Overview.DraftCount}} + 已退回 {{.Overview.RejectedCount}} +
+ {{end}} +
+ + {{if .Error}} +
{{.Error}}
+ {{else}} + + + + {{if .Posts}} +
+ {{range .Posts}} +
+
+ {{.Title}} +
+ {{index $.StatusNames .Status}} + {{.CreatedAt.Format "2006-01-02 15:04"}} + {{if .RejectReason}} + 退回理由:{{.RejectReason}} + {{end}} +
+
+
+ 编辑 + 查看 + +
+
+ {{end}} +
+ + + {{if gt .TotalPages 1}} + + {{end}} + + {{else}} +
+

还没有{{if .CurrentStatus}}{{index $.StatusNames .CurrentStatus}}{{end}}内容的文章

+ 去写一篇 +
+ {{end}} + {{end}} +
+
+ + + +{{template "layout/footer.html" .}} + + + diff --git a/templates/MetaLab-2026/html/studio/sidebar.html b/templates/MetaLab-2026/html/studio/sidebar.html new file mode 100644 index 0000000..6da0d84 --- /dev/null +++ b/templates/MetaLab-2026/html/studio/sidebar.html @@ -0,0 +1,25 @@ + diff --git a/templates/MetaLab-2026/html/studio/write.html b/templates/MetaLab-2026/html/studio/write.html new file mode 100644 index 0000000..0ca1fa1 --- /dev/null +++ b/templates/MetaLab-2026/html/studio/write.html @@ -0,0 +1,72 @@ +{{template "layout/header.html" .}} + + +{{template "layout/nav.html" .}} + +
+ {{template "studio/sidebar.html" .}} + +
+
+
+ + {{if .Post}} + + {{end}} + +
+ +
+ +
+
+
+
+ +
+ +
+ 0 字 + + + +
+
+
+
+
+ +{{template "layout/footer.html" .}} + + + + + + + diff --git a/templates/MetaLab-2026/static/css/studio.css b/templates/MetaLab-2026/static/css/studio.css new file mode 100644 index 0000000..57f8d3a --- /dev/null +++ b/templates/MetaLab-2026/static/css/studio.css @@ -0,0 +1,639 @@ +/* ============================================================ + MetaLab Studio — 创作中心专用样式 + ============================================================ */ + +/* ---------- 布局 ---------- */ +.studio-wrapper { + display: flex; + min-height: calc(100vh - 120px); +} + +/* ---------- 左侧导航 ---------- */ +.studio-sidebar { + width: 220px; + min-width: 220px; + background: var(--color-surface); + border-right: 1px solid var(--color-border); + padding: 24px 0; + flex-shrink: 0; +} + +.sidebar-brand { + font-size: 18px; + font-weight: 700; + color: var(--color-primary); + padding: 0 24px 20px; + border-bottom: 1px solid var(--color-border); + margin-bottom: 12px; +} + +.sidebar-nav { + display: flex; + flex-direction: column; +} + +.sidebar-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 24px; + font-size: 15px; + color: var(--color-text-light); + transition: var(--transition); + border-left: 3px solid transparent; +} + +.sidebar-item:hover { + background: #f0f4ff; + color: var(--color-accent); +} + +.sidebar-item.active { + background: #e8f0fe; + color: var(--color-accent); + border-left-color: var(--color-accent); + font-weight: 600; +} + +.sidebar-icon { + font-size: 18px; + width: 24px; + text-align: center; +} + +/* ---------- 主内容区 ---------- */ +.studio-main { + flex: 1; + padding: 32px 40px; + overflow-y: auto; +} + +.studio-header { + margin-bottom: 28px; +} + +.studio-header h1 { + font-size: 24px; + font-weight: 700; + color: var(--color-primary); + margin-bottom: 6px; +} + +.studio-subtitle { + font-size: 14px; + color: var(--color-secondary); +} + +.header-counts { + display: flex; + gap: 16px; + margin-top: 8px; +} + +.header-count { + font-size: 13px; + padding: 2px 10px; + border-radius: 12px; + background: #f0f0f0; + color: var(--color-text-light); +} + +.header-count.approved { background: #e8f5e9; color: #2e7d32; } +.header-count.pending { background: #fff3e0; color: #e65100; } +.header-count.draft { background: #e3f2fd; color: #1565c0; } +.header-count.rejected { background: #ffebee; color: #c62828; } + +/* ---------- 统计卡片 ---------- */ +.stat-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 16px; + margin-bottom: 28px; +} + +.stat-card { + background: var(--color-surface); + border-radius: var(--radius); + padding: 20px; + box-shadow: var(--shadow); + display: flex; + align-items: center; + gap: 16px; + transition: var(--transition); +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0,0,0,.12); +} + +.stat-card-icon { + font-size: 32px; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background: #f5f5f5; + border-radius: 12px; +} + +.stat-card-body { + display: flex; + flex-direction: column; +} + +.stat-card-value { + font-size: 28px; + font-weight: 700; + color: var(--color-primary); + line-height: 1.2; +} + +.stat-card-label { + font-size: 13px; + color: var(--color-secondary); + margin-top: 2px; +} + +.stat-card-approved .stat-card-icon { background: #e8f5e9; } +.stat-card-approved .stat-card-value { color: #2e7d32; } + +.stat-card-pending .stat-card-icon { background: #fff3e0; } +.stat-card-pending .stat-card-value { color: #e65100; } + +.stat-card-draft .stat-card-icon { background: #e3f2fd; } +.stat-card-draft .stat-card-value { color: #1565c0; } + +.stat-card-rejected .stat-card-icon { background: #ffebee; } +.stat-card-rejected .stat-card-value { color: #c62828; } + +/* ---------- 通用区块 ---------- */ +.studio-section { + background: var(--color-surface); + border-radius: var(--radius); + padding: 24px; + box-shadow: var(--shadow); + margin-bottom: 20px; +} + +.section-title { + font-size: 16px; + font-weight: 600; + color: var(--color-primary); + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--color-border); +} + +/* ---------- 状态条 ---------- */ +.status-bar { + display: flex; + height: 32px; + border-radius: 6px; + overflow: hidden; + gap: 2px; + background: #eee; +} + +.status-bar-segment { + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + color: #fff; + min-width: 80px; +} + +.status-bar-approved { background: #4caf50; } +.status-bar-draft { background: #42a5f5; } +.status-bar-pending { background: #ff9800; } +.status-bar-rejected { background: #ef5350; } + +/* ---------- 快捷操作 ---------- */ +.quick-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.quick-action-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 20px; + border-radius: 8px; + background: #f5f5f5; + color: var(--color-text); + font-size: 14px; + transition: var(--transition); + border: 1px solid var(--color-border); +} + +.quick-action-btn:hover { + background: #e8f0fe; + border-color: var(--color-accent); + color: var(--color-accent); +} + +.quick-action-btn.primary { + background: var(--color-accent); + color: #fff; + border-color: var(--color-accent); +} + +.quick-action-btn.primary:hover { + background: #2980b9; +} + +/* ---------- 状态筛选 Tabs ---------- */ +.status-tabs { + display: flex; + gap: 0; + margin-bottom: 20px; + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; +} + +.status-tab { + padding: 10px 20px; + font-size: 14px; + color: var(--color-text-light); + transition: var(--transition); + border-bottom: 2px solid transparent; +} + +.status-tab:hover { + color: var(--color-accent); + background: #f8f9fa; +} + +.status-tab.active { + color: var(--color-accent); + font-weight: 600; + border-bottom-color: var(--color-accent); + background: #e8f0fe; +} + +/* ---------- 帖子列表 ---------- */ +.post-list { + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; +} + +.post-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--color-border); + transition: var(--transition); +} + +.post-item:last-child { + border-bottom: none; +} + +.post-item:hover { + background: #f8f9fa; +} + +.post-item-main { + flex: 1; + min-width: 0; +} + +.post-item-title { + font-size: 15px; + font-weight: 600; + color: var(--color-primary); + display: block; + margin-bottom: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.post-item-title:hover { + color: var(--color-accent); +} + +.draft-title { + color: var(--color-secondary); +} + +.post-item-meta { + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; + color: var(--color-secondary); +} + +.post-status-badge { + display: inline-block; + padding: 1px 8px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; +} + +.status-approved { background: #e8f5e9; color: #2e7d32; } +.status-pending { background: #fff3e0; color: #e65100; } +.status-draft { background: #e3f2fd; color: #1565c0; } +.status-rejected { background: #ffebee; color: #c62828; } +.status-locked { background: #f3e5f5; color: #6a1b9a; } + +.post-reject-hint { + color: #c62828; + font-style: italic; +} + +.post-item-actions { + display: flex; + gap: 8px; + flex-shrink: 0; + margin-left: 16px; +} + +.action-btn { + padding: 6px 14px; + border-radius: 6px; + font-size: 13px; + border: 1px solid var(--color-border); + background: #f5f5f5; + color: var(--color-text-light); + cursor: pointer; + transition: var(--transition); + white-space: nowrap; +} + +.action-btn:hover { + background: #e8f0fe; + border-color: var(--color-accent); + color: var(--color-accent); +} + +.action-btn.primary { + background: var(--color-accent); + color: #fff; + border-color: var(--color-accent); +} + +.action-btn.primary:hover { + background: #2980b9; +} + +.action-btn.danger { + color: var(--color-danger); + border-color: #ffcdd2; +} + +.action-btn.danger:hover { + background: #ffebee; + border-color: var(--color-danger); +} + +/* ---------- 分页 ---------- */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding: 20px; +} + +.page-btn { + padding: 6px 16px; + border-radius: 6px; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + font-size: 14px; + transition: var(--transition); +} + +.page-btn:hover { + background: #e8f0fe; + border-color: var(--color-accent); + color: var(--color-accent); +} + +.page-info { + font-size: 14px; + color: var(--color-secondary); +} + +/* ---------- 空状态 ---------- */ +.studio-empty { + text-align: center; + padding: 60px 20px; + background: var(--color-surface); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.studio-empty p { + font-size: 16px; + color: var(--color-secondary); + margin-bottom: 20px; +} + +.btn-primary { + display: inline-block; + padding: 10px 24px; + background: var(--color-accent); + color: #fff; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + transition: var(--transition); +} + +.btn-primary:hover { + background: #2980b9; + color: #fff; +} + +/* ---------- 占位提示 ---------- */ +.studio-placeholder { + text-align: center; + padding: 40px; + color: var(--color-secondary); + font-size: 15px; +} + +/* ---------- 错误提示 ---------- */ +.studio-error { + background: #ffebee; + color: #c62828; + padding: 16px 20px; + border-radius: var(--radius); + margin-bottom: 20px; + font-size: 14px; +} + +/* ============================================================ + 编辑器样式(复用自 posts/new.html 的编辑器布局) + ============================================================ */ + +.editor-layout { + width: 100%; +} + +.editor-form { + max-width: 100%; +} + +.editor-header { + margin-bottom: 16px; +} + +.editor-title-input { + width: 100%; + padding: 14px 0; + border: none; + border-bottom: 2px solid var(--color-border); + font-size: 22px; + font-weight: 600; + outline: none; + background: transparent; + color: var(--color-primary); + transition: var(--transition); +} + +.editor-title-input:focus { + border-bottom-color: var(--color-accent); +} + +.editor-title-input::placeholder { + color: #ccc; +} + +.editor-main { + display: flex; + gap: 20px; + margin-bottom: 16px; +} + +.editor-pane { + flex: 1; + min-width: 0; +} + +.editor-sidebar { + width: 200px; + flex-shrink: 0; +} + +.editor-sidebar-section { + background: var(--color-surface); + border-radius: var(--radius); + padding: 16px; + box-shadow: var(--shadow); + margin-bottom: 12px; +} + +.editor-sidebar-section h4 { + font-size: 13px; + font-weight: 600; + color: var(--color-primary); + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid var(--color-border); +} + +.tips-list { + list-style: none; + font-size: 12px; + color: var(--color-secondary); + line-height: 2; +} + +.tips-list code { + background: #f0f0f0; + padding: 1px 5px; + border-radius: 3px; + font-size: 11px; +} + +.editor-statusbar { + display: flex; + align-items: center; + padding: 12px 0; + border-top: 1px solid var(--color-border); + gap: 16px; +} + +.status-item { + font-size: 13px; + color: var(--color-secondary); +} + +.status-spacer { + flex: 1; +} + +.btn-submit { + padding: 8px 24px; +} + +/* ---------- 响应式 ---------- */ +@media (max-width: 768px) { + .studio-wrapper { + flex-direction: column; + } + + .studio-sidebar { + width: 100%; + min-width: 100%; + border-right: none; + border-bottom: 1px solid var(--color-border); + padding: 12px 0; + } + + .sidebar-nav { + flex-direction: row; + overflow-x: auto; + padding: 0 12px; + } + + .sidebar-item { + padding: 8px 16px; + border-left: none; + border-bottom: 2px solid transparent; + white-space: nowrap; + } + + .sidebar-item.active { + border-left: none; + border-bottom-color: var(--color-accent); + } + + .stat-cards { + grid-template-columns: repeat(2, 1fr); + } + + .post-item { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .post-item-actions { + margin-left: 0; + } + + .editor-main { + flex-direction: column; + } + + .editor-sidebar { + width: 100%; + } +} diff --git a/templates/MetaLab-2026/static/js/studio-editor.js b/templates/MetaLab-2026/static/js/studio-editor.js new file mode 100644 index 0000000..f5d8c1f --- /dev/null +++ b/templates/MetaLab-2026/static/js/studio-editor.js @@ -0,0 +1,209 @@ +/** + * Studio Editor — 创作中心 Vditor 编辑器 + * 基于 editor.js,使用 /api/studio/posts 端点 + */ + +let vditor = null +let draftTimer = null +let submitting = 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 = '' + 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', + ], + + customWysiwygToolbar(type, popover) {}, + + counter: { enable: true, type: 'text' }, + + 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' }, + + 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 }, + }, + + 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) + + document.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault() + document.getElementById('postForm')?.dispatchEvent(new Event('submit')) + } + }) +}) + +function updateWordCount() { + const el = document.getElementById('wordCount') + if (!el || !vditor) return + 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) { /* ignore */ } +} + +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) { /* ignore */ } +} + +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 } + + const body = vditor ? vditor.getValue() : '' + if (!body.trim()) { alert('请输入正文'); return } + + const isEdit = !!postId + const url = isEdit ? '/api/studio/posts/' + postId : '/api/studio/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) { + clearDraft() + const newPostId = data.data?.id || data.data?.ID || postId + if (newPostId) { + window.location.href = '/posts/' + newPostId + } else { + window.location.href = '/studio/posts' + } + } else { + alert(data.message || '操作失败') + } + } catch (err) { + alert('请求失败,请重试') + } finally { + submitting = false + if (submitBtn) { + submitBtn.disabled = false + const isEdit = !!document.getElementById('postId')?.value + submitBtn.textContent = isEdit ? '保存修改' : '发布帖子' + } + } +}