From d4b29f5b7cc8a4c355469705379b837662994b29 Mon Sep 17 00:00:00 2001 From: freefire Date: Sat, 29 Aug 2026 06:46:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SSR=20=E8=AF=84=E8=AE=BA=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E3=80=81=E7=82=B9=E8=B5=9E=E4=B8=8E=E7=A7=81=E5=AF=86?= =?UTF-8?q?=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 帖详情支持 reply_to 展示与提交、评论点赞 PRG,以及私密评论选项。 Co-authored-by: Cursor --- docs/rebuild-spec/02-features.md | 6 +- docs/rebuild-spec/06-pages-ux.md | 4 +- .../rebuild-spec/08-gitea-ssr-architecture.md | 2 +- public/assets/site.css | 11 +++ public/assets/site.js | 39 +++++++++ routers/web/home.go | 1 + routers/web/post.go | 86 ++++++++++++++++--- templates/post/body.tmpl | 30 ++++++- web_src/css/site.css | 11 +++ web_src/js/site.js | 39 +++++++++ 10 files changed, 209 insertions(+), 20 deletions(-) diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index 5f6a67a..b977172 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -99,11 +99,11 @@ ## F. 评论 -- [ ] 按帖拉取评论列表(楼层、嵌套父级、引用目标) -- [ ] 发表评论(登录);支持 `reply_to`、私密评论 +- [x] 按帖拉取评论列表(楼层、引用目标)— SSR 帖详情;扁平列表 + `reply_to` 展示(嵌套树 UI 未做) +- [x] 发表评论(登录);支持 `reply_to`、私密评论 — `POST /post/:id/comments` - [ ] 游客评论(公开接口可写,字段 guest_*) - [ ] 编辑评论(时限);删除评论 -- [ ] 评论点赞 +- [x] 评论点赞 — `POST /post/:id/comments/:cid/like` - [ ] 评论举报 - [ ] @ 提及 → 通知 - [ ] 回复提醒(站内信 + 可选邮件) diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index 1acb02e..b9db134 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -26,7 +26,7 @@ |------|------|-----| | `/` | Feed | 已迁 | | `/board/:id` | 板块 Feed | 已迁 | -| `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 | +| `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 | | `/compose` | 发帖(normal;textarea + 图片 + 门控插入) | 已迁 | | `/post/:id/edit` | 编辑帖 | 已迁 | | `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 | @@ -142,7 +142,7 @@ | 投票卡 | 选选项提交;显示百分比;作者可结束 | | 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 | | 抽奖卡 | 显示参与人数;开奖;中奖名单 | -| 评论 | 楼层列表、回复、引用、私密开关、@ 用户搜索、点赞、编辑、举报 | +| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞 — SSR;@ / 编辑 / 举报未迁 | | 修订 | 面板列出历史,可选对比 | | 图片 | Lightbox 查看 | diff --git a/docs/rebuild-spec/08-gitea-ssr-architecture.md b/docs/rebuild-spec/08-gitea-ssr-architecture.md index d320ac5..ea55ec8 100644 --- a/docs/rebuild-spec/08-gitea-ssr-architecture.md +++ b/docs/rebuild-spec/08-gitea-ssr-architecture.md @@ -81,7 +81,7 @@ web_src/ → public/assets/ ### 已迁路径(摘要) -公开写:`/install`、`/login`、`/logout`、`/register`、`/forgot-password`(发码/重置)、`/compose`(含门控插入)、`/post/:id/edit`、帖详情评论/赞/藏、积分解锁 `POST /post/:id/unlock`。 +公开写:`/install`、`/login`、`/logout`、`/register`、`/forgot-password`(发码/重置)、`/compose`(含门控插入)、`/post/:id/edit`、帖详情评论(reply_to/赞/私密)/赞/藏、评论赞、积分解锁 `POST /post/:id/unlock`。 个人闭环:`/profile`(昵称/签名/密码/头像、积分钱包/签到/抽奖)、`/user/:id`、`/favorites`。 `POST /profile/checkin`、`POST /profile/lottery`(CSRF + PRG)。 diff --git a/public/assets/site.css b/public/assets/site.css index 1c67ccd..7c972b6 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -235,12 +235,23 @@ body.j13-body { } .j13-comment__meta { display: flex; + flex-wrap: wrap; gap: 0.75rem; color: var(--j13-muted); font-size: 0.8rem; margin-bottom: 0.35rem; + align-items: center; } +.j13-comment__reply-to a { color: var(--j13-accent); text-decoration: none; } +.j13-comment__actions { + display: flex; + gap: 0.75rem; + align-items: center; + margin-top: 0.4rem; +} +.j13-comment-reply-hint { margin: 0 0 0.5rem; } .j13-comment-form textarea { width: 100%; } +.j13-comment-form .j13-form__row { margin-top: 0.5rem; } .j13-muted { color: var(--j13-muted); } .j13-install fieldset { border: 1px solid var(--j13-border); diff --git a/public/assets/site.js b/public/assets/site.js index 7d24e6e..16e5206 100644 --- a/public/assets/site.js +++ b/public/assets/site.js @@ -203,3 +203,42 @@ document.documentElement.dataset.j13Ssr = "1"; } }); })(); + +// 评论回复:点击「回复」填入 reply_to +(function () { + const form = document.getElementById("comment-form"); + if (!form) return; + const replyInput = document.getElementById("comment-reply-to"); + const hint = document.getElementById("comment-reply-hint"); + const clearBtn = document.getElementById("comment-reply-clear"); + const content = document.getElementById("comment-content"); + function clearReply() { + if (replyInput) replyInput.value = ""; + if (hint) { + hint.hidden = true; + hint.textContent = ""; + } + if (clearBtn) clearBtn.hidden = true; + } + function setReply(id, floor, author) { + if (!replyInput) return; + replyInput.value = id; + if (hint) { + hint.hidden = false; + hint.textContent = "回复 #" + floor + (author ? " " + author : ""); + } + if (clearBtn) clearBtn.hidden = false; + if (content) content.focus(); + form.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + document.querySelectorAll(".j13-comment__reply-btn").forEach((btn) => { + btn.addEventListener("click", () => { + setReply( + btn.getAttribute("data-reply-to") || "", + btn.getAttribute("data-reply-floor") || "", + btn.getAttribute("data-reply-author") || "" + ); + }); + }); + if (clearBtn) clearBtn.addEventListener("click", clearReply); +})(); diff --git a/routers/web/home.go b/routers/web/home.go index 3e2efd2..8ce508f 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -20,6 +20,7 @@ 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.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) g.POST("/post/:id/unlock", authMW.RequireAuth(), deps.PostUnlock) diff --git a/routers/web/post.go b/routers/web/post.go index 26332a4..32a8495 100644 --- a/routers/web/post.go +++ b/routers/web/post.go @@ -40,12 +40,19 @@ type PostPageData struct { // CommentView 评论 type CommentView struct { - Floor int - AuthorName string - AuthorID uint - CreatedLabel string - Content string - ContentHidden bool + ID uint + Floor int + AuthorName string + AuthorID uint + CreatedLabel string + Content string + ContentHidden bool + ReplyToID uint + ReplyToFloor int + ReplyToAuthor string + LikeCount int + Liked bool + IsPrivate bool } // PostView GET /post/:id @@ -76,11 +83,27 @@ func (d Deps) PostView(c *gin.Context) { if an == "" { an = cm.User.Username } - cv = append(cv, CommentView{ - Floor: cm.Floor, AuthorName: an, AuthorID: cm.UserID, + view := CommentView{ + ID: cm.ID, Floor: cm.Floor, AuthorName: an, AuthorID: cm.UserID, CreatedLabel: formatTime(cm.CreatedAt), Content: cm.Content, ContentHidden: cm.ContentHidden, - }) + LikeCount: cm.LikeCount, Liked: cm.Liked, IsPrivate: cm.IsPrivate, + } + if cm.ReplyTarget != nil { + view.ReplyToID = cm.ReplyTarget.ID + view.ReplyToFloor = cm.ReplyTarget.Floor + rn := strings.TrimSpace(cm.ReplyTarget.User.Nickname) + if rn == "" { + rn = cm.ReplyTarget.User.Username + } + if rn == "" { + rn = cm.ReplyTarget.GuestNick + } + view.ReplyToAuthor = rn + } else if cm.ReplyTo != nil { + view.ReplyToID = *cm.ReplyTo + } + cv = append(cv, view) } author := strings.TrimSpace(post.User.Nickname) @@ -144,16 +167,57 @@ func (d Deps) PostComment(c *gin.Context) { ctx.Redirect(fmt.Sprintf("/post/%d#comments", id)) return } + var replyTo *uint + if raw := strings.TrimSpace(c.PostForm("reply_to")); raw != "" { + rid, err := strconv.ParseUint(raw, 10, 64) + if err == nil && rid > 0 { + v := uint(rid) + replyTo = &v + } + } + isPrivate := c.PostForm("is_private") == "1" || c.PostForm("is_private") == "on" safe := "

