feat: SSR 评论编辑与作者删除
帖详情提供时限内编辑与软删入口,作者删除与管理员共用回收站子树语义。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -102,7 +102,7 @@
|
|||||||
- [x] 按帖拉取评论列表(楼层、引用目标)— SSR 帖详情;扁平列表 + `reply_to` 展示(嵌套树 UI 未做)
|
- [x] 按帖拉取评论列表(楼层、引用目标)— SSR 帖详情;扁平列表 + `reply_to` 展示(嵌套树 UI 未做)
|
||||||
- [x] 发表评论(登录);支持 `reply_to`、私密评论 — `POST /post/:id/comments`
|
- [x] 发表评论(登录);支持 `reply_to`、私密评论 — `POST /post/:id/comments`
|
||||||
- [ ] 游客评论(公开接口可写,字段 guest_*)
|
- [ ] 游客评论(公开接口可写,字段 guest_*)
|
||||||
- [ ] 编辑评论(时限);删除评论
|
- [x] 编辑评论(时限);删除评论 — SSR 帖详情入口 + `/comments/:cid/edit|delete`;作者软删进回收站
|
||||||
- [x] 评论点赞 — `POST /post/:id/comments/:cid/like`
|
- [x] 评论点赞 — `POST /post/:id/comments/:cid/like`
|
||||||
- [x] 评论举报 — SSR
|
- [x] 评论举报 — SSR
|
||||||
- [ ] @ 提及 → 通知
|
- [ ] @ 提及 → 通知
|
||||||
|
|||||||
@@ -147,7 +147,7 @@
|
|||||||
| 投票卡 | 选选项提交;显示百分比;作者可结束 |
|
| 投票卡 | 选选项提交;显示百分比;作者可结束 |
|
||||||
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 |
|
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 |
|
||||||
| 抽奖卡 | 显示参与人数;开奖;中奖名单 |
|
| 抽奖卡 | 显示参与人数;开奖;中奖名单 |
|
||||||
| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报 — SSR;@ / 编辑未迁 |
|
| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除 — SSR;@ 未迁 |
|
||||||
| 修订 | 面板列出历史,可选对比 |
|
| 修订 | 面板列出历史,可选对比 |
|
||||||
| 图片 | Lightbox 查看 |
|
| 图片 | Lightbox 查看 |
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
|
|||||||
g.GET("/post/:id/edit", authMW.RequireAuth(), deps.PostEditGet)
|
g.GET("/post/:id/edit", authMW.RequireAuth(), deps.PostEditGet)
|
||||||
g.POST("/post/:id/edit", authMW.RequireAuth(), deps.PostEditPost)
|
g.POST("/post/:id/edit", authMW.RequireAuth(), deps.PostEditPost)
|
||||||
g.POST("/post/:id/comments", authMW.RequireAuth(), deps.PostComment)
|
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/comments/:cid/like", authMW.RequireAuth(), deps.PostCommentLike)
|
||||||
g.POST("/post/:id/like", authMW.RequireAuth(), deps.PostLike)
|
g.POST("/post/:id/like", authMW.RequireAuth(), deps.PostLike)
|
||||||
g.POST("/post/:id/favorite", authMW.RequireAuth(), deps.PostFavorite)
|
g.POST("/post/:id/favorite", authMW.RequireAuth(), deps.PostFavorite)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/models"
|
"git.iioio.com/freefire/jiang13-forum/models"
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||||
"git.iioio.com/freefire/jiang13-forum/services"
|
"git.iioio.com/freefire/jiang13-forum/services"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -59,6 +60,8 @@ type CommentView struct {
|
|||||||
Liked bool
|
Liked bool
|
||||||
IsPrivate bool
|
IsPrivate bool
|
||||||
CanReport bool
|
CanReport bool
|
||||||
|
CanEdit bool
|
||||||
|
CanDelete bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostView GET /post/:id
|
// PostView GET /post/:id
|
||||||
@@ -95,6 +98,8 @@ func (d Deps) PostView(c *gin.Context) {
|
|||||||
Content: cm.Content, ContentHidden: cm.ContentHidden,
|
Content: cm.Content, ContentHidden: cm.ContentHidden,
|
||||||
LikeCount: cm.LikeCount, Liked: cm.Liked, IsPrivate: cm.IsPrivate,
|
LikeCount: cm.LikeCount, Liked: cm.Liked, IsPrivate: cm.IsPrivate,
|
||||||
CanReport: ctx.IsSigned() && (cm.UserID == 0 || cm.UserID != ctx.UserID()),
|
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 {
|
if cm.ReplyTarget != nil {
|
||||||
view.ReplyToID = cm.ReplyTarget.ID
|
view.ReplyToID = cm.ReplyTarget.ID
|
||||||
@@ -298,6 +303,149 @@ func (d Deps) PostFavorite(c *gin.Context) {
|
|||||||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
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 := "<p>" + html.EscapeString(plain) + "</p>"
|
||||||
|
_, 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 发评存的是转义后的 <p>…</p>,编辑时还原为纯文本
|
||||||
|
func commentHTMLToPlain(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
s = strings.TrimPrefix(s, "<p>")
|
||||||
|
s = strings.TrimSuffix(s, "</p>")
|
||||||
|
return html.UnescapeString(s)
|
||||||
|
}
|
||||||
|
|
||||||
func parsePostID(c *gin.Context, d Deps) (uint, error) {
|
func parsePostID(c *gin.Context, d Deps) (uint, error) {
|
||||||
idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext)
|
idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext)
|
||||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
|||||||
@@ -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 {
|
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 ErrPermissionDenied
|
||||||
}
|
}
|
||||||
return s.AdminDelete(commentID)
|
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) {
|
func (s *CommentService) Update(userID, commentID uint, isAdmin, skipModeration bool, content string) (string, bool, error) {
|
||||||
var comment models.Comment
|
var comment models.Comment
|
||||||
if err := models.DB.First(&comment, commentID).Error; err != nil {
|
if err := models.DB.First(&comment, commentID).Error; err != nil {
|
||||||
|
|||||||
47
services/comment_edit_test.go
Normal file
47
services/comment_edit_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,6 +116,15 @@
|
|||||||
{{if not $.CommentsLocked}}
|
{{if not $.CommentsLocked}}
|
||||||
<button type="button" class="j13-linkbtn j13-comment__reply-btn" data-reply-to="{{.ID}}" data-reply-floor="{{.Floor}}" data-reply-author="{{.AuthorName}}">回复</button>
|
<button type="button" class="j13-linkbtn j13-comment__reply-btn" data-reply-to="{{.ID}}" data-reply-floor="{{.Floor}}" data-reply-author="{{.AuthorName}}">回复</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if .CanEdit}}
|
||||||
|
<a class="j13-linkbtn" href="/post/{{$.PostID}}/comments/{{.ID}}/edit">编辑</a>
|
||||||
|
{{end}}
|
||||||
|
{{if .CanDelete}}
|
||||||
|
<form method="post" action="/post/{{$.PostID}}/comments/{{.ID}}/delete" class="j13-inline-form" onsubmit="return confirm('确定删除该评论?回复也会一并移入回收站。');">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
|
||||||
|
<button type="submit" class="j13-linkbtn">删除</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
{{if .CanReport}}
|
{{if .CanReport}}
|
||||||
<details class="j13-report">
|
<details class="j13-report">
|
||||||
<summary class="j13-linkbtn">举报</summary>
|
<summary class="j13-linkbtn">举报</summary>
|
||||||
|
|||||||
19
templates/post/comment_edit.tmpl
Normal file
19
templates/post/comment_edit.tmpl
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{{define "post/comment_edit"}}
|
||||||
|
{{template "base/head" .}}
|
||||||
|
{{template "base/navbar" .}}
|
||||||
|
<main class="j13-main j13-main--solo">
|
||||||
|
<h1>编辑评论</h1>
|
||||||
|
<p class="j13-muted">#{{.Floor}} · <a href="/post/{{.PostID}}#floor-{{.Floor}}">返回帖子</a></p>
|
||||||
|
{{template "base/alert" .}}
|
||||||
|
<form method="post" action="/post/{{.PostID}}/comments/{{.CommentID}}/edit" class="j13-form j13-comment-form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
|
||||||
|
<label for="comment-edit-content">内容</label>
|
||||||
|
<textarea id="comment-edit-content" name="content" rows="6" required maxlength="8000">{{.Content}}</textarea>
|
||||||
|
<div class="j13-form__row">
|
||||||
|
<button type="submit">保存</button>
|
||||||
|
<a class="j13-btn-secondary" href="/post/{{.PostID}}#floor-{{.Floor}}">取消</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
{{template "base/footer" .}}
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user