feat: SSR 投票帖发帖与详情投票卡
compose 支持 poll 类型与选项,详情可投票/查看结果/结束。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -67,9 +67,9 @@
|
|||||||
|
|
||||||
### D.1 帖子类型
|
### D.1 帖子类型
|
||||||
|
|
||||||
- [ ] `normal` 普通讨论
|
- [x] `normal` 普通讨论 — SSR compose 默认
|
||||||
- [ ] `question` 问答:可标记已解决 / 未解决
|
- [ ] `question` 问答:可标记已解决 / 未解决
|
||||||
- [ ] `poll` 投票:2–10 选项;单选/多选;可选截止时间;投票;作者可结束
|
- [x] `poll` 投票:2–10 选项;单选/多选;可选截止时间;投票;作者可结束 — SSR compose + 详情卡
|
||||||
- [ ] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05)
|
- [ ] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05)
|
||||||
- [ ] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖
|
- [ ] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
| `/board/:id` | 板块 Feed | 已迁 |
|
| `/board/:id` | 板块 Feed | 已迁 |
|
||||||
| `/boards` | 板块索引 | 已迁 |
|
| `/boards` | 板块索引 | 已迁 |
|
||||||
| `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 |
|
| `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 |
|
||||||
| `/compose` | 发帖(normal;textarea + 图片 + 门控插入) | 已迁 |
|
| `/compose` | 发帖(normal / poll;textarea + 图片 + 门控插入) | 已迁 |
|
||||||
| `/post/:id/edit` | 编辑帖 | 已迁 |
|
| `/post/:id/edit` | 编辑帖 | 已迁 |
|
||||||
| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 |
|
| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 |
|
||||||
| `/user/:id` | 公开用户页 | 已迁 |
|
| `/user/:id` | 公开用户页 | 已迁 |
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
| 模块 | 行为 |
|
| 模块 | 行为 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 门控块 | 锁定壳 UI(长度/价格/引导);`POST /post/:id/unlock` 返回 inner HTML 后替换 |
|
| 门控块 | 锁定壳 UI(长度/价格/引导);`POST /post/:id/unlock` 返回 inner HTML 后替换 |
|
||||||
| 投票卡 | 选选项提交;显示百分比;作者可结束 |
|
| 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` |
|
||||||
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 |
|
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 |
|
||||||
| 抽奖卡 | 显示参与人数;开奖;中奖名单 |
|
| 抽奖卡 | 显示参与人数;开奖;中奖名单 |
|
||||||
| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除、回复/@ 通知 — SSR;嵌套树 UI 未做 |
|
| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除、回复/@ 通知 — SSR;嵌套树 UI 未做 |
|
||||||
|
|||||||
@@ -488,6 +488,37 @@ body.j13-body {
|
|||||||
}
|
}
|
||||||
.j13-filebtn { display: inline-block; margin: 0; cursor: pointer; }
|
.j13-filebtn { display: inline-block; margin: 0; cursor: pointer; }
|
||||||
.j13-compose { max-width: 720px; margin: 0 auto; padding: 1rem; }
|
.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-post__content img { max-width: 100%; height: auto; border-radius: 4px; }
|
||||||
|
|
||||||
.j13-admin { max-width: 880px; margin: 0 auto; padding: 1rem 1rem 2.5rem; }
|
.j13-admin { max-width: 880px; margin: 0 auto; padding: 1rem 1rem 2.5rem; }
|
||||||
|
|||||||
@@ -14,17 +14,22 @@ import (
|
|||||||
|
|
||||||
type composeData struct {
|
type composeData struct {
|
||||||
PageChrome
|
PageChrome
|
||||||
IsEdit bool
|
IsEdit bool
|
||||||
PostID uint
|
PostID uint
|
||||||
FormAction string
|
FormAction string
|
||||||
BoardID uint
|
BoardID uint
|
||||||
Title string
|
Title string
|
||||||
Tags string
|
Tags string
|
||||||
Content string
|
Content string
|
||||||
Boards []BoardView
|
PostType string
|
||||||
TitleMax int
|
PollMulti bool
|
||||||
TagsMax int
|
PollMaxChoices int
|
||||||
ContentMax int
|
PollEndsAt string
|
||||||
|
PollOptions string
|
||||||
|
Boards []BoardView
|
||||||
|
TitleMax int
|
||||||
|
TagsMax int
|
||||||
|
ContentMax int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComposeGet 发帖页
|
// ComposeGet 发帖页
|
||||||
@@ -37,7 +42,9 @@ func (d Deps) ComposeGet(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64)
|
boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64)
|
||||||
d.renderCompose(ctx, "", composeForm{
|
d.renderCompose(ctx, "", composeForm{
|
||||||
BoardID: uint(boardID),
|
BoardID: uint(boardID),
|
||||||
|
PostType: models.PostTypeNormal,
|
||||||
|
PollMaxChoices: 1,
|
||||||
}, false, 0)
|
}, false, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,13 +66,34 @@ func (d Deps) ComposePost(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
form := composeFormFrom(c)
|
form := composeFormFrom(c)
|
||||||
htmlBody := services.ComposeBodyToHTML(form.Content)
|
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 {
|
if err != nil {
|
||||||
d.renderCompose(ctx, err.Error(), form, false, 0)
|
d.renderCompose(ctx, err.Error(), form, false, 0)
|
||||||
return
|
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 {
|
if post.Status == models.ContentStatusPending {
|
||||||
ctx.SetFlash("帖子已提交,等待审核")
|
ctx.SetFlash("帖子已提交,等待审核")
|
||||||
|
if d.Notify != nil {
|
||||||
|
d.Notify.AsyncNotifyPendingPost(post)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ctx.SetFlash("发帖成功")
|
ctx.SetFlash("发帖成功")
|
||||||
}
|
}
|
||||||
@@ -82,10 +110,11 @@ func (d Deps) PostEditGet(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
d.renderCompose(ctx, "", composeForm{
|
d.renderCompose(ctx, "", composeForm{
|
||||||
BoardID: post.BoardID,
|
BoardID: post.BoardID,
|
||||||
Title: post.Title,
|
Title: post.Title,
|
||||||
Tags: post.Tags,
|
Tags: post.Tags,
|
||||||
Content: services.HTMLToComposePlain(post.Content),
|
Content: services.HTMLToComposePlain(post.Content),
|
||||||
|
PostType: post.PostType,
|
||||||
}, true, post.ID)
|
}, true, post.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,8 +136,9 @@ func (d Deps) PostEditPost(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
form := composeFormFrom(c)
|
form := composeFormFrom(c)
|
||||||
|
form.PostType = post.PostType // 编辑不可改类型
|
||||||
htmlBody := services.ComposeBodyToHTML(form.Content)
|
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)
|
d.renderCompose(ctx, err.Error(), form, true, post.ID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -149,19 +179,37 @@ func (d Deps) ComposeUpload(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type composeForm struct {
|
type composeForm struct {
|
||||||
BoardID uint
|
BoardID uint
|
||||||
Title string
|
Title string
|
||||||
Tags string
|
Tags string
|
||||||
Content string
|
Content string
|
||||||
|
PostType string
|
||||||
|
PollMulti bool
|
||||||
|
PollMaxChoices int
|
||||||
|
PollEndsAt string
|
||||||
|
PollOptions string
|
||||||
}
|
}
|
||||||
|
|
||||||
func composeFormFrom(c *gin.Context) composeForm {
|
func composeFormFrom(c *gin.Context) composeForm {
|
||||||
bid, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
|
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{
|
return composeForm{
|
||||||
BoardID: uint(bid),
|
BoardID: uint(bid),
|
||||||
Title: strings.TrimSpace(c.PostForm("title")),
|
Title: strings.TrimSpace(c.PostForm("title")),
|
||||||
Tags: strings.TrimSpace(c.PostForm("tags")),
|
Tags: strings.TrimSpace(c.PostForm("tags")),
|
||||||
Content: c.PostForm("content"),
|
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 = "编辑帖子"
|
title = "编辑帖子"
|
||||||
action = fmt.Sprintf("/post/%d/edit", postID)
|
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 := d.chrome(ctx, title+" · "+d.Settings.SiteBranding().Name, "", "")
|
||||||
chrome.Error = errMsg
|
chrome.Error = errMsg
|
||||||
chrome.ActiveBoard = form.BoardID
|
chrome.ActiveBoard = form.BoardID
|
||||||
ctx.HTML(http.StatusOK, "compose", composeData{
|
ctx.HTML(http.StatusOK, "compose", composeData{
|
||||||
PageChrome: chrome,
|
PageChrome: chrome,
|
||||||
IsEdit: isEdit,
|
IsEdit: isEdit,
|
||||||
PostID: postID,
|
PostID: postID,
|
||||||
FormAction: action,
|
FormAction: action,
|
||||||
BoardID: form.BoardID,
|
BoardID: form.BoardID,
|
||||||
Title: form.Title,
|
Title: form.Title,
|
||||||
Tags: form.Tags,
|
Tags: form.Tags,
|
||||||
Content: form.Content,
|
Content: form.Content,
|
||||||
Boards: chrome.Boards,
|
PostType: form.PostType,
|
||||||
TitleMax: d.Settings.PostTitleMax(),
|
PollMulti: form.PollMulti,
|
||||||
TagsMax: d.Settings.PostTagsMax(),
|
PollMaxChoices: form.PollMaxChoices,
|
||||||
ContentMax: d.Settings.PostContentMax(),
|
PollEndsAt: form.PollEndsAt,
|
||||||
|
PollOptions: form.PollOptions,
|
||||||
|
Boards: chrome.Boards,
|
||||||
|
TitleMax: d.Settings.PostTitleMax(),
|
||||||
|
TagsMax: d.Settings.PostTagsMax(),
|
||||||
|
ContentMax: d.Settings.PostContentMax(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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/comments/:cid/like", authMW.RequireAuth(), deps.PostCommentLike)
|
||||||
g.POST("/post/:id/like", authMW.RequireAuth(), deps.PostLike)
|
g.POST("/post/:id/like", authMW.RequireAuth(), deps.PostLike)
|
||||||
g.POST("/post/:id/favorite", authMW.RequireAuth(), deps.PostFavorite)
|
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/unlock", authMW.RequireAuth(), deps.PostUnlock)
|
||||||
g.POST("/post/:id/report", authMW.RequireAuth(), deps.PostReportPost)
|
g.POST("/post/:id/report", authMW.RequireAuth(), deps.PostReportPost)
|
||||||
g.POST("/post/:id/comments/:cid/report", authMW.RequireAuth(), deps.CommentReportPost)
|
g.POST("/post/:id/comments/:cid/report", authMW.RequireAuth(), deps.CommentReportPost)
|
||||||
|
|||||||
@@ -46,6 +46,28 @@ type PostPageData struct {
|
|||||||
Status string
|
Status string
|
||||||
StatusLabel string
|
StatusLabel string
|
||||||
ShowModerationBanner bool
|
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 评论
|
// 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{
|
ctx.HTML(http.StatusOK, "post", PostPageData{
|
||||||
PageChrome: chrome, PostID: post.ID,
|
PageChrome: chrome, PostID: post.ID,
|
||||||
PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", 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,
|
Status: post.Status,
|
||||||
StatusLabel: statusLabel,
|
StatusLabel: statusLabel,
|
||||||
ShowModerationBanner: showBanner,
|
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 {
|
func contentStatusLabel(status string) string {
|
||||||
switch status {
|
switch status {
|
||||||
case models.ContentStatusPending:
|
case models.ContentStatusPending:
|
||||||
@@ -349,6 +406,68 @@ func (d Deps) PostFavorite(c *gin.Context) {
|
|||||||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
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 {
|
type commentEditData struct {
|
||||||
PageChrome
|
PageChrome
|
||||||
PostID uint
|
PostID uint
|
||||||
|
|||||||
@@ -96,6 +96,34 @@ func ParsePollOptionsJSON(raw string) ([]PollOptionInput, bool, int, *time.Time,
|
|||||||
return payload.Options, payload.Multi, payload.MaxChoices, endsAt, nil
|
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) {
|
func parsePollEndsAt(raw string) (*time.Time, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -107,7 +135,9 @@ func parsePollEndsAt(raw string) (*time.Time, error) {
|
|||||||
time.RFC3339Nano,
|
time.RFC3339Nano,
|
||||||
time.RFC3339,
|
time.RFC3339,
|
||||||
"2006-01-02T15:04:05",
|
"2006-01-02T15:04:05",
|
||||||
|
"2006-01-02T15:04",
|
||||||
"2006-01-02 15:04:05",
|
"2006-01-02 15:04:05",
|
||||||
|
"2006-01-02 15:04",
|
||||||
} {
|
} {
|
||||||
if t, err := time.Parse(layout, raw); err == nil {
|
if t, err := time.Parse(layout, raw); err == nil {
|
||||||
parsed = t
|
parsed = t
|
||||||
|
|||||||
@@ -15,6 +15,30 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
{{if .IsEdit}}
|
||||||
|
<p class="j13-muted">类型:{{if eq .PostType "poll"}}投票{{else}}普通讨论{{end}}(不可更改)</p>
|
||||||
|
<input type="hidden" name="post_type" value="{{.PostType}}"/>
|
||||||
|
{{else}}
|
||||||
|
<label>类型
|
||||||
|
<select name="post_type" id="compose-post-type">
|
||||||
|
<option value="normal"{{if ne .PostType "poll"}} selected{{end}}>普通讨论</option>
|
||||||
|
<option value="poll"{{if eq .PostType "poll"}} selected{{end}}>投票</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<fieldset class="j13-poll-fields" id="compose-poll-fields"{{if ne .PostType "poll"}} hidden{{end}}>
|
||||||
|
<legend>投票设置</legend>
|
||||||
|
<label class="j13-check"><input type="checkbox" name="poll_multi" value="1"{{if .PollMulti}} checked{{end}}/> 允许多选</label>
|
||||||
|
<label>最多可选
|
||||||
|
<input name="poll_max_choices" type="number" min="1" max="10" value="{{.PollMaxChoices}}"/>
|
||||||
|
</label>
|
||||||
|
<label>截止时间(可选)
|
||||||
|
<input name="poll_ends_at" type="datetime-local" value="{{.PollEndsAt}}"/>
|
||||||
|
</label>
|
||||||
|
<label>选项(每行一项,2–10 个)
|
||||||
|
<textarea name="poll_options" rows="6" placeholder="选项 A 选项 B 选项 C">{{.PollOptions}}</textarea>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
{{end}}
|
||||||
<label>标题
|
<label>标题
|
||||||
<input name="title" required maxlength="{{.TitleMax}}" value="{{.Title}}"/>
|
<input name="title" required maxlength="{{.TitleMax}}" value="{{.Title}}"/>
|
||||||
</label>
|
</label>
|
||||||
@@ -39,4 +63,16 @@
|
|||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
{{template "base/footer" .}}
|
{{template "base/footer" .}}
|
||||||
|
{{if not .IsEdit}}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var sel = document.getElementById('compose-post-type');
|
||||||
|
var box = document.getElementById('compose-poll-fields');
|
||||||
|
if (!sel || !box) return;
|
||||||
|
function sync() { box.hidden = sel.value !== 'poll'; }
|
||||||
|
sel.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -89,6 +89,58 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</header>
|
</header>
|
||||||
|
{{if .Poll}}
|
||||||
|
<section class="j13-poll" aria-label="投票">
|
||||||
|
<h2 class="j13-poll__title">投票</h2>
|
||||||
|
<p class="j13-muted">
|
||||||
|
{{if .Poll.Multi}}多选(最多 {{.Poll.MaxChoices}} 项){{else}}单选{{end}}
|
||||||
|
· 共 {{.Poll.TotalVotes}} 票
|
||||||
|
{{if .Poll.EndsLabel}} · 截止 {{.Poll.EndsLabel}}{{end}}
|
||||||
|
{{if .Poll.Closed}} · 已结束{{else if .Poll.HasVoted}} · 你已投票{{end}}
|
||||||
|
</p>
|
||||||
|
{{if .Poll.CanVote}}
|
||||||
|
<form method="post" action="/post/{{.PostID}}/poll/vote" class="j13-form j13-poll__form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
|
||||||
|
<ul class="j13-poll__options">
|
||||||
|
{{range .Poll.Options}}
|
||||||
|
<li>
|
||||||
|
<label class="j13-check">
|
||||||
|
{{if $.Poll.Multi}}
|
||||||
|
<input type="checkbox" name="option_ids" value="{{.ID}}"/>
|
||||||
|
{{else}}
|
||||||
|
<input type="radio" name="option_id" value="{{.ID}}" required/>
|
||||||
|
{{end}}
|
||||||
|
{{.Text}}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
<button type="submit">提交投票</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<ul class="j13-poll__options">
|
||||||
|
{{range .Poll.Options}}
|
||||||
|
<li class="j13-poll__option{{if .Selected}} is-selected{{end}}">
|
||||||
|
<span class="j13-poll__opt-text">{{.Text}}</span>
|
||||||
|
{{if $.Poll.ShowResults}}
|
||||||
|
<span class="j13-poll__bar" style="--pct: {{.Percent}}%"></span>
|
||||||
|
<span class="j13-muted">{{.Percent}}%({{.VoteCount}})</span>
|
||||||
|
{{end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{if and (not $.LoggedIn) (not .Poll.Closed)}}
|
||||||
|
<p class="j13-muted"><a href="/login?redirect={{.PostPath}}">登录</a> 后可投票。</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if .Poll.CanClose}}
|
||||||
|
<form method="post" action="/post/{{.PostID}}/poll/close" class="j13-inline-form" onsubmit="return confirm('确认结束投票?');">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
|
||||||
|
<button type="submit" class="j13-linkbtn">结束投票</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
<div class="j13-post__content post-detail-content">{{safeHTML .BodyHTML}}</div>
|
<div class="j13-post__content post-detail-content">{{safeHTML .BodyHTML}}</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@@ -488,6 +488,37 @@ body.j13-body {
|
|||||||
}
|
}
|
||||||
.j13-filebtn { display: inline-block; margin: 0; cursor: pointer; }
|
.j13-filebtn { display: inline-block; margin: 0; cursor: pointer; }
|
||||||
.j13-compose { max-width: 720px; margin: 0 auto; padding: 1rem; }
|
.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-post__content img { max-width: 100%; height: auto; border-radius: 4px; }
|
||||||
|
|
||||||
.j13-admin { max-width: 880px; margin: 0 auto; padding: 1rem 1rem 2.5rem; }
|
.j13-admin { max-width: 880px; margin: 0 auto; padding: 1rem 1rem 2.5rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user