feat: SSR 评论回复、点赞与私密开关
帖详情支持 reply_to 展示与提交、评论点赞 PRG,以及私密评论选项。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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`
|
||||
- [ ] 评论举报
|
||||
- [ ] @ 提及 → 通知
|
||||
- [ ] 回复提醒(站内信 + 可选邮件)
|
||||
|
||||
@@ -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 查看 |
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -40,12 +40,19 @@ type PostPageData struct {
|
||||
|
||||
// CommentView 评论
|
||||
type CommentView struct {
|
||||
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 := "<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,
|
||||
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,供详情页渐进增强)
|
||||
|
||||
@@ -35,17 +35,32 @@
|
||||
{{else}}
|
||||
<ul class="j13-comment-list">
|
||||
{{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">
|
||||
<span class="j13-comment__floor">#{{.Floor}}</span>
|
||||
{{if .AuthorID}}<a href="/user/{{.AuthorID}}">{{.AuthorName}}</a>{{else}}<span>{{.AuthorName}}</span>{{end}}
|
||||
<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>
|
||||
{{if .ContentHidden}}
|
||||
<p class="j13-muted">(私密评论不可见)</p>
|
||||
{{else}}
|
||||
<div class="j13-comment__body">{{safeHTML .Content}}</div>
|
||||
{{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>
|
||||
{{end}}
|
||||
</ul>
|
||||
@@ -55,11 +70,20 @@
|
||||
{{if .CommentsLocked}}
|
||||
<p class="j13-muted">评论已锁定。</p>
|
||||
{{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="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>
|
||||
<textarea id="comment-content" name="content" rows="4" required maxlength="8000"></textarea>
|
||||
<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>
|
||||
{{end}}
|
||||
{{else}}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user