feat: 分类系统 + 标签系统 — Phase 4 完成

分类:Model/Repo/Service/Controller + 管理后台树形页面 + 首页导航栏
标签:Model/Repo/Service/Controller + /tags/{slug} 落地页 + 写文章页输入
Post 加 CategoryID,Create/Update 支持分类和标签
SQL 迁移含 categories/tags/post_tags 表及预设未分类
This commit is contained in:
2026-06-22 00:30:29 +08:00
parent 977c52513a
commit 0b8f6f890d
30 changed files with 988 additions and 13 deletions

View File

@ -0,0 +1,100 @@
package admin
import (
"net/http"
"strconv"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)
// CategoryController 分类管理控制器
type CategoryController struct {
svc categoryUseCase
}
type categoryUseCase interface {
Create(c *model.Category) error
Update(c *model.Category) error
Delete(id uint) error
Tree() ([]model.Category, error)
}
// NewCategoryController 构造函数
func NewCategoryController(svc categoryUseCase) *CategoryController {
return &CategoryController{svc: svc}
}
// Page 分类管理页面
func (cc *CategoryController) Page(c *gin.Context) {
tree, _ := cc.svc.Tree()
c.HTML(http.StatusOK, "admin/categories/index.html", common.BuildAdminPageData(c, gin.H{
"Title": "分类管理",
"ExtraCSS": "/admin/static/css/categories.css",
"ExtraJS": "/admin/static/js/categories.js",
"Tree": tree,
}))
}
// List 分类列表 API
func (cc *CategoryController) List(c *gin.Context) {
tree, err := cc.svc.Tree()
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
if tree == nil {
tree = []model.Category{}
}
common.Ok(c, tree)
}
// Create 创建分类
func (cc *CategoryController) Create(c *gin.Context) {
var req model.Category
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
if err := cc.svc.Create(&req); err != nil {
common.Error(c, http.StatusInternalServerError, "创建失败")
return
}
common.OkMessage(c, "分类已创建")
}
// Update 更新分类
func (cc *CategoryController) Update(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的 ID")
return
}
var req model.Category
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "参数错误")
return
}
req.ID = uint(id)
if err := cc.svc.Update(&req); err != nil {
common.Error(c, http.StatusInternalServerError, "更新失败")
return
}
common.OkMessage(c, "分类已更新")
}
// Delete 删除分类
func (cc *CategoryController) Delete(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的 ID")
return
}
if err := cc.svc.Delete(uint(id)); err != nil {
common.Error(c, http.StatusInternalServerError, "删除失败")
return
}
common.OkMessage(c, "分类已删除")
}

View File

@ -42,9 +42,9 @@ type postUseCase interface {
// studioUseCase StudioController 对 PostService 的最小依赖ISP9 个方法)
type studioUseCase interface {
Create(userID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool) (*model.Post, error)
Create(userID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID string) (*model.Post, error)
GetByID(id uint) (*model.Post, error)
Update(postID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool) error
Update(postID uint, title, body, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID string) error
Delete(postID uint) error
SubmitForAudit(postID uint) error
ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error)

View File

@ -1,6 +1,7 @@
package controller
import (
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service"
)
@ -9,11 +10,24 @@ type trendsUseCase interface {
GetTrends(userID uint, since string) (*service.TrendResult, error)
}
// tagParser ISP标签解析
type tagParser interface {
ParseAndSave(tagStr string) ([]uint, error)
SetPostTags(postID uint, tagStr string) error
}
// StudioController 创作中心控制器SSR 页面 + API
type StudioController struct {
postService studioUseCase
energySvc energyDeducter
trendsSvc trendsUseCase
tagSvc tagParser
categorySvc categoryLoader
}
// categoryLoader ISP分类叶子节点查询
type categoryLoader interface {
GetLeaves() ([]model.Category, error)
}
// NewStudioController 构造函数
@ -32,3 +46,15 @@ func (ctrl *StudioController) WithTrendsService(svc trendsUseCase) *StudioContro
ctrl.trendsSvc = svc
return ctrl
}
// WithTagService 链式注入标签服务
func (ctrl *StudioController) WithTagService(svc tagParser) *StudioController {
ctrl.tagSvc = svc
return ctrl
}
// WithCategoryService 链式注入分类服务
func (ctrl *StudioController) WithCategoryService(svc categoryLoader) *StudioController {
ctrl.categorySvc = svc
return ctrl
}

