mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-03 15:56:22 +00:00
✨ feat: 添加评论功能,包括评论输入、评论列表和评论项组件,支持层级深度和私密评论
This commit is contained in:
@ -89,6 +89,11 @@ func (cc *CommentController) GetComment(ctx context.Context, c *app.RequestConte
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (cc *CommentController) GetCommentList(ctx context.Context, c *app.RequestContext) {
|
func (cc *CommentController) GetCommentList(ctx context.Context, c *app.RequestContext) {
|
||||||
|
depth := c.Query("depth")
|
||||||
|
depthInt, err := strconv.Atoi(depth)
|
||||||
|
if err != nil || depthInt < 0 {
|
||||||
|
depthInt = 1
|
||||||
|
}
|
||||||
pagination := ctxutils.GetPaginationParams(c)
|
pagination := ctxutils.GetPaginationParams(c)
|
||||||
if pagination.OrderBy == "" {
|
if pagination.OrderBy == "" {
|
||||||
pagination.OrderBy = constant.OrderByUpdatedAt
|
pagination.OrderBy = constant.OrderByUpdatedAt
|
||||||
@ -107,6 +112,7 @@ func (cc *CommentController) GetCommentList(ctx context.Context, c *app.RequestC
|
|||||||
OrderBy: pagination.OrderBy,
|
OrderBy: pagination.OrderBy,
|
||||||
Page: pagination.Page,
|
Page: pagination.Page,
|
||||||
Size: pagination.Size,
|
Size: pagination.Size,
|
||||||
|
Depth: depthInt,
|
||||||
TargetID: uint(targetID),
|
TargetID: uint(targetID),
|
||||||
TargetType: c.Query("target_type"),
|
TargetType: c.Query("target_type"),
|
||||||
}
|
}
|
||||||
|
@ -17,19 +17,21 @@ type CreateCommentReq struct {
|
|||||||
TargetType string `json:"target_type" binding:"required"` // 目标类型,如 "post", "page"
|
TargetType string `json:"target_type" binding:"required"` // 目标类型,如 "post", "page"
|
||||||
Content string `json:"content" binding:"required"` // 评论内容
|
Content string `json:"content" binding:"required"` // 评论内容
|
||||||
ReplyID uint `json:"reply_id"` // 回复的评论ID
|
ReplyID uint `json:"reply_id"` // 回复的评论ID
|
||||||
IsPrivate bool `json:"is_private" binding:"required"` // 是否私密
|
IsPrivate bool `json:"is_private"` // 是否私密评论,默认false
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateCommentReq struct {
|
type UpdateCommentReq struct {
|
||||||
CommentID uint `json:"comment_id" binding:"required"` // 评论ID
|
CommentID uint `json:"comment_id" binding:"required"` // 评论ID
|
||||||
Content string `json:"content" binding:"required"` // 评论内容
|
Content string `json:"content" binding:"required"` // 评论内容
|
||||||
|
IsPrivate bool `json:"is_private"` // 是否私密
|
||||||
}
|
}
|
||||||
|
|
||||||
type GetCommentListReq struct {
|
type GetCommentListReq struct {
|
||||||
TargetID uint `json:"target_id" binding:"required"`
|
TargetID uint `json:"target_id" binding:"required"`
|
||||||
TargetType string `json:"target_type" binding:"required"`
|
TargetType string `json:"target_type" binding:"required"`
|
||||||
OrderBy string `json:"order_by"` // 排序方式
|
OrderBy string `json:"order_by"` // 排序方式
|
||||||
Page uint64 `json:"page"` // 页码
|
Page uint64 `json:"page"` // 页码
|
||||||
Size uint64 `json:"size"`
|
Size uint64 `json:"size"`
|
||||||
Desc bool `json:"desc"`
|
Desc bool `json:"desc"`
|
||||||
}
|
Depth int `json:"depth"` // 评论的层级深度
|
||||||
|
}
|
||||||
|
@ -3,6 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/cloudwego/hertz/pkg/app"
|
"github.com/cloudwego/hertz/pkg/app"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/snowykami/neo-blog/internal/ctxutils"
|
"github.com/snowykami/neo-blog/internal/ctxutils"
|
||||||
"github.com/snowykami/neo-blog/internal/repo"
|
"github.com/snowykami/neo-blog/internal/repo"
|
||||||
"github.com/snowykami/neo-blog/pkg/constant"
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
@ -15,7 +16,7 @@ import (
|
|||||||
func UseAuth(block bool) app.HandlerFunc {
|
func UseAuth(block bool) app.HandlerFunc {
|
||||||
return func(ctx context.Context, c *app.RequestContext) {
|
return func(ctx context.Context, c *app.RequestContext) {
|
||||||
// For cookie
|
// For cookie
|
||||||
tokenFromCookie := string(c.Cookie("tokenFromCookie"))
|
tokenFromCookie := string(c.Cookie("token"))
|
||||||
tokenFromHeader := strings.TrimPrefix(string(c.GetHeader("Authorization")), "Bearer ")
|
tokenFromHeader := strings.TrimPrefix(string(c.GetHeader("Authorization")), "Bearer ")
|
||||||
refreshToken := string(c.Cookie("refresh_token"))
|
refreshToken := string(c.Cookie("refresh_token"))
|
||||||
|
|
||||||
@ -25,8 +26,10 @@ func UseAuth(block bool) app.HandlerFunc {
|
|||||||
if err == nil && tokenClaims != nil {
|
if err == nil && tokenClaims != nil {
|
||||||
ctx = context.WithValue(ctx, constant.ContextKeyUserID, tokenClaims.UserID)
|
ctx = context.WithValue(ctx, constant.ContextKeyUserID, tokenClaims.UserID)
|
||||||
c.Next(ctx)
|
c.Next(ctx)
|
||||||
|
logrus.Debugf("UseAuth: tokenFromCookie authenticated successfully, userID: %d", tokenClaims.UserID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logrus.Debugf("UseAuth: tokenFromCookie authentication failed, error: %v", err)
|
||||||
}
|
}
|
||||||
// tokenFromCookie 认证失败,尝试用 Bearer tokenFromHeader 认证
|
// tokenFromCookie 认证失败,尝试用 Bearer tokenFromHeader 认证
|
||||||
if tokenFromHeader != "" {
|
if tokenFromHeader != "" {
|
||||||
@ -34,8 +37,10 @@ func UseAuth(block bool) app.HandlerFunc {
|
|||||||
if err == nil && tokenClaims != nil {
|
if err == nil && tokenClaims != nil {
|
||||||
ctx = context.WithValue(ctx, constant.ContextKeyUserID, tokenClaims.UserID)
|
ctx = context.WithValue(ctx, constant.ContextKeyUserID, tokenClaims.UserID)
|
||||||
c.Next(ctx)
|
c.Next(ctx)
|
||||||
|
logrus.Debugf("UseAuth: tokenFromHeader authenticated successfully, userID: %d", tokenClaims.UserID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logrus.Debugf("UseAuth: tokenFromHeader authentication failed, error: %v", err)
|
||||||
}
|
}
|
||||||
// tokenFromCookie 失效 使用 refresh tokenFromCookie 重新签发和鉴权
|
// tokenFromCookie 失效 使用 refresh tokenFromCookie 重新签发和鉴权
|
||||||
refreshTokenClaims, err := utils.Jwt.ParseJsonWebTokenWithoutState(refreshToken)
|
refreshTokenClaims, err := utils.Jwt.ParseJsonWebTokenWithoutState(refreshToken)
|
||||||
@ -56,19 +61,19 @@ func UseAuth(block bool) app.HandlerFunc {
|
|||||||
} else {
|
} else {
|
||||||
resps.InternalServerError(c, resps.ErrInternalServerError)
|
resps.InternalServerError(c, resps.ErrInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Next(ctx)
|
c.Next(ctx)
|
||||||
|
logrus.Debugf("UseAuth: refreshToken authenticated successfully, userID: %d", refreshTokenClaims.UserID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 所有认证方式都失败
|
// 所有认证方式都失败
|
||||||
if block {
|
if block {
|
||||||
// 若需要阻断,返回未授权错误并中止请求
|
logrus.Debug("UseAuth: all authentication methods failed, blocking request")
|
||||||
resps.Unauthorized(c, resps.ErrUnauthorized)
|
resps.Unauthorized(c, resps.ErrUnauthorized)
|
||||||
c.Abort()
|
c.Abort()
|
||||||
} else {
|
} else {
|
||||||
// 若不需要阻断,继续请求但不设置用户ID
|
logrus.Debug("UseAuth: all authentication methods failed, blocking request")
|
||||||
c.Next(ctx)
|
c.Next(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ type Comment struct {
|
|||||||
TargetType string `gorm:"index"` // 目标类型,如 "post", "page"
|
TargetType string `gorm:"index"` // 目标类型,如 "post", "page"
|
||||||
ReplyID uint `gorm:"index"` // 回复的评论ID
|
ReplyID uint `gorm:"index"` // 回复的评论ID
|
||||||
Content string `gorm:"type:text"` // 评论内容
|
Content string `gorm:"type:text"` // 评论内容
|
||||||
Depth int `gorm:"default:0"` // 评论的层级深度
|
Depth int `gorm:"default:0"` // 评论的层级深度,从0开始计数
|
||||||
IsPrivate bool `gorm:"default:false"` // 是否为私密评论,私密评论只有评论者和被评论对象所有者可见
|
IsPrivate bool `gorm:"default:false"` // 是否为私密评论,私密评论只有评论者和被评论对象所有者可见
|
||||||
LikeCount uint64
|
LikeCount uint64
|
||||||
CommentCount uint64
|
CommentCount uint64
|
||||||
|
@ -48,7 +48,6 @@ func (cr *CommentRepo) isCircularReference(tx *gorm.DB, commentID, parentID uint
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 递归删除子评论的辅助函数
|
// 递归删除子评论的辅助函数
|
||||||
func (cr *CommentRepo) deleteChildren(tx *gorm.DB, parentID uint) error {
|
func (cr *CommentRepo) deleteChildren(tx *gorm.DB, parentID uint) error {
|
||||||
var children []*model.Comment
|
var children []*model.Comment
|
||||||
@ -99,7 +98,7 @@ func (cr *CommentRepo) CreateComment(comment *model.Comment) error {
|
|||||||
if err := tx.Create(comment).Error; err != nil {
|
if err := tx.Create(comment).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -173,7 +172,7 @@ func (cr *CommentRepo) GetComment(commentID string) (*model.Comment, error) {
|
|||||||
return &comment, nil
|
return &comment, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *CommentRepo) ListComments(currentUserID uint, targetID uint, targetType string, page, size uint64, orderBy string, desc bool) ([]model.Comment, error) {
|
func (cr *CommentRepo) ListComments(currentUserID uint, targetID uint, targetType string, page, size uint64, orderBy string, desc bool, depth int) ([]model.Comment, error) {
|
||||||
if !slices.Contains(constant.OrderByEnumComment, orderBy) {
|
if !slices.Contains(constant.OrderByEnumComment, orderBy) {
|
||||||
return nil, errs.New(http.StatusBadRequest, "invalid order_by parameter", nil)
|
return nil, errs.New(http.StatusBadRequest, "invalid order_by parameter", nil)
|
||||||
}
|
}
|
||||||
@ -196,9 +195,13 @@ func (cr *CommentRepo) ListComments(currentUserID uint, targetID uint, targetTyp
|
|||||||
query = query.Where("is_private = ?", false)
|
query = query.Where("is_private = ?", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
query = query.Where("target_id = ? AND target_type = ?", targetID, targetType)
|
if depth > 0 {
|
||||||
|
query = query.Where("target_id = ? AND target_type = ? AND depth = ?", targetID, targetType, depth)
|
||||||
|
} else {
|
||||||
|
query = query.Where("target_id = ? AND target_type = ?", targetID, targetType)
|
||||||
|
}
|
||||||
items, _, err := PaginateQuery[model.Comment](query, page, size, orderBy, desc)
|
items, _, err := PaginateQuery[model.Comment](query, page, size, orderBy, desc)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -8,14 +8,14 @@ import (
|
|||||||
|
|
||||||
func registerCommentRoutes(group *route.RouterGroup) {
|
func registerCommentRoutes(group *route.RouterGroup) {
|
||||||
commentController := v1.NewCommentController()
|
commentController := v1.NewCommentController()
|
||||||
commentGroup := group.Group("/comments").Use(middleware.UseAuth(true))
|
commentGroup := group.Group("/comment").Use(middleware.UseAuth(true))
|
||||||
commentGroupWithoutAuth := group.Group("/comments").Use(middleware.UseAuth(false))
|
commentGroupWithoutAuth := group.Group("/comment").Use(middleware.UseAuth(false))
|
||||||
{
|
{
|
||||||
commentGroup.POST("/c", commentController.CreateComment)
|
commentGroup.POST("/c", commentController.CreateComment)
|
||||||
commentGroup.PUT("/c/:id", commentController.UpdateComment)
|
commentGroup.PUT("/c/:id", commentController.UpdateComment)
|
||||||
commentGroup.DELETE("/c/:id", commentController.DeleteComment)
|
commentGroup.DELETE("/c/:id", commentController.DeleteComment)
|
||||||
commentGroup.PUT("/c/:id/react", commentController.ReactComment) // 暂时先不写
|
commentGroup.PUT("/c/:id/react", commentController.ReactComment) // 暂时先不写
|
||||||
commentGroupWithoutAuth.GET("/c/:id", commentController.GetComment)
|
commentGroupWithoutAuth.GET("/c/:id", commentController.GetComment)
|
||||||
commentGroupWithoutAuth.GET("/c/list", commentController.GetCommentList)
|
commentGroupWithoutAuth.GET("/list", commentController.GetCommentList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/snowykami/neo-blog/internal/ctxutils"
|
"github.com/snowykami/neo-blog/internal/ctxutils"
|
||||||
@ -23,6 +24,13 @@ func (cs *CommentService) CreateComment(ctx context.Context, req *dto.CreateComm
|
|||||||
return errs.ErrUnauthorized
|
return errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ok, err := cs.checkTargetExists(req.TargetID, req.TargetType); !ok {
|
||||||
|
if err != nil {
|
||||||
|
return errs.New(errs.ErrBadRequest.Code, "target not found", err)
|
||||||
|
}
|
||||||
|
return errs.ErrBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
comment := &model.Comment{
|
comment := &model.Comment{
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
ReplyID: req.ReplyID,
|
ReplyID: req.ReplyID,
|
||||||
@ -57,6 +65,7 @@ func (cs *CommentService) UpdateComment(ctx context.Context, req *dto.UpdateComm
|
|||||||
}
|
}
|
||||||
|
|
||||||
comment.Content = req.Content
|
comment.Content = req.Content
|
||||||
|
comment.IsPrivate = req.IsPrivate
|
||||||
|
|
||||||
err = repo.Comment.UpdateComment(comment)
|
err = repo.Comment.UpdateComment(comment)
|
||||||
|
|
||||||
@ -117,7 +126,7 @@ func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dt
|
|||||||
func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommentListReq) ([]dto.CommentDto, error) {
|
func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommentListReq) ([]dto.CommentDto, error) {
|
||||||
currentUser, _ := ctxutils.GetCurrentUser(ctx)
|
currentUser, _ := ctxutils.GetCurrentUser(ctx)
|
||||||
|
|
||||||
comments, err := repo.Comment.ListComments(currentUser.ID, req.TargetID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc)
|
comments, err := repo.Comment.ListComments(currentUser.ID, req.TargetID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc, req.Depth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errs.New(errs.ErrInternalServer.Code, "failed to list comments", err)
|
return nil, errs.New(errs.ErrInternalServer.Code, "failed to list comments", err)
|
||||||
}
|
}
|
||||||
@ -139,3 +148,15 @@ func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommen
|
|||||||
}
|
}
|
||||||
return commentDtos, nil
|
return commentDtos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cs *CommentService) checkTargetExists(targetID uint, targetType string) (bool, error) {
|
||||||
|
switch targetType {
|
||||||
|
case constant.TargetTypePost:
|
||||||
|
if _, err := repo.Post.GetPostByID(strconv.Itoa(int(targetID))); err != nil {
|
||||||
|
return false, errs.New(errs.ErrNotFound.Code, "post not found", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false, errs.New(errs.ErrBadRequest.Code, "invalid target type", nil)
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
@ -352,12 +352,12 @@ func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) generate2Token(userID uint) (string, string, error) {
|
func (s *UserService) generate2Token(userID uint) (string, string, error) {
|
||||||
token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault)*int(time.Second)))
|
token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault))*time.Second)
|
||||||
tokenString, err := token.ToString()
|
tokenString, err := token.ToString()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", errs.ErrInternalServer
|
return "", "", errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault)*int(time.Second)))
|
refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault))*time.Second)
|
||||||
refreshTokenString, err := refreshToken.ToString()
|
refreshTokenString, err := refreshToken.ToString()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", errs.ErrInternalServer
|
return "", "", errs.ErrInternalServer
|
||||||
|
41
web/src/api/comment.ts
Normal file
41
web/src/api/comment.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import axiosClient from './client'
|
||||||
|
import { CreateCommentRequest, UpdateCommentRequest, Comment } from '@/models/comment'
|
||||||
|
import type { PaginationParams } from '@/models/common'
|
||||||
|
import { OrderBy } from '@/models/common'
|
||||||
|
import type { BaseResponse } from '@/models/resp'
|
||||||
|
|
||||||
|
|
||||||
|
export async function createComment(
|
||||||
|
data: CreateCommentRequest,
|
||||||
|
): Promise<BaseResponse<Comment>> {
|
||||||
|
const res = await axiosClient.post<BaseResponse<Comment>>('/comment/c', data)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateComment(
|
||||||
|
data: UpdateCommentRequest,
|
||||||
|
): Promise<BaseResponse<Comment>> {
|
||||||
|
const res = await axiosClient.put<BaseResponse<Comment>>(`/comment/c/${data.id}`, data)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteComment(id: number): Promise<void> {
|
||||||
|
await axiosClient.delete(`/comment/c/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listComments(
|
||||||
|
targetType: 'post' | 'page',
|
||||||
|
targetId: number,
|
||||||
|
pagination: PaginationParams = { orderBy: OrderBy.CreatedAt, desc: false, page: 1, size: 10 },
|
||||||
|
depth: number = 1
|
||||||
|
): Promise<BaseResponse<Comment[]>> {
|
||||||
|
const res = await axiosClient.get<BaseResponse<Comment[]>>(`/comment/list`, {
|
||||||
|
params: {
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
...pagination,
|
||||||
|
depth
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
@ -1,14 +1,8 @@
|
|||||||
import type { Post } from '@/models/post'
|
import type { Post } from '@/models/post'
|
||||||
import type { BaseResponse } from '@/models/resp'
|
import type { BaseResponse } from '@/models/resp'
|
||||||
import axiosClient from './client'
|
import axiosClient from './client'
|
||||||
|
import type { ListPostsParams } from '@/models/post'
|
||||||
|
|
||||||
interface ListPostsParams {
|
|
||||||
page?: number
|
|
||||||
size?: number
|
|
||||||
orderBy?: string
|
|
||||||
desc?: boolean
|
|
||||||
keywords?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPostById(id: string, token: string=""): Promise<Post | null> {
|
export async function getPostById(id: string, token: string=""): Promise<Post | null> {
|
||||||
try {
|
try {
|
||||||
|
@ -1,23 +1,8 @@
|
|||||||
import type { OidcConfig } from '@/models/oidc-config'
|
import type { OidcConfig } from '@/models/oidc-config'
|
||||||
import type { BaseResponse } from '@/models/resp'
|
import type { BaseResponse } from '@/models/resp'
|
||||||
import type { User } from '@/models/user'
|
import type { LoginRequest, RegisterRequest, User } from '@/models/user'
|
||||||
import axiosClient from './client'
|
import axiosClient from './client'
|
||||||
|
|
||||||
export interface LoginRequest {
|
|
||||||
username: string
|
|
||||||
password: string
|
|
||||||
rememberMe?: boolean // 可以轻松添加新字段
|
|
||||||
captcha?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegisterRequest {
|
|
||||||
username: string
|
|
||||||
password: string
|
|
||||||
nickname: string
|
|
||||||
email: string
|
|
||||||
verificationCode?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function userLogin(
|
export async function userLogin(
|
||||||
data: LoginRequest,
|
data: LoginRequest,
|
||||||
): Promise<BaseResponse<{ token: string, user: User }>> {
|
): Promise<BaseResponse<{ token: string, user: User }>> {
|
||||||
|
@ -4,7 +4,7 @@ import { Calendar, Clock, FileText, Flame, Heart, MessageCircle, PenLine, Square
|
|||||||
import { RenderMarkdown } from "@/components/common/markdown";
|
import { RenderMarkdown } from "@/components/common/markdown";
|
||||||
import { isMobileByUA } from "@/utils/server/device";
|
import { isMobileByUA } from "@/utils/server/device";
|
||||||
import { calculateReadingTime } from "@/utils/common/post";
|
import { calculateReadingTime } from "@/utils/common/post";
|
||||||
import Link from "next/link";
|
import CommentSection from "@/components/comment";
|
||||||
|
|
||||||
function PostMeta({ post }: { post: Post }) {
|
function PostMeta({ post }: { post: Post }) {
|
||||||
return (
|
return (
|
||||||
@ -75,9 +75,6 @@ async function PostHeader({ post }: { post: Post }) {
|
|||||||
{post.isOriginal && (
|
{post.isOriginal && (
|
||||||
<span className="bg-green-100 text-green-600 text-xs px-2 py-1 rounded">
|
<span className="bg-green-100 text-green-600 text-xs px-2 py-1 rounded">
|
||||||
原创
|
原创
|
||||||
<Link href="./aa" className="text-green-600 hover:underline">
|
|
||||||
查看
|
|
||||||
</Link>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(post.labels || []).map(label => (
|
{(post.labels || []).map(label => (
|
||||||
@ -141,6 +138,7 @@ async function BlogPost({ post }: { post: Post }) {
|
|||||||
{/* <ScrollToTop /> */}
|
{/* <ScrollToTop /> */}
|
||||||
<PostHeader post={post} />
|
<PostHeader post={post} />
|
||||||
<PostContent post={post} />
|
<PostContent post={post} />
|
||||||
|
<CommentSection targetType="post" targetId={post.id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
25
web/src/components/comment/comment-input.tsx
Normal file
25
web/src/components/comment/comment-input.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import Gravatar from "@/components/common/gravatar"
|
||||||
|
|
||||||
|
export function CommentInput() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex py-4">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div>
|
||||||
|
<Gravatar email="snowykami@outlook.com" className="w-10 h-10 rounded-full" />
|
||||||
|
</div>
|
||||||
|
{/* Input Area */}
|
||||||
|
<div className="flex-1 pl-2">
|
||||||
|
<Textarea placeholder="写下你的评论..." className="w-full p-2 border border-gray-300 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button className="px-2 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors">
|
||||||
|
提交
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
37
web/src/components/comment/comment-item.tsx
Normal file
37
web/src/components/comment/comment-item.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Comment } from "@/models/comment";
|
||||||
|
import { CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {useState, useEffect} from "react";
|
||||||
|
|
||||||
|
export function CommentItem(comment: Comment){
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-800 shadow-sm rounded-lg p-4 mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg font-semibold text-slate-800 dark:text-slate-100">
|
||||||
|
{comment.user.nickname}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">{comment.content}</p>
|
||||||
|
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{new Date(comment.updatedAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReplyItem({ reply }: { reply: Comment }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 dark:bg-slate-700 shadow-sm rounded-lg p-4 mb-2 ml-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||||
|
{reply.user.nickname}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<p className="text-xs text-slate-600 dark:text-slate-400">{reply.content}</p>
|
||||||
|
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{new Date(reply.updatedAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
41
web/src/components/comment/index.tsx
Normal file
41
web/src/components/comment/index.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Comment } from "@/models/comment";
|
||||||
|
import { CommentInput } from "@/components/comment/comment-input";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { listComments } from "@/api/comment";
|
||||||
|
import { OrderBy } from "@/models/common";
|
||||||
|
|
||||||
|
interface CommentAreaProps {
|
||||||
|
targetType: 'post' | 'page';
|
||||||
|
targetId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommentSection(props: CommentAreaProps) {
|
||||||
|
const { targetType, targetId } = props;
|
||||||
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [newComment, setNewComment] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listComments({ page: 1, size: 10, orderBy: OrderBy.CreatedAt, desc: true }, 1)
|
||||||
|
.then(response => {
|
||||||
|
setComments(response.data);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
setError("加载评论失败,请稍后再试。");
|
||||||
|
console.error("Error loading comments:", err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [targetType, targetId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>评论区</h2>
|
||||||
|
<CommentInput />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -2,7 +2,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
// 更安全的类型声明
|
|
||||||
function extractText(node: React.ReactNode): string {
|
function extractText(node: React.ReactNode): string {
|
||||||
if (typeof node === "string") return node;
|
if (typeof node === "string") return node;
|
||||||
if (Array.isArray(node)) return node.map(extractText).join("");
|
if (Array.isArray(node)) return node.map(extractText).join("");
|
||||||
@ -71,7 +70,13 @@ export default function CodeBlock(props: React.ComponentPropsWithoutRef<"pre">)
|
|||||||
{language}
|
{language}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex space-x-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 flex space-x-2
|
||||||
|
opacity-100
|
||||||
|
group-hover:opacity-100
|
||||||
|
sm:opacity-0 sm:group-hover:opacity-100
|
||||||
|
transition-opacity"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="px-2 py-1 rounded text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600"
|
className="px-2 py-1 rounded text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600"
|
||||||
|
@ -3,6 +3,8 @@ import { MDXRemote, MDXRemoteProps } from "next-mdx-remote-client/rsc";
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import rehypeHighlight from "rehype-highlight";
|
import rehypeHighlight from "rehype-highlight";
|
||||||
import "highlight.js/styles/github.css"; // 你可以换成喜欢的主题
|
import "highlight.js/styles/github.css"; // 你可以换成喜欢的主题
|
||||||
|
import "highlight.js/styles/github-dark.css"; // 适用于暗黑模式
|
||||||
|
import "highlight.js/styles/github-dark-dimmed.css"; // 适用于暗黑模式
|
||||||
import CodeBlock from "@/components/common/markdown-codeblock";
|
import CodeBlock from "@/components/common/markdown-codeblock";
|
||||||
|
|
||||||
export const markdownComponents = {
|
export const markdownComponents = {
|
||||||
|
18
web/src/components/ui/textarea.tsx
Normal file
18
web/src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
28
web/src/models/comment.ts
Normal file
28
web/src/models/comment.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import type { User } from "./user"
|
||||||
|
|
||||||
|
export interface Comment{
|
||||||
|
id: number
|
||||||
|
targetType: string
|
||||||
|
targetId: number
|
||||||
|
content: string
|
||||||
|
replyId: number
|
||||||
|
depth: number
|
||||||
|
isPrivate: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCommentRequest {
|
||||||
|
targetType: string
|
||||||
|
targetId: number
|
||||||
|
content: string
|
||||||
|
replyId?: number // 可选字段,默认为 null
|
||||||
|
isPrivate?: boolean // 可选字段,默认为 false
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCommentRequest {
|
||||||
|
id: number
|
||||||
|
content: string
|
||||||
|
isPrivate?: boolean // 可选字段,默认为 false
|
||||||
|
}
|
15
web/src/models/common.ts
Normal file
15
web/src/models/common.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export interface PaginationParams {
|
||||||
|
orderBy: OrderBy
|
||||||
|
desc: boolean
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum OrderBy {
|
||||||
|
CreatedAt = 'created_at',
|
||||||
|
UpdatedAt = 'updated_at',
|
||||||
|
Heat = 'heat',
|
||||||
|
CommentCount = 'comment_count',
|
||||||
|
LikeCount = 'like_count',
|
||||||
|
ViewCount = 'view_count',
|
||||||
|
}
|
@ -18,4 +18,12 @@ export interface Post {
|
|||||||
heat: number;
|
heat: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListPostsParams {
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
orderBy?: string
|
||||||
|
desc?: boolean
|
||||||
|
keywords?: string
|
||||||
}
|
}
|
@ -7,4 +7,20 @@ export interface User {
|
|||||||
gender: string;
|
gender: string;
|
||||||
role: string;
|
role: string;
|
||||||
language: string;
|
language: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
rememberMe?: boolean // 可以轻松添加新字段
|
||||||
|
captcha?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
nickname: string
|
||||||
|
email: string
|
||||||
|
verificationCode?: string
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user