diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index bea9162..686f57a 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -99,7 +99,7 @@ ## F. 评论 -- [x] 按帖拉取评论列表(楼层、引用目标)— SSR 帖详情;扁平列表 + `reply_to` 展示(嵌套树 UI 未做) +- [x] 按帖拉取评论列表(楼层、引用目标)— SSR 帖详情;`ThreadParentID` 嵌套树 + 全局 `#floor` 缩进 - [x] 发表评论(登录);支持 `reply_to`、私密评论 — `POST /post/:id/comments` - [ ] 游客评论(公开接口可写,字段 guest_*) - [x] 编辑评论(时限);删除评论 — SSR 帖详情入口 + `/comments/:cid/edit|delete`;作者软删进回收站 diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index 309e907..4fab168 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -151,7 +151,7 @@ | 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` | | 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 — SSR `/post/:id/bounty/award|refund` | | 抽奖卡 | 显示参与人数;开奖;中奖名单 — SSR `/post/:id/lottery/draw` | -| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除、回复/@ 通知 — SSR;嵌套树 UI 未做 | +| 评论 | 楼层列表、`reply_to` 引用、嵌套树(`ThreadParentID` + 缩进,全局 `#floor`)、私密/赞/举报/编辑/删除、回复/@ 通知 — SSR | | 修订 | 列表 + 单条快照 — SSR `/post/:id/revisions`(作者/管理员;无 diff) | | 图片 | Lightbox 查看 | diff --git a/docs/rebuild-spec/09-ssr-progress.md b/docs/rebuild-spec/09-ssr-progress.md index 606867f..46a3475 100644 --- a/docs/rebuild-spec/09-ssr-progress.md +++ b/docs/rebuild-spec/09-ssr-progress.md @@ -2,8 +2,7 @@ > **读者**:产品方 / 实现 AI > **分支**:`rebuild/gitea-ssr`(`main` = SPA 对照) -> **验收清单**: [02-features.md](02-features.md) -> **架构**: [08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md) +> **验收清单**: [02-features.md](02-features.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 | 已迁 | -| §H 等级 / 徽章 / 防刷 | 已迁 | -| 品牌 Logo/Favicon/OG + 发评限流 | 已迁 | -| 内容 Limits + 伪静态 | 已迁 | +| 五种帖 + Admin P1 + §H | 已迁 | +| Limits / 伪静态 / 品牌图 | 已迁 | +| 评论嵌套树 UI | 已迁 | --- ## 未完成 -### P3 体验 - -- 主题、侧栏折叠、虚拟滚动、TipTap、游客评论、嵌套树、修订 diff - -### 后置 - -- OIDC Admin CRUD、S3 热切换 UI、Gitea `/projects`、WebP 产品化 +- P3:主题、TipTip、游客评论、修订 diff +- 后置:OIDC、S3 热切换、Gitea `/projects` --- diff --git a/public/assets/site.css b/public/assets/site.css index a63a5c0..6e27034 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -412,10 +412,28 @@ body.j13-body { } .j13-comments { margin-top: 2rem; } .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 { padding: 0.85rem 0; 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 { display: flex; flex-wrap: wrap; @@ -425,16 +443,26 @@ body.j13-body { margin-bottom: 0.35rem; 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__actions { display: flex; gap: 0.75rem; align-items: center; margin-top: 0.4rem; + flex-wrap: wrap; } .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; } +@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-install fieldset { border: 1px solid var(--j13-border); diff --git a/routers/web/post.go b/routers/web/post.go index 3f92072..05bba26 100644 --- a/routers/web/post.go +++ b/routers/web/post.go @@ -17,37 +17,37 @@ import ( // PostPageData 帖详情 type PostPageData struct { PageChrome - PostID uint - PostHref string - PostPath string - PostTitle string - AuthorName string - AuthorID uint - AuthorHref string - BoardID uint - BoardHref string - BoardName string - Pinned bool // 展示用:全局或版内置顶 - GlobalPinned bool - BoardPinned bool - Featured bool - EditLocked bool - PostTypeLabel string - CreatedLabel string - ViewCount int - LikeCount int - Liked bool - Favorited bool - BodyHTML string - CommentCount int - Comments []CommentView - CommentsLocked bool - CanEdit bool - CanViewRevisions bool - CanReportPost bool - IsAdmin bool - Status string - StatusLabel string + PostID uint + PostHref string + PostPath string + PostTitle string + AuthorName string + AuthorID uint + AuthorHref string + BoardID uint + BoardHref string + BoardName string + Pinned bool // 展示用:全局或版内置顶 + GlobalPinned bool + BoardPinned bool + Featured bool + EditLocked bool + PostTypeLabel string + CreatedLabel string + ViewCount int + LikeCount int + Liked bool + Favorited bool + BodyHTML string + CommentCount int + Comments []commentItemData + CommentsLocked bool + CanEdit bool + CanViewRevisions bool + CanReportPost bool + IsAdmin bool + Status string + StatusLabel string ShowModerationBanner bool IsQuestion bool QuestionResolved bool @@ -67,8 +67,8 @@ type postBountyView struct { } type postLotteryWinnerView struct { - UserID uint - Name string + UserID uint + Name string FloorHint string } @@ -90,16 +90,16 @@ type postPollOptionView struct { } type postPollView struct { - Multi bool - MaxChoices int - Closed bool - EndsLabel string - TotalVotes int - ShowResults bool - HasVoted bool - CanVote bool - CanClose bool - Options []postPollOptionView + Multi bool + MaxChoices int + Closed bool + EndsLabel string + TotalVotes int + ShowResults bool + HasVoted bool + CanVote bool + CanClose bool + Options []postPollOptionView } // CommentView 评论 @@ -114,6 +114,9 @@ type CommentView struct { ReplyToID uint ReplyToFloor int ReplyToAuthor string + ThreadParentID uint + Depth int + Children []CommentView LikeCount int Liked bool IsPrivate bool @@ -126,6 +129,102 @@ type CommentView struct { 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 func (d Deps) PostView(c *gin.Context) { ctx := d.ctx(c) @@ -156,6 +255,7 @@ func (d Deps) PostView(c *gin.Context) { (ctx.IsAdmin() || post.UserID == ctx.UserID()) comments, _ := d.Comment.ListByPost(uint(id), ctx.UserID(), ctx.IsAdmin(), post.UserID, nil) + commentTotal := len(comments) cv := make([]CommentView, 0, len(comments)) for _, cm := range comments { an := strings.TrimSpace(cm.User.Nickname) @@ -165,7 +265,7 @@ func (d Deps) PostView(c *gin.Context) { view := CommentView{ ID: cm.ID, Floor: cm.Floor, AuthorName: an, AuthorID: cm.UserID, 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, CanReport: ctx.IsSigned() && (cm.UserID == 0 || cm.UserID != ctx.UserID()), 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, 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 ctx.IsAdmin() || (cm.UserID > 0 && cm.UserID == ctx.UserID()) { view.StatusLabel = contentStatusLabel(cm.Status) @@ -196,6 +299,7 @@ func (d Deps) PostView(c *gin.Context) { } cv = append(cv, view) } + cv = buildCommentTree(cv) author := strings.TrimSpace(post.User.Nickname) if author == "" { @@ -267,24 +371,25 @@ func (d Deps) PostView(c *gin.Context) { ctx.HTML(http.StatusOK, "post", PostPageData{ PageChrome: chrome, PostID: post.ID, - PostHref: d.postHref(post.ID), - PostPath: url.QueryEscape(d.postHref(post.ID)), + PostHref: d.postHref(post.ID), + PostPath: url.QueryEscape(d.postHref(post.ID)), PostTitle: post.Title, AuthorName: author, AuthorID: 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, Featured: post.Featured, EditLocked: post.EditLocked, PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt), ViewCount: post.ViewCount, LikeCount: post.LikeCount, Liked: d.Post.IsLiked(ctx.UserID(), post.ID), Favorited: d.Post.IsFavorited(ctx.UserID(), post.ID), - BodyHTML: body, CommentCount: len(cv), Comments: cv, - CommentsLocked: post.CommentsLocked, - CanEdit: d.Post.CanUserEdit(post, ctx.UserID(), ctx.IsAdmin()), - CanViewRevisions: canViewPostRevisions(post, ctx.UserID(), ctx.IsAdmin()), - CanReportPost: ctx.IsSigned() && post.UserID != ctx.UserID(), - IsAdmin: ctx.IsAdmin(), - Status: post.Status, - StatusLabel: statusLabel, + BodyHTML: body, CommentCount: commentTotal, + Comments: d.commentItemsForPage(ctx, post.ID, post.CommentsLocked, cv), + CommentsLocked: post.CommentsLocked, + CanEdit: d.Post.CanUserEdit(post, ctx.UserID(), ctx.IsAdmin()), + CanViewRevisions: canViewPostRevisions(post, ctx.UserID(), ctx.IsAdmin()), + CanReportPost: ctx.IsSigned() && post.UserID != ctx.UserID(), + IsAdmin: ctx.IsAdmin(), + Status: post.Status, + StatusLabel: statusLabel, ShowModerationBanner: showBanner, IsQuestion: post.PostType == models.PostTypeQuestion, 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, EndsLabel: ends, TotalVotes: pv.TotalVotes, ShowResults: showResults, HasVoted: hasVoted, - CanVote: loggedIn && !pv.Closed && !hasVoted, + CanVote: loggedIn && !pv.Closed && !hasVoted, CanClose: canClose && !pv.Closed, - Options: opts, + Options: opts, } } @@ -685,10 +790,10 @@ func (d Deps) PostLotteryDrawPost(c *gin.Context) { type commentEditData struct { PageChrome - PostID uint + PostID uint CommentID uint - Floor int - Content string + Floor int + Content string } // CommentEditGet 编辑评论页 @@ -827,9 +932,9 @@ func commentHTMLToPlain(s string) string { } type postRevisionRow struct { - ID uint - EditorName string - Title string + ID uint + EditorName string + Title string CreatedLabel string } diff --git a/templates/post/body.tmpl b/templates/post/body.tmpl index 3c741fb..a26d479 100644 --- a/templates/post/body.tmpl +++ b/templates/post/body.tmpl @@ -204,73 +204,7 @@ {{else}}
(私密评论不可见)
+ {{else}} +
(私密评论不可见)
- {{else}} -举报
- -