diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index 9e1e66c..ae70e5e 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -102,7 +102,7 @@ - [x] 按帖拉取评论列表(楼层、引用目标)— SSR 帖详情;扁平列表 + `reply_to` 展示(嵌套树 UI 未做) - [x] 发表评论(登录);支持 `reply_to`、私密评论 — `POST /post/:id/comments` - [ ] 游客评论(公开接口可写,字段 guest_*) -- [ ] 编辑评论(时限);删除评论 +- [x] 编辑评论(时限);删除评论 — SSR 帖详情入口 + `/comments/:cid/edit|delete`;作者软删进回收站 - [x] 评论点赞 — `POST /post/:id/comments/:cid/like` - [x] 评论举报 — SSR - [ ] @ 提及 → 通知 diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index bafb0b5..88ceecf 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -147,7 +147,7 @@ | 投票卡 | 选选项提交;显示百分比;作者可结束 | | 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 | | 抽奖卡 | 显示参与人数;开奖;中奖名单 | -| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报 — SSR;@ / 编辑未迁 | +| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除 — SSR;@ 未迁 | | 修订 | 面板列出历史,可选对比 | | 图片 | Lightbox 查看 | diff --git a/routers/web/home.go b/routers/web/home.go index b985b32..20d2a9e 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -23,6 +23,9 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { g.GET("/post/:id/edit", authMW.RequireAuth(), deps.PostEditGet) g.POST("/post/:id/edit", authMW.RequireAuth(), deps.PostEditPost) g.POST("/post/:id/comments", authMW.RequireAuth(), deps.PostComment) + g.GET("/post/:id/comments/:cid/edit", authMW.RequireAuth(), deps.CommentEditGet) + g.POST("/post/:id/comments/:cid/edit", authMW.RequireAuth(), deps.CommentEditPost) + g.POST("/post/:id/comments/:cid/delete", authMW.RequireAuth(), deps.CommentDeletePost) g.POST("/post/:id/comments/:cid/like", authMW.RequireAuth(), deps.PostCommentLike) g.POST("/post/:id/like", authMW.RequireAuth(), deps.PostLike) g.POST("/post/:id/favorite", authMW.RequireAuth(), deps.PostFavorite) diff --git a/routers/web/post.go b/routers/web/post.go index 87f2bbd..afb7690 100644 --- a/routers/web/post.go +++ b/routers/web/post.go @@ -9,6 +9,7 @@ import ( "strings" "git.iioio.com/freefire/jiang13-forum/models" + "git.iioio.com/freefire/jiang13-forum/modules/webctx" "git.iioio.com/freefire/jiang13-forum/services" "github.com/gin-gonic/gin" ) @@ -59,6 +60,8 @@ type CommentView struct { Liked bool IsPrivate bool CanReport bool + CanEdit bool + CanDelete bool } // PostView GET /post/:id @@ -95,6 +98,8 @@ func (d Deps) PostView(c *gin.Context) { Content: cm.Content, ContentHidden: cm.ContentHidden, LikeCount: cm.LikeCount, Liked: cm.Liked, IsPrivate: cm.IsPrivate, CanReport: ctx.IsSigned() && (cm.UserID == 0 || cm.UserID != ctx.UserID()), + CanEdit: !cm.ContentHidden && d.Comment.CanUserEditComment(&cm, ctx.UserID(), ctx.IsAdmin()), + CanDelete: !cm.ContentHidden && d.Comment.CanUserDeleteComment(&cm, ctx.UserID(), ctx.IsAdmin()), } if cm.ReplyTarget != nil { view.ReplyToID = cm.ReplyTarget.ID @@ -298,6 +303,149 @@ func (d Deps) PostFavorite(c *gin.Context) { ctx.Redirect(fmt.Sprintf("/post/%d", id)) } +type commentEditData struct { + PageChrome + PostID uint + CommentID uint + Floor int + Content string +} + +// CommentEditGet 编辑评论页 +func (d Deps) CommentEditGet(c *gin.Context) { + ctx := d.ctx(c) + postID, cm, errMsg := d.loadEditableComment(ctx, c) + if errMsg != "" { + ctx.SetFlash(errMsg) + if postID > 0 { + ctx.Redirect(fmt.Sprintf("/post/%d#comments", postID)) + return + } + ctx.Redirect("/") + return + } + chrome := d.chrome(ctx, "编辑评论 · "+d.Settings.SiteBranding().Name, "", "") + ctx.HTML(http.StatusOK, "post/comment_edit", commentEditData{ + PageChrome: chrome, + PostID: postID, + CommentID: cm.ID, + Floor: cm.Floor, + Content: commentHTMLToPlain(cm.Content), + }) +} + +// CommentEditPost 保存评论编辑 +func (d Deps) CommentEditPost(c *gin.Context) { + ctx := d.ctx(c) + postID, cm, errMsg := d.loadEditableComment(ctx, c) + if errMsg != "" { + ctx.SetFlash(errMsg) + if postID > 0 { + ctx.Redirect(fmt.Sprintf("/post/%d#comments", postID)) + return + } + ctx.Redirect("/") + return + } + if !ctx.CheckCSRF() { + d.renderCommentEdit(ctx, "无效请求,请重试", postID, cm, c.PostForm("content")) + return + } + plain := strings.TrimSpace(c.PostForm("content")) + if plain == "" { + d.renderCommentEdit(ctx, "评论不能为空", postID, cm, plain) + return + } + safe := "

" + html.EscapeString(plain) + "

" + _, enteredPending, err := d.Comment.Update(ctx.UserID(), cm.ID, ctx.IsAdmin(), ctx.SkipsModeration(), safe) + if err != nil { + d.renderCommentEdit(ctx, err.Error(), postID, cm, plain) + return + } + if enteredPending { + ctx.SetFlash("评论已更新,审核通过后公开显示") + } else { + ctx.SetFlash("评论已更新") + } + ctx.Redirect(fmt.Sprintf("/post/%d#floor-%d", postID, cm.Floor)) +} + +// CommentDeletePost 软删评论 +func (d Deps) CommentDeletePost(c *gin.Context) { + ctx := d.ctx(c) + postID, err := parsePostID(c, d) + if err != nil || postID == 0 { + d.render404(ctx) + return + } + cid, err := strconv.ParseUint(c.Param("cid"), 10, 64) + if err != nil || cid == 0 { + d.render404(ctx) + return + } + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求,请重试") + ctx.Redirect(fmt.Sprintf("/post/%d#comments", postID)) + return + } + cm, err := d.Comment.GetByID(uint(cid)) + if err != nil || cm.PostID != postID { + d.render404(ctx) + return + } + if !d.Comment.CanUserDeleteComment(cm, ctx.UserID(), ctx.IsAdmin()) { + ctx.SetFlash(services.ErrPermissionDenied.Error()) + ctx.Redirect(fmt.Sprintf("/post/%d#comments", postID)) + return + } + if err := d.Comment.Delete(ctx.UserID(), cm.ID, ctx.IsAdmin()); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect(fmt.Sprintf("/post/%d#floor-%d", postID, cm.Floor)) + return + } + ctx.SetFlash("评论已删除") + ctx.Redirect(fmt.Sprintf("/post/%d#comments", postID)) +} + +func (d Deps) loadEditableComment(ctx *webctx.Context, c *gin.Context) (postID uint, cm *models.Comment, errMsg string) { + pid, err := parsePostID(c, d) + if err != nil || pid == 0 { + return 0, nil, "帖子不存在" + } + cid, err := strconv.ParseUint(c.Param("cid"), 10, 64) + if err != nil || cid == 0 { + return pid, nil, "评论不存在" + } + cm, err = d.Comment.GetByID(uint(cid)) + if err != nil || cm.PostID != pid { + return pid, nil, "评论不存在" + } + if !d.Comment.CanUserEditComment(cm, ctx.UserID(), ctx.IsAdmin()) { + return pid, cm, "无权编辑该评论或已超过可编辑时限" + } + return pid, cm, "" +} + +func (d Deps) renderCommentEdit(ctx *webctx.Context, errMsg string, postID uint, cm *models.Comment, content string) { + chrome := d.chrome(ctx, "编辑评论 · "+d.Settings.SiteBranding().Name, "", "") + chrome.Error = errMsg + ctx.HTML(http.StatusOK, "post/comment_edit", commentEditData{ + PageChrome: chrome, + PostID: postID, + CommentID: cm.ID, + Floor: cm.Floor, + Content: content, + }) +} + +// commentHTMLToPlain 发评存的是转义后的

,编辑时还原为纯文本 +func commentHTMLToPlain(s string) string { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(s, "

") + s = strings.TrimSuffix(s, "

") + return html.UnescapeString(s) +} + func parsePostID(c *gin.Context, d Deps) (uint, error) { idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext) id, err := strconv.ParseUint(idStr, 10, 64) diff --git a/services/comment.go b/services/comment.go index d21c987..15cd29a 100644 --- a/services/comment.go +++ b/services/comment.go @@ -369,12 +369,52 @@ func (s *CommentService) ListPending(page, size int) ([]models.Comment, int64, e } func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error { - if !isAdmin { + var comment models.Comment + if err := models.DB.First(&comment, commentID).Error; err != nil { + return ErrCommentNotFound + } + if !isAdmin && (comment.UserID == 0 || comment.UserID != userID) { return ErrPermissionDenied } return s.AdminDelete(commentID) } +// CanUserEditComment 作者在编辑时限内,或管理员 +func (s *CommentService) CanUserEditComment(comment *models.Comment, userID uint, isAdmin bool) bool { + window := 3 + if s != nil && s.settings != nil { + window = s.settings.CommentEditWindowMinutes() + } + return canEditComment(comment, userID, isAdmin, window) +} + +// CanUserDeleteComment 作者或管理员可软删 +func (s *CommentService) CanUserDeleteComment(comment *models.Comment, userID uint, isAdmin bool) bool { + if comment == nil || userID == 0 { + return false + } + if isAdmin { + return true + } + return comment.UserID > 0 && comment.UserID == userID +} + +func canEditComment(comment *models.Comment, userID uint, isAdmin bool, windowMin int) bool { + if comment == nil || userID == 0 { + return false + } + if isAdmin { + return true + } + if comment.UserID == 0 || comment.UserID != userID { + return false + } + if windowMin > 0 && time.Since(comment.CreatedAt) > time.Duration(windowMin)*time.Minute { + return false + } + return true +} + func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration bool, content string) (string, bool, error) { var comment models.Comment if err := models.DB.First(&comment, commentID).Error; err != nil { diff --git a/services/comment_edit_test.go b/services/comment_edit_test.go new file mode 100644 index 0000000..3e0ce78 --- /dev/null +++ b/services/comment_edit_test.go @@ -0,0 +1,47 @@ +package services + +import ( + "testing" + "time" + + "git.iioio.com/freefire/jiang13-forum/models" +) + +func TestCanEditComment(t *testing.T) { + now := time.Now() + c := &models.Comment{UserID: 7, CreatedAt: now} + if !canEditComment(c, 7, false, 3) { + t.Fatal("author within window") + } + if canEditComment(c, 8, false, 3) { + t.Fatal("other user") + } + if !canEditComment(c, 1, true, 3) { + t.Fatal("admin always") + } + old := &models.Comment{UserID: 7, CreatedAt: now.Add(-10 * time.Minute)} + if canEditComment(old, 7, false, 3) { + t.Fatal("expired") + } + if !canEditComment(old, 7, true, 3) { + t.Fatal("admin ignores window") + } +} + +func TestCanUserDeleteComment(t *testing.T) { + s := &CommentService{} + c := &models.Comment{UserID: 3} + if !s.CanUserDeleteComment(c, 3, false) { + t.Fatal("author") + } + if s.CanUserDeleteComment(c, 4, false) { + t.Fatal("other") + } + if !s.CanUserDeleteComment(c, 1, true) { + t.Fatal("admin") + } + guest := &models.Comment{UserID: 0} + if s.CanUserDeleteComment(guest, 1, false) { + t.Fatal("guest author id 0") + } +} diff --git a/templates/post/body.tmpl b/templates/post/body.tmpl index acd33da..1676b02 100644 --- a/templates/post/body.tmpl +++ b/templates/post/body.tmpl @@ -116,6 +116,15 @@ {{if not $.CommentsLocked}} {{end}} + {{if .CanEdit}} + 编辑 + {{end}} + {{if .CanDelete}} +
+ + +
+ {{end}} {{if .CanReport}}
举报 diff --git a/templates/post/comment_edit.tmpl b/templates/post/comment_edit.tmpl new file mode 100644 index 0000000..3752bc2 --- /dev/null +++ b/templates/post/comment_edit.tmpl @@ -0,0 +1,19 @@ +{{define "post/comment_edit"}} +{{template "base/head" .}} +{{template "base/navbar" .}} +
+

编辑评论

+

#{{.Floor}} · 返回帖子

+ {{template "base/alert" .}} +
+ + + +
+ + 取消 +
+
+
+{{template "base/footer" .}} +{{end}}