数据分析
+ 了解你的创作表现 +状态分布
+ +📈 更详细的阅读趋势、互动数据将在后续版本中推出
+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" .}} + +📈 更详细的阅读趋势、互动数据将在后续版本中推出
+