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:
32
docs/migrations/004_categories_tags.sql
Normal file
32
docs/migrations/004_categories_tags.sql
Normal 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;
|
||||||
100
internal/controller/admin/category_controller.go
Normal file
100
internal/controller/admin/category_controller.go
Normal 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, "分类已删除")
|
||||||
|
}
|
||||||
@ -42,9 +42,9 @@ type postUseCase interface {
|
|||||||
|
|
||||||
// studioUseCase StudioController 对 PostService 的最小依赖(ISP:9 个方法)
|
// studioUseCase StudioController 对 PostService 的最小依赖(ISP:9 个方法)
|
||||||
type studioUseCase interface {
|
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)
|
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
|
Delete(postID uint) error
|
||||||
SubmitForAudit(postID uint) error
|
SubmitForAudit(postID uint) error
|
||||||
ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error)
|
ListByUser(userID uint, status string, page, pageSize int) ([]model.Post, int64, error)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"metazone.cc/mce/internal/model"
|
||||||
"metazone.cc/mce/internal/service"
|
"metazone.cc/mce/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -9,11 +10,24 @@ type trendsUseCase interface {
|
|||||||
GetTrends(userID uint, since string) (*service.TrendResult, error)
|
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)
|
// StudioController 创作中心控制器(SSR 页面 + API)
|
||||||
type StudioController struct {
|
type StudioController struct {
|
||||||
postService studioUseCase
|
postService studioUseCase
|
||||||
energySvc energyDeducter
|
energySvc energyDeducter
|
||||||
trendsSvc trendsUseCase
|
trendsSvc trendsUseCase
|
||||||
|
tagSvc tagParser
|
||||||
|
categorySvc categoryLoader
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoryLoader ISP:分类叶子节点查询
|
||||||
|
type categoryLoader interface {
|
||||||
|
GetLeaves() ([]model.Category, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStudioController 构造函数
|
// NewStudioController 构造函数
|
||||||
@ -32,3 +46,15 @@ func (ctrl *StudioController) WithTrendsService(svc trendsUseCase) *StudioContro
|
|||||||
ctrl.trendsSvc = svc
|
ctrl.trendsSvc = svc
|
||||||
return ctrl
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -30,12 +30,17 @@ func (ctrl *StudioController) Create(c *gin.Context) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
common.Error(c, http.StatusInternalServerError, "发布失败")
|
common.Error(c, http.StatusInternalServerError, "发布失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 关联标签
|
||||||
|
if ctrl.tagSvc != nil && req.Tags != "" {
|
||||||
|
_ = ctrl.tagSvc.SetPostTags(post.ID, req.Tags)
|
||||||
|
}
|
||||||
|
|
||||||
common.OkWithMessage(c, post, "发布成功")
|
common.OkWithMessage(c, post, "发布成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,10 +74,15 @@ func (ctrl *StudioController) Update(c *gin.Context) {
|
|||||||
return
|
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, "编辑失败")
|
common.Error(c, http.StatusInternalServerError, "编辑失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新标签
|
||||||
|
if ctrl.tagSvc != nil {
|
||||||
|
_ = ctrl.tagSvc.SetPostTags(uint(id), req.Tags)
|
||||||
|
}
|
||||||
|
|
||||||
common.OkMessage(c, "编辑成功")
|
common.OkMessage(c, "编辑成功")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,16 @@ func (ctrl *StudioController) WritePage(c *gin.Context) {
|
|||||||
return
|
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 参数
|
// 支持编辑模式:传入 post_id 参数
|
||||||
postIDStr := c.Query("id")
|
postIDStr := c.Query("id")
|
||||||
if postIDStr != "" {
|
if postIDStr != "" {
|
||||||
@ -36,6 +46,7 @@ func (ctrl *StudioController) WritePage(c *gin.Context) {
|
|||||||
"Post": post,
|
"Post": post,
|
||||||
"ActiveTab": "write",
|
"ActiveTab": "write",
|
||||||
"ExtraCSS": "/static/css/studio.css",
|
"ExtraCSS": "/static/css/studio.css",
|
||||||
|
"Categories": categories,
|
||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -46,6 +57,7 @@ func (ctrl *StudioController) WritePage(c *gin.Context) {
|
|||||||
"Title": "写文章 - 创作中心",
|
"Title": "写文章 - 创作中心",
|
||||||
"ActiveTab": "write",
|
"ActiveTab": "write",
|
||||||
"ExtraCSS": "/static/css/studio.css",
|
"ExtraCSS": "/static/css/studio.css",
|
||||||
|
"Categories": categories,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
54
internal/controller/tag_controller.go
Normal file
54
internal/controller/tag_controller.go
Normal 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
18
internal/model/category.go
Normal file
18
internal/model/category.go
Normal 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"`
|
||||||
|
}
|
||||||
@ -39,6 +39,8 @@ type PostCreateRequest struct {
|
|||||||
ReprintSource string `json:"reprint_source"`
|
ReprintSource string `json:"reprint_source"`
|
||||||
Declaration string `json:"declaration"`
|
Declaration string `json:"declaration"`
|
||||||
ReprintProhibited bool `json:"reprint_prohibited"`
|
ReprintProhibited bool `json:"reprint_prohibited"`
|
||||||
|
CategoryID string `json:"category_id"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostUpdateRequest 编辑请求
|
// PostUpdateRequest 编辑请求
|
||||||
@ -50,6 +52,8 @@ type PostUpdateRequest struct {
|
|||||||
ReprintSource string `json:"reprint_source"`
|
ReprintSource string `json:"reprint_source"`
|
||||||
Declaration string `json:"declaration"`
|
Declaration string `json:"declaration"`
|
||||||
ReprintProhibited bool `json:"reprint_prohibited"`
|
ReprintProhibited bool `json:"reprint_prohibited"`
|
||||||
|
CategoryID string `json:"category_id"`
|
||||||
|
Tags string `json:"tags"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostRejectRequest 退回请求
|
// PostRejectRequest 退回请求
|
||||||
|
|||||||
@ -21,6 +21,7 @@ type Post struct {
|
|||||||
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
|
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
|
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
|
PostType string `gorm:"type:varchar(10);default:original" json:"post_type"` // original / reprint
|
||||||
ReprintSource string `gorm:"type:varchar(500);default:''" json:"reprint_source,omitempty"` // 转载来源
|
ReprintSource string `gorm:"type:varchar(500);default:''" json:"reprint_source,omitempty"` // 转载来源
|
||||||
|
|||||||
17
internal/model/tag.go
Normal file
17
internal/model/tag.go
Normal 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"`
|
||||||
|
}
|
||||||
63
internal/repository/category_repo.go
Normal file
63
internal/repository/category_repo.go
Normal 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
|
||||||
|
}
|
||||||
97
internal/repository/tag_repo.go
Normal file
97
internal/repository/tag_repo.go
Normal 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
|
||||||
|
}
|
||||||
@ -33,6 +33,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage)
|
adminPages.GET("/energy/fund-logs", d.adminEnergyCtrl.FundLogPage)
|
||||||
adminPages.GET("/comments", d.adminCommentCtrl.CommentsPage)
|
adminPages.GET("/comments", d.adminCommentCtrl.CommentsPage)
|
||||||
adminPages.GET("/announcements", d.announcementCtrl.Page)
|
adminPages.GET("/announcements", d.announcementCtrl.Page)
|
||||||
|
adminPages.GET("/categories", d.categoryCtrl.Page)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 管理后台 API ---
|
// --- 管理后台 API ---
|
||||||
@ -115,4 +116,17 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
announcementAPI.PUT("/:id", d.announcementCtrl.Update)
|
announcementAPI.PUT("/:id", d.announcementCtrl.Update)
|
||||||
announcementAPI.DELETE("/:id", d.announcementCtrl.Delete)
|
announcementAPI.DELETE("/:id", d.announcementCtrl.Delete)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 分类管理 API(moderator+) ---
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,6 +47,10 @@ type dependencies struct {
|
|||||||
homeCache *cache.HomePageCache
|
homeCache *cache.HomePageCache
|
||||||
announcementService *service.AnnouncementService
|
announcementService *service.AnnouncementService
|
||||||
announcementCtrl *adminCtrl.AnnouncementController
|
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) (
|
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
|
||||||
|
|||||||
@ -103,6 +103,18 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
|
|
||||||
maintenanceMdw := middleware.NewMaintenanceMiddleware(cfg, siteSettings, sessionMgr)
|
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)
|
announcementRepo := repository.NewAnnouncementRepo(db)
|
||||||
announcementService := service.NewAnnouncementService(announcementRepo)
|
announcementService := service.NewAnnouncementService(announcementRepo)
|
||||||
@ -135,5 +147,9 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
homeCache: homeCache,
|
homeCache: homeCache,
|
||||||
announcementService: announcementService,
|
announcementService: announcementService,
|
||||||
announcementCtrl: announcementCtrl,
|
announcementCtrl: announcementCtrl,
|
||||||
|
categoryCtrl: categoryCtrl,
|
||||||
|
categoryService: categoryService,
|
||||||
|
tagCtrl: tagCtrl,
|
||||||
|
tagService: tagService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,12 +34,14 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
announcements, _ := d.announcementService.ListActive()
|
announcements, _ := d.announcementService.ListActive()
|
||||||
|
categoryNav, _ := d.categoryService.GetLeaves()
|
||||||
c.HTML(http.StatusOK, "home/index.html", common.BuildPageData(c, gin.H{
|
c.HTML(http.StatusOK, "home/index.html", common.BuildPageData(c, gin.H{
|
||||||
"Title": "首页",
|
"Title": "首页",
|
||||||
"ExtraCSS": "/static/css/home.css",
|
"ExtraCSS": "/static/css/home.css",
|
||||||
"Posts": posts,
|
"Posts": posts,
|
||||||
"PostCount": total,
|
"PostCount": total,
|
||||||
"Announcements": announcements,
|
"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", d.postCtrl.ListPage)
|
||||||
pages.GET("/posts/:id", d.postCtrl.ShowPage)
|
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) {
|
pages.GET("/posts/:id/edit", d.authMdw.Required(), func(c *gin.Context) {
|
||||||
c.Redirect(http.StatusMovedPermanently, "/studio/write?id="+c.Param("id"))
|
c.Redirect(http.StatusMovedPermanently, "/studio/write?id="+c.Param("id"))
|
||||||
})
|
})
|
||||||
|
|||||||
75
internal/service/category_service.go
Normal file
75
internal/service/category_service.go
Normal 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()
|
||||||
|
}
|
||||||
@ -1,12 +1,14 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"metazone.cc/mce/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
"metazone.cc/mce/internal/model"
|
"metazone.cc/mce/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update 编辑帖子(权限在 controller 层检查)
|
// 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)
|
post, err := s.findPost(postID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -22,6 +24,13 @@ func (s *PostService) Update(postID uint, title, body string, visibility, postTy
|
|||||||
post.ReprintSource = reprintSource
|
post.ReprintSource = reprintSource
|
||||||
post.Declaration = declaration
|
post.Declaration = declaration
|
||||||
post.ReprintProhibited = reprintProhibited
|
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 {
|
if post.Status == model.PostStatusApproved {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"metazone.cc/mce/internal/common"
|
"metazone.cc/mce/internal/common"
|
||||||
@ -68,7 +69,7 @@ func (s *PostService) findPostWithAuthor(id uint) (*model.Post, error) {
|
|||||||
// Create 创建帖子
|
// Create 创建帖子
|
||||||
// body 为 Vditor 输出的 Markdown,后端直接存储不做 HTML 消毒
|
// body 为 Vditor 输出的 Markdown,后端直接存储不做 HTML 消毒
|
||||||
// MD 是纯文本,无 XSS 注入风险;前端渲染时由 Vditor 转 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
|
status := model.PostStatusApproved
|
||||||
if s.auditEnabled() {
|
if s.auditEnabled() {
|
||||||
status = model.PostStatusDraft
|
status = model.PostStatusDraft
|
||||||
@ -87,6 +88,11 @@ func (s *PostService) Create(userID uint, title, body string, visibility, postTy
|
|||||||
Declaration: declaration,
|
Declaration: declaration,
|
||||||
ReprintProhibited: reprintProhibited,
|
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 {
|
if err := s.repo.Create(post); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
82
internal/service/tag_service.go
Normal file
82
internal/service/tag_service.go
Normal 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, "")
|
||||||
|
}
|
||||||
@ -29,6 +29,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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 -->
|
<!-- Main Content -->
|
||||||
<div class="container home-main">
|
<div class="container home-main">
|
||||||
<div class="home-grid">
|
<div class="home-grid">
|
||||||
|
|||||||
@ -64,6 +64,19 @@
|
|||||||
<span class="meta-label"></span>
|
<span class="meta-label"></span>
|
||||||
<label class="meta-checkbox"><input type="checkbox" id="reprintProhibited"> 未经作者许可,禁止转载</label>
|
<label class="meta-checkbox"><input type="checkbox" id="reprintProhibited"> 未经作者许可,禁止转载</label>
|
||||||
</div>
|
</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">
|
<div class="meta-row">
|
||||||
<span class="meta-label">声明</span>
|
<span class="meta-label">声明</span>
|
||||||
<select id="declaration" style="width:auto">
|
<select id="declaration" style="width:auto">
|
||||||
|
|||||||
57
templates/MetaLab-2026/html/tags/detail.html
Normal file
57
templates/MetaLab-2026/html/tags/detail.html
Normal 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>
|
||||||
@ -423,6 +423,32 @@
|
|||||||
}
|
}
|
||||||
.announcement-error .announcement-title { color: #dc2626; }
|
.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 ---------- */
|
/* ---------- Responsive ---------- */
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.home-grid {
|
.home-grid {
|
||||||
|
|||||||
@ -190,6 +190,8 @@ async function handleSubmit(e) {
|
|||||||
const reprintSource = document.getElementById('reprintSource')?.value.trim() || ''
|
const reprintSource = document.getElementById('reprintSource')?.value.trim() || ''
|
||||||
const declaration = document.getElementById('declaration')?.value || ''
|
const declaration = document.getElementById('declaration')?.value || ''
|
||||||
const reprintProhibited = document.getElementById('reprintProhibited')?.checked || false
|
const reprintProhibited = document.getElementById('reprintProhibited')?.checked || false
|
||||||
|
const categoryId = document.getElementById('categoryId')?.value || ''
|
||||||
|
const tags = document.getElementById('tagInput')?.value.trim() || ''
|
||||||
|
|
||||||
const isEdit = !!postId
|
const isEdit = !!postId
|
||||||
const url = isEdit ? '/api/studio/posts/' + postId : '/api/studio/posts'
|
const url = isEdit ? '/api/studio/posts/' + postId : '/api/studio/posts'
|
||||||
@ -207,6 +209,8 @@ async function handleSubmit(e) {
|
|||||||
reprint_source: reprintSource,
|
reprint_source: reprintSource,
|
||||||
declaration,
|
declaration,
|
||||||
reprint_prohibited: reprintProhibited,
|
reprint_prohibited: reprintProhibited,
|
||||||
|
category_id: categoryId,
|
||||||
|
tags,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await resp.json()
|
const data = await resp.json()
|
||||||
|
|||||||
80
templates/admin/html/categories/index.html
Normal file
80
templates/admin/html/categories/index.html
Normal 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">×</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">Slug(URL 标识)</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" .}}
|
||||||
@ -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>
|
<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>
|
||||||
|
<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}}">
|
<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>
|
<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>
|
||||||
公告管理
|
公告管理
|
||||||
|
|||||||
18
templates/admin/static/css/categories.css
Normal file
18
templates/admin/static/css/categories.css
Normal 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; }
|
||||||
118
templates/admin/static/js/categories.js
Normal file
118
templates/admin/static/js/categories.js
Normal 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, '');
|
||||||
|
}
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user