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,32 @@
-- 分类表(两级树形)
CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
slug VARCHAR(50) NOT NULL UNIQUE,
description VARCHAR(200) DEFAULT '',
parent_id INT REFERENCES categories(id) ON DELETE SET NULL,
sort INT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- 预设「未分类」
INSERT INTO categories (id, name, slug) VALUES (1, '未分类', 'uncategorized') ON CONFLICT DO NOTHING;
-- 标签表
CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
slug VARCHAR(50) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT NOW()
);
-- 文章-标签关联表
CREATE TABLE IF NOT EXISTS post_tags (
post_id INT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id INT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
-- 文章加分类外键
ALTER TABLE posts ADD COLUMN IF NOT EXISTS category_id INT REFERENCES categories(id) ON DELETE SET NULL;

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 != "" {
@ -36,6 +46,7 @@ func (ctrl *StudioController) WritePage(c *gin.Context) {
"Post": post,
"ActiveTab": "write",
"ExtraCSS": "/static/css/studio.css",
"Categories": categories,
}))
return
}
@ -46,6 +57,7 @@ func (ctrl *StudioController) WritePage(c *gin.Context) {
"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,
}))
}

View File

@ -0,0 +1,18 @@
package model
import "time"
// Category 文章分类(两级树形结构)
type Category struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"type:varchar(50);not null" json:"name"`
Slug string `gorm:"type:varchar(50);not null;uniqueIndex" json:"slug"`
Description string `gorm:"type:varchar(200);default:''" json:"description,omitempty"`
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
Sort int `gorm:"default:0" json:"sort"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 非 DB 字段(树形填充)
Children []Category `gorm:"-" json:"children,omitempty"`
}

View File

@ -39,6 +39,8 @@ type PostCreateRequest struct {
ReprintSource string `json:"reprint_source"`
Declaration string `json:"declaration"`
ReprintProhibited bool `json:"reprint_prohibited"`
CategoryID string `json:"category_id"`
Tags string `json:"tags"`
}
// PostUpdateRequest 编辑请求
@ -50,6 +52,8 @@ type PostUpdateRequest struct {
ReprintSource string `json:"reprint_source"`
Declaration string `json:"declaration"`
ReprintProhibited bool `json:"reprint_prohibited"`
CategoryID string `json:"category_id"`
Tags string `json:"tags"`
}
// PostRejectRequest 退回请求

View File

