feat: SSR 问答帖发帖与解决状态切换

compose 支持 question;详情徽章并由作者/管理员切换。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 02:04:56 +08:00
parent 92b68b9eef
commit 303755b47a
9 changed files with 71 additions and 11 deletions

View File

@@ -68,7 +68,7 @@
### D.1 帖子类型 ### D.1 帖子类型
- [x] `normal` 普通讨论 — SSR compose 默认 - [x] `normal` 普通讨论 — SSR compose 默认
- [ ] `question` 问答:可标记已解决 / 未解决 - [x] `question` 问答:可标记已解决 / 未解决 — SSR compose + 详情徽章与切换
- [x] `poll` 投票210 选项;单选/多选;可选截止时间;投票;作者可结束 — SSR compose + 详情卡 - [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` | 发帖normal / polltextarea + 图片 + 门控插入) | 已迁 | | `/compose` | 发帖normal / question / polltextarea + 图片 + 门控插入) | 已迁 |
| `/post/:id/edit` | 编辑帖 | 已迁 | | `/post/:id/edit` | 编辑帖 | 已迁 |
| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 | | `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 |
| `/user/:id` | 公开用户页 | 已迁 | | `/user/:id` | 公开用户页 | 已迁 |
@@ -115,7 +115,7 @@
| 类型 | 附加 UI | | 类型 | 附加 UI |
|------|---------| |------|---------|
| 讨论 | 无 | | 讨论 | 无 |
| 问答 | 无额外字段(解决状态在详情) | | 问答 | 无额外字段(解决状态在详情)— SSR compose 可选;详情切换已迁 |
| 投票 | 选项列表、多选开关、最多可选、截止时间或无截止 | | 投票 | 选项列表、多选开关、最多可选、截止时间或无截止 |
| 悬赏 | 积分输入(显示余额) | | 悬赏 | 积分输入(显示余额) |
| 抽奖 | 中奖人数 120 | | 抽奖 | 中奖人数 120 |
@@ -144,6 +144,7 @@
| 模块 | 行为 | | 模块 | 行为 |
|------|------| |------|------|
| 门控块 | 锁定壳 UI长度/价格/引导);`POST /post/:id/unlock` 返回 inner HTML 后替换 | | 门控块 | 锁定壳 UI长度/价格/引导);`POST /post/:id/unlock` 返回 inner HTML 后替换 |
| 问答状态 | 已解决/未解决徽章;作者或管理员 `POST /post/:id/question/resolve` 切换 — SSR |
| 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` | | 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` |
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 | | 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 |
| 抽奖卡 | 显示参与人数;开奖;中奖名单 | | 抽奖卡 | 显示参与人数;开奖;中奖名单 |

View File

