feat: SSR 投票帖发帖与详情投票卡

compose 支持 poll 类型与选项,详情可投票/查看结果/结束。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 01:18:58 +08:00
parent 7f21ffb14e
commit 4f93397ef3
10 changed files with 403 additions and 43 deletions

View File

@@ -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, ""
}
}

View File

@@ -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)

View File

@@ -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