feat: 评论按 ThreadParentID 嵌套树展示

修复 html/template 中 {{template}} 重置 $ 导致渲染失败,页面字段随 commentItemData 下传。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 04:28:44 +08:00
parent f0358e0f71
commit 7f048bd9b5
8 changed files with 310 additions and 148 deletions

View File

@@ -99,7 +99,7 @@
## F. 评论 ## F. 评论
- [x] 按帖拉取评论列表(楼层、引用目标)— SSR 帖详情;扁平列表 + `reply_to` 展示(嵌套树 UI 未做) - [x] 按帖拉取评论列表(楼层、引用目标)— SSR 帖详情;`ThreadParentID` 嵌套树 + 全局 `#floor` 缩进
- [x] 发表评论(登录);支持 `reply_to`、私密评论 — `POST /post/:id/comments` - [x] 发表评论(登录);支持 `reply_to`、私密评论 — `POST /post/:id/comments`
- [ ] 游客评论(公开接口可写,字段 guest_* - [ ] 游客评论(公开接口可写,字段 guest_*
- [x] 编辑评论(时限);删除评论 — SSR 帖详情入口 + `/comments/:cid/edit|delete`;作者软删进回收站 - [x] 编辑评论(时限);删除评论 — SSR 帖详情入口 + `/comments/:cid/edit|delete`;作者软删进回收站

View File

@@ -151,7 +151,7 @@
| 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` | | 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` |
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 — SSR `/post/:id/bounty/award|refund` | | 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 — SSR `/post/:id/bounty/award|refund` |
| 抽奖卡 | 显示参与人数;开奖;中奖名单 — SSR `/post/:id/lottery/draw` | | 抽奖卡 | 显示参与人数;开奖;中奖名单 — SSR `/post/:id/lottery/draw` |
| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报编辑(时限)、删除、回复/@ 通知 — SSR;嵌套树 UI 未做 | | 评论 | 楼层列表、`reply_to` 引用、嵌套树(`ThreadParentID` + 缩进,全局 `#floor`)、私密/赞/举报/编辑/删除、回复/@ 通知 — SSR |
| 修订 | 列表 + 单条快照 — SSR `/post/:id/revisions`(作者/管理员;无 diff | | 修订 | 列表 + 单条快照 — SSR `/post/:id/revisions`(作者/管理员;无 diff |
| 图片 | Lightbox 查看 | | 图片 | Lightbox 查看 |

View File

@@ -2,8 +2,7 @@
> **读者**:产品方 / 实现 AI > **读者**:产品方 / 实现 AI
> **分支**`rebuild/gitea-ssr``main` = SPA 对照) > **分支**`rebuild/gitea-ssr``main` = SPA 对照)
> **验收清单** [02-features.md](02-features.md) > **验收清单** [02-features.md](02-features.md)
> **架构** [08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md)
--- ---
@@ -11,15 +10,15 @@
| 项 | 值 | | 项 | 值 |
|----|-----| |----|-----|
| **上一刀** | 完整 Limits + 伪静态 Admin / 规范 URL | | **上一刀** | 嵌套评论树(全局楼层 + 缩进) |
| **下一刀** | **后置或体验**需点名OIDC、S3 热切换、编辑器、嵌套评论、Gitea…) | | **下一刀** | 需点名OIDC / S3 / 编辑器 / Gitea / 主题…) |
| **工作区** | 应干净 | | **工作区** | 应干净 |
--- ---
## 一句话状态 ## 一句话状态
核心论坛、Admin、§H、品牌图、发评限流、**完整 Limits伪静态**已齐。余量多为后置产品化与体验打磨。 核心论坛、Admin、Limits/伪静态、**嵌套评论**已齐。余量多为后置与体验打磨。
--- ---
@@ -27,22 +26,16 @@
| 域 | 状态 | | 域 | 状态 |
|----|------| |----|------|
| Admin users / badges / media | 已迁 | | 五种帖 + Admin P1 + §H | 已迁 |
| §H 等级 / 徽章 / 防刷 | 已迁 | | Limits / 伪静态 / 品牌图 | 已迁 |
| 品牌 Logo/Favicon/OG + 发评限流 | 已迁 | | 评论嵌套树 UI | 已迁 |
| 内容 Limits + 伪静态 | 已迁 |
--- ---
## 未完成 ## 未完成
### P3 体验 - P3主题、TipTip、游客评论、修订 diff
- 后置OIDC、S3 热切换、Gitea `/projects`
- 主题、侧栏折叠、虚拟滚动、TipTap、游客评论、嵌套树、修订 diff
### 后置
- OIDC Admin CRUD、S3 热切换 UI、Gitea `/projects`、WebP 产品化
--- ---

