mirror of
https://github.com/snowykami/neo-blog.git
synced 2025-09-23 17:46:22 +00:00
feat: implement advanced comment features including reply and like functionality
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 13s
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 13s
- Added support for nested comments with reply functionality. - Implemented like/unlike feature for comments and posts. - Enhanced comment DTO to include reply count, like count, and like status. - Updated comment and like services to handle new functionalities. - Created new API endpoints for toggling likes and listing comments. - Improved UI components for comments to support replies and likes with animations. - Added localization for new comment-related messages. - Introduced a TODO list for future enhancements in the comment module.
This commit is contained in:
13
README.md
13
README.md
@ -9,7 +9,7 @@
|
|||||||
- [ ] 支持文章置顶
|
- [ ] 支持文章置顶
|
||||||
- [x] OIDC认证和注册
|
- [x] OIDC认证和注册
|
||||||
- [x] 支持多用户
|
- [x] 支持多用户
|
||||||
- [ ] 高级评论功能(后端已实装)
|
- [x] 高级评论功能(后端已实装)
|
||||||
- [ ] 支持多语言
|
- [ ] 支持多语言
|
||||||
- [x] 移动端适配
|
- [x] 移动端适配
|
||||||
- [ ] 后台管理
|
- [ ] 后台管理
|
||||||
@ -24,7 +24,7 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
container_name: neo-blog-frontend
|
container_name: neo-blog-frontend
|
||||||
environment:
|
environment:
|
||||||
- BACKEND_URL=http://neo-blog-backend:8888 # 此处请保证和后端服务的名称一致
|
- BACKEND_URL=http://neo-blog-backend:8888 # 此处的后端地址用于前端服务器访问后端服务
|
||||||
image: snowykami/neo-blog-frontend:latest
|
image: snowykami/neo-blog-frontend:latest
|
||||||
networks:
|
networks:
|
||||||
- internal-network
|
- internal-network
|
||||||
@ -38,7 +38,7 @@ services:
|
|||||||
container_name: neo-blog-backend
|
container_name: neo-blog-backend
|
||||||
image: snowykami/neo-blog-backend:latest
|
image: snowykami/neo-blog-backend:latest
|
||||||
environment:
|
environment:
|
||||||
- BASE_URL=https://neo-blog-dev.sfkm.me # 此处是外部用户访问端点,也许你使用了nginx等反向代理
|
- BASE_URL=https://neo-blog-dev.sfkm.me # 此处是外部用户访问端点,用于在某些情况下后端可以生成正确的URL
|
||||||
networks:
|
networks:
|
||||||
- internal-network
|
- internal-network
|
||||||
restart: always
|
restart: always
|
||||||
@ -72,7 +72,7 @@ helm repo update
|
|||||||
helm install neo-blog git.liteyuki.org/neo-blog
|
helm install neo-blog git.liteyuki.org/neo-blog
|
||||||
```
|
```
|
||||||
|
|
||||||
### 使用源码构建部署(除开发场景外不推荐)
|
### 使用源码构建部署(不推荐)
|
||||||
|
|
||||||
需要准备:go、nodejs、pnpm
|
需要准备:go、nodejs、pnpm
|
||||||
|
|
||||||
@ -132,5 +132,10 @@ pnpm install
|
|||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 联合调试
|
||||||
|
|
||||||
|
默认情况下,本机启动后端和前端服务器无须额外配置即可互联,若后端在不同的主机上,需要在.env.development(自己创建)中配置`BACKEND_URL`变量
|
||||||
|
|
||||||
## 环境变量配置
|
## 环境变量配置
|
||||||
|
|
||||||
后端所有环境变量及其示例在[`.env.example`](./.env.example)文件中
|
后端所有环境变量及其示例在[`.env.example`](./.env.example)文件中
|
12
TODO.md
Normal file
12
TODO.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# TODO List
|
||||||
|
|
||||||
|
## 主页模块
|
||||||
|
|
||||||
|
- [ ]主页文章列表
|
||||||
|
- [ ]主页侧边栏卡片列表
|
||||||
|
|
||||||
|
## 评论模块
|
||||||
|
|
||||||
|
- [ ]评论列表
|
||||||
|
- [ ]评论输入框
|
||||||
|
- [ ]评论回复功能
|
@ -107,6 +107,16 @@ func (cc *CommentController) GetCommentList(ctx context.Context, c *app.RequestC
|
|||||||
resps.BadRequest(c, "无效的 target_id")
|
resps.BadRequest(c, "无效的 target_id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
commentIDStr := c.Query("comment_id")
|
||||||
|
var commentID uint
|
||||||
|
if commentIDStr != "" {
|
||||||
|
commentIDInt, err := strconv.Atoi(commentIDStr)
|
||||||
|
if err != nil {
|
||||||
|
resps.BadRequest(c, "无效的 comment_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
commentID = uint(commentIDInt)
|
||||||
|
}
|
||||||
req := dto.GetCommentListReq{
|
req := dto.GetCommentListReq{
|
||||||
Desc: pagination.Desc,
|
Desc: pagination.Desc,
|
||||||
OrderBy: pagination.OrderBy,
|
OrderBy: pagination.OrderBy,
|
||||||
@ -115,6 +125,7 @@ func (cc *CommentController) GetCommentList(ctx context.Context, c *app.RequestC
|
|||||||
Depth: depthInt,
|
Depth: depthInt,
|
||||||
TargetID: uint(targetID),
|
TargetID: uint(targetID),
|
||||||
TargetType: c.Query("target_type"),
|
TargetType: c.Query("target_type"),
|
||||||
|
CommentID: commentID,
|
||||||
}
|
}
|
||||||
resp, err := cc.service.GetCommentList(ctx, &req)
|
resp, err := cc.service.GetCommentList(ctx, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -2,15 +2,39 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/cloudwego/hertz/pkg/app"
|
"github.com/cloudwego/hertz/pkg/app"
|
||||||
|
"github.com/cloudwego/hertz/pkg/common/utils"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/snowykami/neo-blog/internal/dto"
|
||||||
|
"github.com/snowykami/neo-blog/internal/service"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/errs"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/resps"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LikeController struct{}
|
type LikeController struct {
|
||||||
|
service *service.LikeService
|
||||||
|
}
|
||||||
|
|
||||||
func NewLikeController() *LikeController {
|
func NewLikeController() *LikeController {
|
||||||
return &LikeController{}
|
return &LikeController{
|
||||||
|
service: service.NewLikeService(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lc *LikeController) ToggleLike(ctx context.Context, c *app.RequestContext) {
|
func (lc *LikeController) ToggleLike(ctx context.Context, c *app.RequestContext) {
|
||||||
// Implementation for creating a like
|
var toggleLikeReq dto.ToggleLikeReq
|
||||||
|
if err := c.BindAndValidate(&toggleLikeReq); err != nil {
|
||||||
|
logrus.Error(err)
|
||||||
|
resps.BadRequest(c, resps.ErrParamInvalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
liked, err := lc.service.ToggleLike(ctx, toggleLikeReq.TargetID, toggleLikeReq.TargetType)
|
||||||
|
if err != nil {
|
||||||
|
serviceErr := errs.AsServiceError(err)
|
||||||
|
logrus.Error(serviceErr.Error())
|
||||||
|
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resps.Ok(c, resps.Success, utils.H{"status": liked})
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,8 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/cloudwego/hertz/pkg/app"
|
"github.com/cloudwego/hertz/pkg/app"
|
||||||
"github.com/cloudwego/hertz/pkg/common/utils"
|
"github.com/cloudwego/hertz/pkg/common/utils"
|
||||||
"github.com/snowykami/neo-blog/internal/ctxutils"
|
"github.com/snowykami/neo-blog/internal/ctxutils"
|
||||||
@ -10,7 +12,6 @@ import (
|
|||||||
"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 UserController struct {
|
type UserController struct {
|
||||||
|
@ -10,6 +10,9 @@ type CommentDto struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
User UserDto `json:"user"` // 评论的
|
User UserDto `json:"user"` // 评论的
|
||||||
|
ReplyCount int64 `json:"reply_count"` // 回复数量
|
||||||
|
LikeCount uint64 `json:"like_count"` // 点赞数量
|
||||||
|
IsLiked bool `json:"is_liked"` // 当前用户是否点赞
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateCommentReq struct {
|
type CreateCommentReq struct {
|
||||||
@ -29,6 +32,7 @@ type UpdateCommentReq struct {
|
|||||||
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"`
|
||||||
|
CommentID uint `json:"comment_id"` // 获取某条评论的所有子评论
|
||||||
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"`
|
||||||
|
@ -1 +1,6 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
|
type ToggleLikeReq struct {
|
||||||
|
TargetID uint `json:"target_id" binding:"required"`
|
||||||
|
TargetType string `json:"target_type" binding:"required"` // 目标类型,如 "post", "comment"
|
||||||
|
}
|
||||||
|
@ -2,6 +2,7 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/snowykami/neo-blog/pkg/constant"
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@ -32,10 +33,10 @@ func (l *Like) AfterDelete(tx *gorm.DB) (err error) {
|
|||||||
switch l.TargetType {
|
switch l.TargetType {
|
||||||
case constant.TargetTypePost:
|
case constant.TargetTypePost:
|
||||||
return tx.Model(&Post{}).Where("id = ?", l.TargetID).
|
return tx.Model(&Post{}).Where("id = ?", l.TargetID).
|
||||||
UpdateColumn("like_count", gorm.Expr("GREATEST(like_count - ?, 0)", 1)).Error
|
UpdateColumn("like_count", gorm.Expr("like_count - ?", 1)).Error
|
||||||
case constant.TargetTypeComment:
|
case constant.TargetTypeComment:
|
||||||
return tx.Model(&Comment{}).Where("id = ?", l.TargetID).
|
return tx.Model(&Comment{}).Where("id = ?", l.TargetID).
|
||||||
UpdateColumn("like_count", gorm.Expr("GREATEST(like_count - ?, 0)", 1)).Error
|
UpdateColumn("like_count", gorm.Expr("like_count - ?", 1)).Error
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("不支持的目标类型: %s", l.TargetType)
|
return fmt.Errorf("不支持的目标类型: %s", l.TargetType)
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/snowykami/neo-blog/internal/model"
|
"github.com/snowykami/neo-blog/internal/model"
|
||||||
"github.com/snowykami/neo-blog/pkg/constant"
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
"github.com/snowykami/neo-blog/pkg/errs"
|
"github.com/snowykami/neo-blog/pkg/errs"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/utils"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -92,16 +93,15 @@ func (cr *CommentRepo) CreateComment(comment *model.Comment) error {
|
|||||||
}
|
}
|
||||||
depth = parentComment.Depth + 1
|
depth = parentComment.Depth + 1
|
||||||
}
|
}
|
||||||
|
if depth > utils.Env.GetAsInt(constant.EnvKeyMaxReplyDepth, constant.MaxReplyDepthDefault) {
|
||||||
|
return errs.New(http.StatusBadRequest, "exceeded maximum reply depth", nil)
|
||||||
|
}
|
||||||
comment.Depth = depth
|
comment.Depth = depth
|
||||||
|
|
||||||
if err := tx.Create(comment).Error; err != nil {
|
if err := tx.Create(comment).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,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, depth int) ([]model.Comment, error) {
|
func (cr *CommentRepo) ListComments(currentUserID, targetID, commentID 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)
|
||||||
}
|
}
|
||||||
@ -189,13 +189,17 @@ func (cr *CommentRepo) ListComments(currentUserID uint, targetID uint, targetTyp
|
|||||||
|
|
||||||
query := GetDB().Model(&model.Comment{}).Preload("User")
|
query := GetDB().Model(&model.Comment{}).Preload("User")
|
||||||
|
|
||||||
|
if commentID > 0 {
|
||||||
|
query = query.Where("reply_id = ?", commentID)
|
||||||
|
}
|
||||||
|
|
||||||
if currentUserID > 0 {
|
if currentUserID > 0 {
|
||||||
query = query.Where("(is_private = ? OR (is_private = ? AND (user_id = ? OR user_id = ?)))", false, true, currentUserID, masterID)
|
query = query.Where("(is_private = ? OR (is_private = ? AND (user_id = ? OR user_id = ?)))", false, true, currentUserID, masterID)
|
||||||
} else {
|
} else {
|
||||||
query = query.Where("is_private = ?", false)
|
query = query.Where("is_private = ?", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if depth > 0 {
|
if depth >= 0 {
|
||||||
query = query.Where("target_id = ? AND target_type = ? AND depth = ?", targetID, targetType, depth)
|
query = query.Where("target_id = ? AND target_type = ? AND depth = ?", targetID, targetType, depth)
|
||||||
} else {
|
} else {
|
||||||
query = query.Where("target_id = ? AND target_type = ?", targetID, targetType)
|
query = query.Where("target_id = ? AND target_type = ?", targetID, targetType)
|
||||||
@ -208,3 +212,11 @@ func (cr *CommentRepo) ListComments(currentUserID uint, targetID uint, targetTyp
|
|||||||
|
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (cr *CommentRepo) CountReplyComments(commentID uint) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
if err := GetDB().Model(&model.Comment{}).Where("reply_id = ?", commentID).Count(&count).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
@ -2,6 +2,8 @@ package repo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/snowykami/neo-blog/internal/model"
|
"github.com/snowykami/neo-blog/internal/model"
|
||||||
"github.com/snowykami/neo-blog/pkg/constant"
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@ -11,24 +13,28 @@ type likeRepo struct{}
|
|||||||
|
|
||||||
var Like = &likeRepo{}
|
var Like = &likeRepo{}
|
||||||
|
|
||||||
func (l *likeRepo) ToggleLike(userID, targetID uint, targetType string) error {
|
func (l *likeRepo) ToggleLike(userID, targetID uint, targetType string) (bool, error) {
|
||||||
err := l.checkTargetType(targetType)
|
err := l.checkTargetType(targetType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
return GetDB().Transaction(func(tx *gorm.DB) error {
|
var finalStatus bool
|
||||||
// 判断是否已点赞
|
err = GetDB().Transaction(func(tx *gorm.DB) error {
|
||||||
isLiked, err := l.IsLiked(userID, targetID, targetType)
|
isLiked, err := l.IsLiked(userID, targetID, targetType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logrus.Error(err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if isLiked {
|
if isLiked {
|
||||||
// 已点赞,执行取消点赞逻辑
|
if err :=
|
||||||
if err := tx.Where("target_type = ? AND target_id = ? AND user_id = ?", targetType, targetID, userID).Delete(&model.Like{}).Error; err != nil {
|
tx.Where("target_type = ? AND target_id = ? AND user_id = ?", targetType, targetID, userID).
|
||||||
|
Delete(&model.Like{TargetType: targetType, TargetID: targetID, UserID: userID}).
|
||||||
|
Error; err != nil {
|
||||||
|
logrus.Error(err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
finalStatus = false
|
||||||
} else {
|
} else {
|
||||||
// 未点赞,执行新增点赞逻辑
|
|
||||||
like := &model.Like{
|
like := &model.Like{
|
||||||
TargetType: targetType,
|
TargetType: targetType,
|
||||||
TargetID: targetID,
|
TargetID: targetID,
|
||||||
@ -37,6 +43,7 @@ func (l *likeRepo) ToggleLike(userID, targetID uint, targetType string) error {
|
|||||||
if err := tx.Create(like).Error; err != nil {
|
if err := tx.Create(like).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
finalStatus = true
|
||||||
}
|
}
|
||||||
// 重新计算点赞数量
|
// 重新计算点赞数量
|
||||||
var count int64
|
var count int64
|
||||||
@ -58,6 +65,7 @@ func (l *likeRepo) ToggleLike(userID, targetID uint, targetType string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
return finalStatus, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsLiked 检查是否点赞
|
// IsLiked 检查是否点赞
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
package repo
|
package repo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/snowykami/neo-blog/internal/model"
|
"github.com/snowykami/neo-blog/internal/model"
|
||||||
"github.com/snowykami/neo-blog/pkg/constant"
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
"github.com/snowykami/neo-blog/pkg/errs"
|
"github.com/snowykami/neo-blog/pkg/errs"
|
||||||
"net/http"
|
|
||||||
"slices"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type postRepo struct{}
|
type postRepo struct{}
|
||||||
@ -73,13 +74,13 @@ func (p *postRepo) ListPosts(currentUserID uint, keywords []string, page, size u
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *postRepo) ToggleLikePost(postID uint, userID uint) error {
|
func (p *postRepo) ToggleLikePost(postID uint, userID uint) (bool, error) {
|
||||||
if postID == 0 || userID == 0 {
|
if postID == 0 || userID == 0 {
|
||||||
return errs.New(http.StatusBadRequest, "invalid post ID or user ID", nil)
|
return false, errs.New(http.StatusBadRequest, "invalid post ID or user ID", nil)
|
||||||
}
|
}
|
||||||
err := Like.ToggleLike(userID, postID, constant.TargetTypePost)
|
liked, err := Like.ToggleLike(userID, postID, constant.TargetTypePost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return false, err
|
||||||
}
|
}
|
||||||
return nil
|
return liked, nil
|
||||||
}
|
}
|
||||||
|
@ -130,7 +130,7 @@ func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommen
|
|||||||
currentUserID = currentUser.ID
|
currentUserID = currentUser.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
comments, err := repo.Comment.ListComments(currentUserID, req.TargetID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc, req.Depth)
|
comments, err := repo.Comment.ListComments(currentUserID, req.TargetID, req.CommentID, 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)
|
||||||
}
|
}
|
||||||
@ -138,15 +138,25 @@ func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommen
|
|||||||
commentDtos := make([]dto.CommentDto, 0)
|
commentDtos := make([]dto.CommentDto, 0)
|
||||||
|
|
||||||
for _, comment := range comments {
|
for _, comment := range comments {
|
||||||
|
replyCount, _ := repo.Comment.CountReplyComments(comment.ID)
|
||||||
|
isLiked := false
|
||||||
|
if currentUserID != 0 {
|
||||||
|
isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment)
|
||||||
|
}
|
||||||
|
|
||||||
commentDto := dto.CommentDto{
|
commentDto := dto.CommentDto{
|
||||||
ID: comment.ID,
|
ID: comment.ID,
|
||||||
Content: comment.Content,
|
Content: comment.Content,
|
||||||
TargetID: comment.TargetID,
|
TargetID: comment.TargetID,
|
||||||
TargetType: comment.TargetType,
|
TargetType: comment.TargetType,
|
||||||
|
ReplyID: comment.ReplyID,
|
||||||
CreatedAt: comment.CreatedAt.String(),
|
CreatedAt: comment.CreatedAt.String(),
|
||||||
UpdatedAt: comment.UpdatedAt.String(),
|
UpdatedAt: comment.UpdatedAt.String(),
|
||||||
Depth: comment.Depth,
|
Depth: comment.Depth,
|
||||||
User: comment.User.ToDto(),
|
User: comment.User.ToDto(),
|
||||||
|
ReplyCount: replyCount,
|
||||||
|
LikeCount: comment.LikeCount,
|
||||||
|
IsLiked: isLiked,
|
||||||
}
|
}
|
||||||
commentDtos = append(commentDtos, commentDto)
|
commentDtos = append(commentDtos, commentDto)
|
||||||
}
|
}
|
||||||
|
@ -1 +1,23 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/snowykami/neo-blog/internal/ctxutils"
|
||||||
|
"github.com/snowykami/neo-blog/internal/repo"
|
||||||
|
"github.com/snowykami/neo-blog/pkg/errs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LikeService struct{}
|
||||||
|
|
||||||
|
func NewLikeService() *LikeService {
|
||||||
|
return &LikeService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ls *LikeService) ToggleLike(ctx context.Context, targetID uint, targetType string) (bool, error) {
|
||||||
|
currentUser, ok := ctxutils.GetCurrentUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
return false, errs.ErrUnauthorized
|
||||||
|
}
|
||||||
|
return repo.Like.ToggleLike(currentUser.ID, targetID, targetType)
|
||||||
|
}
|
||||||
|
@ -2,12 +2,13 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"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"
|
||||||
"strconv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type PostService struct{}
|
type PostService struct{}
|
||||||
@ -126,27 +127,28 @@ func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) ([]*d
|
|||||||
return postDtos, nil
|
return postDtos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PostService) ToggleLikePost(ctx context.Context, id string) 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 errs.ErrUnauthorized
|
return false, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return 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 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 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 errs.New(errs.ErrBadRequest.Code, "invalid post ID", err)
|
return false, errs.New(errs.ErrBadRequest.Code, "invalid post ID", err)
|
||||||
}
|
}
|
||||||
if err := repo.Post.ToggleLikePost(uint(idInt), currentUser.ID); err != nil {
|
liked, err := repo.Post.ToggleLikePost(uint(idInt), currentUser.ID)
|
||||||
return errs.ErrInternalServer
|
if err != nil {
|
||||||
|
return false, errs.ErrInternalServer
|
||||||
}
|
}
|
||||||
return nil
|
return liked, nil
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,7 @@ const (
|
|||||||
EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥
|
EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥
|
||||||
EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐
|
EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐
|
||||||
EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期
|
EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期
|
||||||
|
EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度
|
||||||
EnvKeyTokenDurationDefault = 300
|
EnvKeyTokenDurationDefault = 300
|
||||||
EnvKeyRefreshTokenDurationDefault = 604800
|
EnvKeyRefreshTokenDurationDefault = 604800
|
||||||
EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期
|
EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期
|
||||||
@ -35,6 +36,7 @@ const (
|
|||||||
OrderByCommentCount = "comment_count" // 按评论数排序
|
OrderByCommentCount = "comment_count" // 按评论数排序
|
||||||
OrderByViewCount = "view_count" // 按浏览量排序
|
OrderByViewCount = "view_count" // 按浏览量排序
|
||||||
OrderByHeat = "heat"
|
OrderByHeat = "heat"
|
||||||
|
MaxReplyDepthDefault = 3 // 默认最大回复深度
|
||||||
HeatFactorViewWeight = 1 // 热度因子:浏览量权重
|
HeatFactorViewWeight = 1 // 热度因子:浏览量权重
|
||||||
HeatFactorLikeWeight = 5 // 热度因子:点赞权重
|
HeatFactorLikeWeight = 5 // 热度因子:点赞权重
|
||||||
HeatFactorCommentWeight = 10 // 热度因子:评论权重
|
HeatFactorCommentWeight = 10 // 热度因子:评论权重
|
||||||
|
@ -2,11 +2,12 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/snowykami/neo-blog/pkg/constant"
|
"github.com/snowykami/neo-blog/pkg/constant"
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -32,6 +32,7 @@ export interface ListCommentsParams {
|
|||||||
desc?: boolean
|
desc?: boolean
|
||||||
page?: number
|
page?: number
|
||||||
size?: number
|
size?: number
|
||||||
|
commentId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listComments(params: ListCommentsParams): Promise<BaseResponse<Comment[]>> {
|
export async function listComments(params: ListCommentsParams): Promise<BaseResponse<Comment[]>> {
|
||||||
@ -43,6 +44,7 @@ export async function listComments(params: ListCommentsParams): Promise<BaseResp
|
|||||||
desc = true,
|
desc = true,
|
||||||
page = 1,
|
page = 1,
|
||||||
size = 10,
|
size = 10,
|
||||||
|
commentId = 0,
|
||||||
} = params
|
} = params
|
||||||
const res = await axiosClient.get<BaseResponse<Comment[]>>(`/comment/list`, {
|
const res = await axiosClient.get<BaseResponse<Comment[]>>(`/comment/list`, {
|
||||||
params: {
|
params: {
|
||||||
@ -52,7 +54,8 @@ export async function listComments(params: ListCommentsParams): Promise<BaseResp
|
|||||||
orderBy,
|
orderBy,
|
||||||
desc,
|
desc,
|
||||||
page,
|
page,
|
||||||
size
|
size,
|
||||||
|
commentId,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return res.data
|
return res.data
|
||||||
|
11
web/src/api/like.ts
Normal file
11
web/src/api/like.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import axiosClient from './client'
|
||||||
|
import type { BaseResponse } from '@/models/resp'
|
||||||
|
import { TargetType } from '@/models/types'
|
||||||
|
|
||||||
|
|
||||||
|
export async function toggleLike(
|
||||||
|
{ targetType, targetId }: { targetType: TargetType, targetId: number },
|
||||||
|
): Promise<BaseResponse<{ status: boolean }>> {
|
||||||
|
const res = await axiosClient.put<BaseResponse<{ status: boolean }>>('/like/toggle', { targetType, targetId })
|
||||||
|
return res.data
|
||||||
|
}
|
@ -112,6 +112,10 @@
|
|||||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--animation-duration: 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
47
web/src/components/comment/comment-animations.css
Normal file
47
web/src/components/comment/comment-animations.css
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
/* 评论区原生动画:淡入、上移 */
|
||||||
|
.fade-in {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeIn 0.5s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-up {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px);
|
||||||
|
animation: fadeInUp 0.5s cubic-bezier(.33,1,.68,1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}/* 评论区原生动画:淡入、上移 */
|
||||||
|
.fade-in {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeIn 0.5s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-up {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px);
|
||||||
|
animation: fadeInUp 0.5s cubic-bezier(.33,1,.68,1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
@ -13,9 +13,13 @@ import { TargetType } from "@/models/types";
|
|||||||
import { useToLogin } from "@/hooks/use-to-login";
|
import { useToLogin } from "@/hooks/use-to-login";
|
||||||
import NeedLogin from "../common/need-login";
|
import NeedLogin from "../common/need-login";
|
||||||
|
|
||||||
|
|
||||||
|
import "./comment-animations.css";
|
||||||
|
|
||||||
export function CommentInput(
|
export function CommentInput(
|
||||||
{ targetId, targetType, onCommentSubmitted }: { targetId: number, targetType: TargetType, onCommentSubmitted: () => void }
|
{ targetId, targetType, replyId, onCommentSubmitted }: { targetId: number, targetType: TargetType, replyId: number | null, onCommentSubmitted: () => void }
|
||||||
) {
|
) {
|
||||||
|
|
||||||
const t = useTranslations('Comment')
|
const t = useTranslations('Comment')
|
||||||
const toLogin = useToLogin()
|
const toLogin = useToLogin()
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
@ -41,6 +45,8 @@ export function CommentInput(
|
|||||||
targetType: targetType,
|
targetType: targetType,
|
||||||
targetId: targetId,
|
targetId: targetId,
|
||||||
content: commentContent,
|
content: commentContent,
|
||||||
|
replyId: replyId,
|
||||||
|
isPrivate: false,
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
setCommentContent("");
|
setCommentContent("");
|
||||||
toast.success(t("comment_success"));
|
toast.success(t("comment_success"));
|
||||||
@ -52,25 +58,25 @@ export function CommentInput(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="fade-in-up">
|
||||||
<div className="flex py-4">
|
<div className="flex py-4 fade-in">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div onClick={user ? undefined : toLogin} className="flex-shrink-0 w-10 h-10">
|
<div onClick={user ? undefined : toLogin} className="flex-shrink-0 w-10 h-10 fade-in">
|
||||||
{user && getGravatarByUser(user)}
|
{user && getGravatarByUser(user)}
|
||||||
{!user && <CircleUser className="w-full h-full" />}
|
{!user && <CircleUser className="w-full h-full fade-in" />}
|
||||||
</div>
|
</div>
|
||||||
{/* Input Area */}
|
{/* Input Area */}
|
||||||
<div className="flex-1 pl-2">
|
<div className="flex-1 pl-2 fade-in-up">
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={t("placeholder")}
|
placeholder={t("placeholder")}
|
||||||
className="w-full p-2 border border-gray-300 rounded-md"
|
className="w-full p-2 border border-gray-300 rounded-md fade-in-up"
|
||||||
value={commentContent}
|
value={commentContent}
|
||||||
onChange={(e) => setCommentContent(e.target.value)}
|
onChange={(e) => setCommentContent(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end fade-in-up">
|
||||||
<button onClick={handleCommentSubmit} className="px-2 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors">
|
<button onClick={handleCommentSubmit} className="px-2 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors fade-in-up">
|
||||||
{t("submit")}
|
{t("submit")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,42 +1,155 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { Comment } from "@/models/comment";
|
import type { Comment } from "@/models/comment";
|
||||||
import { CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { getGravatarByUser } from "@/components/common/gravatar";
|
import { getGravatarByUser } from "@/components/common/gravatar";
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { get } from "http";
|
import { Reply, Trash } from "lucide-react";
|
||||||
|
import { toggleLike } from "@/api/like";
|
||||||
|
import { TargetType } from "@/models/types";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { CommentInput } from "./comment-input";
|
||||||
|
import { deleteComment, listComments } from "@/api/comment";
|
||||||
|
import { OrderBy } from "@/models/common";
|
||||||
|
import { getLoginUser } from "@/api/user";
|
||||||
|
import type { User } from "@/models/user";
|
||||||
|
import Link from "next/link";
|
||||||
|
import "./comment-animations.css";
|
||||||
|
|
||||||
|
export function CommentItem({comment, parentComment}:{comment: Comment, parentComment: Comment | null}) {
|
||||||
|
const t = useTranslations("Comment")
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [liked, setLiked] = useState(comment.isLiked);
|
||||||
|
const [likeCount, setLikeCount] = useState(comment.likeCount);
|
||||||
|
const [replyCount, setReplyCount] = useState(comment.replyCount);
|
||||||
|
const [showReplyInput, setShowReplyInput] = useState(false);
|
||||||
|
const [showReplies, setShowReplies] = useState(false);
|
||||||
|
// 二次确认删除
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getLoginUser()
|
||||||
|
.then(response => {
|
||||||
|
setUser(response.data);
|
||||||
|
})
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleLike = () => {
|
||||||
|
toggleLike({ targetType: TargetType.Comment, targetId: comment.id })
|
||||||
|
.then(res => {
|
||||||
|
setLiked(res.data.status);
|
||||||
|
setLikeCount(res.data.status ? likeCount + 1 : likeCount - 1);
|
||||||
|
toast.success(res.data.status ? t("like_success") : t("unlike_success"));
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
toast.error(t("like_failed") + ": " + error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteComment = (id: number) => {
|
||||||
|
deleteComment(id)
|
||||||
|
.then(() => {
|
||||||
|
toast.success(t("delete_success"));
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
toast.error(t("delete_failed") + ": " + error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const onReplySubmitted = () => {
|
||||||
|
setReplyCount(replyCount + 1);
|
||||||
|
setShowReplyInput(false);
|
||||||
|
setShowReplies(true);
|
||||||
|
}
|
||||||
|
|
||||||
export function CommentItem(comment: Comment) {
|
|
||||||
const [replies, setReplies] = useState<Comment[]>([]);
|
|
||||||
const [loadingReplies, setLoadingReplies] = useState(false);
|
|
||||||
return (
|
return (
|
||||||
<div className="flex">
|
<div className="flex fade-in-up">
|
||||||
<div>
|
<div className="fade-in">
|
||||||
{getGravatarByUser(comment.user)}
|
{getGravatarByUser(comment.user)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 pl-2">
|
<div className="flex-1 pl-2 fade-in-up">
|
||||||
<div className="font-bold">{comment.user.nickname}</div>
|
<div className="font-bold text-base text-slate-800 dark:text-slate-100 fade-in-up">{comment.user.nickname}</div>
|
||||||
<p className="text-xs text-slate-600 dark:text-slate-400">{comment.content}</p>
|
<p className="text-lg text-slate-600 dark:text-slate-400 fade-in">
|
||||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
{parentComment && <>{t("reply")} <Link href={`/u/${parentComment.user.username}`} className="text-primary">{parentComment?.user.nickname}</Link>: </>}
|
||||||
{new Date(comment.updatedAt).toLocaleString()}
|
{comment.content}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-4 fade-in">
|
||||||
|
<span>{new Date(comment.updatedAt).toLocaleString()}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleLike}
|
||||||
|
className={`flex items-center justify-center px-2 py-1 h-5 text-xs rounded
|
||||||
|
${liked ? 'bg-primary text-primary-foreground dark:text-white' : 'bg-slate-400 hover:bg-slate-600'}
|
||||||
|
dark:hover:bg-slate-500 fade-in`}
|
||||||
|
>
|
||||||
|
👍 {likeCount}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowReplyInput(!showReplyInput)}
|
||||||
|
className="flex items-center justify-center px-2 py-1 h-5
|
||||||
|
text-primary-foreground dark:text-white text-xs
|
||||||
|
rounded bg-slate-400 hover:bg-slate-600 dark:hover:bg-slate-500 fade-in-up">
|
||||||
|
<Reply className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
{comment.user.id === user?.id && (
|
||||||
|
deleteConfirm ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteComment(comment.id)}
|
||||||
|
className="flex items-center justify-center px-2 py-1 h-5 text-primary-foreground dark:text-white text-xs rounded bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 fade-in"
|
||||||
|
onBlur={() => setDeleteConfirm(false)}
|
||||||
|
title={t("confirm_delete")}
|
||||||
|
>
|
||||||
|
{t("confirm_delete")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(true)}
|
||||||
|
className="flex items-center justify-center px-2 py-1 h-5 text-primary-foreground dark:text-white text-xs rounded bg-slate-400 hover:bg-red-600 dark:hover:bg-red-500 fade-in"
|
||||||
|
title={t("delete")}
|
||||||
|
>
|
||||||
|
<Trash className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{replyCount > 0 &&
|
||||||
|
<button onClick={() => setShowReplies(!showReplies)} className="fade-in-up">
|
||||||
|
{!showReplies ? t("expand_replies", { count: replyCount }) : t("collapse_replies")}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
{showReplyInput && <CommentInput targetId={comment.targetId} targetType={comment.targetType} replyId={comment.id} onCommentSubmitted={onReplySubmitted} />}
|
||||||
|
{showReplies && replyCount > 0 && <RepliesList parentComment={comment} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReplyItem({ reply }: { reply: Comment }) {
|
// 一个评论的回复区域组件
|
||||||
|
function RepliesList({ parentComment }: { parentComment: Comment }) {
|
||||||
|
const t = useTranslations("Comment")
|
||||||
|
const [replies, setReplies] = useState<Comment[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
listComments({
|
||||||
|
targetType: parentComment.targetType,
|
||||||
|
targetId: parentComment.targetId,
|
||||||
|
commentId: parentComment.id,
|
||||||
|
depth: parentComment.depth + 1,
|
||||||
|
orderBy: OrderBy.CreatedAt,
|
||||||
|
desc: false,
|
||||||
|
page: 1,
|
||||||
|
size: 9999,
|
||||||
|
}).then(res => {
|
||||||
|
setReplies(res.data);
|
||||||
|
}).catch(error => {
|
||||||
|
toast.error(t("load_replies_failed") + ": " + error.message);
|
||||||
|
});
|
||||||
|
}, [parentComment])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 dark:bg-slate-700 shadow-sm rounded-lg p-4 mb-2 ml-4">
|
<div className="mt-4 border-l border-slate-300 pl-4">
|
||||||
<CardHeader>
|
{replies.map(reply => (
|
||||||
<CardTitle className="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
<div key={reply.id} className="mb-4">
|
||||||
{reply.user.nickname}
|
<CommentItem comment={reply} parentComment={parentComment} />
|
||||||
</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>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { Comment } from "@/models/comment";
|
import type { Comment } from "@/models/comment";
|
||||||
import { CommentInput } from "@/components/comment/comment-input";
|
import { CommentInput } from "@/components/comment/comment-input";
|
||||||
|
import "./comment-animations.css";
|
||||||
import { Suspense, useEffect, useState } from "react";
|
import { Suspense, useEffect, useState } from "react";
|
||||||
import { listComments } from "@/api/comment";
|
import { listComments } from "@/api/comment";
|
||||||
import { OrderBy } from "@/models/common";
|
import { OrderBy } from "@/models/common";
|
||||||
@ -57,13 +58,13 @@ export default function CommentSection(props: CommentAreaProps) {
|
|||||||
<div>
|
<div>
|
||||||
<Separator className="my-16" />
|
<Separator className="my-16" />
|
||||||
<div className="font-bold text-2xl">评论</div>
|
<div className="font-bold text-2xl">评论</div>
|
||||||
<CommentInput targetType={targetType} targetId={targetId} onCommentSubmitted={onCommentSubmitted} />
|
<CommentInput targetType={targetType} targetId={targetId} replyId={0} onCommentSubmitted={onCommentSubmitted} />
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Suspense fallback={<CommentLoading />}>
|
<Suspense fallback={<CommentLoading />}>
|
||||||
{comments.map(comment => (
|
{comments.map((comment, idx) => (
|
||||||
<div key={comment.id}>
|
<div key={comment.id} className="fade-in-up" style={{ animationDelay: `${idx * 60}ms` }}>
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<CommentItem {...comment} />
|
<CommentItem comment={comment} parentComment={null} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@ -77,12 +78,12 @@ function CommentLoading() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 py-8">
|
<div className="space-y-6 py-8">
|
||||||
{[...Array(3)].map((_, i) => (
|
{[...Array(3)].map((_, i) => (
|
||||||
<div key={i} className="flex gap-3">
|
<div key={i} className="flex gap-3 fade-in-up" style={{ animationDelay: `${i * 80}ms` }}>
|
||||||
<Skeleton className="w-10 h-10 rounded-full" />
|
<Skeleton className="w-10 h-10 rounded-full fade-in" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Skeleton className="h-4 w-1/4" />
|
<Skeleton className="h-4 w-1/4 fade-in" />
|
||||||
<Skeleton className="h-4 w-3/4" />
|
<Skeleton className="h-4 w-3/4 fade-in" />
|
||||||
<Skeleton className="h-4 w-2/3" />
|
<Skeleton className="h-4 w-2/3 fade-in" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
@ -78,6 +78,7 @@ export function LoginForm({
|
|||||||
key={uniqueKey}
|
key={uniqueKey}
|
||||||
// 这个REDIRECT_BACK需要前端自己拼接,传给后端服务器,后端服务器拿来响应给前端另一个页面获取,然后改变路由
|
// 这个REDIRECT_BACK需要前端自己拼接,传给后端服务器,后端服务器拿来响应给前端另一个页面获取,然后改变路由
|
||||||
// 因为这个是我暑假那会写的,后面因为其他事情太忙了,好久没看了,忘了为什么当时要这么设计了,在弄清楚之前先保持这样
|
// 因为这个是我暑假那会写的,后面因为其他事情太忙了,好久没看了,忘了为什么当时要这么设计了,在弄清楚之前先保持这样
|
||||||
|
// 貌似是因为oidc认证时是后端响应重定向的,所以前端只能把redirect_back传给后端,由后端再传回来;普通登录时,这个参数可以被前端直接拿到进行路由跳转
|
||||||
loginUrl={config.loginUrl.replace("REDIRECT_BACK", encodeURIComponent(`?redirect_back=${redirectBack}`))}
|
loginUrl={config.loginUrl.replace("REDIRECT_BACK", encodeURIComponent(`?redirect_back=${redirectBack}`))}
|
||||||
displayName={config.displayName}
|
displayName={config.displayName}
|
||||||
icon={config.icon}
|
icon={config.icon}
|
||||||
|
@ -3,12 +3,21 @@
|
|||||||
"title": "Hello world!"
|
"title": "Hello world!"
|
||||||
},
|
},
|
||||||
"Comment": {
|
"Comment": {
|
||||||
"placeholder": "写下你的评论...",
|
"placeholder": "写你的评论...",
|
||||||
"submit": "提交",
|
"submit": "提交",
|
||||||
"login_required": "请先登录后再评论。",
|
"login_required": "请先登录后再评论。",
|
||||||
"content_required": "评论内容不能为空。",
|
"content_required": "评论内容不能为空。",
|
||||||
"comment_success": "评论提交成功!",
|
"comment_success": "评论提交成功!",
|
||||||
"comment_failed": "评论提交失败"
|
"comment_failed": "评论提交失败",
|
||||||
|
"delete": "删除",
|
||||||
|
"delete_success": "评论删除成功",
|
||||||
|
"confirm_delete": "确定删除?",
|
||||||
|
"like_success": "点赞评论成功",
|
||||||
|
"unlike_success": "取消点赞成功",
|
||||||
|
"like_failed": "点赞失败",
|
||||||
|
"reply": "回复",
|
||||||
|
"expand_replies": "展开 {count} 条回复",
|
||||||
|
"collapse_replies": "收起回复"
|
||||||
},
|
},
|
||||||
"Login": {
|
"Login": {
|
||||||
"welcome": "欢迎回来",
|
"welcome": "欢迎回来",
|
||||||
|
@ -12,14 +12,17 @@ export interface Comment {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
user: User
|
user: User
|
||||||
|
replyCount: number
|
||||||
|
likeCount: number
|
||||||
|
isLiked: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCommentRequest {
|
export interface CreateCommentRequest {
|
||||||
targetType: TargetType
|
targetType: TargetType
|
||||||
targetId: number
|
targetId: number
|
||||||
content: string
|
content: string
|
||||||
replyId?: number // 可选字段,默认为 null
|
replyId: number | null// 可选字段,默认为 null
|
||||||
isPrivate?: boolean // 可选字段,默认为 false
|
isPrivate: boolean// 可选字段,默认为 false
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateCommentRequest {
|
export interface UpdateCommentRequest {
|
||||||
|
0
web/src/models/like.tsx
Normal file
0
web/src/models/like.tsx
Normal file
@ -1,8 +1,10 @@
|
|||||||
|
|
||||||
// 目标类型枚举
|
// 目标类型枚举
|
||||||
export enum TargetType {
|
export enum TargetType {
|
||||||
Post = "post",
|
Post = "post",
|
||||||
Page = "page",
|
Page = "page",
|
||||||
Article = "article",
|
Article = "article",
|
||||||
|
Comment = "comment",
|
||||||
Video = "video",
|
Video = "video",
|
||||||
Image = "image",
|
Image = "image",
|
||||||
Other = "other"
|
Other = "other"
|
||||||
|
Reference in New Issue
Block a user