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

@@ -67,9 +67,9 @@
### D.1 帖子类型 ### D.1 帖子类型
- [ ] `normal` 普通讨论 - [x] `normal` 普通讨论 — SSR compose 默认
- [ ] `question` 问答:可标记已解决 / 未解决 - [ ] `question` 问答:可标记已解决 / 未解决
- [ ] `poll` 投票210 选项;单选/多选;可选截止时间;投票;作者可结束 - [x] `poll` 投票210 选项;单选/多选;可选截止时间;投票;作者可结束 — SSR compose + 详情卡
- [ ] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05 - [ ] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05
- [ ] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖 - [ ] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖

View File

@@ -28,7 +28,7 @@
| `/board/:id` | 板块 Feed | 已迁 | | `/board/:id` | 板块 Feed | 已迁 |
| `/boards` | 板块索引 | 已迁 | | `/boards` | 板块索引 | 已迁 |
| `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 | | `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 |
| `/compose` | 发帖normaltextarea + 图片 + 门控插入) | 已迁 | | `/compose` | 发帖normal / polltextarea + 图片 + 门控插入) | 已迁 |
| `/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 未做 |

View File

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

View File

@@ -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(),
}) })
} }
@@ -221,4 +280,4 @@ func (d Deps) loadEditablePost(ctx *webctx.Context, idParam string) (*models.Pos
return nil, reason return nil, reason
} }
return post, "" 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/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)

View File

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

View File

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

View File

@@ -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>选项每行一项210 个)
<textarea name="poll_options" rows="6" placeholder="选项 A&#10;选项 B&#10;选项 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}}

View File

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

View File

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