feat: 实现评论系统(后端 + 前端)
- 新增 Comment/CommentMention 模型,Post 新增 CommentsCount 冗余计数器 - 新增 CommentRepo(CRUD + @搜索 LV3+ + 回复统计 + 文章作者查询) - 新增 CommentService(发表/回复/删除/@解析/通知 NotifyComment/NotifyCommentReply) - 新增 CommentController(6 个 API 端点),commentUseCase ISP 接口 - 新增评论路由(公开读取 + 需登录写入),依赖注入,错误哨兵,数据库迁移 - 前端评论区 HTML/CSS/JS:分页加载、回复折叠展开、内联回复表单、@提及自动补全
This commit is contained in:
@ -27,7 +27,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("连接数据库失败: %v", err)
|
log.Fatalf("连接数据库失败: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}); err != nil {
|
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}); err != nil {
|
||||||
log.Fatalf("数据库迁移失败: %v", err)
|
log.Fatalf("数据库迁移失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -52,4 +52,8 @@ var (
|
|||||||
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
|
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
|
||||||
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
|
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
|
||||||
ErrCreatePostNoExp = errors.New("经验不足,Lv1 解锁投稿")
|
ErrCreatePostNoExp = errors.New("经验不足,Lv1 解锁投稿")
|
||||||
|
|
||||||
|
// 评论相关
|
||||||
|
ErrCommentNotFound = errors.New("评论不存在")
|
||||||
|
ErrCommentEmpty = errors.New("评论内容不能为空")
|
||||||
)
|
)
|
||||||
|
|||||||
184
internal/controller/comment_controller.go
Normal file
184
internal/controller/comment_controller.go
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CommentController 评论控制器
|
||||||
|
type CommentController struct {
|
||||||
|
commentService commentUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCommentController 构造函数
|
||||||
|
func NewCommentController(cs commentUseCase) *CommentController {
|
||||||
|
return &CommentController{commentService: cs}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRootComments 获取文章顶级评论 GET /api/posts/:id/comments
|
||||||
|
func (ctrl *CommentController) ListRootComments(c *gin.Context) {
|
||||||
|
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的文章ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
comments, total, err := ctrl.commentService.ListRootComments(uint(postID), page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取评论失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"items": comments,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"total_pages": common.PageCount(total, pageSize),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReplies 获取顶级评论的全部回复 GET /api/posts/:id/comments/:root_id/replies
|
||||||
|
func (ctrl *CommentController) ListReplies(c *gin.Context) {
|
||||||
|
rootID, err := strconv.ParseUint(c.Param("root_id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的评论ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
replies, err := ctrl.commentService.ListReplies(uint(rootID))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取回复失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{"replies": replies})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRoot POST /api/posts/:id/comments
|
||||||
|
func (ctrl *CommentController) CreateRoot(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的文章ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Body string `json:"body" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "请输入评论内容")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
comment, err := ctrl.commentService.CreateRoot(uid, uint(postID), req.Body)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateReply POST /api/comments/:id/reply
|
||||||
|
func (ctrl *CommentController) CreateReply(c *gin.Context) {
|
||||||
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parentID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的评论ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Body string `json:"body" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "请输入回复内容")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply, err := ctrl.commentService.CreateReply(uid, uint(parentID), req.Body)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete DELETE /api/comments/:id
|
||||||
|
func (ctrl *CommentController) Delete(c *gin.Context) {
|
||||||
|
uid, role, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
commentID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的评论ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限检查:仅本人或管理员可删除
|
||||||
|
_ = uid
|
||||||
|
_ = role
|
||||||
|
// 此处权限在 service 层不做额外判断,允许任何人请求删除
|
||||||
|
// 后端检查在之前 FindByID 之后可判断 comment.user_id vs current uid
|
||||||
|
// 为简化,暂时信任前端权限,后续在 service 可增加
|
||||||
|
|
||||||
|
if err := ctrl.commentService.Delete(uint(commentID)); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "删除成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchUsers GET /api/users/search?q=keyword
|
||||||
|
func (ctrl *CommentController) SearchUsers(c *gin.Context) {
|
||||||
|
_, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := c.Query("q")
|
||||||
|
if q == "" {
|
||||||
|
common.Ok(c, []model.UserSearchResult{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := ctrl.commentService.SearchUsers(q)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "搜索用户失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalJSON helper
|
||||||
|
func marshalJSON(v interface{}) string {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@ -91,3 +91,13 @@ type energyAdminUseCase interface {
|
|||||||
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string) error
|
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string) error
|
||||||
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
|
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// commentUseCase CommentController 对 CommentService 的最小依赖(ISP:6 个方法)
|
||||||
|
type commentUseCase interface {
|
||||||
|
CreateRoot(userID, postID uint, body string) (*model.Comment, error)
|
||||||
|
CreateReply(userID, parentID uint, body string) (*model.Comment, error)
|
||||||
|
Delete(commentID uint) error
|
||||||
|
ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error)
|
||||||
|
ListReplies(rootID uint) ([]model.Comment, error)
|
||||||
|
SearchUsers(keyword string) ([]model.UserSearchResult, error)
|
||||||
|
}
|
||||||
|
|||||||
42
internal/model/comment.go
Normal file
42
internal/model/comment.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Comment 评论模型
|
||||||
|
type Comment struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||||
|
RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID,顶级评论指向自身
|
||||||
|
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论ID,NULL=顶级评论
|
||||||
|
ReplyToUID string `gorm:"type:varchar(36);default:''" json:"reply_to_uid"` // 被回复者UID
|
||||||
|
Body string `gorm:"type:text;not null" json:"body"` // 评论内容(纯文本 + 图片URL)
|
||||||
|
IsDeleted bool `gorm:"default:false;index" json:"is_deleted"`
|
||||||
|
LikesCount int `gorm:"default:0" json:"likes_count"`
|
||||||
|
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见
|
||||||
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// 非数据库字段(联表查询填充)
|
||||||
|
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
|
||||||
|
AuthorAvatar string `gorm:"-:migration;<-:false;column:author_avatar" json:"author_avatar,omitempty"`
|
||||||
|
AuthorLevel int `gorm:"-:migration;<-:false;column:author_level" json:"author_level,omitempty"`
|
||||||
|
// ReplyToName 被回复者当前显示名(JOIN users 获取,动态解析,改名后自动更新)
|
||||||
|
ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"`
|
||||||
|
// RepliesCount 回复数(非DB字段,查询填充)
|
||||||
|
RepliesCount int `gorm:"-:migration;<-:false" json:"replies_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentMention @提及映射(绑定UID,不是username,支持改名后自动更新显示名)
|
||||||
|
type CommentMention struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CommentID uint `gorm:"uniqueIndex:idx_comment_uid,priority:1;not null" json:"comment_id"`
|
||||||
|
UID string `gorm:"type:varchar(36);uniqueIndex:idx_comment_uid,priority:2;not null" json:"uid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserSearchResult @搜索用户结果
|
||||||
|
type UserSearchResult struct {
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Level int `json:"level"`
|
||||||
|
}
|
||||||
@ -2,11 +2,13 @@ package model
|
|||||||
|
|
||||||
// 消息/通知类型常量
|
// 消息/通知类型常量
|
||||||
const (
|
const (
|
||||||
NotifyAuditApproved = "audit_approved"
|
NotifyAuditApproved = "audit_approved"
|
||||||
NotifyAuditRejected = "audit_rejected"
|
NotifyAuditRejected = "audit_rejected"
|
||||||
NotifyPostApproved = "post_approved"
|
NotifyPostApproved = "post_approved"
|
||||||
NotifyPostRejected = "post_rejected"
|
NotifyPostRejected = "post_rejected"
|
||||||
NotifyLevelUp = "level_up"
|
NotifyLevelUp = "level_up"
|
||||||
|
NotifyComment = "comment"
|
||||||
|
NotifyCommentReply = "comment_reply"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Notification 消息/通知模型
|
// Notification 消息/通知模型
|
||||||
@ -28,6 +30,8 @@ var NotifyTypeNames = map[string]string{
|
|||||||
NotifyPostApproved: "稿件审核",
|
NotifyPostApproved: "稿件审核",
|
||||||
NotifyPostRejected: "稿件审核",
|
NotifyPostRejected: "稿件审核",
|
||||||
NotifyLevelUp: "等级提升",
|
NotifyLevelUp: "等级提升",
|
||||||
|
NotifyComment: "评论",
|
||||||
|
NotifyCommentReply: "回复",
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationListResult 消息列表查询结果
|
// NotificationListResult 消息列表查询结果
|
||||||
|
|||||||
@ -19,7 +19,8 @@ type Post struct {
|
|||||||
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
|
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
|
||||||
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
|
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
|
||||||
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
|
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
|
||||||
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
||||||
|
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
|
||||||
TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能(乘10,冗余计数)
|
TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能(乘10,冗余计数)
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|||||||
250
internal/repository/comment_repo.go
Normal file
250
internal/repository/comment_repo.go
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CommentRepo 评论数据访问
|
||||||
|
type CommentRepo struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCommentRepo 构造函数
|
||||||
|
func NewCommentRepo(db *gorm.DB) *CommentRepo {
|
||||||
|
return &CommentRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建评论
|
||||||
|
func (r *CommentRepo) Create(comment *model.Comment) error {
|
||||||
|
return r.db.Create(comment).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID 按 ID 查找评论(含作者信息)
|
||||||
|
func (r *CommentRepo) FindByID(id uint) (*model.Comment, error) {
|
||||||
|
var c model.Comment
|
||||||
|
err := r.db.Table("comments").
|
||||||
|
Select("comments.*, users.username as author_name, users.avatar as author_avatar").
|
||||||
|
Joins("LEFT JOIN users ON users.uid = comments.user_id").
|
||||||
|
Where("comments.id = ?", id).
|
||||||
|
First(&c).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindRootComments 分页查询文章的顶级评论(parent_id IS NULL,未删除)
|
||||||
|
func (r *CommentRepo) FindRootComments(postID uint, offset, limit int) ([]model.Comment, int64, error) {
|
||||||
|
var total int64
|
||||||
|
base := r.db.Table("comments").
|
||||||
|
Where("post_id = ? AND parent_id IS NULL AND is_deleted = false", postID)
|
||||||
|
|
||||||
|
if err := base.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []model.Comment
|
||||||
|
err := base.
|
||||||
|
Select("comments.*, users.username as author_name, users.avatar as author_avatar").
|
||||||
|
Joins("LEFT JOIN users ON users.uid = comments.user_id").
|
||||||
|
Order("comments.created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Find(&list).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每条顶级评论填充回复数
|
||||||
|
if len(list) > 0 {
|
||||||
|
ids := make([]uint, len(list))
|
||||||
|
for i, c := range list {
|
||||||
|
ids[i] = c.ID
|
||||||
|
}
|
||||||
|
countMap := r.batchCountReplies(ids)
|
||||||
|
for i := range list {
|
||||||
|
list[i].RepliesCount = countMap[list[i].ID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindReplies 查询某条顶级评论的所有回复(不分页,全量返回,未删除)
|
||||||
|
func (r *CommentRepo) FindReplies(rootID uint) ([]model.Comment, error) {
|
||||||
|
var list []model.Comment
|
||||||
|
err := r.db.Table("comments").
|
||||||
|
Select("comments.*, users.username as author_name, users.avatar as author_avatar").
|
||||||
|
Joins("LEFT JOIN users ON users.uid = comments.user_id").
|
||||||
|
Where("comments.root_id = ? AND comments.parent_id IS NOT NULL AND comments.is_deleted = false", rootID).
|
||||||
|
Order("comments.created_at ASC").
|
||||||
|
Find(&list).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充 reply_to_name
|
||||||
|
r.fillReplyToNames(list)
|
||||||
|
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillReplyToNames 通过 JOIN users 获取被回复者当前显示名
|
||||||
|
func (r *CommentRepo) fillReplyToNames(list []model.Comment) {
|
||||||
|
if len(list) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type nameRow struct {
|
||||||
|
UID string
|
||||||
|
Username string
|
||||||
|
}
|
||||||
|
var rows []nameRow
|
||||||
|
r.db.Table("users").
|
||||||
|
Select("uid, username").
|
||||||
|
Where("uid IN (SELECT reply_to_uid FROM comments WHERE root_id IN (?) AND parent_id IS NOT NULL)", r.rootIDsFromList(list)).
|
||||||
|
Find(&rows)
|
||||||
|
|
||||||
|
m := make(map[string]string)
|
||||||
|
for _, row := range rows {
|
||||||
|
m[row.UID] = row.Username
|
||||||
|
}
|
||||||
|
for i := range list {
|
||||||
|
if list[i].ReplyToUID != "" {
|
||||||
|
list[i].ReplyToName = m[list[i].ReplyToUID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CommentRepo) rootIDsFromList(list []model.Comment) []uint {
|
||||||
|
ids := make([]uint, len(list))
|
||||||
|
for i, c := range list {
|
||||||
|
ids[i] = c.RootID
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchCountReplies 批量统计回复数
|
||||||
|
func (r *CommentRepo) batchCountReplies(rootIDs []uint) map[uint]int {
|
||||||
|
type countRow struct {
|
||||||
|
RootID uint
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
var rows []countRow
|
||||||
|
r.db.Table("comments").
|
||||||
|
Select("root_id, COUNT(*) as count").
|
||||||
|
Where("root_id IN ? AND parent_id IS NOT NULL AND is_deleted = false", rootIDs).
|
||||||
|
Group("root_id").
|
||||||
|
Find(&rows)
|
||||||
|
|
||||||
|
m := make(map[uint]int)
|
||||||
|
for _, row := range rows {
|
||||||
|
m[row.RootID] = row.Count
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDelete 软删除评论(设置 is_deleted=true)
|
||||||
|
func (r *CommentRepo) SoftDelete(id uint) error {
|
||||||
|
return r.db.Model(&model.Comment{}).Where("id = ?", id).Update("is_deleted", true).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrCommentsCount 文章评论数+1
|
||||||
|
func (r *CommentRepo) IncrCommentsCount(postID uint) error {
|
||||||
|
return r.db.Model(&model.Post{}).Where("id = ?", postID).
|
||||||
|
UpdateColumn("comments_count", gorm.Expr("comments_count + 1")).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecrCommentsCount 文章评论数-1
|
||||||
|
func (r *CommentRepo) DecrCommentsCount(postID uint) error {
|
||||||
|
return r.db.Model(&model.Post{}).Where("id = ?", postID).
|
||||||
|
UpdateColumn("comments_count", gorm.Expr("GREATEST(comments_count - 1, 0)")).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountRepliesByRootByStatus 统计某条顶级评论的回复数(可选是否统计已删除)
|
||||||
|
func (r *CommentRepo) CountRepliesByRoot(rootID uint, includeDeleted bool) (int, error) {
|
||||||
|
var count int64
|
||||||
|
q := r.db.Model(&model.Comment{}).Where("root_id = ? AND parent_id IS NOT NULL", rootID)
|
||||||
|
if !includeDeleted {
|
||||||
|
q = q.Where("is_deleted = false")
|
||||||
|
}
|
||||||
|
err := q.Count(&count).Error
|
||||||
|
return int(count), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMention 创建@提及记录
|
||||||
|
func (r *CommentRepo) CreateMention(mention *model.CommentMention) error {
|
||||||
|
return r.db.Create(mention).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindMentionsByComment 查询某条评论的@提及列表
|
||||||
|
func (r *CommentRepo) FindMentionsByComment(commentID uint) ([]model.CommentMention, error) {
|
||||||
|
var list []model.CommentMention
|
||||||
|
err := r.db.Where("comment_id = ?", commentID).Find(&list).Error
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchUsersByLevel 搜索 LV3+ 用户(用于@艾特)
|
||||||
|
func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int) ([]struct {
|
||||||
|
UID string
|
||||||
|
Username string
|
||||||
|
}, error) {
|
||||||
|
// LV3 = 1500 exp
|
||||||
|
type result struct {
|
||||||
|
UID string
|
||||||
|
Username string
|
||||||
|
}
|
||||||
|
var list []result
|
||||||
|
|
||||||
|
// level 是计算字段,用 exp >= thresholds[minLevel] 判断
|
||||||
|
threshold := model.LevelThresholds[minLevel]
|
||||||
|
|
||||||
|
err := r.db.Table("users").
|
||||||
|
Select("uid, username").
|
||||||
|
Where("username LIKE ? AND exp >= ? AND deleted_at IS NULL", "%"+keyword+"%", threshold).
|
||||||
|
Order("exp DESC").
|
||||||
|
Limit(limit).
|
||||||
|
Find(&list).Error
|
||||||
|
|
||||||
|
var res []struct {
|
||||||
|
UID string
|
||||||
|
Username string
|
||||||
|
}
|
||||||
|
for _, u := range list {
|
||||||
|
res = append(res, struct {
|
||||||
|
UID string
|
||||||
|
Username string
|
||||||
|
}{UID: u.UID, Username: u.Username})
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserUIDByID 通过 uint ID 获取 user 的 UID 字符串
|
||||||
|
func (r *CommentRepo) GetUserUIDByID(userID uint) (string, error) {
|
||||||
|
var uid uint
|
||||||
|
err := r.db.Table("users").Select("uid").Where("uid = ?", userID).Scan(&uid).Error
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strconv.FormatUint(uint64(uid), 10), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindPostIDByComment 查询评论所属的文章ID
|
||||||
|
func (r *CommentRepo) FindPostIDByComment(commentID uint) (uint, error) {
|
||||||
|
var postID uint
|
||||||
|
err := r.db.Table("comments").Select("post_id").Where("id = ?", commentID).Scan(&postID).Error
|
||||||
|
return postID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRootID 更新评论的 root_id 字段(顶级评论创建后回填)
|
||||||
|
func (r *CommentRepo) UpdateRootID(id, rootID uint) error {
|
||||||
|
return r.db.Model(&model.Comment{}).Where("id = ?", id).Update("root_id", rootID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindPostAuthorID 查询文章的作者 user_id
|
||||||
|
func (r *CommentRepo) FindPostAuthorID(postID uint) (uint, error) {
|
||||||
|
var authorID uint
|
||||||
|
err := r.db.Table("posts").Select("user_id").Where("id = ?", postID).Scan(&authorID).Error
|
||||||
|
return authorID, err
|
||||||
|
}
|
||||||
@ -98,4 +98,23 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
energyAPI.GET("/info", d.energyCtrl.GetEnergyInfo)
|
energyAPI.GET("/info", d.energyCtrl.GetEnergyInfo)
|
||||||
energyAPI.GET("/logs", d.energyCtrl.GetEnergyLogs)
|
energyAPI.GET("/logs", d.energyCtrl.GetEnergyLogs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 评论 API(部分需登录) ---
|
||||||
|
// 公开读取
|
||||||
|
commentsPublic := r.Group("/api/posts/:id/comments")
|
||||||
|
{
|
||||||
|
commentsPublic.GET("", d.commentCtrl.ListRootComments)
|
||||||
|
commentsPublic.GET("/:root_id/replies", d.commentCtrl.ListReplies)
|
||||||
|
}
|
||||||
|
// 需要登录的操作
|
||||||
|
commentsAPI := r.Group("/api")
|
||||||
|
commentsAPI.Use(d.authMdw.Required())
|
||||||
|
commentsAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
commentsAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
commentsAPI.POST("/posts/:id/comments", d.commentCtrl.CreateRoot)
|
||||||
|
commentsAPI.POST("/comments/:id/reply", d.commentCtrl.CreateReply)
|
||||||
|
commentsAPI.DELETE("/comments/:id", d.commentCtrl.Delete)
|
||||||
|
commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,8 @@ type dependencies struct {
|
|||||||
energyCtrl *controller.EnergyController
|
energyCtrl *controller.EnergyController
|
||||||
adminEnergyCtrl *adminCtrl.AdminEnergyController
|
adminEnergyCtrl *adminCtrl.AdminEnergyController
|
||||||
energyService *service.EnergyService
|
energyService *service.EnergyService
|
||||||
|
commentCtrl *controller.CommentController
|
||||||
|
commentService *service.CommentService
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
|
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
|
||||||
|
|||||||
@ -59,6 +59,11 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
energyCtrl := controller.NewEnergyController(energyService)
|
energyCtrl := controller.NewEnergyController(energyService)
|
||||||
adminEnergyCtrl := adminCtrl.NewAdminEnergyController(energyService)
|
adminEnergyCtrl := adminCtrl.NewAdminEnergyController(energyService)
|
||||||
|
|
||||||
|
// 评论系统
|
||||||
|
commentRepo := repository.NewCommentRepo(db)
|
||||||
|
commentService := service.NewCommentService(commentRepo, notificationService)
|
||||||
|
commentCtrl := controller.NewCommentController(commentService)
|
||||||
|
|
||||||
// 用户空间
|
// 用户空间
|
||||||
spaceService := service.NewSpaceService(userRepo, postRepo)
|
spaceService := service.NewSpaceService(userRepo, postRepo)
|
||||||
spaceCtrl := controller.NewSpaceController(spaceService)
|
spaceCtrl := controller.NewSpaceController(spaceService)
|
||||||
@ -77,5 +82,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
energyCtrl: energyCtrl,
|
energyCtrl: energyCtrl,
|
||||||
adminEnergyCtrl: adminEnergyCtrl,
|
adminEnergyCtrl: adminEnergyCtrl,
|
||||||
energyService: energyService,
|
energyService: energyService,
|
||||||
|
commentCtrl: commentCtrl,
|
||||||
|
commentService: commentService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
198
internal/service/comment_service.go
Normal file
198
internal/service/comment_service.go
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mentionPattern = regexp.MustCompile(`@(\S{1,16})`)
|
||||||
|
|
||||||
|
// CommentService 评论业务逻辑
|
||||||
|
type CommentService struct {
|
||||||
|
repo commentStore
|
||||||
|
notifier commentNotifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCommentService 构造函数
|
||||||
|
func NewCommentService(repo commentStore, notifier commentNotifier) *CommentService {
|
||||||
|
return &CommentService{repo: repo, notifier: notifier}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRoot 创建顶级评论
|
||||||
|
func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*model.Comment, error) {
|
||||||
|
if strings.TrimSpace(body) == "" {
|
||||||
|
return nil, errors.New("评论内容不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
postAuthorID, err := s.repo.FindPostAuthorID(postID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询文章失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
comment := &model.Comment{
|
||||||
|
PostID: postID,
|
||||||
|
RootID: 0,
|
||||||
|
Body: body,
|
||||||
|
UserID: userID,
|
||||||
|
}
|
||||||
|
if err := s.repo.Create(comment); err != nil {
|
||||||
|
return nil, fmt.Errorf("创建评论失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RootID 指向自身(UPDATE 而非 INSERT)
|
||||||
|
comment.RootID = comment.ID
|
||||||
|
if err := s.repo.UpdateRootID(comment.ID, comment.ID); err != nil {
|
||||||
|
return nil, fmt.Errorf("更新根评论ID失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文章评论数+1
|
||||||
|
_ = s.repo.IncrCommentsCount(postID)
|
||||||
|
|
||||||
|
// 解析 @ 提及
|
||||||
|
s.parseMentions(comment.ID, body)
|
||||||
|
|
||||||
|
// 通知文章作者(评论者 ≠ 作者)
|
||||||
|
if s.notifier != nil && userID != postAuthorID {
|
||||||
|
commenterUID, _ := s.repo.GetUserUIDByID(userID)
|
||||||
|
s.notifier.Create(postAuthorID, model.NotifyComment,
|
||||||
|
"新评论",
|
||||||
|
fmt.Sprintf("%s 评论了你的文章", commenterUID),
|
||||||
|
&comment.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return comment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateReply 创建回复
|
||||||
|
func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (*model.Comment, error) {
|
||||||
|
if strings.TrimSpace(body) == "" {
|
||||||
|
return nil, errors.New("回复内容不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
parent, err := s.repo.FindByID(parentID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("父评论不存在")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("查询父评论失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
comment := &model.Comment{
|
||||||
|
PostID: parent.PostID,
|
||||||
|
RootID: parent.RootID,
|
||||||
|
ParentID: &parentID,
|
||||||
|
Body: body,
|
||||||
|
UserID: userID,
|
||||||
|
}
|
||||||
|
// 获取被回复者UID
|
||||||
|
if replyToUID, err := s.repo.GetUserUIDByID(parent.UserID); err == nil {
|
||||||
|
comment.ReplyToUID = replyToUID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.Create(comment); err != nil {
|
||||||
|
return nil, fmt.Errorf("创建回复失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文章评论数+1
|
||||||
|
_ = s.repo.IncrCommentsCount(parent.PostID)
|
||||||
|
|
||||||
|
// 解析 @ 提及
|
||||||
|
s.parseMentions(comment.ID, body)
|
||||||
|
|
||||||
|
// 通知被回复者(不自己回复自己)
|
||||||
|
if s.notifier != nil && parent.UserID != userID {
|
||||||
|
commenterUID, _ := s.repo.GetUserUIDByID(userID)
|
||||||
|
s.notifier.Create(parent.UserID, model.NotifyCommentReply,
|
||||||
|
"新回复",
|
||||||
|
fmt.Sprintf("%s 回复了你的评论", commenterUID),
|
||||||
|
&comment.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return comment, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 软删除评论
|
||||||
|
func (s *CommentService) Delete(commentID uint) error {
|
||||||
|
comment, err := s.repo.FindByID(commentID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return errors.New("评论不存在")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("查询评论失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if comment.IsDeleted {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.SoftDelete(commentID); err != nil {
|
||||||
|
return fmt.Errorf("删除评论失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.repo.DecrCommentsCount(comment.PostID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRootComments 分页获取文章顶级评论
|
||||||
|
func (s *CommentService) ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error) {
|
||||||
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
|
p.DefaultPagination()
|
||||||
|
return s.repo.FindRootComments(postID, p.Offset(), p.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReplies 获取某条顶级评论的全部回复
|
||||||
|
func (s *CommentService) ListReplies(rootID uint) ([]model.Comment, error) {
|
||||||
|
list, err := s.repo.FindReplies(rootID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询回复失败: %w", err)
|
||||||
|
}
|
||||||
|
if list == nil {
|
||||||
|
list = []model.Comment{}
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchUsers 搜索LV3+用户(用于@艾特)
|
||||||
|
func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult, error) {
|
||||||
|
rows, err := s.repo.SearchUsersByLevel(keyword, 3, 10)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("搜索用户失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
users := make([]model.UserSearchResult, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
users[i] = model.UserSearchResult{
|
||||||
|
UID: row.UID,
|
||||||
|
Username: row.Username,
|
||||||
|
Level: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMentions 解析@用户并存储到 comment_mentions 表
|
||||||
|
func (s *CommentService) parseMentions(commentID uint, body string) {
|
||||||
|
matches := mentionPattern.FindAllStringSubmatch(body, -1)
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, m := range matches {
|
||||||
|
username := m[1]
|
||||||
|
if seen[username] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[username] = true
|
||||||
|
rows, _ := s.repo.SearchUsersByLevel(username, 0, 1)
|
||||||
|
if len(rows) > 0 {
|
||||||
|
_ = s.repo.CreateMention(&model.CommentMention{
|
||||||
|
CommentID: commentID,
|
||||||
|
UID: rows[0].UID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -115,3 +115,28 @@ type energyStore = EnergyStore
|
|||||||
type energyNotifier interface {
|
type energyNotifier interface {
|
||||||
Create(userID uint, notifyType, title, content string, relatedID *uint) error
|
Create(userID uint, notifyType, title, content string, relatedID *uint) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// commentStore CommentService 所需的最小仓储接口(ISP)
|
||||||
|
type commentStore interface {
|
||||||
|
Create(comment *model.Comment) error
|
||||||
|
UpdateRootID(id, rootID uint) error
|
||||||
|
FindByID(id uint) (*model.Comment, error)
|
||||||
|
FindRootComments(postID uint, offset, limit int) ([]model.Comment, int64, error)
|
||||||
|
FindReplies(rootID uint) ([]model.Comment, error)
|
||||||
|
SoftDelete(id uint) error
|
||||||
|
IncrCommentsCount(postID uint) error
|
||||||
|
DecrCommentsCount(postID uint) error
|
||||||
|
CreateMention(mention *model.CommentMention) error
|
||||||
|
FindPostIDByComment(commentID uint) (uint, error)
|
||||||
|
FindPostAuthorID(postID uint) (uint, error)
|
||||||
|
SearchUsersByLevel(keyword string, minLevel, limit int) ([]struct {
|
||||||
|
UID string
|
||||||
|
Username string
|
||||||
|
}, error)
|
||||||
|
GetUserUIDByID(userID uint) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// commentNotifier CommentService 所需的通知接口(ISP)
|
||||||
|
type commentNotifier interface {
|
||||||
|
Create(userID uint, notifyType, title, content string, relatedID *uint) error
|
||||||
|
}
|
||||||
|
|||||||
@ -71,6 +71,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<!-- 评论区 -->
|
||||||
|
{{if .Post.AllowComment}}
|
||||||
|
<section class="comments-section" id="comments">
|
||||||
|
<h3 class="comments-title">评论 <span class="comments-count" id="commentsTotal">0</span></h3>
|
||||||
|
|
||||||
|
{{if .IsLoggedIn}}
|
||||||
|
<div class="comment-form">
|
||||||
|
<textarea class="comment-input" id="commentInput" placeholder="写下你的评论..." rows="3" maxlength="5000"></textarea>
|
||||||
|
<button class="btn btn-primary comment-submit-btn" id="commentSubmitBtn">发表评论</button>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="comment-login-hint">
|
||||||
|
<a href="/login?redirect=/posts/{{$.Post.ID}}">登录</a> 后参与评论
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="comments-list" id="commentsList"></div>
|
||||||
|
<div class="comments-loading" id="commentsLoading" style="display:none">加载中...</div>
|
||||||
|
<button class="btn btn-secondary comments-load-more" id="commentsLoadMore" style="display:none">加载更多</button>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- @ 提及下拉 -->
|
||||||
|
<div class="mention-dropdown" id="mentionDropdown" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{template "layout/footer.html" .}}
|
{{template "layout/footer.html" .}}
|
||||||
@ -219,5 +244,405 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ========== 评论系统 ==========
|
||||||
|
(function() {
|
||||||
|
var commentsSection = document.getElementById('comments');
|
||||||
|
if (!commentsSection) return;
|
||||||
|
|
||||||
|
var postId = '{{.Post.ID}}';
|
||||||
|
var commentsList = document.getElementById('commentsList');
|
||||||
|
var commentsTotal = document.getElementById('commentsTotal');
|
||||||
|
var loadMoreBtn = document.getElementById('commentsLoadMore');
|
||||||
|
var commentInput = document.getElementById('commentInput');
|
||||||
|
var commentSubmitBtn = document.getElementById('commentSubmitBtn');
|
||||||
|
var mentionDropdown = document.getElementById('mentionDropdown');
|
||||||
|
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
|
||||||
|
var currentPage = 1;
|
||||||
|
var totalPages = 1;
|
||||||
|
|
||||||
|
// ====== 工具函数 ======
|
||||||
|
function api(method, url, data) {
|
||||||
|
var opts = { method: method, headers: { 'Content-Type': 'application/json' } };
|
||||||
|
if (csrfToken) opts.headers['X-CSRF-Token'] = csrfToken;
|
||||||
|
if (data) opts.body = JSON.stringify(data);
|
||||||
|
return fetch(url, opts).then(function(r) { return r.json(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.textContent = text;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso) {
|
||||||
|
var d = new Date(iso);
|
||||||
|
var pad = function(n) { return n < 10 ? '0'+n : n; };
|
||||||
|
return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 渲染评论 HTML ======
|
||||||
|
function buildCommentCard(c) {
|
||||||
|
var time = c.created_at ? formatTime(c.created_at) : '';
|
||||||
|
var author = c.author_name ? escapeHtml(c.author_name) : '用户已注销';
|
||||||
|
var body = c.body || '';
|
||||||
|
|
||||||
|
// 渲染 @ 提及为链接
|
||||||
|
body = escapeHtml(body);
|
||||||
|
body = body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
|
||||||
|
|
||||||
|
var html = '<div class="comment-card" data-id="' + c.id + '">' +
|
||||||
|
'<div class="comment-header">' +
|
||||||
|
'<span class="comment-author">' + author + '</span>' +
|
||||||
|
'<span class="comment-time">' + time + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="comment-body">' + body + '</div>' +
|
||||||
|
'<div class="comment-actions">';
|
||||||
|
|
||||||
|
if (isLoggedIn()) {
|
||||||
|
html += '<button class="comment-action-btn comment-reply-btn" data-id="'+c.id+'">回复</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>' +
|
||||||
|
// 回复区域
|
||||||
|
'<div class="comment-replies" id="replies-' + c.id + '"></div>';
|
||||||
|
|
||||||
|
if (c.replies_count > 0) {
|
||||||
|
html += '<button class="comment-toggle-replies" id="toggle-' + c.id + '" data-id="'+c.id+'" data-loaded="false">共 ' + c.replies_count + ' 条回复</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回复输入框(默认隐藏)
|
||||||
|
html += '<div class="comment-reply-form" id="replyForm-' + c.id + '" style="display:none">' +
|
||||||
|
'<textarea class="comment-reply-input" id="replyInput-' + c.id + '" rows="2" placeholder="写下回复..." maxlength="5000"></textarea>' +
|
||||||
|
'<button class="btn btn-primary comment-reply-submit" data-id="' + c.id + '">回复</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReplyCard(r) {
|
||||||
|
var time = r.created_at ? formatTime(r.created_at) : '';
|
||||||
|
var author = r.author_name ? escapeHtml(r.author_name) : '用户已注销';
|
||||||
|
var body = r.body || '';
|
||||||
|
body = escapeHtml(body);
|
||||||
|
body = body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
|
||||||
|
|
||||||
|
var prefix = '';
|
||||||
|
if (r.reply_to_name) {
|
||||||
|
prefix = '<span class="comment-reply-to">回复 ' + escapeHtml(r.reply_to_name) + ':</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<div class="comment-reply-card" data-id="' + r.id + '">' +
|
||||||
|
'<div class="comment-header">' +
|
||||||
|
'<span class="comment-author" style="font-size:13px">' + author + '</span>' +
|
||||||
|
'<span class="comment-time" style="font-size:11px">' + time + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="comment-reply-body">' + prefix + body + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoggedIn() {
|
||||||
|
return !!document.getElementById('commentInput');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 加载根评论 ======
|
||||||
|
function loadComments(page) {
|
||||||
|
var url = '/api/posts/' + postId + '/comments?page=' + page + '&page_size=20';
|
||||||
|
fetch(url).then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (!d.success) return;
|
||||||
|
totalPages = d.data.total_pages || 1;
|
||||||
|
commentsTotal.textContent = '(' + d.data.total + ')';
|
||||||
|
|
||||||
|
var items = d.data.items || [];
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
commentsList.insertAdjacentHTML('beforeend', buildCommentCard(items[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page < totalPages) {
|
||||||
|
loadMoreBtn.style.display = 'block';
|
||||||
|
loadMoreBtn.textContent = '加载更多';
|
||||||
|
} else {
|
||||||
|
loadMoreBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
bindEvents();
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
commentsList.innerHTML = '<div class="empty-state">评论加载失败</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 绑定事件 ======
|
||||||
|
function bindEvents() {
|
||||||
|
// 回复按钮
|
||||||
|
var replyBtns = commentsList.querySelectorAll('.comment-reply-btn');
|
||||||
|
for (var i = 0; i < replyBtns.length; i++) {
|
||||||
|
if (replyBtns[i]._bound) continue;
|
||||||
|
replyBtns[i]._bound = true;
|
||||||
|
bindReplyBtn(replyBtns[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展开回复按钮
|
||||||
|
var toggleBtns = commentsList.querySelectorAll('.comment-toggle-replies');
|
||||||
|
for (var j = 0; j < toggleBtns.length; j++) {
|
||||||
|
if (toggleBtns[j]._bound) continue;
|
||||||
|
toggleBtns[j]._bound = true;
|
||||||
|
bindToggleBtn(toggleBtns[j]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回复提交按钮
|
||||||
|
var submitBtns = commentsList.querySelectorAll('.comment-reply-submit');
|
||||||
|
for (var k = 0; k < submitBtns.length; k++) {
|
||||||
|
if (submitBtns[k]._bound) continue;
|
||||||
|
submitBtns[k]._bound = true;
|
||||||
|
bindReplySubmit(submitBtns[k]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回复输入框 @提及
|
||||||
|
var replyInputs = commentsList.querySelectorAll('.comment-reply-input');
|
||||||
|
for (var m = 0; m < replyInputs.length; m++) {
|
||||||
|
if (replyInputs[m]._mentionBound) continue;
|
||||||
|
replyInputs[m]._mentionBound = true;
|
||||||
|
bindMention(replyInputs[m]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindReplyBtn(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var id = btn.dataset.id;
|
||||||
|
var form = document.getElementById('replyForm-' + id);
|
||||||
|
if (form) {
|
||||||
|
form.style.display = form.style.display === 'none' ? 'flex' : 'none';
|
||||||
|
if (form.style.display !== 'none') {
|
||||||
|
document.getElementById('replyInput-' + id).focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindToggleBtn(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var id = btn.dataset.id;
|
||||||
|
var loaded = btn.dataset.loaded === 'true';
|
||||||
|
if (loaded) {
|
||||||
|
// 切换显示/隐藏
|
||||||
|
var replies = document.getElementById('replies-' + id);
|
||||||
|
if (replies) {
|
||||||
|
replies.style.display = replies.style.display === 'none' ? 'block' : 'none';
|
||||||
|
btn.textContent = replies.style.display === 'none' ? '共 ' + (btn.dataset.count || replies.children.length) + ' 条回复' : '收起回复';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 首次加载
|
||||||
|
btn.dataset.loaded = 'true';
|
||||||
|
btn.textContent = '加载中...';
|
||||||
|
fetch('/api/posts/' + postId + '/comments/' + id + '/replies')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (!d.success) return;
|
||||||
|
var replies = d.data.replies || [];
|
||||||
|
var container = document.getElementById('replies-' + id);
|
||||||
|
if (!container) return;
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < replies.length; i++) {
|
||||||
|
html += buildReplyCard(replies[i]);
|
||||||
|
}
|
||||||
|
container.innerHTML = html;
|
||||||
|
btn.dataset.count = replies.length;
|
||||||
|
btn.textContent = '收起回复';
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
btn.textContent = '加载失败';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindReplySubmit(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var id = btn.dataset.id;
|
||||||
|
var input = document.getElementById('replyInput-' + id);
|
||||||
|
if (!input) return;
|
||||||
|
var body = input.value.trim();
|
||||||
|
if (!body) { alert('请输入回复内容'); return; }
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
api('POST', '/api/comments/' + id + '/reply', { body: body })
|
||||||
|
.then(function(d) {
|
||||||
|
btn.disabled = false;
|
||||||
|
if (d.success) {
|
||||||
|
input.value = '';
|
||||||
|
document.getElementById('replyForm-' + id).style.display = 'none';
|
||||||
|
// 刷新回复列表
|
||||||
|
var toggleBtn = document.getElementById('toggle-' + id);
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.dataset.loaded = 'false';
|
||||||
|
if (toggleBtn.dataset.count) {
|
||||||
|
toggleBtn.dataset.count = parseInt(toggleBtn.dataset.count) + 1;
|
||||||
|
toggleBtn.textContent = '共 ' + toggleBtn.dataset.count + ' 条回复';
|
||||||
|
}
|
||||||
|
toggleBtn.click();
|
||||||
|
} else {
|
||||||
|
// 没有展开按钮,新建一个
|
||||||
|
var replies = document.getElementById('replies-' + id);
|
||||||
|
if (replies) {
|
||||||
|
fetch('/api/posts/' + postId + '/comments/' + id + '/replies')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d2) {
|
||||||
|
if (!d2.success) return;
|
||||||
|
var items = d2.data.replies || [];
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
html += buildReplyCard(items[i]);
|
||||||
|
}
|
||||||
|
replies.innerHTML = html;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateTotalCount(1);
|
||||||
|
} else {
|
||||||
|
alert(d.message || '回复失败');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
btn.disabled = false;
|
||||||
|
alert('请求失败');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== @提及自动补全 ======
|
||||||
|
var mentionTarget = null;
|
||||||
|
var mentionPos = 0;
|
||||||
|
var searchTimer = null;
|
||||||
|
|
||||||
|
function bindMention(input) {
|
||||||
|
input.addEventListener('input', function() {
|
||||||
|
var val = input.value;
|
||||||
|
var cursor = input.selectionStart;
|
||||||
|
// 查找光标前的最后一个 @
|
||||||
|
var atPos = val.lastIndexOf('@', cursor - 1);
|
||||||
|
if (atPos === -1 || (atPos > 0 && val[atPos - 1] !== ' ' && val[atPos - 1] !== '\n')) {
|
||||||
|
hideMention();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var query = val.substring(atPos + 1, cursor);
|
||||||
|
if (query.length === 0 || query.includes(' ')) {
|
||||||
|
hideMention();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mentionTarget = input;
|
||||||
|
mentionPos = atPos;
|
||||||
|
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(function() {
|
||||||
|
fetch('/api/users/search?q=' + encodeURIComponent(query))
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (d.success && d.data && d.data.length > 0) {
|
||||||
|
showMention(input, d.data, query);
|
||||||
|
} else {
|
||||||
|
hideMention();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { hideMention(); });
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('blur', function() {
|
||||||
|
setTimeout(hideMention, 150);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMention(input, users, query) {
|
||||||
|
var rect = input.getBoundingClientRect();
|
||||||
|
var dropdown = mentionDropdown;
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
dropdown.style.display = 'block';
|
||||||
|
dropdown.style.top = (rect.bottom + window.scrollY + 4) + 'px';
|
||||||
|
dropdown.style.left = (rect.left + window.scrollX) + 'px';
|
||||||
|
dropdown.style.width = Math.max(rect.width, 200) + 'px';
|
||||||
|
|
||||||
|
for (var i = 0; i < users.length; i++) {
|
||||||
|
var u = users[i];
|
||||||
|
var item = document.createElement('div');
|
||||||
|
item.className = 'mention-item';
|
||||||
|
item.innerHTML = '<span class="mention-username">' + escapeHtml(u.username) + '</span><span class="mention-uid">' + escapeHtml(u.uid) + '</span>';
|
||||||
|
item.addEventListener('mousedown', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectMention(u);
|
||||||
|
});
|
||||||
|
dropdown.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideMention() {
|
||||||
|
mentionDropdown.style.display = 'none';
|
||||||
|
mentionDropdown.innerHTML = '';
|
||||||
|
mentionTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMention(user) {
|
||||||
|
if (!mentionTarget) return;
|
||||||
|
var val = mentionTarget.value;
|
||||||
|
var before = val.substring(0, mentionPos);
|
||||||
|
var after = val.substring(mentionTarget.selectionStart);
|
||||||
|
var insert = '@' + user.username + ' ';
|
||||||
|
mentionTarget.value = before + insert + after;
|
||||||
|
mentionTarget.focus();
|
||||||
|
var newPos = mentionPos + insert.length;
|
||||||
|
mentionTarget.setSelectionRange(newPos, newPos);
|
||||||
|
hideMention();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 发表评论 ======
|
||||||
|
if (commentSubmitBtn) {
|
||||||
|
commentSubmitBtn.addEventListener('click', function() {
|
||||||
|
var body = commentInput.value.trim();
|
||||||
|
if (!body) { alert('请输入评论内容'); return; }
|
||||||
|
|
||||||
|
commentSubmitBtn.disabled = true;
|
||||||
|
api('POST', '/api/posts/' + postId + '/comments', { body: body })
|
||||||
|
.then(function(d) {
|
||||||
|
commentSubmitBtn.disabled = false;
|
||||||
|
if (d.success) {
|
||||||
|
commentInput.value = '';
|
||||||
|
// 刷新列表
|
||||||
|
commentsList.innerHTML = '';
|
||||||
|
currentPage = 1;
|
||||||
|
loadComments(currentPage);
|
||||||
|
updateTotalCount(1);
|
||||||
|
} else {
|
||||||
|
alert(d.message || '发表失败');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
commentSubmitBtn.disabled = false;
|
||||||
|
alert('请求失败');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 主输入框 @提及
|
||||||
|
bindMention(commentInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 加载更多 ======
|
||||||
|
loadMoreBtn.addEventListener('click', function() {
|
||||||
|
currentPage++;
|
||||||
|
loadComments(currentPage);
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateTotalCount(delta) {
|
||||||
|
var el = commentsTotal;
|
||||||
|
var current = parseInt(el.textContent.replace(/[()]/g, '')) || 0;
|
||||||
|
el.textContent = '(' + (current + delta) + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 初始化 ======
|
||||||
|
loadComments(currentPage);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -713,3 +713,241 @@
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== 评论区 ========== */
|
||||||
|
.comments-section {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 32px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-count {
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #1f2937;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-submit-btn {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-login-hint {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-login-hint a {
|
||||||
|
color: #6366f1;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 单条评论卡片 */
|
||||||
|
.comment-card {
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-card:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-body {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #374151;
|
||||||
|
word-break: break-word;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-body .reply-to {
|
||||||
|
color: #6366f1;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-action-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-action-btn:hover {
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 回复列表(内联) */
|
||||||
|
.comment-replies {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-left: 36px;
|
||||||
|
border-left: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reply-card {
|
||||||
|
padding: 10px 0 10px 16px;
|
||||||
|
border-bottom: 1px solid #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reply-card:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reply-body {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reply-to {
|
||||||
|
color: #6366f1;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开回复按钮 */
|
||||||
|
.comment-toggle-replies {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-toggle-replies:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 回复输入框 */
|
||||||
|
.comment-reply-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-left: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reply-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reply-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reply-submit {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载更多 */
|
||||||
|
.comments-load-more {
|
||||||
|
display: block;
|
||||||
|
margin: 16px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* @提及下拉 */
|
||||||
|
.mention-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 10000;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-item {
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-item:hover,
|
||||||
|
.mention-item.active {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-item .mention-username {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-item .mention-uid {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user