feat: SSR 评论回复、点赞与私密开关

帖详情支持 reply_to 展示与提交、评论点赞 PRG,以及私密评论选项。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 06:46:41 +08:00
parent 4dc196b055
commit d4b29f5b7c
10 changed files with 209 additions and 20 deletions

View File

@@ -99,11 +99,11 @@
## F. 评论 ## F. 评论
- [ ] 按帖拉取评论列表(楼层、嵌套父级、引用目标) - [x] 按帖拉取评论列表(楼层、引用目标)— SSR 帖详情;扁平列表 + `reply_to` 展示(嵌套树 UI 未做
- [ ] 发表评论(登录);支持 `reply_to`、私密评论 - [x] 发表评论(登录);支持 `reply_to`、私密评论`POST /post/:id/comments`
- [ ] 游客评论(公开接口可写,字段 guest_* - [ ] 游客评论(公开接口可写,字段 guest_*
- [ ] 编辑评论(时限);删除评论 - [ ] 编辑评论(时限);删除评论
- [ ] 评论点赞 - [x] 评论点赞`POST /post/:id/comments/:cid/like`
- [ ] 评论举报 - [ ] 评论举报
- [ ] @ 提及 → 通知 - [ ] @ 提及 → 通知
- [ ] 回复提醒(站内信 + 可选邮件) - [ ] 回复提醒(站内信 + 可选邮件)

View File

@@ -26,7 +26,7 @@
|------|------|-----| |------|------|-----|
| `/` | Feed | 已迁 | | `/` | Feed | 已迁 |
| `/board/:id` | 板块 Feed | 已迁 | | `/board/:id` | 板块 Feed | 已迁 |
| `/post/:id` | 帖详情 + 评论/赞/藏 | 已迁 | | `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 |
| `/compose` | 发帖normaltextarea + 图片 + 门控插入) | 已迁 | | `/compose` | 发帖normaltextarea + 图片 + 门控插入) | 已迁 |
| `/post/:id/edit` | 编辑帖 | 已迁 | | `/post/:id/edit` | 编辑帖 | 已迁 |
| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 | | `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 |
@@ -142,7 +142,7 @@
| 投票卡 | 选选项提交;显示百分比;作者可结束 | | 投票卡 | 选选项提交;显示百分比;作者可结束 |
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 | | 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 |
| 抽奖卡 | 显示参与人数;开奖;中奖名单 | | 抽奖卡 | 显示参与人数;开奖;中奖名单 |
| 评论 | 楼层列表、回复、引用、私密开关、@ 用户搜索、点赞、编辑、举报 | | 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞 — SSR@ / 编辑 / 举报未迁 |
| 修订 | 面板列出历史,可选对比 | | 修订 | 面板列出历史,可选对比 |
| 图片 | Lightbox 查看 | | 图片 | Lightbox 查看 |

View File

@@ -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` 个人闭环:`/profile`(昵称/签名/密码/头像、积分钱包/签到/抽奖)、`/user/:id``/favorites`
`POST /profile/checkin``POST /profile/lottery`CSRF + PRG `POST /profile/checkin``POST /profile/lottery`CSRF + PRG

View File

@@ -235,12 +235,23 @@ body.j13-body {
} }
.j13-comment__meta { .j13-comment__meta {
display: flex; display: flex;
flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
color: var(--j13-muted); color: var(--j13-muted);
font-size: 0.8rem; font-size: 0.8rem;
margin-bottom: 0.35rem; 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 textarea { width: 100%; }
.j13-comment-form .j13-form__row { margin-top: 0.5rem; }
.j13-muted { color: var(--j13-muted); } .j13-muted { color: var(--j13-muted); }
.j13-install fieldset { .j13-install fieldset {
border: 1px solid var(--j13-border); border: 1px solid var(--j13-border);

View File

@@ -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);
})();

View File

@@ -20,6 +20,7 @@ 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.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)
g.POST("/post/:id/unlock", authMW.RequireAuth(), deps.PostUnlock) g.POST("/post/:id/unlock", authMW.RequireAuth(), deps.PostUnlock)

View File

@@ -40,12 +40,19 @@ type PostPageData struct {
// CommentView 评论 // CommentView 评论
type CommentView struct { type CommentView struct {
Floor int ID uint
AuthorName string Floor int
AuthorID uint AuthorName string
CreatedLabel string AuthorID uint
Content string CreatedLabel string
ContentHidden bool Content string
ContentHidden bool
ReplyToID uint
ReplyToFloor int
ReplyToAuthor string
LikeCount int
Liked bool
IsPrivate bool
} }
// PostView GET /post/:id // PostView GET /post/:id
@@ -76,11 +83,27 @@ func (d Deps) PostView(c *gin.Context) {
if an == "" { if an == "" {
an = cm.User.Username an = cm.User.Username
} }
cv = append(cv, CommentView{ view := CommentView{
Floor: cm.Floor, AuthorName: an, AuthorID: cm.UserID, ID: cm.ID, Floor: cm.Floor, AuthorName: an, AuthorID: cm.UserID,
CreatedLabel: formatTime(cm.CreatedAt), CreatedLabel: formatTime(cm.CreatedAt),
Content: cm.Content, ContentHidden: cm.ContentHidden, 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) 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)) ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
return 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 := "<p>" + html.EscapeString(content) + "</p>" safe := "<p>" + html.EscapeString(content) + "</p>"
_, err = d.Comment.Create(services.CommentCreateInput{ cm, err := d.Comment.Create(services.CommentCreateInput{
PostID: id, UserID: ctx.UserID(), Content: safe, PostID: id, UserID: ctx.UserID(), Content: safe,
ReplyTo: replyTo, IsPrivate: isPrivate,
}) })
if err != nil { if err != nil {
ctx.SetFlash(err.Error()) ctx.SetFlash(err.Error())
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id)) ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
return 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供详情页渐进增强 // PostUnlock POST /post/:id/unlock — 积分解锁JSON供详情页渐进增强

View File

@@ -35,17 +35,32 @@
{{else}} {{else}}
<ul class="j13-comment-list"> <ul class="j13-comment-list">
{{range .Comments}} {{range .Comments}}
<li class="j13-comment" id="floor-{{.Floor}}"> <li class="j13-comment" id="floor-{{.Floor}}" data-comment-id="{{.ID}}" data-floor="{{.Floor}}" data-author="{{.AuthorName}}">
<div class="j13-comment__meta"> <div class="j13-comment__meta">
<span class="j13-comment__floor">#{{.Floor}}</span> <span class="j13-comment__floor">#{{.Floor}}</span>
{{if .AuthorID}}<a href="/user/{{.AuthorID}}">{{.AuthorName}}</a>{{else}}<span>{{.AuthorName}}</span>{{end}} {{if .AuthorID}}<a href="/user/{{.AuthorID}}">{{.AuthorName}}</a>{{else}}<span>{{.AuthorName}}</span>{{end}}
<span>{{.CreatedLabel}}</span> <span>{{.CreatedLabel}}</span>
{{if .IsPrivate}}<span class="j13-tag">私密</span>{{end}}
{{if .ReplyToFloor}}
<span class="j13-comment__reply-to">回复 <a href="#floor-{{.ReplyToFloor}}">#{{.ReplyToFloor}}</a>{{if .ReplyToAuthor}} {{.ReplyToAuthor}}{{end}}</span>
{{end}}
</div> </div>
{{if .ContentHidden}} {{if .ContentHidden}}
<p class="j13-muted">(私密评论不可见)</p> <p class="j13-muted">(私密评论不可见)</p>
{{else}} {{else}}
<div class="j13-comment__body">{{safeHTML .Content}}</div> <div class="j13-comment__body">{{safeHTML .Content}}</div>
{{end}} {{end}}
{{if $.LoggedIn}}
<div class="j13-comment__actions">
<form method="post" action="/post/{{$.PostID}}/comments/{{.ID}}/like" class="j13-inline-form">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
<button type="submit" class="j13-linkbtn">{{if .Liked}}取消赞{{else}}赞{{end}} ({{.LikeCount}})</button>
</form>
{{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>
{{end}}
</div>
{{end}}
</li> </li>
{{end}} {{end}}
</ul> </ul>
@@ -55,11 +70,20 @@
{{if .CommentsLocked}} {{if .CommentsLocked}}
<p class="j13-muted">评论已锁定。</p> <p class="j13-muted">评论已锁定。</p>
{{else}} {{else}}
<form class="j13-comment-form" method="post" action="/post/{{.PostID}}/comments"> <form class="j13-comment-form" method="post" action="/post/{{.PostID}}/comments" id="comment-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/> <input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<input type="hidden" name="reply_to" id="comment-reply-to" value=""/>
<p class="j13-muted j13-comment-reply-hint" id="comment-reply-hint" hidden></p>
<label for="comment-content">发表评论</label> <label for="comment-content">发表评论</label>
<textarea id="comment-content" name="content" rows="4" required maxlength="8000"></textarea> <textarea id="comment-content" name="content" rows="4" required maxlength="8000"></textarea>
<button type="submit">提交</button> <label class="j13-check">
<input type="checkbox" name="is_private" value="1"/>
私密评论(仅相关可见)
</label>
<div class="j13-form__row">
<button type="submit">提交</button>
<button type="button" class="j13-btn-secondary" id="comment-reply-clear" hidden>取消回复</button>
</div>
</form> </form>
{{end}} {{end}}
{{else}} {{else}}

View File

@@ -235,12 +235,23 @@ body.j13-body {
} }
.j13-comment__meta { .j13-comment__meta {
display: flex; display: flex;
flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
color: var(--j13-muted); color: var(--j13-muted);
font-size: 0.8rem; font-size: 0.8rem;
margin-bottom: 0.35rem; 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 textarea { width: 100%; }
.j13-comment-form .j13-form__row { margin-top: 0.5rem; }
.j13-muted { color: var(--j13-muted); } .j13-muted { color: var(--j13-muted); }
.j13-install fieldset { .j13-install fieldset {
border: 1px solid var(--j13-border); border: 1px solid var(--j13-border);

View File

@@ -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);
})();