View File

@@ -412,10 +412,28 @@ body.j13-body {
} }
.j13-comments { margin-top: 2rem; } .j13-comments { margin-top: 2rem; }
.j13-comment-list { list-style: none; margin: 0; padding: 0; } .j13-comment-list { list-style: none; margin: 0; padding: 0; }
.j13-comment-list--nested {
margin: 0.5rem 0 0;
padding: 0 0 0 0.85rem;
border-left: 2px solid var(--j13-border);
}
.j13-comment { .j13-comment {
padding: 0.85rem 0; padding: 0.85rem 0;
border-bottom: 1px solid var(--j13-border); border-bottom: 1px solid var(--j13-border);
} }
.j13-comment-list--nested > .j13-comment {
border-bottom-color: transparent;
padding-left: 0.65rem;
}
.j13-comment--depth-0 { }
.j13-comment--depth-1,
.j13-comment--depth-2,
.j13-comment--depth-3,
.j13-comment--depth-4,
.j13-comment--depth-5,
.j13-comment--depth-6 {
background: transparent;
}
.j13-comment__meta { .j13-comment__meta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -425,16 +443,26 @@ body.j13-body {
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
align-items: center; align-items: center;
} }
.j13-comment__floor { font-weight: 600; color: var(--j13-fg, inherit); }
.j13-comment__reply-to a { color: var(--j13-accent); text-decoration: none; } .j13-comment__reply-to a { color: var(--j13-accent); text-decoration: none; }
.j13-comment__actions { .j13-comment__actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
align-items: center; align-items: center;
margin-top: 0.4rem; margin-top: 0.4rem;
flex-wrap: wrap;
} }
.j13-comment-reply-hint { margin: 0 0 0.5rem; } .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-comment-form .j13-form__row { margin-top: 0.5rem; }
@media (max-width: 600px) {
.j13-comment-list--nested {
padding-left: 0.55rem;
}
.j13-comment-list--nested > .j13-comment {
padding-left: 0.4rem;
}
}
.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

