feat: 评论按 ThreadParentID 嵌套树展示
修复 html/template 中 {{template}} 重置 $ 导致渲染失败,页面字段随 commentItemData 下传。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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`;作者软删进回收站
|
||||||
|
|||||||
@@ -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 查看 |
|
||||||
|
|
||||||
|
|||||||
@@ -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 产品化
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
74
templates/post/comment_item.tmpl
Normal file
74
templates/post/comment_item.tmpl
Normal 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}}
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user