diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index af9d8e9..f76cab2 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -70,7 +70,7 @@ - [x] `normal` 普通讨论 — SSR compose 默认 - [x] `question` 问答:可标记已解决 / 未解决 — SSR compose + 详情徽章与切换 - [x] `poll` 投票:2–10 选项;单选/多选;可选截止时间;投票;作者可结束 — SSR compose + 详情卡 -- [ ] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05) +- [x] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05)— SSR compose + 详情条 + award/refund - [ ] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖 ### D.2 运营标记(管理员) diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index 152387e..4562309 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -28,7 +28,7 @@ | `/board/:id` | 板块 Feed | 已迁 | | `/boards` | 板块索引 | 已迁 | | `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 | -| `/compose` | 发帖(normal / question / poll;textarea + 图片 + 门控插入) | 已迁 | +| `/compose` | 发帖(normal / question / poll / bounty;textarea + 图片 + 门控插入) | 已迁 | | `/post/:id/edit` | 编辑帖 | 已迁 | | `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 | | `/user/:id` | 公开用户页 | 已迁 | @@ -117,7 +117,7 @@ | 讨论 | 无 | | 问答 | 无额外字段(解决状态在详情)— SSR compose 可选;详情切换已迁 | | 投票 | 选项列表、多选开关、最多可选、截止时间或无截止 | -| 悬赏 | 积分输入(显示余额) | +| 悬赏 | 积分输入(显示余额)— SSR compose;发帖托管 | | 抽奖 | 中奖人数 1–20 | ### 3.2 编辑器能力(应对齐) @@ -146,7 +146,7 @@ | 门控块 | 锁定壳 UI(长度/价格/引导);`POST /post/:id/unlock` 返回 inner HTML 后替换 | | 问答状态 | 已解决/未解决徽章;作者或管理员 `POST /post/:id/question/resolve` 切换 — SSR | | 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` | -| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 | +| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 — SSR `/post/:id/bounty/award|refund` | | 抽奖卡 | 显示参与人数;开奖;中奖名单 | | 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除、回复/@ 通知 — SSR;嵌套树 UI 未做 | | 修订 | 列表 + 单条快照 — SSR `/post/:id/revisions`(作者/管理员;无 diff) | diff --git a/public/assets/site.css b/public/assets/site.css index 1b2e9bf..560df69 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -497,6 +497,21 @@ body.j13-body { border-radius: var(--j13-radius); } .j13-poll-fields legend { padding: 0 0.35rem; font-weight: 600; } +.j13-bounty-fields { + margin: 0.75rem 0 1rem; + padding: 0.75rem 1rem; + border: 1px solid var(--j13-border); + border-radius: var(--j13-radius); +} +.j13-bounty-fields legend { padding: 0 0.35rem; font-weight: 600; } +.j13-bounty { + margin: 1rem 0 1.25rem; + padding: 1rem; + border: 1px solid var(--j13-border); + border-radius: var(--j13-radius); + background: var(--j13-surface, #fff); +} +.j13-bounty__title { margin: 0 0 0.35rem; font-size: 1.1rem; } .j13-poll { margin: 1rem 0 1.25rem; padding: 1rem; diff --git a/routers/web/compose.go b/routers/web/compose.go index cb16cda..108e4ea 100644 --- a/routers/web/compose.go +++ b/routers/web/compose.go @@ -26,6 +26,7 @@ type composeData struct { PollMaxChoices int PollEndsAt string PollOptions string + BountyPoints int Boards []BoardView TitleMax int TagsMax int @@ -66,23 +67,25 @@ func (d Deps) ComposePost(c *gin.Context) { } form := composeFormFrom(c) htmlBody := services.ComposeBodyToHTML(form.Content) - postType := form.PostType - if postType != models.PostTypePoll && postType != models.PostTypeQuestion { - postType = models.PostTypeNormal - } + postType := normalizeComposePostType(form.PostType) post, err := d.Post.Create(ctx.UserID(), form.BoardID, form.Title, htmlBody, form.Tags, postType, ctx.SkipsModeration()) if err != nil { d.renderCompose(ctx, err.Error(), form, false, 0) return } - if post.PostType == models.PostTypePoll { - pollJSON, err := services.BuildPollOptionsJSON(form.PollMulti, form.PollMaxChoices, form.PollEndsAt, form.PollOptions) - if err != nil { - _ = d.Post.Delete(ctx.UserID(), post.ID, true) - d.renderCompose(ctx, err.Error(), form, false, 0) - return + if post.PostType == models.PostTypePoll || post.PostType == models.PostTypeBounty { + var extras services.PostCreateExtras + if post.PostType == models.PostTypePoll { + pollJSON, err := services.BuildPollOptionsJSON(form.PollMulti, form.PollMaxChoices, form.PollEndsAt, form.PollOptions) + if err != nil { + _ = d.Post.Delete(ctx.UserID(), post.ID, true) + d.renderCompose(ctx, err.Error(), form, false, 0) + return + } + extras = services.ParsePostExtrasFromForm(pollJSON, "", "") + } else { + extras = services.ParsePostExtrasFromForm("", strconv.Itoa(form.BountyPoints), "") } - extras := services.ParsePostExtrasFromForm(pollJSON, "", "") if err := services.FinalizeSpecialPostCreate(post, ctx.UserID(), extras); err != nil { _ = d.Post.Delete(ctx.UserID(), post.ID, true) d.renderCompose(ctx, err.Error(), form, false, 0) @@ -188,6 +191,16 @@ type composeForm struct { PollMaxChoices int PollEndsAt string PollOptions string + BountyPoints int +} + +func normalizeComposePostType(t string) string { + switch t { + case models.PostTypePoll, models.PostTypeQuestion, models.PostTypeBounty: + return t + default: + return models.PostTypeNormal + } } func composeFormFrom(c *gin.Context) composeForm { @@ -196,20 +209,18 @@ func composeFormFrom(c *gin.Context) composeForm { if maxChoices < 1 { maxChoices = 1 } - postType := strings.TrimSpace(c.PostForm("post_type")) - if postType != models.PostTypePoll && postType != models.PostTypeQuestion { - postType = models.PostTypeNormal - } + bountyPoints, _ := strconv.Atoi(c.PostForm("bounty_points")) return composeForm{ BoardID: uint(bid), Title: strings.TrimSpace(c.PostForm("title")), Tags: strings.TrimSpace(c.PostForm("tags")), Content: c.PostForm("content"), - PostType: postType, + PostType: normalizeComposePostType(strings.TrimSpace(c.PostForm("post_type"))), PollMulti: c.PostForm("poll_multi") == "1" || c.PostForm("poll_multi") == "on", PollMaxChoices: maxChoices, PollEndsAt: strings.TrimSpace(c.PostForm("poll_ends_at")), PollOptions: c.PostForm("poll_options"), + BountyPoints: bountyPoints, } } @@ -226,6 +237,9 @@ func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm if form.PollMaxChoices < 1 { form.PollMaxChoices = 1 } + if form.BountyPoints < 1 { + form.BountyPoints = 1 + } chrome := d.chrome(ctx, title+" · "+d.Settings.SiteBranding().Name, "", "") chrome.Error = errMsg chrome.ActiveBoard = form.BoardID @@ -243,6 +257,7 @@ func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm PollMaxChoices: form.PollMaxChoices, PollEndsAt: form.PollEndsAt, PollOptions: form.PollOptions, + BountyPoints: form.BountyPoints, Boards: chrome.Boards, TitleMax: d.Settings.PostTitleMax(), TagsMax: d.Settings.PostTagsMax(), diff --git a/routers/web/home.go b/routers/web/home.go index 54a38be..a336a8d 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -34,6 +34,8 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { g.POST("/post/:id/poll/vote", authMW.RequireAuth(), deps.PostPollVote) g.POST("/post/:id/poll/close", authMW.RequireAuth(), deps.PostPollClose) g.POST("/post/:id/question/resolve", authMW.RequireAuth(), deps.PostQuestionResolvePost) + g.POST("/post/:id/bounty/award", authMW.RequireAuth(), deps.PostBountyAwardPost) + g.POST("/post/:id/bounty/refund", authMW.RequireAuth(), deps.PostBountyRefundPost) g.POST("/post/:id/unlock", authMW.RequireAuth(), deps.PostUnlock) g.POST("/post/:id/report", authMW.RequireAuth(), deps.PostReportPost) g.POST("/post/:id/comments/:cid/report", authMW.RequireAuth(), deps.CommentReportPost) diff --git a/routers/web/post.go b/routers/web/post.go index 5510f6f..495f1dd 100644 --- a/routers/web/post.go +++ b/routers/web/post.go @@ -49,9 +49,19 @@ type PostPageData struct { IsQuestion bool QuestionResolved bool CanToggleQuestion bool + Bounty *postBountyView Poll *postPollView } +type postBountyView struct { + Points int + Status string + StatusLabel string + CanRefund bool + RefundBlockReason string + AwardedCommentID uint +} + type postPollOptionView struct { ID uint Text string @@ -93,6 +103,8 @@ type CommentView struct { CanDelete bool Status string StatusLabel string + CanAwardBounty bool + IsBountyAward bool } // PostView GET /post/:id @@ -116,6 +128,10 @@ func (d Deps) PostView(c *gin.Context) { hasReplied := ctx.UserID() > 0 && d.Comment.HasUserReplied(uint(id), ctx.UserID()) body := services.ApplyPostContentGates(post.Content, post, ctx.UserID(), ctx.IsAdmin(), hasReplied) + canAwardBounty := post.PostType == models.PostTypeBounty && + post.BountyStatus == models.BountyStatusOpen && + (ctx.IsAdmin() || post.UserID == ctx.UserID()) + comments, _ := d.Comment.ListByPost(uint(id), ctx.UserID(), ctx.IsAdmin(), post.UserID, nil) cv := make([]CommentView, 0, len(comments)) for _, cm := range comments { @@ -132,6 +148,9 @@ func (d Deps) PostView(c *gin.Context) { CanEdit: !cm.ContentHidden && d.Comment.CanUserEditComment(&cm, ctx.UserID(), ctx.IsAdmin()), CanDelete: !cm.ContentHidden && d.Comment.CanUserDeleteComment(&cm, ctx.UserID(), ctx.IsAdmin()), Status: cm.Status, + CanAwardBounty: canAwardBounty && !cm.ContentHidden && + cm.Status == models.ContentStatusPublished && cm.UserID != post.UserID && cm.UserID > 0, + IsBountyAward: post.BountyCommentID > 0 && cm.ID == post.BountyCommentID, } if cm.Status == models.ContentStatusPending || cm.Status == models.ContentStatusRejected { if ctx.IsAdmin() || (cm.UserID > 0 && cm.UserID == ctx.UserID()) { @@ -183,6 +202,23 @@ func (d Deps) PostView(c *gin.Context) { } } + var bountyView *postBountyView + if post.PostType == models.PostTypeBounty { + isOperator := ctx.IsAdmin() || post.UserID == ctx.UserID() + canRefund, blockReason := services.CanRefundBounty(post, ctx.IsAdmin()) + bv := &postBountyView{ + Points: post.BountyPoints, + Status: post.BountyStatus, + StatusLabel: bountyStatusLabel(post.BountyStatus), + CanRefund: isOperator && canRefund, + AwardedCommentID: post.BountyCommentID, + } + if isOperator && !canRefund { + bv.RefundBlockReason = blockReason + } + bountyView = bv + } + ctx.HTML(http.StatusOK, "post", PostPageData{ PageChrome: chrome, PostID: post.ID, PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)), @@ -205,6 +241,7 @@ func (d Deps) PostView(c *gin.Context) { IsQuestion: post.PostType == models.PostTypeQuestion, QuestionResolved: post.QuestionResolved, CanToggleQuestion: post.PostType == models.PostTypeQuestion && (ctx.IsAdmin() || post.UserID == ctx.UserID()), + Bounty: bountyView, Poll: pollView, }) } @@ -247,6 +284,19 @@ func contentStatusLabel(status string) string { } } +func bountyStatusLabel(status string) string { + switch status { + case models.BountyStatusOpen: + return "进行中" + case models.BountyStatusAwarded: + return "已采纳" + case models.BountyStatusRefunded: + return "已退回" + default: + return status + } +} + func postTypeLabel(t string) string { switch t { case models.PostTypeQuestion: @@ -501,6 +551,51 @@ func (d Deps) PostQuestionResolvePost(c *gin.Context) { ctx.Redirect(fmt.Sprintf("/post/%d", id)) } +// PostBountyAwardPost 采纳评论并发放悬赏 +func (d Deps) PostBountyAwardPost(c *gin.Context) { + ctx := d.ctx(c) + id, err := parsePostID(c, d) + if err != nil || id == 0 { + d.render404(ctx) + return + } + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求,请重试") + ctx.Redirect(fmt.Sprintf("/post/%d", id)) + return + } + cid, _ := strconv.ParseUint(c.PostForm("comment_id"), 10, 64) + if err := services.AwardBounty(id, ctx.UserID(), ctx.IsAdmin(), uint(cid)); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect(fmt.Sprintf("/post/%d#comments", id)) + return + } + ctx.SetFlash("已采纳并发放悬赏") + ctx.Redirect(fmt.Sprintf("/post/%d#comments", id)) +} + +// PostBountyRefundPost 取消悬赏并退回积分 +func (d Deps) PostBountyRefundPost(c *gin.Context) { + ctx := d.ctx(c) + id, err := parsePostID(c, d) + if err != nil || id == 0 { + d.render404(ctx) + return + } + if !ctx.CheckCSRF() { + ctx.SetFlash("无效请求,请重试") + ctx.Redirect(fmt.Sprintf("/post/%d", id)) + return + } + if err := services.RefundBounty(id, ctx.UserID(), ctx.IsAdmin()); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect(fmt.Sprintf("/post/%d", id)) + return + } + ctx.SetFlash("悬赏已退回") + ctx.Redirect(fmt.Sprintf("/post/%d", id)) +} + type commentEditData struct { PageChrome PostID uint diff --git a/templates/compose.tmpl b/templates/compose.tmpl index e5e22be..0ed97ea 100644 --- a/templates/compose.tmpl +++ b/templates/compose.tmpl @@ -16,7 +16,7 @@ {{if .IsEdit}} -
类型:{{if eq .PostType "poll"}}投票{{else if eq .PostType "question"}}问答{{else}}普通讨论{{end}}(不可更改)
+类型:{{if eq .PostType "poll"}}投票{{else if eq .PostType "question"}}问答{{else if eq .PostType "bounty"}}悬赏{{else}}普通讨论{{end}}(不可更改)
{{else}}问答帖无额外字段;解决状态可在帖详情由作者或管理员切换。
@@ -40,6 +41,13 @@ + {{end}}