mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-26 11:06:23 +00:00
feat: 添加仪表板功能,整合统计数据并优化后台管理界面
This commit is contained in:
@ -2,12 +2,13 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/cloudwego/hertz/pkg/app"
|
"github.com/cloudwego/hertz/pkg/app"
|
||||||
"github.com/snowykami/neo-blog/internal/dto"
|
"github.com/snowykami/neo-blog/internal/dto"
|
||||||
"github.com/snowykami/neo-blog/internal/service"
|
"github.com/snowykami/neo-blog/internal/service"
|
||||||
"github.com/snowykami/neo-blog/pkg/errs"
|
"github.com/snowykami/neo-blog/pkg/errs"
|
||||||
"github.com/snowykami/neo-blog/pkg/resps"
|
"github.com/snowykami/neo-blog/pkg/resps"
|
||||||
"strconv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AdminController struct {
|
type AdminController struct {
|
||||||
@ -20,6 +21,15 @@ func NewAdminController() *AdminController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cc *AdminController) GetDashboard(ctx context.Context, c *app.RequestContext) {
|
||||||
|
dashboardData, err := cc.service.GetDashboard()
|
||||||
|
if err != nil {
|
||||||
|
serviceErr := errs.AsServiceError(err)
|
||||||
|
resps.Custom(c, serviceErr.Code, err.Error(), nil)
|
||||||
|
}
|
||||||
|
resps.Ok(c, resps.Success, dashboardData)
|
||||||
|
}
|
||||||
|
|
||||||
func (cc *AdminController) CreateOidc(ctx context.Context, c *app.RequestContext) {
|
func (cc *AdminController) CreateOidc(ctx context.Context, c *app.RequestContext) {
|
||||||
var adminCreateOidcReq dto.AdminOidcConfigDto
|
var adminCreateOidcReq dto.AdminOidcConfigDto
|
||||||
if err := c.BindAndValidate(&adminCreateOidcReq); err != nil {
|
if err := c.BindAndValidate(&adminCreateOidcReq); err != nil {
|
||||||
|
@ -6,19 +6,24 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type PostBase struct {
|
||||||
|
Title string `gorm:"type:text;not null"`
|
||||||
|
Cover string `gorm:"type:text"`
|
||||||
|
Content string `gorm:"type:text;not null"`
|
||||||
|
Type string `gorm:"type:text;default:markdown"`
|
||||||
|
CategoryID uint `gorm:"index"`
|
||||||
|
Category Category `gorm:"foreignKey:CategoryID;references:ID"`
|
||||||
|
Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
|
||||||
|
IsOriginal bool `gorm:"default:true"`
|
||||||
|
IsPrivate bool `gorm:"default:false"`
|
||||||
|
}
|
||||||
type Post struct {
|
type Post struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
UserID uint `gorm:"index"` // 发布者的用户ID
|
UserID uint `gorm:"index"` // 发布者的用户ID
|
||||||
User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户
|
User User `gorm:"foreignKey:UserID;references:ID"` // 关联的用户
|
||||||
Title string `gorm:"type:text;not null"` // 帖子标题
|
// core fields
|
||||||
Cover string `gorm:"type:text"` // 帖子封面图
|
PostBase
|
||||||
Content string `gorm:"type:text;not null"` // 帖子内容
|
//
|
||||||
Type string `gorm:"type:text;default:markdown"` // markdown类型,支持markdown或html或txt
|
|
||||||
CategoryID uint `gorm:"index"` // 帖子分类ID
|
|
||||||
Category Category `gorm:"foreignKey:CategoryID;references:ID"` // 关联的分类
|
|
||||||
Labels []Label `gorm:"many2many:post_labels;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` // 关联的标签
|
|
||||||
IsOriginal bool `gorm:"default:true"` // 是否为原创帖子
|
|
||||||
IsPrivate bool `gorm:"default:false"`
|
|
||||||
LikeCount uint64
|
LikeCount uint64
|
||||||
CommentCount uint64
|
CommentCount uint64
|
||||||
ViewCount uint64
|
ViewCount uint64
|
||||||
@ -71,3 +76,11 @@ func (p *Post) ToDtoWithShortContent(contentLength int) *dto.PostDto {
|
|||||||
}
|
}
|
||||||
return dtoPost
|
return dtoPost
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draft 草稿
|
||||||
|
type Draft struct {
|
||||||
|
gorm.Model
|
||||||
|
PostID uint `gorm:"uniqueIndex"` // 关联的文章ID
|
||||||
|
Post Post `gorm:"foreignKey:PostID;references:ID"`
|
||||||
|
PostBase
|
||||||
|
}
|
||||||
|
@ -1,21 +1,22 @@
|
|||||||
package apiv1
|
package apiv1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/cloudwego/hertz/pkg/route"
|
"github.com/cloudwego/hertz/pkg/route"
|
||||||
v1 "github.com/snowykami/neo-blog/internal/controller/v1"
|
v1 "github.com/snowykami/neo-blog/internal/controller/v1"
|
||||||
"github.com/snowykami/neo-blog/internal/middleware"
|
"github.com/snowykami/neo-blog/internal/middleware"
|
||||||
"github.com/snowykami/neo-blog/pkg/constant"
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
)
|
)
|
||||||
|
|
||||||
func registerAdminRoutes(group *route.RouterGroup) {
|
func registerAdminRoutes(group *route.RouterGroup) {
|
||||||
// Need Admin Middleware
|
// Need Admin Middleware
|
||||||
adminController := v1.NewAdminController()
|
adminController := v1.NewAdminController()
|
||||||
consoleGroup := group.Group("/admin").Use(middleware.UseAuth(true)).Use(middleware.UseRole(constant.RoleAdmin))
|
consoleGroup := group.Group("/admin").Use(middleware.UseAuth(true)).Use(middleware.UseRole(constant.RoleAdmin))
|
||||||
{
|
{
|
||||||
consoleGroup.POST("/oidc/o", adminController.CreateOidc)
|
consoleGroup.POST("/oidc/o", adminController.CreateOidc)
|
||||||
consoleGroup.DELETE("/oidc/o/:id", adminController.DeleteOidc)
|
consoleGroup.DELETE("/oidc/o/:id", adminController.DeleteOidc)
|
||||||
consoleGroup.GET("/oidc/o/:id", adminController.GetOidcByID)
|
consoleGroup.GET("/oidc/o/:id", adminController.GetOidcByID)
|
||||||
consoleGroup.GET("/oidc/list", adminController.ListOidc)
|
consoleGroup.GET("/oidc/list", adminController.ListOidc)
|
||||||
consoleGroup.PUT("/oidc/o/:id", adminController.UpdateOidc)
|
consoleGroup.PUT("/oidc/o/:id", adminController.UpdateOidc)
|
||||||
}
|
consoleGroup.GET("/dashboard", adminController.GetDashboard)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,77 +1,110 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/snowykami/neo-blog/internal/dto"
|
"github.com/snowykami/neo-blog/internal/dto"
|
||||||
"github.com/snowykami/neo-blog/internal/model"
|
"github.com/snowykami/neo-blog/internal/model"
|
||||||
"github.com/snowykami/neo-blog/internal/repo"
|
"github.com/snowykami/neo-blog/internal/repo"
|
||||||
"github.com/snowykami/neo-blog/pkg/errs"
|
"github.com/snowykami/neo-blog/pkg/errs"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AdminService struct{}
|
type AdminService struct{}
|
||||||
|
|
||||||
func NewAdminService() *AdminService {
|
func NewAdminService() *AdminService {
|
||||||
return &AdminService{}
|
return &AdminService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AdminService) GetDashboard() (map[string]any, error) {
|
||||||
|
var (
|
||||||
|
postCount, commentCount, userCount, viewCount int64
|
||||||
|
err error
|
||||||
|
mustCount = func(q *gorm.DB, dest *int64) {
|
||||||
|
if err == nil {
|
||||||
|
err = q.Count(dest).Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mustScan = func(q *gorm.DB, dest *int64) {
|
||||||
|
if err == nil {
|
||||||
|
err = q.Scan(dest).Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db := repo.GetDB()
|
||||||
|
|
||||||
|
mustCount(db.Model(&model.Comment{}), &commentCount)
|
||||||
|
mustCount(db.Model(&model.Post{}), &postCount)
|
||||||
|
mustCount(db.Model(&model.User{}), &userCount)
|
||||||
|
mustScan(db.Model(&model.Post{}).Select("SUM(view_count)"), &viewCount)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"total_comments": commentCount,
|
||||||
|
"total_posts": postCount,
|
||||||
|
"total_users": userCount,
|
||||||
|
"total_views": viewCount,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AdminService) CreateOidcConfig(req *dto.AdminOidcConfigDto) error {
|
func (c *AdminService) CreateOidcConfig(req *dto.AdminOidcConfigDto) error {
|
||||||
oidcConfig := &model.OidcConfig{
|
oidcConfig := &model.OidcConfig{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
DisplayName: req.DisplayName,
|
DisplayName: req.DisplayName,
|
||||||
Icon: req.Icon,
|
Icon: req.Icon,
|
||||||
ClientID: req.ClientID,
|
ClientID: req.ClientID,
|
||||||
ClientSecret: req.ClientSecret,
|
ClientSecret: req.ClientSecret,
|
||||||
OidcDiscoveryUrl: req.OidcDiscoveryUrl,
|
OidcDiscoveryUrl: req.OidcDiscoveryUrl,
|
||||||
Enabled: req.Enabled,
|
Enabled: req.Enabled,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
}
|
}
|
||||||
return repo.Oidc.CreateOidcConfig(oidcConfig)
|
return repo.Oidc.CreateOidcConfig(oidcConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AdminService) DeleteOidcConfig(id string) error {
|
func (c *AdminService) DeleteOidcConfig(id string) error {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return errs.ErrBadRequest
|
return errs.ErrBadRequest
|
||||||
}
|
}
|
||||||
return repo.Oidc.DeleteOidcConfig(id)
|
return repo.Oidc.DeleteOidcConfig(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AdminService) GetOidcConfigByID(id string) (*dto.AdminOidcConfigDto, error) {
|
func (c *AdminService) GetOidcConfigByID(id string) (*dto.AdminOidcConfigDto, error) {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return nil, errs.ErrBadRequest
|
return nil, errs.ErrBadRequest
|
||||||
}
|
}
|
||||||
config, err := repo.Oidc.GetOidcConfigByID(id)
|
config, err := repo.Oidc.GetOidcConfigByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return config.ToAdminDto(), nil
|
return config.ToAdminDto(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AdminService) ListOidcConfigs(onlyEnabled bool) ([]*dto.AdminOidcConfigDto, error) {
|
func (c *AdminService) ListOidcConfigs(onlyEnabled bool) ([]*dto.AdminOidcConfigDto, error) {
|
||||||
configs, err := repo.Oidc.ListOidcConfigs(onlyEnabled)
|
configs, err := repo.Oidc.ListOidcConfigs(onlyEnabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var dtos []*dto.AdminOidcConfigDto
|
var dtos []*dto.AdminOidcConfigDto
|
||||||
for _, config := range configs {
|
for _, config := range configs {
|
||||||
dtos = append(dtos, config.ToAdminDto())
|
dtos = append(dtos, config.ToAdminDto())
|
||||||
}
|
}
|
||||||
return dtos, nil
|
return dtos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AdminService) UpdateOidcConfig(req *dto.AdminOidcConfigDto) error {
|
func (c *AdminService) UpdateOidcConfig(req *dto.AdminOidcConfigDto) error {
|
||||||
if req.ID == 0 {
|
if req.ID == 0 {
|
||||||
return errs.ErrBadRequest
|
return errs.ErrBadRequest
|
||||||
}
|
}
|
||||||
oidcConfig := &model.OidcConfig{
|
oidcConfig := &model.OidcConfig{
|
||||||
Model: gorm.Model{ID: req.ID},
|
Model: gorm.Model{ID: req.ID},
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
DisplayName: req.DisplayName,
|
DisplayName: req.DisplayName,
|
||||||
Icon: req.Icon,
|
Icon: req.Icon,
|
||||||
ClientID: req.ClientID,
|
ClientID: req.ClientID,
|
||||||
ClientSecret: req.ClientSecret,
|
ClientSecret: req.ClientSecret,
|
||||||
OidcDiscoveryUrl: req.OidcDiscoveryUrl,
|
OidcDiscoveryUrl: req.OidcDiscoveryUrl,
|
||||||
Enabled: req.Enabled,
|
Enabled: req.Enabled,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
}
|
}
|
||||||
return repo.Oidc.UpdateOidcConfig(oidcConfig)
|
return repo.Oidc.UpdateOidcConfig(oidcConfig)
|
||||||
}
|
}
|
||||||
|
@ -1,155 +1,157 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/snowykami/neo-blog/internal/ctxutils"
|
"github.com/snowykami/neo-blog/internal/ctxutils"
|
||||||
"github.com/snowykami/neo-blog/internal/dto"
|
"github.com/snowykami/neo-blog/internal/dto"
|
||||||
"github.com/snowykami/neo-blog/internal/model"
|
"github.com/snowykami/neo-blog/internal/model"
|
||||||
"github.com/snowykami/neo-blog/internal/repo"
|
"github.com/snowykami/neo-blog/internal/repo"
|
||||||
"github.com/snowykami/neo-blog/pkg/errs"
|
"github.com/snowykami/neo-blog/pkg/errs"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PostService struct{}
|
type PostService struct{}
|
||||||
|
|
||||||
func NewPostService() *PostService {
|
func NewPostService() *PostService {
|
||||||
return &PostService{}
|
return &PostService{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostService) CreatePost(ctx context.Context, req *dto.CreateOrUpdatePostReq) (uint, error) {
|
func (p *PostService) CreatePost(ctx context.Context, req *dto.CreateOrUpdatePostReq) (uint, error) {
|
||||||
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, errs.ErrUnauthorized
|
return 0, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
post := &model.Post{
|
post := &model.Post{
|
||||||
Title: req.Title,
|
UserID: currentUser.ID,
|
||||||
Content: req.Content,
|
PostBase: model.PostBase{
|
||||||
UserID: currentUser.ID,
|
Title: req.Title,
|
||||||
Labels: func() []model.Label {
|
Content: req.Content,
|
||||||
labelModels := make([]model.Label, 0)
|
Labels: func() []model.Label {
|
||||||
for _, labelID := range req.Labels {
|
labelModels := make([]model.Label, 0)
|
||||||
labelModel, err := repo.Label.GetLabelByID(strconv.Itoa(int(labelID)))
|
for _, labelID := range req.Labels {
|
||||||
if err == nil {
|
labelModel, err := repo.Label.GetLabelByID(strconv.Itoa(int(labelID)))
|
||||||
labelModels = append(labelModels, *labelModel)
|
if err == nil {
|
||||||
}
|
labelModels = append(labelModels, *labelModel)
|
||||||
}
|
}
|
||||||
return labelModels
|
}
|
||||||
}(),
|
return labelModels
|
||||||
IsPrivate: req.IsPrivate,
|
}(),
|
||||||
}
|
IsPrivate: req.IsPrivate,
|
||||||
if err := repo.Post.CreatePost(post); err != nil {
|
},
|
||||||
return 0, err
|
}
|
||||||
}
|
if err := repo.Post.CreatePost(post); err != nil {
|
||||||
return post.ID, nil
|
return 0, err
|
||||||
|
}
|
||||||
|
return post.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostService) DeletePost(ctx context.Context, id string) error {
|
func (p *PostService) DeletePost(ctx context.Context, id string) error {
|
||||||
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errs.ErrUnauthorized
|
return errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return errs.ErrBadRequest
|
return errs.ErrBadRequest
|
||||||
}
|
}
|
||||||
post, err := repo.Post.GetPostByID(id)
|
post, err := repo.Post.GetPostByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errs.New(errs.ErrNotFound.Code, "post not found", err)
|
return errs.New(errs.ErrNotFound.Code, "post not found", err)
|
||||||
}
|
}
|
||||||
if post.UserID != currentUser.ID {
|
if post.UserID != currentUser.ID {
|
||||||
return errs.ErrForbidden
|
return errs.ErrForbidden
|
||||||
}
|
}
|
||||||
if err := repo.Post.DeletePost(id); err != nil {
|
if err := repo.Post.DeletePost(id); err != nil {
|
||||||
return errs.ErrInternalServer
|
return errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostService) GetPost(ctx context.Context, id string) (*dto.PostDto, error) {
|
func (p *PostService) GetPost(ctx context.Context, id string) (*dto.PostDto, error) {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return nil, errs.ErrBadRequest
|
return nil, errs.ErrBadRequest
|
||||||
}
|
}
|
||||||
post, err := repo.Post.GetPostByID(id)
|
post, err := repo.Post.GetPostByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.New(errs.ErrNotFound.Code, "post not found", err)
|
return nil, errs.New(errs.ErrNotFound.Code, "post not found", err)
|
||||||
}
|
}
|
||||||
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
||||||
if post.IsPrivate && (!ok || post.UserID != currentUser.ID) {
|
if post.IsPrivate && (!ok || post.UserID != currentUser.ID) {
|
||||||
return nil, errs.ErrForbidden
|
return nil, errs.ErrForbidden
|
||||||
}
|
}
|
||||||
|
|
||||||
return post.ToDto(), nil
|
return post.ToDto(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.CreateOrUpdatePostReq) (uint, error) {
|
func (p *PostService) UpdatePost(ctx context.Context, id string, req *dto.CreateOrUpdatePostReq) (uint, error) {
|
||||||
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return 0, errs.ErrUnauthorized
|
return 0, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return 0, errs.ErrBadRequest
|
return 0, errs.ErrBadRequest
|
||||||
}
|
}
|
||||||
post, err := repo.Post.GetPostByID(id)
|
post, err := repo.Post.GetPostByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, errs.New(errs.ErrNotFound.Code, "post not found", err)
|
return 0, errs.New(errs.ErrNotFound.Code, "post not found", err)
|
||||||
}
|
}
|
||||||
if post.UserID != currentUser.ID {
|
if post.UserID != currentUser.ID {
|
||||||
return 0, errs.ErrForbidden
|
return 0, errs.ErrForbidden
|
||||||
}
|
}
|
||||||
post.Title = req.Title
|
post.Title = req.Title
|
||||||
post.Content = req.Content
|
post.Content = req.Content
|
||||||
post.IsPrivate = req.IsPrivate
|
post.IsPrivate = req.IsPrivate
|
||||||
post.Labels = func() []model.Label {
|
post.Labels = func() []model.Label {
|
||||||
labelModels := make([]model.Label, len(req.Labels))
|
labelModels := make([]model.Label, len(req.Labels))
|
||||||
for _, labelID := range req.Labels {
|
for _, labelID := range req.Labels {
|
||||||
labelModel, err := repo.Label.GetLabelByID(strconv.Itoa(int(labelID)))
|
labelModel, err := repo.Label.GetLabelByID(strconv.Itoa(int(labelID)))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
labelModels = append(labelModels, *labelModel)
|
labelModels = append(labelModels, *labelModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return labelModels
|
return labelModels
|
||||||
}()
|
}()
|
||||||
if err := repo.Post.UpdatePost(post); err != nil {
|
if err := repo.Post.UpdatePost(post); err != nil {
|
||||||
return 0, errs.ErrInternalServer
|
return 0, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
return post.ID, nil
|
return post.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) ([]*dto.PostDto, int64, error) {
|
func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) ([]*dto.PostDto, int64, error) {
|
||||||
postDtos := make([]*dto.PostDto, 0)
|
postDtos := make([]*dto.PostDto, 0)
|
||||||
currentUserID, _ := ctxutils.GetCurrentUserID(ctx)
|
currentUserID, _ := ctxutils.GetCurrentUserID(ctx)
|
||||||
posts, total, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Labels, req.LabelRule, req.Page, req.Size, req.OrderBy, req.Desc)
|
posts, total, err := repo.Post.ListPosts(currentUserID, req.Keywords, req.Labels, req.LabelRule, req.Page, req.Size, req.OrderBy, req.Desc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, total, errs.New(errs.ErrInternalServer.Code, "failed to list posts", err)
|
return nil, total, errs.New(errs.ErrInternalServer.Code, "failed to list posts", err)
|
||||||
}
|
}
|
||||||
for _, post := range posts {
|
for _, post := range posts {
|
||||||
postDtos = append(postDtos, post.ToDtoWithShortContent(100))
|
postDtos = append(postDtos, post.ToDtoWithShortContent(100))
|
||||||
}
|
}
|
||||||
return postDtos, total, nil
|
return postDtos, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostService) ToggleLikePost(ctx context.Context, id string) (bool, error) {
|
func (p *PostService) ToggleLikePost(ctx context.Context, id string) (bool, error) {
|
||||||
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false, errs.ErrUnauthorized
|
return false, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return false, errs.ErrBadRequest
|
return false, errs.ErrBadRequest
|
||||||
}
|
}
|
||||||
post, err := repo.Post.GetPostByID(id)
|
post, err := repo.Post.GetPostByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errs.New(errs.ErrNotFound.Code, "post not found", err)
|
return false, errs.New(errs.ErrNotFound.Code, "post not found", err)
|
||||||
}
|
}
|
||||||
if post.UserID == currentUser.ID {
|
if post.UserID == currentUser.ID {
|
||||||
return false, errs.ErrForbidden
|
return false, errs.ErrForbidden
|
||||||
}
|
}
|
||||||
idInt, err := strconv.ParseUint(id, 10, 64)
|
idInt, err := strconv.ParseUint(id, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errs.New(errs.ErrBadRequest.Code, "invalid post ID", err)
|
return false, errs.New(errs.ErrBadRequest.Code, "invalid post ID", err)
|
||||||
}
|
}
|
||||||
liked, err := repo.Post.ToggleLikePost(uint(idInt), currentUser.ID)
|
liked, err := repo.Post.ToggleLikePost(uint(idInt), currentUser.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, errs.ErrInternalServer
|
return false, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
return liked, nil
|
return liked, nil
|
||||||
}
|
}
|
||||||
|
14
web/src/api/admin.ts
Normal file
14
web/src/api/admin.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { BaseResponse } from "@/models/resp"
|
||||||
|
import axiosClient from "./client"
|
||||||
|
|
||||||
|
export interface DashboardResp {
|
||||||
|
totalUsers: number
|
||||||
|
totalPosts: number
|
||||||
|
totalComments: number
|
||||||
|
totalViews: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboard(): Promise<BaseResponse<DashboardResp>> {
|
||||||
|
const res = await axiosClient.get<BaseResponse<DashboardResp>>('/admin/dashboard')
|
||||||
|
return res.data
|
||||||
|
}
|
@ -1,3 +1,5 @@
|
|||||||
|
import { Dashboard } from "@/components/console/dashboard";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <div>Console</div>;
|
return <Dashboard />;
|
||||||
}
|
}
|
@ -1,3 +1,5 @@
|
|||||||
|
import { PostManage } from "@/components/console/post-manage";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <div>文章管理</div>;
|
return <PostManage />;
|
||||||
}
|
}
|
@ -18,11 +18,12 @@ export function CurrentLogged() {
|
|||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
const handleLoggedContinue = () => {
|
const handleLoggedContinue = () => {
|
||||||
|
console.log("continue to", redirectBack);
|
||||||
router.push(redirectBack);
|
router.push(redirectBack);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogOut = () => {
|
const handleLogOut = () => {
|
||||||
logout();
|
logout();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
@ -30,8 +31,8 @@ export function CurrentLogged() {
|
|||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<SectionDivider className="mb-4">{t("currently_logged_in")}</SectionDivider>
|
<SectionDivider className="mb-4">{t("currently_logged_in")}</SectionDivider>
|
||||||
<div className="flex justify-evenly items-center border border-border rounded-md p-2">
|
<div className="flex justify-evenly items-center border border-border rounded-md p-2">
|
||||||
<div className="flex gap-4 items-center cursor-pointer">
|
<div onClick={handleLoggedContinue} className="flex gap-4 items-center cursor-pointer">
|
||||||
<div onClick={handleLoggedContinue} className="flex gap-2 justify-center items-center ">
|
<div className="flex gap-2 justify-center items-center ">
|
||||||
<Avatar className="h-10 w-10 rounded-full">
|
<Avatar className="h-10 w-10 rounded-full">
|
||||||
<AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />
|
<AvatarImage src={getGravatarFromUser({ user })} alt={user.username} />
|
||||||
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(user.nickname || user.username)}</AvatarFallback>
|
<AvatarFallback className="rounded-full">{getFallbackAvatarFromUsername(user.nickname || user.username)}</AvatarFallback>
|
||||||
|
73
web/src/components/console/dashboard/index.tsx
Normal file
73
web/src/components/console/dashboard/index.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
"use client"
|
||||||
|
import { getDashboard, DashboardResp } from "@/api/admin"
|
||||||
|
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Eye, MessageCircle, Newspaper, Users } from "lucide-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { path } from "../data"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
return (
|
||||||
|
<div className="">
|
||||||
|
<DataOverview />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataOverview() {
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
"key": "totalPosts",
|
||||||
|
"label": "Total Posts",
|
||||||
|
"icon": Newspaper,
|
||||||
|
"url": path.post
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "totalUsers",
|
||||||
|
"label": "Total Users",
|
||||||
|
"icon": Users,
|
||||||
|
"url": path.user
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "totalComments",
|
||||||
|
"label": "Total Comments",
|
||||||
|
"icon": MessageCircle,
|
||||||
|
"url": path.comment
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "totalViews",
|
||||||
|
"label": "Total Views",
|
||||||
|
"icon": Eye,
|
||||||
|
"url": path.file
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const [fetchData, setFetchData] = useState<DashboardResp | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getDashboard().then(res => {
|
||||||
|
setFetchData(res.data);
|
||||||
|
}).catch(err => {
|
||||||
|
toast.error(err.message || "Failed to fetch dashboard data");
|
||||||
|
});
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!fetchData) return <div>Loading...</div>
|
||||||
|
|
||||||
|
return <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{data.map(item => (
|
||||||
|
<Link key={item.key} href={item.url}>
|
||||||
|
<Card key={item.key} className="p-4">
|
||||||
|
<CardHeader className="pb-2 text-lg font-medium">
|
||||||
|
<CardDescription>{item.label}</CardDescription>
|
||||||
|
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl text-primary">
|
||||||
|
<item.icon className="inline mr-2" />
|
||||||
|
{(fetchData as any)[item.key]}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
@ -11,41 +11,53 @@ export interface SidebarItem {
|
|||||||
permission: ({ user }: { user: User }) => boolean;
|
permission: ({ user }: { user: User }) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const path = {
|
||||||
|
dashboard: "/console",
|
||||||
|
post: "/console/post",
|
||||||
|
comment: "/console/comment",
|
||||||
|
file: "/console/file",
|
||||||
|
user: "/console/user",
|
||||||
|
global: "/console/global",
|
||||||
|
userProfile: "/console/user-profile",
|
||||||
|
userSecurity: "/console/user-security",
|
||||||
|
userPreference: "/console/user-preference",
|
||||||
|
}
|
||||||
|
|
||||||
export const sidebarData: { navMain: SidebarItem[]; navUserCenter: SidebarItem[] } = {
|
export const sidebarData: { navMain: SidebarItem[]; navUserCenter: SidebarItem[] } = {
|
||||||
navMain: [
|
navMain: [
|
||||||
{
|
{
|
||||||
title: "dashboard.title",
|
title: "dashboard.title",
|
||||||
url: "/console",
|
url: path.dashboard,
|
||||||
icon: Gauge,
|
icon: Gauge,
|
||||||
permission: isAdmin
|
permission: isAdmin
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "post.title",
|
title: "post.title",
|
||||||
url: "/console/post",
|
url: path.post,
|
||||||
icon: Newspaper,
|
icon: Newspaper,
|
||||||
permission: isEditor
|
permission: isEditor
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "comment.title",
|
title: "comment.title",
|
||||||
url: "/console/comment",
|
url: path.comment,
|
||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
permission: isEditor
|
permission: isEditor
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "file.title",
|
title: "file.title",
|
||||||
url: "/console/file",
|
url: path.file,
|
||||||
icon: Folder,
|
icon: Folder,
|
||||||
permission: () => true
|
permission: () => true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "user.title",
|
title: "user.title",
|
||||||
url: "/console/user",
|
url: path.user,
|
||||||
icon: Users,
|
icon: Users,
|
||||||
permission: isAdmin
|
permission: isAdmin
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "global.title",
|
title: "global.title",
|
||||||
url: "/console/global",
|
url: path.global,
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
permission: isAdmin
|
permission: isAdmin
|
||||||
},
|
},
|
||||||
@ -53,19 +65,19 @@ export const sidebarData: { navMain: SidebarItem[]; navUserCenter: SidebarItem[]
|
|||||||
navUserCenter: [
|
navUserCenter: [
|
||||||
{
|
{
|
||||||
title: "user_profile.title",
|
title: "user_profile.title",
|
||||||
url: "/console/user-profile",
|
url: path.userProfile,
|
||||||
icon: UserPen,
|
icon: UserPen,
|
||||||
permission: () => true
|
permission: () => true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "user_security.title",
|
title: "user_security.title",
|
||||||
url: "/console/user-security",
|
url: path.userSecurity,
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
permission: () => true
|
permission: () => true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "user-preference.title",
|
title: "user-preference.title",
|
||||||
url: "/console/user-preference",
|
url: path.userPreference,
|
||||||
icon: Palette,
|
icon: Palette,
|
||||||
permission: () => true
|
permission: () => true
|
||||||
}
|
}
|
||||||
|
47
web/src/components/console/post-manage/index.tsx
Normal file
47
web/src/components/console/post-manage/index.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
import { listPosts } from "@/api/post";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import config from "@/config";
|
||||||
|
import { OrderBy } from "@/models/common";
|
||||||
|
import { Post } from "@/models/post"
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function PostManage() {
|
||||||
|
const [posts, setPosts] = useState<Post[]>([]);
|
||||||
|
const [orderBy, setOrderBy] = useState<OrderBy>(OrderBy.CreatedAt);
|
||||||
|
const [desc, setDesc] = useState<boolean>(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listPosts({ page, size: config.postsPerPage, orderBy, desc }).then(res => {
|
||||||
|
setPosts(res.data.posts);
|
||||||
|
});
|
||||||
|
}, [page, orderBy, desc]);
|
||||||
|
|
||||||
|
return <div>
|
||||||
|
{posts.map(post => <PostItem key={post.id} post={post} />)}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostItem({ post }: { post: Post }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex w-full items-center gap-3 py-2">
|
||||||
|
{/* left */}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{post.title}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground">ID: {post.id}</span>
|
||||||
|
<span className="mx-2 text-xs text-muted-foreground">|</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Created At: {new Date(post.createdAt).toLocaleDateString()}</span>
|
||||||
|
<span className="mx-2 text-xs text-muted-foreground">|</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Updated At: {new Date(post.updatedAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
export interface PaginationParams {
|
export interface PaginationParams {
|
||||||
orderBy: OrderBy
|
orderBy: OrderBy
|
||||||
desc: boolean
|
desc: boolean // 是否降序
|
||||||
page: number
|
page: number
|
||||||
size: number
|
size: number
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user