@@ -17,37 +17,37 @@ import (
// PostPageData 帖详情 // PostPageData 帖详情
type PostPageData struct { type PostPageData struct {
PageChrome PageChrome
PostID uint PostID uint
PostHref string PostHref string
PostPath string PostPath string
PostTitle string PostTitle string
AuthorName string AuthorName string
AuthorID uint AuthorID uint
AuthorHref string AuthorHref string
BoardID uint BoardID uint
BoardHref string BoardHref string
BoardName string BoardName string
Pinned bool // 展示用:全局或版内置顶 Pinned bool // 展示用:全局或版内置顶
GlobalPinned bool GlobalPinned bool
BoardPinned bool BoardPinned bool
Featured bool Featured bool
EditLocked bool EditLocked bool
PostTypeLabel string PostTypeLabel string
CreatedLabel string CreatedLabel string
ViewCount int ViewCount int
LikeCount int LikeCount int
Liked bool Liked bool
Favorited bool Favorited bool
BodyHTML string BodyHTML string
CommentCount int CommentCount int
Comments []CommentView Comments []commentItemData
CommentsLocked bool CommentsLocked bool
CanEdit bool CanEdit bool
CanViewRevisions bool CanViewRevisions bool
CanReportPost bool CanReportPost bool
IsAdmin bool IsAdmin bool
Status string Status string
StatusLabel string StatusLabel string
ShowModerationBanner bool ShowModerationBanner bool
IsQuestion bool IsQuestion bool
QuestionResolved bool QuestionResolved bool
@@ -67,8 +67,8 @@ type postBountyView struct {
} }
type postLotteryWinnerView struct { type postLotteryWinnerView struct {
UserID uint UserID uint
Name string Name string
FloorHint string FloorHint string
} }
@@ -90,16 +90,16 @@ type postPollOptionView struct {
} }
type postPollView struct { type postPollView struct {
Multi bool Multi bool
MaxChoices int MaxChoices int
Closed bool Closed bool
EndsLabel string EndsLabel string
TotalVotes int TotalVotes int
ShowResults bool ShowResults bool
HasVoted bool HasVoted bool
CanVote bool CanVote bool
CanClose bool CanClose bool
Options []postPollOptionView Options []postPollOptionView
} }
// CommentView 评论 // CommentView 评论
@@ -114,6 +114,9 @@ type CommentView struct {
ReplyToID uint ReplyToID uint
ReplyToFloor int ReplyToFloor int
ReplyToAuthor string ReplyToAuthor string
ThreadParentID uint
Depth int
Children []CommentView
LikeCount int LikeCount int
Liked bool Liked bool
IsPrivate bool IsPrivate bool
@@ -126,6 +129,102 @@ type CommentView struct {
IsBountyAward bool IsBountyAward bool
} }
const commentNestMaxDepth = 6
// commentItemData 嵌套评论渲染上下文({{template}} 会重置 $,需自带页面字段)
type commentItemData struct {
CommentView
PostID uint
CSRF string
LoggedIn bool
CommentsLocked bool
PermalinkSuffix string
}
// Wrap 子评论继承同一帖上下文
func (d commentItemData) Wrap(c CommentView) commentItemData {
return commentItemData{
CommentView: c,
PostID: d.PostID,
CSRF: d.CSRF,
LoggedIn: d.LoggedIn,
CommentsLocked: d.CommentsLocked,
PermalinkSuffix: d.PermalinkSuffix,
}
}
func (d Deps) commentItemsForPage(ctx *webctx.Context, postID uint, locked bool, tree []CommentView) []commentItemData {
out := make([]commentItemData, 0, len(tree))
base := commentItemData{
PostID: postID,
CSRF: ctx.EnsureCSRF(),
LoggedIn: ctx.IsSigned(),
CommentsLocked: locked,
PermalinkSuffix: d.permalink().Suffix(),
}
for _, c := range tree {
item := base
item.CommentView = c
out = append(out, item)
}
return out
}
// buildCommentTree 按 ThreadParentID 组装嵌套树(同层保持楼层顺序)
func buildCommentTree(flat []CommentView) []CommentView {
if len(flat) == 0 {
return nil
}
nodes := make([]CommentView, len(flat))
copy(nodes, flat)
byID := make(map[uint]int, len(nodes))
for i := range nodes {
byID[nodes[i].ID] = i
nodes[i].Children = nil
}
childIdx := make(map[uint][]int, len(nodes))
roots := make([]int, 0, len(nodes))
for i := range nodes {
pid := nodes[i].ThreadParentID
if pid == 0 {
roots = append(roots, i)
continue
}
if _, ok := byID[pid]; !ok {
roots = append(roots, i)
continue
}
childIdx[pid] = append(childIdx[pid], i)
}
var attach func(i, depth int) CommentView
attach = func(i, depth int) CommentView {
n := nodes[i]
if depth > commentNestMaxDepth {
depth = commentNestMaxDepth
}
n.Depth = depth
kids := childIdx[n.ID]
if len(kids) == 0 {
n.Children = nil
return n
}
n.Children = make([]CommentView, 0, len(kids))
nextDepth := depth + 1
if nextDepth > commentNestMaxDepth {
nextDepth = commentNestMaxDepth
}
for _, ci := range kids {
n.Children = append(n.Children, attach(ci, nextDepth))
}
return n
}
out := make([]CommentView, 0, len(roots))
for _, i := range roots {
out = append(out, attach(i, 0))
}
return out
}
// PostView GET /post/:id // PostView GET /post/:id
func (d Deps) PostView(c *gin.Context) { func (d Deps) PostView(c *gin.Context) {
ctx := d.ctx(c) ctx := d.ctx(c)
@@ -156,6 +255,7 @@ func (d Deps) PostView(c *gin.Context) {
(ctx.IsAdmin() || post.UserID == ctx.UserID()) (ctx.IsAdmin() || post.UserID == ctx.UserID())
comments, _ := d.Comment.ListByPost(uint(id), ctx.UserID(), ctx.IsAdmin(), post.UserID, nil) comments, _ := d.Comment.ListByPost(uint(id), ctx.UserID(), ctx.IsAdmin(), post.UserID, nil)
commentTotal := len(comments)
cv := make([]CommentView, 0, len(comments)) cv := make([]CommentView, 0, len(comments))
for _, cm := range comments { for _, cm := range comments {
an := strings.TrimSpace(cm.User.Nickname) an := strings.TrimSpace(cm.User.Nickname)
@@ -165,7 +265,7 @@ func (d Deps) PostView(c *gin.Context) {
view := CommentView{ view := CommentView{
ID: cm.ID, 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, 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()), CanEdit: !cm.ContentHidden && d.Comment.CanUserEditComment(&cm, ctx.UserID(), ctx.IsAdmin()),
@@ -175,6 +275,9 @@ func (d Deps) PostView(c *gin.Context) {
cm.Status == models.ContentStatusPublished && cm.UserID != post.UserID && cm.UserID > 0, cm.Status == models.ContentStatusPublished && cm.UserID != post.UserID && cm.UserID > 0,
IsBountyAward: post.BountyCommentID > 0 && cm.ID == post.BountyCommentID, IsBountyAward: post.BountyCommentID > 0 && cm.ID == post.BountyCommentID,
} }
if cm.ThreadParentID != nil {
view.ThreadParentID = *cm.ThreadParentID
}
if cm.Status == models.ContentStatusPending || cm.Status == models.ContentStatusRejected { if cm.Status == models.ContentStatusPending || cm.Status == models.ContentStatusRejected {
if ctx.IsAdmin() || (cm.UserID > 0 && cm.UserID == ctx.UserID()) { if ctx.IsAdmin() || (cm.UserID > 0 && cm.UserID == ctx.UserID()) {
view.StatusLabel = contentStatusLabel(cm.Status) view.StatusLabel = contentStatusLabel(cm.Status)
@@ -196,6 +299,7 @@ func (d Deps) PostView(c *gin.Context) {
} }
cv = append(cv, view) cv = append(cv, view)
} }
cv = buildCommentTree(cv)
author := strings.TrimSpace(post.User.Nickname) author := strings.TrimSpace(post.User.Nickname)
if author == "" { if author == "" {
@@ -267,24 +371,25 @@ func (d Deps) PostView(c *gin.Context) {
ctx.HTML(http.StatusOK, "post", PostPageData{ ctx.HTML(http.StatusOK, "post", PostPageData{
PageChrome: chrome, PostID: post.ID, PageChrome: chrome, PostID: post.ID,
PostHref: d.postHref(post.ID), PostHref: d.postHref(post.ID),
PostPath: url.QueryEscape(d.postHref(post.ID)), PostPath: url.QueryEscape(d.postHref(post.ID)),
PostTitle: post.Title, AuthorName: author, AuthorID: post.UserID, PostTitle: post.Title, AuthorName: author, AuthorID: post.UserID,
AuthorHref: d.userHref(post.UserID), AuthorHref: d.userHref(post.UserID),
BoardID: post.BoardID, BoardHref: d.boardHref(post.BoardID), BoardName: boardName, BoardID: post.BoardID, BoardHref: d.boardHref(post.BoardID), BoardName: boardName,
Pinned: post.Pinned || post.BoardPinned, GlobalPinned: post.Pinned, BoardPinned: post.BoardPinned, Pinned: post.Pinned || post.BoardPinned, GlobalPinned: post.Pinned, BoardPinned: post.BoardPinned,
Featured: post.Featured, EditLocked: post.EditLocked, Featured: post.Featured, EditLocked: post.EditLocked,
PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt), PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt),
ViewCount: post.ViewCount, LikeCount: post.LikeCount, ViewCount: post.ViewCount, LikeCount: post.LikeCount,
Liked: d.Post.IsLiked(ctx.UserID(), post.ID), Favorited: d.Post.IsFavorited(ctx.UserID(), post.ID), Liked: d.Post.IsLiked(ctx.UserID(), post.ID), Favorited: d.Post.IsFavorited(ctx.UserID(), post.ID),
BodyHTML: body, CommentCount: len(cv), Comments: cv, BodyHTML: body, CommentCount: commentTotal,
CommentsLocked: post.CommentsLocked, Comments: d.commentItemsForPage(ctx, post.ID, post.CommentsLocked, cv),
CanEdit: d.Post.CanUserEdit(post, ctx.UserID(), ctx.IsAdmin()), CommentsLocked: post.CommentsLocked,
CanViewRevisions: canViewPostRevisions(post, ctx.UserID(), ctx.IsAdmin()), CanEdit: d.Post.CanUserEdit(post, ctx.UserID(), ctx.IsAdmin()),
CanReportPost: ctx.IsSigned() && post.UserID != ctx.UserID(), CanViewRevisions: canViewPostRevisions(post, ctx.UserID(), ctx.IsAdmin()),
IsAdmin: ctx.IsAdmin(), CanReportPost: ctx.IsSigned() && post.UserID != ctx.UserID(),
Status: post.Status, IsAdmin: ctx.IsAdmin(),
StatusLabel: statusLabel, Status: post.Status,
StatusLabel: statusLabel,
ShowModerationBanner: showBanner, ShowModerationBanner: showBanner,
IsQuestion: post.PostType == models.PostTypeQuestion, IsQuestion: post.PostType == models.PostTypeQuestion,
QuestionResolved: post.QuestionResolved, QuestionResolved: post.QuestionResolved,
@@ -316,9 +421,9 @@ func mapPollView(pv *services.PollView, loggedIn, canClose bool) *postPollView {
Multi: pv.Multi, MaxChoices: pv.MaxChoices, Closed: pv.Closed, Multi: pv.Multi, MaxChoices: pv.MaxChoices, Closed: pv.Closed,
EndsLabel: ends, TotalVotes: pv.TotalVotes, EndsLabel: ends, TotalVotes: pv.TotalVotes,
ShowResults: showResults, HasVoted: hasVoted, ShowResults: showResults, HasVoted: hasVoted,
CanVote: loggedIn && !pv.Closed && !hasVoted, CanVote: loggedIn && !pv.Closed && !hasVoted,
CanClose: canClose && !pv.Closed, CanClose: canClose && !pv.Closed,
Options: opts, Options: opts,
} }
} }
@@ -685,10 +790,10 @@ func (d Deps) PostLotteryDrawPost(c *gin.Context) {
type commentEditData struct { type commentEditData struct {
PageChrome PageChrome
PostID uint PostID uint
CommentID uint CommentID uint
Floor int Floor int
Content string Content string
} }
// CommentEditGet 编辑评论页 // CommentEditGet 编辑评论页
@@ -827,9 +932,9 @@ func commentHTMLToPlain(s string) string {
} }
type postRevisionRow struct { type postRevisionRow struct {
ID uint ID uint
EditorName string EditorName string
Title string Title string
CreatedLabel string CreatedLabel string
} }

View File

@@ -204,73 +204,7 @@
{{else}} {{else}}
<ul class="j13-comment-list"> <ul class="j13-comment-list">
{{range .Comments}} {{range .Comments}}
<li class="j13-comment" id="floor-{{.Floor}}" data-comment-id="{{.ID}}" data-floor="{{.Floor}}" data-author="{{.AuthorName}}"> {{template "post/comment_item" .}}
<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 .StatusLabel}}<span class="j13-tag j13-tag--{{.Status}}">{{.StatusLabel}}</span>{{end}}
{{if .IsPrivate}}<span class="j13-tag">私密</span>{{end}}
{{if .IsBountyAward}}<span class="j13-tag j13-tag--resolved">已采纳</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">
{{if .CanAwardBounty}}
<form method="post" action="/post/{{$.PostID}}/bounty/award" class="j13-inline-form">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
<input type="hidden" name="comment_id" value="{{.ID}}"/>
<button type="submit">采纳并发放悬赏</button>
</form>
{{end}}
<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}}
{{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}}
<details class="j13-report">
<summary class="j13-linkbtn">举报</summary>
<form method="post" action="/post/{{$.PostID}}/comments/{{.ID}}/report" class="j13-report__form">
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
<label>原因
<select name="reason" required>
<option value="">请选择</option>
<option value="spam">垃圾广告</option>
<option value="abuse">人身攻击 / 辱骂</option>
<option value="illegal">违法违规</option>
<option value="irrelevant">内容无关 / 灌水</option>
<option value="other">其他</option>
</select>
</label>
<label>补充说明(可选)
<input name="detail" maxlength="500" placeholder="可选"/>
</label>
<button type="submit" class="j13-btn j13-btn--sm">提交举报</button>
</form>
</details>
{{end}}
</div>
{{end}}
</li>
{{end}} {{end}}
</ul> </ul>
{{end}} {{end}}

View File

@@ -0,0 +1,74 @@
{{define "post/comment_item"}}
<li class="j13-comment j13-comment--depth-{{.Depth}}" id="floor-{{.Floor}}" data-comment-id="{{.ID}}" data-floor="{{.Floor}}" data-author="{{.AuthorName}}" data-depth="{{.Depth}}">
<div class="j13-comment__meta">
<span class="j13-comment__floor">#{{.Floor}}</span>
{{if .AuthorID}}<a href="/user/{{.AuthorID}}{{.PermalinkSuffix}}">{{.AuthorName}}</a>{{else}}<span>{{.AuthorName}}</span>{{end}}
<span>{{.CreatedLabel}}</span>
{{if .StatusLabel}}<span class="j13-tag j13-tag--{{.Status}}">{{.StatusLabel}}</span>{{end}}
{{if .IsPrivate}}<span class="j13-tag">私密</span>{{end}}
{{if .IsBountyAward}}<span class="j13-tag j13-tag--resolved">已采纳</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">
{{if .CanAwardBounty}}
<form method="post" action="/post/{{.PostID}}/bounty/award" class="j13-inline-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<input type="hidden" name="comment_id" value="{{.ID}}"/>
<button type="submit">采纳并发放悬赏</button>
</form>
{{end}}
<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}}
{{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}}
<details class="j13-report">
<summary class="j13-linkbtn">举报</summary>
<form method="post" action="/post/{{.PostID}}/comments/{{.ID}}/report" class="j13-report__form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<label>原因
<select name="reason" required>
<option value="">请选择</option>
<option value="spam">垃圾广告</option>
<option value="abuse">人身攻击 / 辱骂</option>
<option value="illegal">违法违规</option>
<option value="irrelevant">内容无关 / 灌水</option>
<option value="other">其他</option>
</select>
</label>
<label>补充说明(可选)
<input name="detail" maxlength="500" placeholder="可选"/>
</label>
<button type="submit" class="j13-btn j13-btn--sm">提交举报</button>
</form>
</details>
{{end}}
</div>
{{end}}
{{if .Children}}
<ul class="j13-comment-list j13-comment-list--nested">
{{range .Children}}{{template "post/comment_item" ($.Wrap .)}}{{end}}
</ul>
{{end}}
</li>
{{end}}

View File

@@ -412,10 +412,28 @@ body.j13-body {
} }
.j13-comments { margin-top: 2rem; } .j13-comments { margin-top: 2rem; }
.j13-comment-list { list-style: none; margin: 0; padding: 0; } .j13-comment-list { list-style: none; margin: 0; padding: 0; }
.j13-comment-list--nested {
margin: 0.5rem 0 0;
padding: 0 0 0 0.85rem;
border-left: 2px solid var(--j13-border);
}
.j13-comment { .j13-comment {
padding: 0.85rem 0; padding: 0.85rem 0;
border-bottom: 1px solid var(--j13-border); border-bottom: 1px solid var(--j13-border);
} }
.j13-comment-list--nested > .j13-comment {
border-bottom-color: transparent;
padding-left: 0.65rem;
}
.j13-comment--depth-0 { }
.j13-comment--depth-1,
.j13-comment--depth-2,
.j13-comment--depth-3,
.j13-comment--depth-4,
.j13-comment--depth-5,
.j13-comment--depth-6 {
background: transparent;
}
.j13-comment__meta { .j13-comment__meta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -425,16 +443,26 @@ body.j13-body {
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
align-items: center; align-items: center;
} }
.j13-comment__floor { font-weight: 600; color: var(--j13-fg, inherit); }
.j13-comment__reply-to a { color: var(--j13-accent); text-decoration: none; } .j13-comment__reply-to a { color: var(--j13-accent); text-decoration: none; }
.j13-comment__actions { .j13-comment__actions {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
align-items: center; align-items: center;
margin-top: 0.4rem; margin-top: 0.4rem;
flex-wrap: wrap;
} }
.j13-comment-reply-hint { margin: 0 0 0.5rem; } .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-comment-form .j13-form__row { margin-top: 0.5rem; }
@media (max-width: 600px) {
.j13-comment-list--nested {
padding-left: 0.55rem;
}
.j13-comment-list--nested > .j13-comment {
padding-left: 0.4rem;
}
}
.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);