" + html.EscapeString(content) + "

" - _, err = d.Comment.Create(services.CommentCreateInput{ + cm, err := d.Comment.Create(services.CommentCreateInput{ PostID: id, UserID: ctx.UserID(), Content: safe, + ReplyTo: replyTo, IsPrivate: isPrivate, }) if err != nil { ctx.SetFlash(err.Error()) ctx.Redirect(fmt.Sprintf("/post/%d#comments", id)) return } - ctx.Redirect(fmt.Sprintf("/post/%d#comments", id)) + anchor := "#comments" + if cm != nil && cm.Floor > 0 { + anchor = fmt.Sprintf("#floor-%d", cm.Floor) + } + ctx.Redirect(fmt.Sprintf("/post/%d%s", id, anchor)) +} + +// PostCommentLike 评论点赞切换(PRG) +func (d Deps) PostCommentLike(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求") + ctx.Redirect("/") + return + } + postID, err := parsePostID(c, d) + if err != nil { + d.render404(ctx) + return + } + cid, err := strconv.ParseUint(c.Param("cid"), 10, 64) + if err != nil || cid == 0 { + d.render404(ctx) + return + } + cm, err := d.Comment.GetByID(uint(cid)) + if err != nil || cm.PostID != postID { + d.render404(ctx) + return + } + _, _, _ = d.Comment.ToggleLike(ctx.UserID(), uint(cid)) + ctx.Redirect(fmt.Sprintf("/post/%d#floor-%d", postID, cm.Floor)) } // PostUnlock POST /post/:id/unlock — 积分解锁(JSON,供详情页渐进增强) diff --git a/templates/post/body.tmpl b/templates/post/body.tmpl index f63feb3..b151a75 100644 --- a/templates/post/body.tmpl +++ b/templates/post/body.tmpl @@ -35,17 +35,32 @@ {{else}} @@ -55,11 +70,20 @@ {{if .CommentsLocked}}

