diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index c450715..e0e8de1 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -67,9 +67,9 @@ ### D.1 帖子类型 -- [ ] `normal` 普通讨论 +- [x] `normal` 普通讨论 — SSR compose 默认 - [ ] `question` 问答:可标记已解决 / 未解决 -- [ ] `poll` 投票:2–10 选项;单选/多选;可选截止时间;投票;作者可结束 +- [x] `poll` 投票:2–10 选项;单选/多选;可选截止时间;投票;作者可结束 — SSR compose + 详情卡 - [ ] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05) - [ ] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖 diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index 2bceb90..f56f506 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;textarea + 图片 + 门控插入) | 已迁 | +| `/compose` | 发帖(normal / poll;textarea + 图片 + 门控插入) | 已迁 | | `/post/:id/edit` | 编辑帖 | 已迁 | | `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 | | `/user/:id` | 公开用户页 | 已迁 | @@ -144,7 +144,7 @@ | 模块 | 行为 | |------|------| | 门控块 | 锁定壳 UI(长度/价格/引导);`POST /post/:id/unlock` 返回 inner HTML 后替换 | -| 投票卡 | 选选项提交;显示百分比;作者可结束 | +| 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` | | 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 | | 抽奖卡 | 显示参与人数;开奖;中奖名单 | | 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除、回复/@ 通知 — SSR;嵌套树 UI 未做 | diff --git a/public/assets/site.css b/public/assets/site.css index aab75f7..1d36f91 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -488,6 +488,37 @@ body.j13-body { } .j13-filebtn { display: inline-block; margin: 0; cursor: pointer; } .j13-compose { max-width: 720px; margin: 0 auto; padding: 1rem; } +.j13-poll-fields { + margin: 0.75rem 0 1rem; + padding: 0.75rem 1rem; + border: 1px solid var(--j13-border); + border-radius: var(--j13-radius); +} +.j13-poll-fields legend { padding: 0 0.35rem; font-weight: 600; } +.j13-poll { + margin: 1rem 0 1.25rem; + padding: 1rem; + border: 1px solid var(--j13-border); + border-radius: var(--j13-radius); + background: var(--j13-surface, #fff); +} +.j13-poll__title { margin: 0 0 0.35rem; font-size: 1.1rem; } +.j13-poll__options { list-style: none; margin: 0.75rem 0; padding: 0; } +.j13-poll__options li { margin: 0.45rem 0; } +.j13-poll__option { + display: grid; + gap: 0.25rem; + padding: 0.35rem 0; +} +.j13-poll__option.is-selected .j13-poll__opt-text { font-weight: 600; color: var(--j13-accent); } +.j13-poll__bar { + display: block; + height: 0.4rem; + border-radius: 999px; + background: linear-gradient(90deg, var(--j13-accent), var(--j13-accent)) no-repeat; + background-size: var(--pct, 0%) 100%; + background-color: var(--j13-border); +} .j13-post__content img { max-width: 100%; height: auto; border-radius: 4px; } .j13-admin { max-width: 880px; margin: 0 auto; padding: 1rem 1rem 2.5rem; } diff --git a/routers/web/compose.go b/routers/web/compose.go index d32ff97..25dfab5 100644 --- a/routers/web/compose.go +++ b/routers/web/compose.go @@ -14,17 +14,22 @@ import ( type composeData struct { PageChrome - IsEdit bool - PostID uint - FormAction string - BoardID uint - Title string - Tags string - Content string - Boards []BoardView - TitleMax int - TagsMax int - ContentMax int + IsEdit bool + PostID uint + FormAction string + BoardID uint + Title string + Tags string + Content string + PostType string + PollMulti bool + PollMaxChoices int + PollEndsAt string + PollOptions string + Boards []BoardView + TitleMax int + TagsMax int + ContentMax int } // ComposeGet 发帖页 @@ -37,7 +42,9 @@ func (d Deps) ComposeGet(c *gin.Context) { } boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64) d.renderCompose(ctx, "", composeForm{ - BoardID: uint(boardID), + BoardID: uint(boardID), + PostType: models.PostTypeNormal, + PollMaxChoices: 1, }, false, 0) } @@ -59,13 +66,34 @@ func (d Deps) ComposePost(c *gin.Context) { } form := composeFormFrom(c) htmlBody := services.ComposeBodyToHTML(form.Content) - post, err := d.Post.Create(ctx.UserID(), form.BoardID, form.Title, htmlBody, form.Tags, models.PostTypeNormal, ctx.SkipsModeration()) + postType := form.PostType + if postType != models.PostTypePoll { + postType = models.PostTypeNormal + } + 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 + } + 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) + return + } + } if post.Status == models.ContentStatusPending { ctx.SetFlash("帖子已提交,等待审核") + if d.Notify != nil { + d.Notify.AsyncNotifyPendingPost(post) + } } else { ctx.SetFlash("发帖成功") } @@ -82,10 +110,11 @@ func (d Deps) PostEditGet(c *gin.Context) { return } d.renderCompose(ctx, "", composeForm{ - BoardID: post.BoardID, - Title: post.Title, - Tags: post.Tags, - Content: services.HTMLToComposePlain(post.Content), + BoardID: post.BoardID, + Title: post.Title, + Tags: post.Tags, + Content: services.HTMLToComposePlain(post.Content), + PostType: post.PostType, }, true, post.ID) } @@ -107,8 +136,9 @@ func (d Deps) PostEditPost(c *gin.Context) { return } form := composeFormFrom(c) + form.PostType = post.PostType // 编辑不可改类型 htmlBody := services.ComposeBodyToHTML(form.Content) - if err := d.Post.Update(ctx.UserID(), post.ID, ctx.IsAdmin(), ctx.SkipsModeration(), form.Title, htmlBody, form.Tags, models.PostTypeNormal, form.BoardID); err != nil { + if err := d.Post.Update(ctx.UserID(), post.ID, ctx.IsAdmin(), ctx.SkipsModeration(), form.Title, htmlBody, form.Tags, post.PostType, form.BoardID); err != nil { d.renderCompose(ctx, err.Error(), form, true, post.ID) return } @@ -149,19 +179,37 @@ func (d Deps) ComposeUpload(c *gin.Context) { } type composeForm struct { - BoardID uint - Title string - Tags string - Content string + BoardID uint + Title string + Tags string + Content string + PostType string + PollMulti bool + PollMaxChoices int + PollEndsAt string + PollOptions string } func composeFormFrom(c *gin.Context) composeForm { bid, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64) + maxChoices, _ := strconv.Atoi(c.PostForm("poll_max_choices")) + if maxChoices < 1 { + maxChoices = 1 + } + postType := strings.TrimSpace(c.PostForm("post_type")) + if postType != models.PostTypePoll { + postType = models.PostTypeNormal + } return composeForm{ - BoardID: uint(bid), - Title: strings.TrimSpace(c.PostForm("title")), - Tags: strings.TrimSpace(c.PostForm("tags")), - Content: c.PostForm("content"), + BoardID: uint(bid), + Title: strings.TrimSpace(c.PostForm("title")), + Tags: strings.TrimSpace(c.PostForm("tags")), + Content: c.PostForm("content"), + PostType: postType, + 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"), } } @@ -172,22 +220,33 @@ func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm title = "编辑帖子" action = fmt.Sprintf("/post/%d/edit", postID) } + if form.PostType == "" { + form.PostType = models.PostTypeNormal + } + if form.PollMaxChoices < 1 { + form.PollMaxChoices = 1 + } chrome := d.chrome(ctx, title+" · "+d.Settings.SiteBranding().Name, "", "") chrome.Error = errMsg chrome.ActiveBoard = form.BoardID ctx.HTML(http.StatusOK, "compose", composeData{ - PageChrome: chrome, - IsEdit: isEdit, - PostID: postID, - FormAction: action, - BoardID: form.BoardID, - Title: form.Title, - Tags: form.Tags, - Content: form.Content, - Boards: chrome.Boards, - TitleMax: d.Settings.PostTitleMax(), - TagsMax: d.Settings.PostTagsMax(), - ContentMax: d.Settings.PostContentMax(), + PageChrome: chrome, + IsEdit: isEdit, + PostID: postID, + FormAction: action, + BoardID: form.BoardID, + Title: form.Title, + Tags: form.Tags, + Content: form.Content, + PostType: form.PostType, + PollMulti: form.PollMulti, + PollMaxChoices: form.PollMaxChoices, + PollEndsAt: form.PollEndsAt, + PollOptions: form.PollOptions, + Boards: chrome.Boards, + TitleMax: d.Settings.PostTitleMax(), + TagsMax: d.Settings.PostTagsMax(), + ContentMax: d.Settings.PostContentMax(), }) } @@ -221,4 +280,4 @@ func (d Deps) loadEditablePost(ctx *webctx.Context, idParam string) (*models.Pos return nil, reason } return post, "" -} +} \ No newline at end of file diff --git a/routers/web/home.go b/routers/web/home.go index 7699425..2868271 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -31,6 +31,8 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { g.POST("/post/:id/comments/:cid/like", authMW.RequireAuth(), deps.PostCommentLike) g.POST("/post/:id/like", authMW.RequireAuth(), deps.PostLike) g.POST("/post/:id/favorite", authMW.RequireAuth(), deps.PostFavorite) + g.POST("/post/:id/poll/vote", authMW.RequireAuth(), deps.PostPollVote) + g.POST("/post/:id/poll/close", authMW.RequireAuth(), deps.PostPollClose) 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 7fa04dc..0cd9052 100644 --- a/routers/web/post.go +++ b/routers/web/post.go @@ -46,6 +46,28 @@ type PostPageData struct { Status string StatusLabel string ShowModerationBanner bool + Poll *postPollView +} + +type postPollOptionView struct { + ID uint + Text string + VoteCount int + Percent int + Selected bool +} + +type postPollView struct { + Multi bool + MaxChoices int + Closed bool + EndsLabel string + TotalVotes int + ShowResults bool + HasVoted bool + CanVote bool + CanClose bool + Options []postPollOptionView } // CommentView 评论 @@ -151,6 +173,13 @@ func (d Deps) PostView(c *gin.Context) { } } + var pollView *postPollView + if post.PostType == models.PostTypePoll { + if pv, err := services.GetPollView(post.ID, ctx.UserID()); err == nil && pv != nil { + pollView = mapPollView(pv, ctx.IsSigned(), ctx.IsAdmin() || post.UserID == ctx.UserID()) + } + } + ctx.HTML(http.StatusOK, "post", PostPageData{ PageChrome: chrome, PostID: post.ID, PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)), @@ -170,9 +199,37 @@ func (d Deps) PostView(c *gin.Context) { Status: post.Status, StatusLabel: statusLabel, ShowModerationBanner: showBanner, + Poll: pollView, }) } +func mapPollView(pv *services.PollView, loggedIn, canClose bool) *postPollView { + hasVoted := len(pv.MyOptionIDs) > 0 + selected := make(map[uint]bool, len(pv.MyOptionIDs)) + for _, id := range pv.MyOptionIDs { + selected[id] = true + } + showResults := pv.Closed || hasVoted + opts := make([]postPollOptionView, 0, len(pv.Options)) + for _, o := range pv.Options { + opts = append(opts, postPollOptionView{ + ID: o.ID, Text: o.Text, VoteCount: o.VoteCount, Percent: o.Percent, Selected: selected[o.ID], + }) + } + ends := "" + if pv.EndsAt != nil { + ends = formatTime(*pv.EndsAt) + } + return &postPollView{ + Multi: pv.Multi, MaxChoices: pv.MaxChoices, Closed: pv.Closed, + EndsLabel: ends, TotalVotes: pv.TotalVotes, + ShowResults: showResults, HasVoted: hasVoted, + CanVote: loggedIn && !pv.Closed && !hasVoted, + CanClose: canClose && !pv.Closed, + Options: opts, + } +} + func contentStatusLabel(status string) string { switch status { case models.ContentStatusPending: @@ -349,6 +406,68 @@ func (d Deps) PostFavorite(c *gin.Context) { ctx.Redirect(fmt.Sprintf("/post/%d", id)) } +// PostPollVote 投票提交 +func (d Deps) PostPollVote(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 + } + raw := c.PostFormArray("option_ids") + if len(raw) == 0 { + if one := strings.TrimSpace(c.PostForm("option_id")); one != "" { + raw = []string{one} + } + } + oids := make([]uint, 0, len(raw)) + for _, s := range raw { + v, err := strconv.ParseUint(strings.TrimSpace(s), 10, 64) + if err == nil && v > 0 { + oids = append(oids, uint(v)) + } + } + if err := services.VotePoll(id, ctx.UserID(), oids); err != nil { + ctx.SetFlash(err.Error()) + ctx.Redirect(fmt.Sprintf("/post/%d", id)) + return + } + ctx.SetFlash("投票成功") + ctx.Redirect(fmt.Sprintf("/post/%d", id)) +} + +// PostPollClose 结束投票 +func (d Deps) PostPollClose(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 + } + post, err := d.Post.FindByID(id) + if err != nil { + d.render404(ctx) + return + } + if err := services.ClosePoll(id, ctx.UserID(), ctx.IsAdmin(), post.UserID); 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/services/poll.go b/services/poll.go index fce813f..7673749 100644 --- a/services/poll.go +++ b/services/poll.go @@ -96,6 +96,34 @@ func ParsePollOptionsJSON(raw string) ([]PollOptionInput, bool, int, *time.Time, return payload.Options, payload.Multi, payload.MaxChoices, endsAt, nil } +// BuildPollOptionsJSON 由 SSR 表单字段组装 poll_options JSON +func BuildPollOptionsJSON(multi bool, maxChoices int, endsAt, optionsText string) (string, error) { + var opts []PollOptionInput + for _, line := range strings.Split(optionsText, "\n") { + text := strings.TrimSpace(line) + if text == "" { + continue + } + opts = append(opts, PollOptionInput{Text: text}) + } + if len(opts) < 2 || len(opts) > 10 { + return "", errors.New("投票选项需 2-10 个(每行一项)") + } + payload := struct { + Multi bool `json:"multi"` + MaxChoices int `json:"max_choices"` + EndsAt string `json:"ends_at,omitempty"` + Options []PollOptionInput `json:"options"` + }{ + Multi: multi, MaxChoices: maxChoices, EndsAt: strings.TrimSpace(endsAt), Options: opts, + } + b, err := json.Marshal(payload) + if err != nil { + return "", err + } + return string(b), nil +} + func parsePollEndsAt(raw string) (*time.Time, error) { raw = strings.TrimSpace(raw) if raw == "" { @@ -107,7 +135,9 @@ func parsePollEndsAt(raw string) (*time.Time, error) { time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05", + "2006-01-02T15:04", "2006-01-02 15:04:05", + "2006-01-02 15:04", } { if t, err := time.Parse(layout, raw); err == nil { parsed = t diff --git a/templates/compose.tmpl b/templates/compose.tmpl index 23992e1..3f2b5b3 100644 --- a/templates/compose.tmpl +++ b/templates/compose.tmpl @@ -15,6 +15,30 @@ {{end}} + {{if .IsEdit}} +
类型:{{if eq .PostType "poll"}}投票{{else}}普通讨论{{end}}(不可更改)
+ + {{else}} + + + {{end}} @@ -39,4 +63,16 @@ {{template "base/footer" .}} +{{if not .IsEdit}} + +{{end}} {{end}} diff --git a/templates/post/body.tmpl b/templates/post/body.tmpl index c7ea1a3..437001b 100644 --- a/templates/post/body.tmpl +++ b/templates/post/body.tmpl @@ -89,6 +89,58 @@ {{end}} + {{if .Poll}} ++ {{if .Poll.Multi}}多选(最多 {{.Poll.MaxChoices}} 项){{else}}单选{{end}} + · 共 {{.Poll.TotalVotes}} 票 + {{if .Poll.EndsLabel}} · 截止 {{.Poll.EndsLabel}}{{end}} + {{if .Poll.Closed}} · 已结束{{else if .Poll.HasVoted}} · 你已投票{{end}} +
+ {{if .Poll.CanVote}} + + {{else}} + + {{if and (not $.LoggedIn) (not .Poll.Closed)}} +登录 后可投票。
+ {{end}} + {{end}} + {{if .Poll.CanClose}} + + {{end}} +