feat: 评论系统补充功能完成——图片上传、后台管理、通知跳转高亮
- 评论图片上传(LV4+,经验≥1500),JPEG/PNG 自动转 WebP,二进制搜索质量优化 - 后台评论管理页面,支持关键词搜索、分页、删除,侧边栏新增入口 - 通知跳转高亮:评论通知 RelatedID 改为 postID,消息页链接到 #comments,前端自动滚动 - 新增 adminCommentUseCase/commentStore 接口(遵循 ISP),AdminCommentRow 移至 model 层 - 前端 @mention 联想/图片上传光标插入/评论展开收起/内联回复交互完善
This commit is contained in:
66
internal/controller/admin/admin_comment_controller.go
Normal file
66
internal/controller/admin/admin_comment_controller.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminCommentController 后台评论管理
|
||||||
|
type AdminCommentController struct {
|
||||||
|
commentService adminCommentUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminCommentController 构造函数
|
||||||
|
func NewAdminCommentController(cs adminCommentUseCase) *AdminCommentController {
|
||||||
|
return &AdminCommentController{commentService: cs}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentsPage SSR 页面
|
||||||
|
func (ctrl *AdminCommentController) CommentsPage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "admin/comments/index.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "评论管理",
|
||||||
|
"ExtraCSS": "/admin/static/css/comments.css",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListComments API 列表
|
||||||
|
func (ctrl *AdminCommentController) ListComments(c *gin.Context) {
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
comments, total, err := ctrl.commentService.ListAllComments(keyword, false, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取评论列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if comments == nil {
|
||||||
|
comments = []model.AdminCommentRow{}
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{
|
||||||
|
"items": comments,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"total_pages": common.PageCount(total, pageSize),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 软删除评论
|
||||||
|
func (ctrl *AdminCommentController) Delete(c *gin.Context) {
|
||||||
|
commentID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的评论ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ctrl.commentService.Delete(uint(commentID)); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.OkMessage(c, "删除成功")
|
||||||
|
}
|
||||||
@ -46,3 +46,9 @@ type adminPostUseCase interface {
|
|||||||
Unlock(postID uint) error
|
Unlock(postID uint) error
|
||||||
Restore(postID uint) error
|
Restore(postID uint) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adminCommentUseCase AdminCommentController 对 CommentService 的最小依赖(ISP:2 个方法)
|
||||||
|
type adminCommentUseCase interface {
|
||||||
|
ListAllComments(keyword string, showDeleted bool, page, pageSize int) ([]model.AdminCommentRow, int64, error)
|
||||||
|
Delete(commentID uint) error
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,17 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/common"
|
"metazone.cc/metalab/internal/common"
|
||||||
"metazone.cc/metalab/internal/model"
|
"metazone.cc/metalab/internal/model"
|
||||||
@ -177,8 +185,102 @@ func (ctrl *CommentController) SearchUsers(c *gin.Context) {
|
|||||||
common.Ok(c, users)
|
common.Ok(c, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
// marshalJSON helper
|
// UploadImage 上传评论图片 POST /api/comments/upload-image
|
||||||
func marshalJSON(v interface{}) string {
|
// 需要 LV4+(Exp ≥ 1500),复用帖子图片上传的 WebP 转码逻辑
|
||||||
b, _ := json.Marshal(v)
|
func (ctrl *CommentController) UploadImage(c *gin.Context) {
|
||||||
return string(b)
|
uid, _, ok := common.GetGinUser(c)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// LV4+ 检查(Exp ≥ 1500)
|
||||||
|
expVal, exists := c.Get("exp")
|
||||||
|
if !exists {
|
||||||
|
common.Error(c, http.StatusForbidden, "无法获取经验值")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exp := expVal.(int)
|
||||||
|
if exp < 1500 {
|
||||||
|
common.Error(c, http.StatusForbidden, "需 Lv4 以上才能上传评论图片")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, header, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "请选择文件")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// 检查扩展名
|
||||||
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||||
|
allowedExt := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true}
|
||||||
|
if !allowedExt[ext] {
|
||||||
|
common.Error(c, http.StatusBadRequest, "仅支持 jpg/jpeg/png/gif/webp 格式")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制 5MB
|
||||||
|
if header.Size > 5<<20 {
|
||||||
|
common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "读取文件失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storageDir := "storage/uploads/posts"
|
||||||
|
if err := os.MkdirAll(storageDir, 0755); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := time.Now().UnixMilli()
|
||||||
|
isJPEGPNG := ext == ".jpg" || ext == ".jpeg" || ext == ".png"
|
||||||
|
|
||||||
|
var savePath, url string
|
||||||
|
|
||||||
|
// JPEG/PNG 转 WebP
|
||||||
|
if isJPEGPNG {
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "图片格式无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
webpData := encodeWebPBinary(img, len(data))
|
||||||
|
if webpData != nil && len(webpData) < len(data) {
|
||||||
|
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ".webp"
|
||||||
|
savePath = filepath.Join(storageDir, filename)
|
||||||
|
os.WriteFile(savePath, webpData, 0644)
|
||||||
|
url = "/uploads/posts/" + filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退存储
|
||||||
|
if savePath == "" {
|
||||||
|
dataOut := data
|
||||||
|
if isJPEGPNG {
|
||||||
|
decImg, _, decErr := image.Decode(bytes.NewReader(data))
|
||||||
|
if decErr == nil {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if ext == ".png" {
|
||||||
|
png.Encode(&buf, decImg)
|
||||||
|
} else {
|
||||||
|
jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92})
|
||||||
|
}
|
||||||
|
dataOut = buf.Bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ext
|
||||||
|
savePath = filepath.Join(storageDir, filename)
|
||||||
|
os.WriteFile(savePath, dataOut, 0644)
|
||||||
|
url = "/uploads/posts/" + filename
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回标准 JSON(前端将 URL 插入评论文本)
|
||||||
|
common.Ok(c, gin.H{"url": url})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,3 +40,15 @@ type UserSearchResult struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Level int `json:"level"`
|
Level int `json:"level"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminCommentRow 后台评论列表行
|
||||||
|
type AdminCommentRow struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
PostID uint `json:"post_id"`
|
||||||
|
PostTitle string `json:"post_title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
AuthorName string `json:"author_name"`
|
||||||
|
IsDeleted bool `json:"is_deleted"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|||||||
@ -248,3 +248,31 @@ func (r *CommentRepo) FindPostAuthorID(postID uint) (uint, error) {
|
|||||||
err := r.db.Table("posts").Select("user_id").Where("id = ?", postID).Scan(&authorID).Error
|
err := r.db.Table("posts").Select("user_id").Where("id = ?", postID).Scan(&authorID).Error
|
||||||
return authorID, err
|
return authorID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAllComments 后台评论列表(支持关键词搜索、分页)
|
||||||
|
func (r *CommentRepo) ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error) {
|
||||||
|
var total int64
|
||||||
|
q := r.db.Table("comments").
|
||||||
|
Joins("LEFT JOIN users ON users.uid = comments.user_id").
|
||||||
|
Joins("LEFT JOIN posts ON posts.id = comments.post_id")
|
||||||
|
|
||||||
|
if !showDeleted {
|
||||||
|
q = q.Where("comments.is_deleted = false")
|
||||||
|
}
|
||||||
|
if keyword != "" {
|
||||||
|
like := "%" + keyword + "%"
|
||||||
|
q = q.Where("comments.body LIKE ? OR users.username LIKE ? OR posts.title LIKE ?", like, like, like)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []model.AdminCommentRow
|
||||||
|
err := q.
|
||||||
|
Select("comments.id, comments.post_id, posts.title as post_title, comments.body, users.username as author_name, comments.is_deleted, comments.created_at, comments.updated_at").
|
||||||
|
Order("comments.created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).
|
||||||
|
Scan(&rows).Error
|
||||||
|
return rows, total, err
|
||||||
|
}
|
||||||
|
|||||||
@ -30,6 +30,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
middleware.RequirePageRole(model.RoleOwner))
|
middleware.RequirePageRole(model.RoleOwner))
|
||||||
adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage)
|
adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage)
|
||||||
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
|
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
|
||||||
|
adminPages.GET("/comments", d.adminCommentCtrl.CommentsPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 管理后台 API ---
|
// --- 管理后台 API ---
|
||||||
@ -85,4 +86,15 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
middleware.RequireMinRole(model.RoleOwner))
|
middleware.RequireMinRole(model.RoleOwner))
|
||||||
adminEnergyAPI.GET("/logs", d.adminEnergyCtrl.ListEnergyLogs)
|
adminEnergyAPI.GET("/logs", d.adminEnergyCtrl.ListEnergyLogs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 评论管理 API(admin+) ---
|
||||||
|
adminCommentAPI := r.Group("/api/admin/comments")
|
||||||
|
adminCommentAPI.Use(d.authMdw.Required())
|
||||||
|
adminCommentAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
|
||||||
|
adminCommentAPI.Use(d.authMdw.BannedWriteGuard())
|
||||||
|
adminCommentAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
adminCommentAPI.GET("", d.adminCommentCtrl.ListComments)
|
||||||
|
adminCommentAPI.DELETE("/:id", d.adminCommentCtrl.Delete)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -116,5 +116,6 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
commentsAPI.POST("/comments/:id/reply", d.commentCtrl.CreateReply)
|
commentsAPI.POST("/comments/:id/reply", d.commentCtrl.CreateReply)
|
||||||
commentsAPI.DELETE("/comments/:id", d.commentCtrl.Delete)
|
commentsAPI.DELETE("/comments/:id", d.commentCtrl.Delete)
|
||||||
commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers)
|
commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers)
|
||||||
|
commentsAPI.POST("/comments/upload-image", d.commentCtrl.UploadImage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,7 @@ type dependencies struct {
|
|||||||
energyService *service.EnergyService
|
energyService *service.EnergyService
|
||||||
commentCtrl *controller.CommentController
|
commentCtrl *controller.CommentController
|
||||||
commentService *service.CommentService
|
commentService *service.CommentService
|
||||||
|
adminCommentCtrl *adminCtrl.AdminCommentController
|
||||||
}
|
}
|
||||||
|
|
||||||
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) (
|
||||||
|
|||||||
@ -64,6 +64,9 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
commentService := service.NewCommentService(commentRepo, notificationService)
|
commentService := service.NewCommentService(commentRepo, notificationService)
|
||||||
commentCtrl := controller.NewCommentController(commentService)
|
commentCtrl := controller.NewCommentController(commentService)
|
||||||
|
|
||||||
|
// 后台评论管理
|
||||||
|
adminCommentCtrl := adminCtrl.NewAdminCommentController(commentService)
|
||||||
|
|
||||||
// 用户空间
|
// 用户空间
|
||||||
spaceService := service.NewSpaceService(userRepo, postRepo)
|
spaceService := service.NewSpaceService(userRepo, postRepo)
|
||||||
spaceCtrl := controller.NewSpaceController(spaceService)
|
spaceCtrl := controller.NewSpaceController(spaceService)
|
||||||
@ -84,5 +87,6 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
energyService: energyService,
|
energyService: energyService,
|
||||||
commentCtrl: commentCtrl,
|
commentCtrl: commentCtrl,
|
||||||
commentService: commentService,
|
commentService: commentService,
|
||||||
|
adminCommentCtrl: adminCommentCtrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,13 +58,13 @@ func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*mod
|
|||||||
// 解析 @ 提及
|
// 解析 @ 提及
|
||||||
s.parseMentions(comment.ID, body)
|
s.parseMentions(comment.ID, body)
|
||||||
|
|
||||||
// 通知文章作者(评论者 ≠ 作者)
|
// 通知文章作者(评论者 ≠ 作者),RelatedID 存 postID 以便消息页生成跳转链接
|
||||||
if s.notifier != nil && userID != postAuthorID {
|
if s.notifier != nil && userID != postAuthorID {
|
||||||
commenterUID, _ := s.repo.GetUserUIDByID(userID)
|
commenterUID, _ := s.repo.GetUserUIDByID(userID)
|
||||||
s.notifier.Create(postAuthorID, model.NotifyComment,
|
s.notifier.Create(postAuthorID, model.NotifyComment,
|
||||||
"新评论",
|
"新评论",
|
||||||
fmt.Sprintf("%s 评论了你的文章", commenterUID),
|
fmt.Sprintf("%s 评论了你的文章", commenterUID),
|
||||||
&comment.ID)
|
&comment.PostID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return comment, nil
|
return comment, nil
|
||||||
@ -106,13 +106,13 @@ func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (*
|
|||||||
// 解析 @ 提及
|
// 解析 @ 提及
|
||||||
s.parseMentions(comment.ID, body)
|
s.parseMentions(comment.ID, body)
|
||||||
|
|
||||||
// 通知被回复者(不自己回复自己)
|
// 通知被回复者(不自己回复自己),RelatedID 存 postID 以便消息页生成跳转链接
|
||||||
if s.notifier != nil && parent.UserID != userID {
|
if s.notifier != nil && parent.UserID != userID {
|
||||||
commenterUID, _ := s.repo.GetUserUIDByID(userID)
|
commenterUID, _ := s.repo.GetUserUIDByID(userID)
|
||||||
s.notifier.Create(parent.UserID, model.NotifyCommentReply,
|
s.notifier.Create(parent.UserID, model.NotifyCommentReply,
|
||||||
"新回复",
|
"新回复",
|
||||||
fmt.Sprintf("%s 回复了你的评论", commenterUID),
|
fmt.Sprintf("%s 回复了你的评论", commenterUID),
|
||||||
&comment.ID)
|
&parent.PostID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return comment, nil
|
return comment, nil
|
||||||
@ -159,6 +159,17 @@ func (s *CommentService) ListReplies(rootID uint) ([]model.Comment, error) {
|
|||||||
return list, nil
|
return list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListAllComments 后台评论列表(分页+搜索)
|
||||||
|
func (s *CommentService) ListAllComments(keyword string, showDeleted bool, page, pageSize int) ([]model.AdminCommentRow, int64, error) {
|
||||||
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
|
p.DefaultPagination()
|
||||||
|
rows, total, err := s.repo.ListAllComments(keyword, showDeleted, p.Offset(), p.PageSize)
|
||||||
|
if rows == nil {
|
||||||
|
rows = []model.AdminCommentRow{}
|
||||||
|
}
|
||||||
|
return rows, total, err
|
||||||
|
}
|
||||||
|
|
||||||
// SearchUsers 搜索LV3+用户(用于@艾特)
|
// SearchUsers 搜索LV3+用户(用于@艾特)
|
||||||
func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult, error) {
|
func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult, error) {
|
||||||
rows, err := s.repo.SearchUsersByLevel(keyword, 3, 10)
|
rows, err := s.repo.SearchUsersByLevel(keyword, 3, 10)
|
||||||
|
|||||||
@ -134,6 +134,7 @@ type commentStore interface {
|
|||||||
Username string
|
Username string
|
||||||
}, error)
|
}, error)
|
||||||
GetUserUIDByID(userID uint) (string, error)
|
GetUserUIDByID(userID uint) (string, error)
|
||||||
|
ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// commentNotifier CommentService 所需的通知接口(ISP)
|
// commentNotifier CommentService 所需的通知接口(ISP)
|
||||||
|
|||||||
@ -41,6 +41,8 @@
|
|||||||
{{$link := ""}}
|
{{$link := ""}}
|
||||||
{{if or (eq $m.NotifyType "post_approved") (eq $m.NotifyType "post_rejected")}}
|
{{if or (eq $m.NotifyType "post_approved") (eq $m.NotifyType "post_rejected")}}
|
||||||
{{$link = printf "/posts/%d" (derefUint $m.RelatedID)}}
|
{{$link = printf "/posts/%d" (derefUint $m.RelatedID)}}
|
||||||
|
{{else if or (eq $m.NotifyType "comment") (eq $m.NotifyType "comment_reply")}}
|
||||||
|
{{$link = printf "/posts/%d#comments" (derefUint $m.RelatedID)}}
|
||||||
{{else if or (eq $m.NotifyType "audit_approved") (eq $m.NotifyType "audit_rejected")}}
|
{{else if or (eq $m.NotifyType "audit_approved") (eq $m.NotifyType "audit_rejected")}}
|
||||||
{{$link = "/settings"}}
|
{{$link = "/settings"}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -80,8 +80,16 @@
|
|||||||
{{if .IsLoggedIn}}
|
{{if .IsLoggedIn}}
|
||||||
<div class="comment-form">
|
<div class="comment-form">
|
||||||
<textarea class="comment-input" id="commentInput" placeholder="写下你的评论..." rows="3" maxlength="5000"></textarea>
|
<textarea class="comment-input" id="commentInput" placeholder="写下你的评论..." rows="3" maxlength="5000"></textarea>
|
||||||
|
<div class="comment-form-actions">
|
||||||
|
<label class="comment-upload-btn" id="commentUploadLabel">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||||
|
图片
|
||||||
|
</label>
|
||||||
|
<input type="file" id="commentImageInput" accept="image/*" style="display:none" />
|
||||||
<button class="btn btn-primary comment-submit-btn" id="commentSubmitBtn">发表评论</button>
|
<button class="btn btn-primary comment-submit-btn" id="commentSubmitBtn">发表评论</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="comment-upload-preview" id="commentUploadPreview"></div>
|
||||||
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="comment-login-hint">
|
<div class="comment-login-hint">
|
||||||
<a href="/login?redirect=/posts/{{$.Post.ID}}">登录</a> 后参与评论
|
<a href="/login?redirect=/posts/{{$.Post.ID}}">登录</a> 后参与评论
|
||||||
@ -640,6 +648,71 @@
|
|||||||
el.textContent = '(' + (current + delta) + ')';
|
el.textContent = '(' + (current + delta) + ')';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====== 图片上传 ======
|
||||||
|
var imageUploadInput = document.getElementById('commentImageInput');
|
||||||
|
var uploadLabel = document.getElementById('commentUploadLabel');
|
||||||
|
var uploadPreview = document.getElementById('commentUploadPreview');
|
||||||
|
|
||||||
|
if (imageUploadInput && uploadLabel) {
|
||||||
|
uploadLabel.addEventListener('click', function() {
|
||||||
|
imageUploadInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
imageUploadInput.addEventListener('change', function() {
|
||||||
|
var file = imageUploadInput.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
uploadLabel.style.pointerEvents = 'none';
|
||||||
|
uploadLabel.style.opacity = '0.5';
|
||||||
|
uploadLabel.textContent = '上传中...';
|
||||||
|
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
fetch('/api/comments/upload-image', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': csrfToken },
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
uploadLabel.style.pointerEvents = 'auto';
|
||||||
|
uploadLabel.style.opacity = '1';
|
||||||
|
uploadLabel.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> 图片';
|
||||||
|
|
||||||
|
if (d.success && d.data && d.data.url) {
|
||||||
|
// 插入图片 URL 到文本框当前光标位置
|
||||||
|
var imgUrl = '\n\n';
|
||||||
|
var input = commentInput;
|
||||||
|
var start = input.selectionStart;
|
||||||
|
var end = input.selectionEnd;
|
||||||
|
input.value = input.value.substring(0, start) + imgUrl + input.value.substring(end);
|
||||||
|
input.focus();
|
||||||
|
input.selectionStart = input.selectionEnd = start + imgUrl.length;
|
||||||
|
} else {
|
||||||
|
alert(d.message || '上传失败');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
uploadLabel.style.pointerEvents = 'auto';
|
||||||
|
uploadLabel.style.opacity = '1';
|
||||||
|
uploadLabel.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg> 图片';
|
||||||
|
alert('上传失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
imageUploadInput.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== Hash 定位:从消息通知跳转时滚动到评论区 ======
|
||||||
|
if (window.location.hash === '#comments') {
|
||||||
|
setTimeout(function() {
|
||||||
|
var section = document.getElementById('comments');
|
||||||
|
if (section) {
|
||||||
|
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
// ====== 初始化 ======
|
// ====== 初始化 ======
|
||||||
loadComments(currentPage);
|
loadComments(currentPage);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -756,10 +756,43 @@
|
|||||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-submit-btn {
|
.comment-form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment-upload-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
transition: border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-upload-btn:hover {
|
||||||
|
border-color: #6366f1;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-upload-preview {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-submit-btn {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.comment-login-hint {
|
.comment-login-hint {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|||||||
33
templates/admin/html/comments/index.html
Normal file
33
templates/admin/html/comments/index.html
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{{template "admin/layout/base.html" .}}
|
||||||
|
|
||||||
|
<div class="admin-page-header">
|
||||||
|
<h2>评论管理</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<input type="text" id="commentSearch" class="admin-search-input" placeholder="搜索评论内容、作者或文章标题..." />
|
||||||
|
<button id="commentSearchBtn" class="btn btn-secondary">搜索</button>
|
||||||
|
<button id="commentClearBtn" class="btn btn-secondary" style="display:none">清除</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-table-wrap" id="commentTableWrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:60px">ID</th>
|
||||||
|
<th style="width:100px">作者</th>
|
||||||
|
<th>评论内容</th>
|
||||||
|
<th style="width:160px">所属文章</th>
|
||||||
|
<th style="width:140px">时间</th>
|
||||||
|
<th style="width:100px">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="commentTableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-pagination" id="commentPagination"></div>
|
||||||
|
<div class="admin-loading" id="commentLoading" style="display:none">加载中...</div>
|
||||||
|
|
||||||
|
{{template "admin/layout/footer.html" .}}
|
||||||
|
<script src="/admin/static/js/comments.js?v={{assetV "/admin/static/js/comments.js"}}"></script>
|
||||||
@ -51,6 +51,10 @@
|
|||||||
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
|
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
|
||||||
域能日志
|
域能日志
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/comments" class="sidebar-item {{if eq .CurrentPath "/admin/comments"}}active{{end}}">
|
||||||
|
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
|
||||||
|
评论管理
|
||||||
|
</a>
|
||||||
{{if .CanManageSettings}}
|
{{if .CanManageSettings}}
|
||||||
<a href="/admin/site-settings" class="sidebar-item {{if eq .CurrentPath "/admin/site-settings"}}active{{end}}">
|
<a href="/admin/site-settings" class="sidebar-item {{if eq .CurrentPath "/admin/site-settings"}}active{{end}}">
|
||||||
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||||
|
|||||||
132
templates/admin/static/js/comments.js
Normal file
132
templates/admin/static/js/comments.js
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
var currentPage = 1;
|
||||||
|
var currentKeyword = '';
|
||||||
|
var totalPages = 1;
|
||||||
|
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
|
||||||
|
|
||||||
|
var searchInput = document.getElementById('commentSearch');
|
||||||
|
var searchBtn = document.getElementById('commentSearchBtn');
|
||||||
|
var clearBtn = document.getElementById('commentClearBtn');
|
||||||
|
var tbody = document.getElementById('commentTableBody');
|
||||||
|
var pagination = document.getElementById('commentPagination');
|
||||||
|
var loading = document.getElementById('commentLoading');
|
||||||
|
|
||||||
|
function api(url, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
return fetch(url, opts).then(function(r) { return r.json(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadComments(page) {
|
||||||
|
loading.style.display = 'block';
|
||||||
|
var url = '/api/admin/comments?page=' + page + '&page_size=20';
|
||||||
|
if (currentKeyword) url += '&keyword=' + encodeURIComponent(currentKeyword);
|
||||||
|
|
||||||
|
api(url).then(function(d) {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
if (!d.success) return;
|
||||||
|
totalPages = d.data.total_pages || 1;
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
var items = d.data.items || [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:40px;color:#9ca3af">暂无评论</td></tr>';
|
||||||
|
pagination.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
var c = items[i];
|
||||||
|
var row = document.createElement('tr');
|
||||||
|
var body = escapeHtml(c.body).substring(0, 80) + (c.body && c.body.length > 80 ? '...' : '');
|
||||||
|
row.innerHTML =
|
||||||
|
'<td>' + c.id + '</td>' +
|
||||||
|
'<td>' + escapeHtml(c.author_name) + '</td>' +
|
||||||
|
'<td class="comment-body-cell">' + body + '</td>' +
|
||||||
|
'<td><a href="/posts/' + c.post_id + '" target="_blank">' + escapeHtml(c.post_title || '文章#' + c.post_id) + '</a></td>' +
|
||||||
|
'<td>' + formatTime(c.created_at) + '</td>' +
|
||||||
|
'<td><button class="btn btn-danger btn-sm comment-delete-btn" data-id="' + c.id + '">删除</button></td>';
|
||||||
|
tbody.appendChild(row);
|
||||||
|
}
|
||||||
|
renderPagination(page);
|
||||||
|
bindDeleteBtns();
|
||||||
|
}).catch(function() {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:40px;color:#dc2626">加载失败</td></tr>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindDeleteBtns() {
|
||||||
|
var btns = tbody.querySelectorAll('.comment-delete-btn');
|
||||||
|
for (var i = 0; i < btns.length; i++) {
|
||||||
|
if (btns[i]._bound) continue;
|
||||||
|
btns[i]._bound = true;
|
||||||
|
btns[i].addEventListener('click', function() {
|
||||||
|
var id = this.dataset.id;
|
||||||
|
if (!confirm('确认删除此评论?')) return;
|
||||||
|
fetch('/api/admin/comments/' + id, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'X-CSRF-Token': csrfToken }
|
||||||
|
}).then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (d.success) {
|
||||||
|
loadComments(currentPage);
|
||||||
|
} else {
|
||||||
|
alert(d.message || '删除失败');
|
||||||
|
}
|
||||||
|
}).catch(function() { alert('请求失败'); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination(page) {
|
||||||
|
if (totalPages <= 1) { pagination.innerHTML = ''; return; }
|
||||||
|
var html = '';
|
||||||
|
if (page > 1) html += '<a href="javascript:void(0)" class="admin-page-link" data-page="' + (page-1) + '">上一页</a>';
|
||||||
|
html += '<span class="admin-page-info">' + page + ' / ' + totalPages + '</span>';
|
||||||
|
if (page < totalPages) html += '<a href="javascript:void(0)" class="admin-page-link" data-page="' + (page+1) + '">下一页</a>';
|
||||||
|
pagination.innerHTML = html;
|
||||||
|
|
||||||
|
var links = pagination.querySelectorAll('.admin-page-link');
|
||||||
|
for (var j = 0; j < links.length; j++) {
|
||||||
|
links[j].addEventListener('click', function() {
|
||||||
|
currentPage = parseInt(this.dataset.page);
|
||||||
|
loadComments(currentPage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchBtn.addEventListener('click', function() {
|
||||||
|
currentKeyword = searchInput.value.trim();
|
||||||
|
currentPage = 1;
|
||||||
|
if (currentKeyword) { clearBtn.style.display = 'inline-block'; }
|
||||||
|
else { clearBtn.style.display = 'none'; }
|
||||||
|
loadComments(currentPage);
|
||||||
|
});
|
||||||
|
|
||||||
|
clearBtn.addEventListener('click', function() {
|
||||||
|
currentKeyword = '';
|
||||||
|
currentPage = 1;
|
||||||
|
searchInput.value = '';
|
||||||
|
clearBtn.style.display = 'none';
|
||||||
|
loadComments(currentPage);
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') searchBtn.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadComments(currentPage);
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user