@@ -318,6 +318,8 @@ body.j13-body {
border: 1px solid #fecaca; border: 1px solid #fecaca;
} }
.j13-tag--feat { background: #ede9fe; color: #5b21b6; } .j13-tag--feat { background: #ede9fe; color: #5b21b6; }
.j13-tag--resolved { background: #ecfdf5; color: #065f46; }
.j13-tag--unresolved { background: #f8fafc; color: #475569; }
.j13-empty { color: var(--j13-muted); } .j13-empty { color: var(--j13-muted); }
.j13-pager { .j13-pager {

View File

@@ -67,7 +67,7 @@ func (d Deps) ComposePost(c *gin.Context) {
form := composeFormFrom(c) form := composeFormFrom(c)
htmlBody := services.ComposeBodyToHTML(form.Content) htmlBody := services.ComposeBodyToHTML(form.Content)
postType := form.PostType postType := form.PostType
if postType != models.PostTypePoll { if postType != models.PostTypePoll && postType != models.PostTypeQuestion {
postType = models.PostTypeNormal postType = models.PostTypeNormal
} }
post, err := d.Post.Create(ctx.UserID(), form.BoardID, form.Title, htmlBody, form.Tags, postType, ctx.SkipsModeration()) post, err := d.Post.Create(ctx.UserID(), form.BoardID, form.Title, htmlBody, form.Tags, postType, ctx.SkipsModeration())
@@ -197,7 +197,7 @@ func composeFormFrom(c *gin.Context) composeForm {
maxChoices = 1 maxChoices = 1
} }
postType := strings.TrimSpace(c.PostForm("post_type")) postType := strings.TrimSpace(c.PostForm("post_type"))
if postType != models.PostTypePoll { if postType != models.PostTypePoll && postType != models.PostTypeQuestion {
postType = models.PostTypeNormal postType = models.PostTypeNormal
} }
return composeForm{ return composeForm{

View File

@@ -33,6 +33,7 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
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/vote", authMW.RequireAuth(), deps.PostPollVote)
g.POST("/post/:id/poll/close", authMW.RequireAuth(), deps.PostPollClose) g.POST("/post/:id/poll/close", authMW.RequireAuth(), deps.PostPollClose)
g.POST("/post/:id/question/resolve", authMW.RequireAuth(), deps.PostQuestionResolvePost)
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,7 +46,10 @@ type PostPageData struct {
Status string Status string
StatusLabel string StatusLabel string
ShowModerationBanner bool ShowModerationBanner bool
Poll *postPollView IsQuestion bool
QuestionResolved bool
CanToggleQuestion bool
Poll *postPollView
} }
type postPollOptionView struct { type postPollOptionView struct {
@@ -199,7 +202,10 @@ func (d Deps) PostView(c *gin.Context) {
Status: post.Status, Status: post.Status,
StatusLabel: statusLabel, StatusLabel: statusLabel,
ShowModerationBanner: showBanner, ShowModerationBanner: showBanner,
Poll: pollView, IsQuestion: post.PostType == models.PostTypeQuestion,
QuestionResolved: post.QuestionResolved,
CanToggleQuestion: post.PostType == models.PostTypeQuestion && (ctx.IsAdmin() || post.UserID == ctx.UserID()),
Poll: pollView,
}) })
} }
@@ -468,6 +474,33 @@ func (d Deps) PostPollClose(c *gin.Context) {
ctx.Redirect(fmt.Sprintf("/post/%d", id)) ctx.Redirect(fmt.Sprintf("/post/%d", id))
} }
// PostQuestionResolvePost 标记问答帖已解决 / 未解决
func (d Deps) PostQuestionResolvePost(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
}
resolved := c.PostForm("resolved") == "1" || c.PostForm("resolved") == "on"
if err := d.Post.SetQuestionResolved(ctx.UserID(), id, ctx.IsAdmin(), resolved); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect(fmt.Sprintf("/post/%d", id))
return
}
if resolved {
ctx.SetFlash("已标为已解决")
} else {
ctx.SetFlash("已标为未解决")
}
ctx.Redirect(fmt.Sprintf("/post/%d", id))
}
type commentEditData struct { type commentEditData struct {
PageChrome PageChrome
PostID uint PostID uint

View File

@@ -16,15 +16,17 @@
</select> </select>
</label> </label>
{{if .IsEdit}} {{if .IsEdit}}
<p class="j13-muted">类型:{{if eq .PostType "poll"}}投票{{else}}普通讨论{{end}}(不可更改)</p> <p class="j13-muted">类型:{{if eq .PostType "poll"}}投票{{else if eq .PostType "question"}}问答{{else}}普通讨论{{end}}(不可更改)</p>
<input type="hidden" name="post_type" value="{{.PostType}}"/> <input type="hidden" name="post_type" value="{{.PostType}}"/>
{{else}} {{else}}
<label>类型 <label>类型
<select name="post_type" id="compose-post-type"> <select name="post_type" id="compose-post-type">
<option value="normal"{{if ne .PostType "poll"}} selected{{end}}>普通讨论</option> <option value="normal"{{if or (eq .PostType "normal") (eq .PostType "")}} selected{{end}}>普通讨论</option>
<option value="question"{{if eq .PostType "question"}} selected{{end}}>问答</option>
<option value="poll"{{if eq .PostType "poll"}} selected{{end}}>投票</option> <option value="poll"{{if eq .PostType "poll"}} selected{{end}}>投票</option>
</select> </select>
</label> </label>
<p class="j13-muted" id="compose-question-hint"{{if ne .PostType "question"}} hidden{{end}}>问答帖无额外字段;解决状态可在帖详情由作者或管理员切换。</p>
<fieldset class="j13-poll-fields" id="compose-poll-fields"{{if ne .PostType "poll"}} hidden{{end}}> <fieldset class="j13-poll-fields" id="compose-poll-fields"{{if ne .PostType "poll"}} hidden{{end}}>
<legend>投票设置</legend> <legend>投票设置</legend>
<label class="j13-check"><input type="checkbox" name="poll_multi" value="1"{{if .PollMulti}} checked{{end}}/> 允许多选</label> <label class="j13-check"><input type="checkbox" name="poll_multi" value="1"{{if .PollMulti}} checked{{end}}/> 允许多选</label>
@@ -68,8 +70,12 @@
(function () { (function () {
var sel = document.getElementById('compose-post-type'); var sel = document.getElementById('compose-post-type');
var box = document.getElementById('compose-poll-fields'); var box = document.getElementById('compose-poll-fields');
if (!sel || !box) return; var qHint = document.getElementById('compose-question-hint');
function sync() { box.hidden = sel.value !== 'poll'; } if (!sel) return;
function sync() {
if (box) box.hidden = sel.value !== 'poll';
if (qHint) qHint.hidden = sel.value !== 'question';
}
sel.addEventListener('change', sync); sel.addEventListener('change', sync);
sync(); sync();
})(); })();

View File

@@ -14,6 +14,9 @@
{{if .EditLocked}}<span class="j13-tag">禁编</span>{{end}} {{if .EditLocked}}<span class="j13-tag">禁编</span>{{end}}
{{if .CommentsLocked}}<span class="j13-tag">已结贴</span>{{end}} {{if .CommentsLocked}}<span class="j13-tag">已结贴</span>{{end}}
{{if .PostTypeLabel}}<span class="j13-tag">{{.PostTypeLabel}}</span>{{end}} {{if .PostTypeLabel}}<span class="j13-tag">{{.PostTypeLabel}}</span>{{end}}
{{if .IsQuestion}}
{{if .QuestionResolved}}<span class="j13-tag j13-tag--resolved">已解决</span>{{else}}<span class="j13-tag j13-tag--unresolved">未解决</span>{{end}}
{{end}}
{{if .BoardName}}<a class="j13-tag" href="/board/{{.BoardID}}">{{.BoardName}}</a>{{end}} {{if .BoardName}}<a class="j13-tag" href="/board/{{.BoardID}}">{{.BoardName}}</a>{{end}}
{{if .AuthorID}}<a href="/user/{{.AuthorID}}">{{.AuthorName}}</a>{{else}}<span>{{.AuthorName}}</span>{{end}} {{if .AuthorID}}<a href="/user/{{.AuthorID}}">{{.AuthorName}}</a>{{else}}<span>{{.AuthorName}}</span>{{end}}
<span>{{.CreatedLabel}}</span> <span>{{.CreatedLabel}}</span>
@@ -21,6 +24,18 @@
</div> </div>
{{if .LoggedIn}} {{if .LoggedIn}}
<div class="j13-post__actions"> <div class="j13-post__actions">
{{if .CanToggleQuestion}}
<form method="post" action="/post/{{.PostID}}/question/resolve" class="j13-inline-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
{{if .QuestionResolved}}
<input type="hidden" name="resolved" value="0"/>
<button type="submit" class="j13-btn-secondary">标为未解决</button>
{{else}}
<input type="hidden" name="resolved" value="1"/>
<button type="submit">标为已解决</button>
{{end}}
</form>
{{end}}
<form method="post" action="/post/{{.PostID}}/like" class="j13-inline-form"> <form method="post" action="/post/{{.PostID}}/like" class="j13-inline-form">
<input type="hidden" name="_csrf" value="{{.CSRF}}"/> <input type="hidden" name="_csrf" value="{{.CSRF}}"/>
<button type="submit">{{if .Liked}}取消赞{{else}}赞{{end}} ({{.LikeCount}})</button> <button type="submit">{{if .Liked}}取消赞{{else}}赞{{end}} ({{.LikeCount}})</button>

View File

@@ -318,6 +318,8 @@ body.j13-body {
border: 1px solid #fecaca; border: 1px solid #fecaca;
} }
.j13-tag--feat { background: #ede9fe; color: #5b21b6; } .j13-tag--feat { background: #ede9fe; color: #5b21b6; }
.j13-tag--resolved { background: #ecfdf5; color: #065f46; }
.j13-tag--unresolved { background: #f8fafc; color: #475569; }
.j13-empty { color: var(--j13-muted); } .j13-empty { color: var(--j13-muted); }
.j13-pager { .j13-pager {