@ -21,6 +21,7 @@ type Post struct {
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
// 文章属性
CategoryID *uint `gorm:"index" json:"category_id,omitempty"` // 二级分类 ID
Visibility string `gorm:"type:varchar(10);default:public" json:"visibility"` // public / private
PostType string `gorm:"type:varchar(10);default:original" json:"post_type"` // original / reprint
ReprintSource string `gorm:"type:varchar(500);default:''" json:"reprint_source,omitempty"` // 转载来源

17
internal/model/tag.go Normal file
View File

@ -0,0 +1,17 @@
package model
import "time"
// Tag 标签
type Tag struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"type:varchar(50);not null" json:"name"`
Slug string `gorm:"type:varchar(50);not null;uniqueIndex" json:"slug"`
CreatedAt time.Time `json:"created_at"`
}
// PostTag 文章-标签关联
type PostTag struct {
PostID uint `gorm:"primarykey" json:"post_id"`
TagID uint `gorm:"primarykey" json:"tag_id"`
}

View File

@ -0,0 +1,63 @@
package repository
import (
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service"
"gorm.io/gorm"
)
// CategoryRepo 分类数据访问
type CategoryRepo struct {
db *gorm.DB
}
// NewCategoryRepo 构造函数
func NewCategoryRepo(db *gorm.DB) *CategoryRepo {
return &CategoryRepo{db: db}
}
// WithTx 基于事务创建新实例
func (r *CategoryRepo) WithTx(tx *gorm.DB) service.CategoryStore {
return &CategoryRepo{db: tx}
}
// Create 创建分类
func (r *CategoryRepo) Create(c *model.Category) error {
return r.db.Create(c).Error
}
// Update 更新分类
func (r *CategoryRepo) Update(c *model.Category) error {
return r.db.Save(c).Error
}
// Delete 删除分类
func (r *CategoryRepo) Delete(id uint) error {
return r.db.Delete(&model.Category{}, id).Error
}
// FindByID 按 ID 查询
func (r *CategoryRepo) FindByID(id uint) (*model.Category, error) {
var c model.Category
err := r.db.First(&c, id).Error
if err != nil {
return nil, err
}
return &c, nil
}
// ListAll 查询全部分类(按 sort 排序)
func (r *CategoryRepo) ListAll() ([]model.Category, error) {
var list []model.Category
err := r.db.Order("sort ASC, id ASC").Find(&list).Error
return list, err
}
// GetLeaves 查询所有叶子分类(有子分类的父节点不返回)
func (r *CategoryRepo) GetLeaves() ([]model.Category, error) {
var leaves []model.Category
err := r.db.Where("id NOT IN (SELECT DISTINCT parent_id FROM categories WHERE parent_id IS NOT NULL)").
Order("sort ASC, id ASC").Find(&leaves).Error
return leaves, err
}

View File

@ -0,0 +1,97 @@
package repository
import (
"metazone.cc/mce/internal/model"
"metazone.cc/mce/internal/service"
"gorm.io/gorm"
)
// TagRepo 标签数据访问
type TagRepo struct {
db *gorm.DB
}
// NewTagRepo 构造函数
func NewTagRepo(db *gorm.DB) *TagRepo {
return &TagRepo{db: db}
}
// WithTx 基于事务创建新实例
func (r *TagRepo) WithTx(tx *gorm.DB) service.TagStore {
return &TagRepo{db: tx}
}
// FindOrCreate 查找或创建标签(按名字)
func (r *TagRepo) FindOrCreate(name, slug string) (*model.Tag, error) {
var tag model.Tag
err := r.db.Where("slug = ?", slug).First(&tag).Error
if err == nil {
return &tag, nil
}
if err != gorm.ErrRecordNotFound {
return nil, err
}
tag = model.Tag{Name: name, Slug: slug}
if err := r.db.Create(&tag).Error; err != nil {
return nil, err
}
return &tag, nil
}
// FindBySlug 按 slug 查询
func (r *TagRepo) FindBySlug(slug string) (*model.Tag, error) {
var tag model.Tag
err := r.db.Where("slug = ?", slug).First(&tag).Error
if err != nil {
return nil, err
}
return &tag, nil
}
// SetPostTags 设置文章的标签(删除旧的,插入新的)
func (r *TagRepo) SetPostTags(postID uint, tagIDs []uint) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("post_id = ?", postID).Delete(&model.PostTag{}).Error; err != nil {
return err
}
for _, tagID := range tagIDs {
if err := tx.Create(&model.PostTag{PostID: postID, TagID: tagID}).Error; err != nil {
return err
}
}
return nil
})
}
// GetPostTags 获取文章关联的标签
func (r *TagRepo) GetPostTags(postID uint) ([]model.Tag, error) {
var tags []model.Tag
err := r.db.Table("tags").
Joins("JOIN post_tags ON post_tags.tag_id = tags.id").
Where("post_tags.post_id = ?", postID).
Find(&tags).Error
return tags, err
}
// GetPostsByTag 按标签查询文章(公开+已发布)
func (r *TagRepo) GetPostsByTag(tagID uint, offset, limit int) ([]model.Post, int64, error) {
var total int64
query := r.db.Table("posts").
Joins("JOIN post_tags ON post_tags.post_id = posts.id").
Joins("LEFT JOIN users ON users.id = posts.user_id").
Where("post_tags.tag_id = ?", tagID).
Where("posts.status = ?", model.PostStatusApproved).
Where("posts.visibility = ?", model.VisibilityPublic).
Where("posts.deleted_at IS NULL").
Select("posts.*, users.username as author_name")
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var posts []model.Post
err := query.Order("CASE WHEN posts.pin_type = 'global' THEN 0 ELSE 1 END ASC, posts.pinned_at DESC NULLS LAST, posts.created_at DESC").
Offset(offset).Limit(limit).Find(&posts).Error
return posts, total, err
}

View File

@ -33,6 +33,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage)
adminPages.GET("/comments", d.adminCommentCtrl.CommentsPage)
adminPages.GET("/announcements", d.announcementCtrl.Page)
adminPages.GET("/categories", d.categoryCtrl.Page)
}
// --- 管理后台 API ---
@ -115,4 +116,17 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
announcementAPI.PUT("/:id", d.announcementCtrl.Update)
announcementAPI.DELETE("/:id", d.announcementCtrl.Delete)
}
// --- 分类管理 APImoderator+ ---
categoryAPI := r.Group("/api/admin/categories")
categoryAPI.Use(d.authMdw.Required())
categoryAPI.Use(middleware.RequireMinRole(model.RoleModerator))
categoryAPI.Use(d.authMdw.BannedWriteGuard())
categoryAPI.Use(middleware.CSRF(cfg))
{
categoryAPI.GET("", d.categoryCtrl.List)
categoryAPI.POST("", d.categoryCtrl.Create)
categoryAPI.PUT("/:id", d.categoryCtrl.Update)
categoryAPI.DELETE("/:id", d.categoryCtrl.Delete)
}
}

View File

@ -47,6 +47,10 @@ type dependencies struct {
homeCache *cache.HomePageCache
announcementService *service.AnnouncementService
announcementCtrl *adminCtrl.AnnouncementController
categoryCtrl *adminCtrl.CategoryController
categoryService *service.CategoryService
tagCtrl *controller.TagController
tagService *service.TagService
}
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (

View File

@ -103,6 +103,18 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr)
// 分类系统
categoryRepo := repository.NewCategoryRepo(db)
categoryService := service.NewCategoryService(categoryRepo)
categoryCtrl := adminCtrl.NewCategoryController(categoryService)
studioCtrl.WithCategoryService(categoryService)
// 标签系统
tagRepo := repository.NewTagRepo(db)
tagService := service.NewTagService(tagRepo)
tagCtrl := controller.NewTagController(tagService)
studioCtrl.WithTagService(tagService)
// 公告系统
announcementRepo := repository.NewAnnouncementRepo(db)
announcementService := service.NewAnnouncementService(announcementRepo)
@ -135,5 +147,9 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
homeCache: homeCache,
announcementService: announcementService,
announcementCtrl: announcementCtrl,
categoryCtrl: categoryCtrl,
categoryService: categoryService,
tagCtrl: tagCtrl,
tagService: tagService,
}
}

View File

@ -34,12 +34,14 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
}
}
announcements, _ := d.announcementService.ListActive()
categoryNav, _ := d.categoryService.GetLeaves()
c.HTML(http.StatusOK, "home/index.html", common.BuildPageData(c, gin.H{
"Title": "首页",
"ExtraCSS": "/static/css/home.css",
"Posts": posts,
"PostCount": total,
"Announcements": announcements,
"CategoryNav": categoryNav,
}))
})
@ -56,6 +58,9 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
// 帖子页面
pages.GET("/posts", d.postCtrl.ListPage)
pages.GET("/posts/:id", d.postCtrl.ShowPage)
// 标签落地页
pages.GET("/tags/:slug", d.tagCtrl.TagPage)
pages.GET("/posts/:id/edit", d.authMdw.Required(), func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/studio/write?id="+c.Param("id"))
})

View File

@ -0,0 +1,75 @@
package service
import "metazone.cc/mce/internal/model"
// CategoryStore 分类仓库接口ISP
type CategoryStore interface {
Create(c *model.Category) error
Update(c *model.Category) error
Delete(id uint) error
FindByID(id uint) (*model.Category, error)
ListAll() ([]model.Category, error)
GetLeaves() ([]model.Category, error)
}
// CategoryService 分类业务逻辑
type CategoryService struct {
repo CategoryStore
}
// NewCategoryService 构造函数
func NewCategoryService(repo CategoryStore) *CategoryService {
return &CategoryService{repo: repo}
}
// Create 创建分类
func (s *CategoryService) Create(c *model.Category) error {
return s.repo.Create(c)
}
// Update 更新分类
func (s *CategoryService) Update(c *model.Category) error {
return s.repo.Update(c)
}
// Delete 删除分类
func (s *CategoryService) Delete(id uint) error {
return s.repo.Delete(id)
}
// Tree 获取两级分类树
func (s *CategoryService) Tree() ([]model.Category, error) {
all, err := s.repo.ListAll()
if err != nil {
return nil, err
}
// 构建树parent_id=nil 的为根节点
parentMap := make(map[uint]*model.Category)
var roots []*model.Category
for i := range all {
c := &all[i]
c.Children = []model.Category{}
parentMap[c.ID] = c
if c.ParentID == nil {
roots = append(roots, c)
}
}
for i := range all {
c := &all[i]
if c.ParentID != nil {
if parent, ok := parentMap[*c.ParentID]; ok {
parent.Children = append(parent.Children, *c)
}
}
}
result := make([]model.Category, len(roots))
for i, r := range roots {
result[i] = *r
}
return result, nil
}
// GetLeaves 获取所有叶子分类(用于写文章页下拉选择)
func (s *CategoryService) GetLeaves() ([]model.Category, error) {
return s.repo.GetLeaves()
}

View File

@ -1,12 +1,14 @@
package service
import (
"strconv"
"metazone.cc/mce/internal/common"
"metazone.cc/mce/internal/model"
)
// Update 编辑帖子(权限在 controller 层检查)
func (s *PostService) Update(postID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool) error {
func (s *PostService) Update(postID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID string) error {
post, err := s.findPost(postID)
if err != nil {
return err
@ -22,6 +24,13 @@ func (s *PostService) Update(postID uint, title, body string, visibility, postTy
post.ReprintSource = reprintSource
post.Declaration = declaration
post.ReprintProhibited = reprintProhibited
// 分类
if cid, err := strconv.ParseUint(categoryID, 10, 64); err == nil && cid > 0 {
id := uint(cid)
post.CategoryID = &id
} else {
post.CategoryID = nil
}
// 已通过帖子:编辑内容保存为待审修订,不影响当前展示
if post.Status == model.PostStatusApproved {

View File

@ -3,6 +3,7 @@ package service
import (
"errors"
"fmt"
"strconv"
"time"
"metazone.cc/mce/internal/common"
@ -68,7 +69,7 @@ func (s *PostService) findPostWithAuthor(id uint) (*model.Post, error) {
// Create 创建帖子
// body 为 Vditor 输出的 Markdown后端直接存储不做 HTML 消毒
// MD 是纯文本,无 XSS 注入风险;前端渲染时由 Vditor 转 HTML
func (s *PostService) Create(userID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool) (*model.Post, error) {
func (s *PostService) Create(userID uint, title, body string, visibility, postType, reprintSource, declaration string, reprintProhibited bool, categoryID string) (*model.Post, error) {
status := model.PostStatusApproved
if s.auditEnabled() {
status = model.PostStatusDraft
@ -87,6 +88,11 @@ func (s *PostService) Create(userID uint, title, body string, visibility, postTy
Declaration: declaration,
ReprintProhibited: reprintProhibited,
}
// 解析分类 ID
if cid, err := strconv.ParseUint(categoryID, 10, 64); err == nil && cid > 0 {
id := uint(cid)
post.CategoryID = &id
}
if err := s.repo.Create(post); err != nil {
return nil, err
}

View File

@ -0,0 +1,82 @@
package service
import (
"regexp"
"strings"
"metazone.cc/mce/internal/model"
)
// TagStore 标签仓库接口ISP
type TagStore interface {
FindOrCreate(name, slug string) (*model.Tag, error)
FindBySlug(slug string) (*model.Tag, error)
SetPostTags(postID uint, tagIDs []uint) error
GetPostTags(postID uint) ([]model.Tag, error)
GetPostsByTag(tagID uint, offset, limit int) ([]model.Post, int64, error)
}
var slugRegex = regexp.MustCompile(`[^a-z0-9\-]`)
// TagService 标签业务逻辑
type TagService struct {
repo TagStore
}
// NewTagService 构造函数
func NewTagService(repo TagStore) *TagService {
return &TagService{repo: repo}
}
// ParseAndSave 解析标签名列表(逗号分隔),查找或创建,返回 tagID 列表
func (s *TagService) ParseAndSave(tagStr string) ([]uint, error) {
if strings.TrimSpace(tagStr) == "" {
return nil, nil
}
names := strings.Split(tagStr, ",")
var tagIDs []uint
seen := make(map[string]bool)
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" || len(name) > 50 {
continue
}
slug := slugify(name)
if seen[slug] {
continue
}
seen[slug] = true
tag, err := s.repo.FindOrCreate(name, slug)
if err != nil {
return nil, err
}
tagIDs = append(tagIDs, tag.ID)
}
return tagIDs, nil
}
// SetPostTags 解析标签字符串并关联到文章
func (s *TagService) SetPostTags(postID uint, tagStr string) error {
tagIDs, err := s.ParseAndSave(tagStr)
if err != nil {
return err
}
return s.repo.SetPostTags(postID, tagIDs)
}
// GetPostsByTag 按标签分页查询文章
func (s *TagService) GetPostsByTag(slug string, page, pageSize int) ([]model.Post, int64, *model.Tag, error) {
tag, err := s.repo.FindBySlug(slug)
if err != nil {
return nil, 0, nil, err
}
offset := (page - 1) * pageSize
posts, total, err := s.repo.GetPostsByTag(tag.ID, offset, pageSize)
return posts, total, tag, err
}
func slugify(name string) string {
s := strings.ToLower(strings.TrimSpace(name))
s = strings.ReplaceAll(s, " ", "-")
return slugRegex.ReplaceAllString(s, "")
}

View File

@ -29,6 +29,16 @@
</div>
{{end}}
{{if .CategoryNav}}
<!-- Category Nav -->
<div class="category-nav">
<a href="/" class="category-tag active">全部</a>
{{range .CategoryNav}}
<a href="/posts?category={{.ID}}" class="category-tag">{{.Name}}</a>
{{end}}
</div>
{{end}}
<!-- Main Content -->
<div class="container home-main">
<div class="home-grid">

View File

@ -64,6 +64,19 @@
<span class="meta-label"></span>
<label class="meta-checkbox"><input type="checkbox" id="reprintProhibited"> 未经作者许可,禁止转载</label>
</div>
<div class="meta-row">
<span class="meta-label">分类</span>
<select id="categoryId" style="width:auto">
<option value="">(未分类)</option>
{{range .Categories}}
<option value="{{.ID}}" {{if and $.Post $.Post.CategoryID}}{{if eq .ID (derefUint $.Post.CategoryID)}}selected{{end}}{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div class="meta-row">
<span class="meta-label">标签</span>
<input type="text" id="tagInput" placeholder="逗号分隔,最多 10 个" style="width:300px">
</div>
<div class="meta-row">
<span class="meta-label">声明</span>
<select id="declaration" style="width:auto">

View File

@ -0,0 +1,57 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
<div class="container home-main">
<div class="home-grid">
<main class="home-feed">
<div class="feed-header">
<h2 class="feed-title">标签:{{.Tag.Name}}</h2>
<span class="feed-count">{{.Total}} 篇文章</span>
</div>
{{if .Posts}}
<div class="article-list">
{{range .Posts}}
<article class="article-card">
<div class="article-body">
<h3 class="article-title">
<a href="/posts/{{.ID}}">{{.Title}}</a>
</h3>
<p class="article-excerpt">{{.Excerpt}}</p>
<div class="article-meta">
<span class="article-author">{{.AuthorName}}</span>
<span class="meta-dot">·</span>
<span class="article-time">{{.CreatedAt.Format "2006-01-02"}}</span>
</div>
</div>
</article>
{{end}}
</div>
{{else}}
<div class="empty-feed">暂无相关文章</div>
{{end}}
{{if gt .TotalPages 1}}
<div class="pagination">
{{if gt .Page 1}}<a href="?page={{.PrevPage}}" class="page-btn">上一页</a>{{end}}
<span class="page-info">{{.Page}} / {{.TotalPages}}</span>
{{if lt .Page .TotalPages}}<a href="?page={{.NextPage}}" class="page-btn">下一页</a>{{end}}
</div>
{{end}}
</main>
<aside class="home-sidebar">
<div class="sidebar-card links-card">
<h3 class="links-title">快捷入口</h3>
<a href="/" class="links-item">← 返回首页</a>
<a href="/posts" class="links-item">浏览全部文章</a>
</div>
</aside>
</div>
</div>
{{template "layout/footer.html" .}}
</body>
</html>

View File

@ -423,6 +423,32 @@
}
.announcement-error .announcement-title { color: #dc2626; }
/* --- Category Nav --- */
.category-nav {
max-width: 900px;
margin: 0 auto 1.5rem;
padding: 0 16px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.category-tag {
padding: 4px 14px;
border-radius: 20px;
font-size: 13px;
color: #4b5563;
background: #f3f4f6;
text-decoration: none;
transition: all .15s;
}
.category-tag:hover,
.category-tag.active {
background: var(--color-accent);
color: #fff;
}
/* ---------- Responsive ---------- */
@media (max-width: 960px) {
.home-grid {

View File

@ -190,6 +190,8 @@ async function handleSubmit(e) {
const reprintSource = document.getElementById('reprintSource')?.value.trim() || ''
const declaration = document.getElementById('declaration')?.value || ''
const reprintProhibited = document.getElementById('reprintProhibited')?.checked || false
const categoryId = document.getElementById('categoryId')?.value || ''
const tags = document.getElementById('tagInput')?.value.trim() || ''
const isEdit = !!postId
const url = isEdit ? '/api/studio/posts/' + postId : '/api/studio/posts'
@ -207,6 +209,8 @@ async function handleSubmit(e) {
reprint_source: reprintSource,
declaration,
reprint_prohibited: reprintProhibited,
category_id: categoryId,
tags,
}),
})
const data = await resp.json()

View File

@ -0,0 +1,80 @@
{{template "admin/layout/base.html" .}}
<div class="page-header">
<h1>分类管理</h1>
<p class="page-desc">两级树形分类,仅叶子节点可被文章选择</p>
</div>
<div class="toolbar">
<button class="btn btn-primary" id="btnNewCategory">+ 新建主分类</button>
</div>
<div id="categoryTree" class="category-tree">
{{range .Tree}}
<div class="cat-node cat-parent">
<div class="cat-row">
<span class="cat-name">{{.Name}}</span>
<span class="cat-slug">/{{.Slug}}</span>
<span class="cat-actions">
<a href="#" class="cat-btn-edit" data-id="{{.ID}}" data-name="{{.Name}}" data-slug="{{.Slug}}" data-desc="{{.Description}}" data-sort="{{.Sort}}">编辑</a>
<a href="#" class="cat-btn-delete" data-id="{{.ID}}">删除</a>
<a href="#" class="cat-btn-add-child" data-parent-id="{{.ID}}">+ 子分类</a>
</span>
</div>
{{if .Children}}
<div class="cat-children">
{{range .Children}}
<div class="cat-row cat-child">
<span class="cat-name">{{.Name}}</span>
<span class="cat-slug">/{{$.Slug}}/{{.Slug}}</span>
<span class="cat-actions">
<a href="#" class="cat-btn-edit" data-id="{{.ID}}" data-name="{{.Name}}" data-slug="{{.Slug}}" data-desc="{{.Description}}" data-sort="{{.Sort}}" data-parent-id="{{.ParentID}}">编辑</a>
<a href="#" class="cat-btn-delete" data-id="{{.ID}}">删除</a>
</span>
</div>
{{end}}
</div>
{{end}}
</div>
{{else}}
<div class="cat-empty">暂无分类</div>
{{end}}
</div>
<!-- 编辑弹窗 -->
<div class="modal-overlay" id="modalOverlay">
<div class="modal">
<div class="modal-header">
<h3 id="modalTitle">新建分类</h3>
<button class="modal-close" id="modalClose">&times;</button>
</div>
<div class="modal-body">
<form id="categoryForm">
<input type="hidden" id="catId">
<input type="hidden" id="catParentId">
<div class="form-group">
<label for="catName">名称</label>
<input type="text" id="catName" class="form-input" maxlength="50" required>
</div>
<div class="form-group">
<label for="catSlug">SlugURL 标识)</label>
<input type="text" id="catSlug" class="form-input" maxlength="50" pattern="[a-z0-9\-]+">
</div>
<div class="form-group">
<label for="catDesc">简介</label>
<input type="text" id="catDesc" class="form-input" maxlength="200">
</div>
<div class="form-group">
<label for="catSort">排序</label>
<input type="number" id="catSort" class="form-input" value="0" min="0" style="width:100px">
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="modalCancel">取消</button>
<button class="btn btn-primary" id="modalSave">保存</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
{{template "admin/layout/footer.html" .}}

View File

@ -55,6 +55,10 @@
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg></span>
公户流水
</a>
<a href="/admin/categories" class="sidebar-item {{if eq .CurrentPath "/admin/categories"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3h7v7H3V3z"/><path d="M14 3h7v7h-7V3z"/><path d="M3 14h7v7H3v-7z"/><path d="M14 14h7v7h-7v-7z"/></svg></span>
分类管理
</a>
<a href="/admin/announcements" class="sidebar-item {{if eq .CurrentPath "/admin/announcements"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg></span>
公告管理

View File

@ -0,0 +1,18 @@
/* Categories Admin */
.category-tree { margin-top: 16px; }
.cat-node { margin-bottom: 12px; }
.cat-parent { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px 16px; }
.cat-children { margin-left: 24px; margin-top: 8px; }
.cat-row {
display: flex; align-items: center; gap: 12px;
padding: 8px 0; border-bottom: 1px solid #f3f4f6;
}
.cat-children .cat-row:last-child { border-bottom: none; }
.cat-name { font-weight: 600; font-size: 14px; color: #111827; min-width: 80px; }
.cat-slug { font-size: 12px; color: #9ca3af; }
.cat-actions { margin-left: auto; display: flex; gap: 8px; font-size: 13px; }
.cat-actions a { color: #3b82f6; text-decoration: none; }
.cat-actions a:hover { text-decoration: underline; }
.cat-btn-delete { color: #ef4444 !important; }
.cat-btn-add-child { color: #22c55e !important; }
.cat-empty { text-align: center; color: #9ca3af; padding: 40px 0; }

View File

@ -0,0 +1,118 @@
(function () {
'use strict';
var modalOverlay = document.getElementById('modalOverlay');
var modalTitle = document.getElementById('modalTitle');
var modalClose = document.getElementById('modalClose');
var modalCancel = document.getElementById('modalCancel');
var modalSave = document.getElementById('modalSave');
var catId = document.getElementById('catId');
var catParentId = document.getElementById('catParentId');
var catName = document.getElementById('catName');
var catSlug = document.getElementById('catSlug');
var catDesc = document.getElementById('catDesc');
var catSort = document.getElementById('catSort');
var toast = document.getElementById('toast');
function showToast(msg, isError) {
toast.textContent = msg;
toast.className = 'toast' + (isError ? ' toast-error' : ' toast-success');
toast.style.display = 'block';
setTimeout(function () { toast.style.display = 'none'; }, 2500);
}
function closeModal() { modalOverlay.classList.remove('active'); }
modalClose.addEventListener('click', closeModal);
modalCancel.addEventListener('click', closeModal);
modalOverlay.addEventListener('click', function (e) { if (e.target === modalOverlay) closeModal(); });
function openModal(data) {
if (data) {
modalTitle.textContent = '编辑分类';
catId.value = data.id || '';
catParentId.value = data.parentId || '';
catName.value = data.name || '';
catSlug.value = data.slug || '';
catDesc.value = data.desc || '';
catSort.value = data.sort || 0;
} else {
modalTitle.textContent = '新建分类';
catId.value = '';
// Keep parentId for child categories
catName.value = '';
catSlug.value = '';
catDesc.value = '';
catSort.value = 0;
}
modalOverlay.classList.add('active');
}
document.getElementById('btnNewCategory').addEventListener('click', function () {
catParentId.value = '';
openModal(null);
});
document.addEventListener('click', function (e) {
if (e.target.classList.contains('cat-btn-add-child')) {
e.preventDefault();
catParentId.value = e.target.getAttribute('data-parent-id');
openModal(null);
}
if (e.target.classList.contains('cat-btn-edit')) {
e.preventDefault();
openModal({
id: e.target.getAttribute('data-id'),
parentId: e.target.getAttribute('data-parent-id') || '',
name: e.target.getAttribute('data-name'),
slug: e.target.getAttribute('data-slug'),
desc: e.target.getAttribute('data-desc'),
sort: e.target.getAttribute('data-sort'),
});
}
if (e.target.classList.contains('cat-btn-delete')) {
e.preventDefault();
var id = e.target.getAttribute('data-id');
if (!confirm('确定删除此分类?')) return;
fetch('/api/admin/categories/' + id, { method: 'DELETE' })
.then(function (r) { return r.json(); })
.then(function (resp) {
showToast(resp.message || '已删除', !resp.success);
if (resp.success) window.location.reload();
});
}
});
modalSave.addEventListener('click', function () {
var data = {
name: catName.value.trim(),
slug: catSlug.value.trim() || slugify(catName.value.trim()),
description: catDesc.value.trim(),
sort: parseInt(catSort.value) || 0,
};
var pid = catParentId.value;
if (pid) data.parent_id = parseInt(pid);
if (!data.name) { showToast('请输入名称', true); return; }
var url = '/api/admin/categories';
var method = 'POST';
var id = catId.value;
if (id) { url += '/' + id; method = 'PUT'; }
fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
.then(function (r) { return r.json(); })
.then(function (resp) {
showToast(resp.message || '保存成功', !resp.success);
if (resp.success) window.location.reload();
})
.catch(function () { showToast('网络异常', true); });
});
function slugify(s) {
return s.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, '');
}
})();