评论已锁定。

{{else}} -
+ + + - + +
+ + +
{{end}} {{else}} diff --git a/web_src/css/site.css b/web_src/css/site.css index 1c67ccd..7c972b6 100644 --- a/web_src/css/site.css +++ b/web_src/css/site.css @@ -235,12 +235,23 @@ body.j13-body { } .j13-comment__meta { display: flex; + flex-wrap: wrap; gap: 0.75rem; color: var(--j13-muted); font-size: 0.8rem; margin-bottom: 0.35rem; + align-items: center; } +.j13-comment__reply-to a { color: var(--j13-accent); text-decoration: none; } +.j13-comment__actions { + display: flex; + gap: 0.75rem; + align-items: center; + margin-top: 0.4rem; +} +.j13-comment-reply-hint { margin: 0 0 0.5rem; } .j13-comment-form textarea { width: 100%; } +.j13-comment-form .j13-form__row { margin-top: 0.5rem; } .j13-muted { color: var(--j13-muted); } .j13-install fieldset { border: 1px solid var(--j13-border); diff --git a/web_src/js/site.js b/web_src/js/site.js index 7d24e6e..16e5206 100644 --- a/web_src/js/site.js +++ b/web_src/js/site.js @@ -203,3 +203,42 @@ document.documentElement.dataset.j13Ssr = "1"; } }); })(); + +// 评论回复:点击「回复」填入 reply_to +(function () { + const form = document.getElementById("comment-form"); + if (!form) return; + const replyInput = document.getElementById("comment-reply-to"); + const hint = document.getElementById("comment-reply-hint"); + const clearBtn = document.getElementById("comment-reply-clear"); + const content = document.getElementById("comment-content"); + function clearReply() { + if (replyInput) replyInput.value = ""; + if (hint) { + hint.hidden = true; + hint.textContent = ""; + } + if (clearBtn) clearBtn.hidden = true; + } + function setReply(id, floor, author) { + if (!replyInput) return; + replyInput.value = id; + if (hint) { + hint.hidden = false; + hint.textContent = "回复 #" + floor + (author ? " " + author : ""); + } + if (clearBtn) clearBtn.hidden = false; + if (content) content.focus(); + form.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + document.querySelectorAll(".j13-comment__reply-btn").forEach((btn) => { + btn.addEventListener("click", () => { + setReply( + btn.getAttribute("data-reply-to") || "", + btn.getAttribute("data-reply-floor") || "", + btn.getAttribute("data-reply-author") || "" + ); + }); + }); + if (clearBtn) clearBtn.addEventListener("click", clearReply); +})();