diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index f76cab2..f1412fc 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -71,7 +71,7 @@ - [x] `question` 问答:可标记已解决 / 未解决 — SSR compose + 详情徽章与切换 - [x] `poll` 投票:2–10 选项;单选/多选;可选截止时间;投票;作者可结束 — SSR compose + 详情卡 - [x] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05)— SSR compose + 详情条 + award/refund -- [ ] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖 +- [x] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖 — SSR compose + 详情卡 + draw ### D.2 运营标记(管理员) @@ -196,10 +196,10 @@ ## O. 基础设施 -- [ ] `GET /health` -- [ ] `robots.txt` / `sitemap.xml` -- [ ] 静态上传文件可达 -- [ ] 限流:发帖、评论、注册、登录、举报、私信、友链等 +- [x] `GET /health` — `routers/setup.go` +- [x] `robots.txt` / `sitemap.xml` — SSR 同源路径 +- [x] 静态上传文件可达 — `Static /uploads` → data/uploads +- [x] 限流:发帖、注册、登录、举报、私信、友链 — SSR 写路径已挂 `RateLimiter`(评论动作键服务层有、web 发评尚未挂,可后补) --- diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index 4562309..21adae4 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 / bounty;textarea + 图片 + 门控插入) | 已迁 | +| `/compose` | 发帖(normal / question / poll / bounty / lottery;textarea + 图片 + 门控插入) | 已迁 | | `/post/:id/edit` | 编辑帖 | 已迁 | | `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 | | `/user/:id` | 公开用户页 | 已迁 | @@ -118,7 +118,7 @@ | 问答 | 无额外字段(解决状态在详情)— SSR compose 可选;详情切换已迁 | | 投票 | 选项列表、多选开关、最多可选、截止时间或无截止 | | 悬赏 | 积分输入(显示余额)— SSR compose;发帖托管 | -| 抽奖 | 中奖人数 1–20 | +| 抽奖 | 中奖人数 1–20 — SSR compose | ### 3.2 编辑器能力(应对齐) @@ -147,7 +147,7 @@ | 问答状态 | 已解决/未解决徽章;作者或管理员 `POST /post/:id/question/resolve` 切换 — SSR | | 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` | | 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 — SSR `/post/:id/bounty/award|refund` | -| 抽奖卡 | 显示参与人数;开奖;中奖名单 | +| 抽奖卡 | 显示参与人数;开奖;中奖名单 — SSR `/post/:id/lottery/draw` | | 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除、回复/@ 通知 — SSR;嵌套树 UI 未做 | | 修订 | 列表 + 单条快照 — SSR `/post/:id/revisions`(作者/管理员;无 diff) | | 图片 | Lightbox 查看 | diff --git a/public/assets/site.css b/public/assets/site.css index 560df69..8102ebf 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -512,6 +512,27 @@ body.j13-body { background: var(--j13-surface, #fff); } .j13-bounty__title { margin: 0 0 0.35rem; font-size: 1.1rem; } +.j13-lottery-fields { + margin: 0.75rem 0 1rem; + padding: 0.75rem 1rem; + border: 1px solid var(--j13-border); + border-radius: var(--j13-radius); +} +.j13-lottery-fields legend { padding: 0 0.35rem; font-weight: 600; } +.j13-lottery { + margin: 1rem 0 1.25rem; + padding: 1rem; + border: 1px solid var(--j13-border); + border-radius: var(--j13-radius); + background: var(--j13-surface, #fff); +} +.j13-lottery__title { margin: 0 0 0.35rem; font-size: 1.1rem; } +.j13-lottery__winners { + list-style: none; + margin: 0.75rem 0 0; + padding: 0; +} +.j13-lottery__winners li { margin: 0.35rem 0; } .j13-poll { margin: 1rem 0 1.25rem; padding: 1rem; diff --git a/routers/web/compose.go b/routers/web/compose.go index 108e4ea..614c0a8 100644 --- a/routers/web/compose.go +++ b/routers/web/compose.go @@ -27,6 +27,7 @@ type composeData struct { PollEndsAt string PollOptions string BountyPoints int + LotteryWinners int Boards []BoardView TitleMax int TagsMax int @@ -46,6 +47,8 @@ func (d Deps) ComposeGet(c *gin.Context) { BoardID: uint(boardID), PostType: models.PostTypeNormal, PollMaxChoices: 1, + BountyPoints: 1, + LotteryWinners: 1, }, false, 0) } @@ -73,9 +76,10 @@ func (d Deps) ComposePost(c *gin.Context) { d.renderCompose(ctx, err.Error(), form, false, 0) return } - if post.PostType == models.PostTypePoll || post.PostType == models.PostTypeBounty { + if post.PostType == models.PostTypePoll || post.PostType == models.PostTypeBounty || post.PostType == models.PostTypeLottery { var extras services.PostCreateExtras - if post.PostType == models.PostTypePoll { + switch post.PostType { + case 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) @@ -83,8 +87,10 @@ func (d Deps) ComposePost(c *gin.Context) { return } extras = services.ParsePostExtrasFromForm(pollJSON, "", "") - } else { + case models.PostTypeBounty: extras = services.ParsePostExtrasFromForm("", strconv.Itoa(form.BountyPoints), "") + case models.PostTypeLottery: + extras = services.ParsePostExtrasFromForm("", "", strconv.Itoa(form.LotteryWinners)) } if err := services.FinalizeSpecialPostCreate(post, ctx.UserID(), extras); err != nil { _ = d.Post.Delete(ctx.UserID(), post.ID, true) @@ -192,11 +198,12 @@ type composeForm struct { PollEndsAt string PollOptions string BountyPoints int + LotteryWinners int } func normalizeComposePostType(t string) string { switch t { - case models.PostTypePoll, models.PostTypeQuestion, models.PostTypeBounty: + case models.PostTypePoll, models.PostTypeQuestion, models.PostTypeBounty, models.PostTypeLottery: return t default: return models.PostTypeNormal @@ -210,6 +217,10 @@ func composeFormFrom(c *gin.Context) composeForm { maxChoices = 1 } bountyPoints, _ := strconv.Atoi(c.PostForm("bounty_points")) + lotteryWinners, _ := strconv.Atoi(c.PostForm("lottery_winner_count")) + if lotteryWinners < 1 { + lotteryWinners = 1 + } return composeForm{ BoardID: uint(bid), Title: strings.TrimSpace(c.PostForm("title")), @@ -221,6 +232,7 @@ func composeFormFrom(c *gin.Context) composeForm { PollEndsAt: strings.TrimSpace(c.PostForm("poll_ends_at")), PollOptions: c.PostForm("poll_options"), BountyPoints: bountyPoints, + LotteryWinners: lotteryWinners, } } @@ -240,6 +252,9 @@ func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm if form.BountyPoints < 1 { form.BountyPoints = 1 } + if form.LotteryWinners < 1 { + form.LotteryWinners = 1 + } chrome := d.chrome(ctx, title+" · "+d.Settings.SiteBranding().Name, "", "") chrome.Error = errMsg chrome.ActiveBoard = form.BoardID @@ -258,6 +273,7 @@ func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm PollEndsAt: form.PollEndsAt, PollOptions: form.PollOptions, BountyPoints: form.BountyPoints, + LotteryWinners: form.LotteryWinners, Boards: chrome.Boards, TitleMax: d.Settings.PostTitleMax(), TagsMax: d.Settings.PostTagsMax(), diff --git a/routers/web/home.go b/routers/web/home.go index a336a8d..f439ced 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -36,6 +36,7 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { 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/lottery/draw", authMW.RequireAuth(), deps.PostLotteryDrawPost) 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 495f1dd..e69906a 100644 --- a/routers/web/post.go +++ b/routers/web/post.go @@ -50,6 +50,7 @@ type PostPageData struct { QuestionResolved bool CanToggleQuestion bool Bounty *postBountyView + Lottery *postLotteryView Poll *postPollView } @@ -62,6 +63,21 @@ type postBountyView struct { AwardedCommentID uint } +type postLotteryWinnerView struct { + UserID uint + Name string + FloorHint string +} + +type postLotteryView struct { + WinnerCount int + Status string + StatusLabel string + ParticipantCount int + CanDraw bool + Winners []postLotteryWinnerView +} + type postPollOptionView struct { ID uint Text string @@ -219,6 +235,29 @@ func (d Deps) PostView(c *gin.Context) { bountyView = bv } + var lotteryView *postLotteryView + if post.PostType == models.PostTypeLottery { + if lv, err := services.GetPostLotteryView(post); err == nil && lv != nil { + isOperator := ctx.IsAdmin() || post.UserID == ctx.UserID() + winners := make([]postLotteryWinnerView, 0, len(lv.Winners)) + for _, w := range lv.Winners { + name := strings.TrimSpace(w.Nickname) + if name == "" { + name = w.Username + } + winners = append(winners, postLotteryWinnerView{UserID: w.UserID, Name: name}) + } + lotteryView = &postLotteryView{ + WinnerCount: lv.WinnerCount, + Status: lv.Status, + StatusLabel: lotteryStatusLabel(lv.Status), + ParticipantCount: lv.ParticipantCount, + CanDraw: isOperator && lv.Status != models.PostLotteryStatusDrawn, + Winners: winners, + } + } + } + ctx.HTML(http.StatusOK, "post", PostPageData{ PageChrome: chrome, PostID: post.ID, PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)), @@ -242,6 +281,7 @@ func (d Deps) PostView(c *gin.Context) { QuestionResolved: post.QuestionResolved, CanToggleQuestion: post.PostType == models.PostTypeQuestion && (ctx.IsAdmin() || post.UserID == ctx.UserID()), Bounty: bountyView, + Lottery: lotteryView, Poll: pollView, }) } @@ -284,6 +324,17 @@ func contentStatusLabel(status string) string { } } +func lotteryStatusLabel(status string) string { + switch status { + case models.PostLotteryStatusDrawn: + return "已开奖" + case models.PostLotteryStatusOpen: + return "待开奖" + default: + return status + } +} + func bountyStatusLabel(status string) string { switch status { case models.BountyStatusOpen: @@ -596,6 +647,28 @@ func (d Deps) PostBountyRefundPost(c *gin.Context) { ctx.Redirect(fmt.Sprintf("/post/%d", id)) } +// PostLotteryDrawPost 抽奖帖开奖 +func (d Deps) PostLotteryDrawPost(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.DrawPostLottery(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 0ed97ea..0755dff 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 if eq .PostType "bounty"}}悬赏{{else}}普通讨论{{end}}(不可更改)

+

类型:{{if eq .PostType "poll"}}投票{{else if eq .PostType "question"}}问答{{else if eq .PostType "bounty"}}悬赏{{else if eq .PostType "lottery"}}抽奖{{else}}普通讨论{{end}}(不可更改)

{{else}}

问答帖无额外字段;解决状态可在帖详情由作者或管理员切换。

@@ -48,6 +49,13 @@ +
+ 抽奖设置 +

参与者:已发布评论且非楼主(按用户去重)。人数足够后可由作者或管理员开奖。

+ +
{{end}}