View File

@ -30,12 +30,17 @@ func (ctrl *StudioController) Create(c *gin.Context) {
return
}
post, err := ctrl.postService.Create(uid, req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited)
post, err := ctrl.postService.Create(uid, req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID)
if err != nil {
common.Error(c, http.StatusInternalServerError, "发布失败")
return
}
// 关联标签
if ctrl.tagSvc != nil && req.Tags != "" {
_ = ctrl.tagSvc.SetPostTags(post.ID, req.Tags)
}
common.OkWithMessage(c, post, "发布成功")
}
@ -69,10 +74,15 @@ func (ctrl *StudioController) Update(c *gin.Context) {
return
}
if err := ctrl.postService.Update(uint(id), req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited); err != nil {
if err := ctrl.postService.Update(uint(id), req.Title, req.Body, req.Visibility, req.PostType, req.ReprintSource, req.Declaration, req.ReprintProhibited, req.CategoryID); err != nil {
common.Error(c, http.StatusInternalServerError, "编辑失败")
return
}
// 更新标签
if ctrl.tagSvc != nil {
_ = ctrl.tagSvc.SetPostTags(uint(id), req.Tags)
}
common.OkMessage(c, "编辑成功")
}

View File

@ -17,6 +17,16 @@ func (ctrl *StudioController) WritePage(c *gin.Context) {
return
}
// 加载分类列表(叶子节点)
var categories []interface{}
if ctrl.categorySvc != nil {
if leaves, err := ctrl.categorySvc.GetLeaves(); err == nil {
for _, cat := range leaves {
categories = append(categories, cat)
}
}
}
// 支持编辑模式:传入 post_id 参数
postIDStr := c.Query("id")
if postIDStr != "" {
@ -32,10 +42,11 @@ func (ctrl *StudioController) WritePage(c *gin.Context) {
return
}
c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{
"Title": "编辑文章 - 创作中心",
"Post": post,
"ActiveTab": "write",
"ExtraCSS": "/static/css/studio.css",
"Title": "编辑文章 - 创作中心",
"Post": post,
"ActiveTab": "write",
"ExtraCSS": "/static/css/studio.css",
"Categories": categories,
}))
return
}
@ -43,9 +54,10 @@ func (ctrl *StudioController) WritePage(c *gin.Context) {
}
c.HTML(http.StatusOK, "studio/write.html", common.BuildPageData(c, gin.H{
"Title": "写文章 - 创作中心",
"ActiveTab": "write",
"ExtraCSS": "/static/css/studio.css",
"Title": "写文章 - 创作中心",
"ActiveTab": "write",
"ExtraCSS": "/static/css/studio.css",
"Categories": categories,
}))
}

View File

@ -0,0 +1,54 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
"github.com/gin-gonic/gin"
)
// TagController 标签控制器(标签落地页)
type TagController struct {
svc tagUseCase
}
type tagUseCase interface {
GetPostsByTag(slug string, page, pageSize int) ([]model.Post, int64, *model.Tag, error)
}
// NewTagController 构造函数
func NewTagController(svc tagUseCase) *TagController {
return &TagController{svc: svc}
}
// TagPage 标签落地页
func (tc *TagController) TagPage(c *gin.Context) {
slug := c.Param("slug")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize := 20
posts, total, tag, err := tc.svc.GetPostsByTag(slug, page, pageSize)
if err != nil {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
"ErrorMsg": "该标签不存在",
}))
return
}
totalPages := common.PageCount(total, pageSize)
c.HTML(http.StatusOK, "tags/detail.html", common.BuildPageData(c, gin.H{
"Title": "标签:" + tag.Name,
"ExtraCSS": "/static/css/posts.css",
"Posts": posts,
"Total": total,
"Page": page,
"TotalPages": totalPages,
"PrevPage": max(1, page-1),
"NextPage": min(totalPages, page+1),
"Tag": tag,
}))
}