feat: SSR 悬赏帖托管、采纳与退款
compose 支持 bounty;详情悬赏条可采纳评论或按规则退回。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -70,7 +70,7 @@
|
|||||||
- [x] `normal` 普通讨论 — SSR compose 默认
|
- [x] `normal` 普通讨论 — SSR compose 默认
|
||||||
- [x] `question` 问答:可标记已解决 / 未解决 — SSR compose + 详情徽章与切换
|
- [x] `question` 问答:可标记已解决 / 未解决 — SSR compose + 详情徽章与切换
|
||||||
- [x] `poll` 投票:2–10 选项;单选/多选;可选截止时间;投票;作者可结束 — SSR compose + 详情卡
|
- [x] `poll` 投票:2–10 选项;单选/多选;可选截止时间;投票;作者可结束 — SSR compose + 详情卡
|
||||||
- [ ] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05)
|
- [x] `bounty` 悬赏:发帖托管积分;采纳评论发奖;可退款(规则见 05)— SSR compose + 详情条 + award/refund
|
||||||
- [ ] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖
|
- [ ] `lottery` 抽奖帖:设定中奖人数;从评论参与者开奖
|
||||||
|
|
||||||
### D.2 运营标记(管理员)
|
### D.2 运营标记(管理员)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
| `/board/:id` | 板块 Feed | 已迁 |
|
| `/board/:id` | 板块 Feed | 已迁 |
|
||||||
| `/boards` | 板块索引 | 已迁 |
|
| `/boards` | 板块索引 | 已迁 |
|
||||||
| `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 |
|
| `/post/:id` | 帖详情 + 评论(回复/赞/私密)/赞/藏 | 已迁 |
|
||||||
| `/compose` | 发帖(normal / question / poll;textarea + 图片 + 门控插入) | 已迁 |
|
| `/compose` | 发帖(normal / question / poll / bounty;textarea + 图片 + 门控插入) | 已迁 |
|
||||||
| `/post/:id/edit` | 编辑帖 | 已迁 |
|
| `/post/:id/edit` | 编辑帖 | 已迁 |
|
||||||
| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 |
|
| `/profile` | 个人中心(资料/密码/头像/积分钱包) | 已迁 |
|
||||||
| `/user/:id` | 公开用户页 | 已迁 |
|
| `/user/:id` | 公开用户页 | 已迁 |
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
| 讨论 | 无 |
|
| 讨论 | 无 |
|
||||||
| 问答 | 无额外字段(解决状态在详情)— SSR compose 可选;详情切换已迁 |
|
| 问答 | 无额外字段(解决状态在详情)— SSR compose 可选;详情切换已迁 |
|
||||||
| 投票 | 选项列表、多选开关、最多可选、截止时间或无截止 |
|
| 投票 | 选项列表、多选开关、最多可选、截止时间或无截止 |
|
||||||
| 悬赏 | 积分输入(显示余额) |
|
| 悬赏 | 积分输入(显示余额)— SSR compose;发帖托管 |
|
||||||
| 抽奖 | 中奖人数 1–20 |
|
| 抽奖 | 中奖人数 1–20 |
|
||||||
|
|
||||||
### 3.2 编辑器能力(应对齐)
|
### 3.2 编辑器能力(应对齐)
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
| 门控块 | 锁定壳 UI(长度/价格/引导);`POST /post/:id/unlock` 返回 inner HTML 后替换 |
|
| 门控块 | 锁定壳 UI(长度/价格/引导);`POST /post/:id/unlock` 返回 inner HTML 后替换 |
|
||||||
| 问答状态 | 已解决/未解决徽章;作者或管理员 `POST /post/:id/question/resolve` 切换 — SSR |
|
| 问答状态 | 已解决/未解决徽章;作者或管理员 `POST /post/:id/question/resolve` 切换 — SSR |
|
||||||
| 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` |
|
| 投票卡 | 选选项提交;显示百分比;作者可结束 — SSR `/post/:id/poll/vote|close` |
|
||||||
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 |
|
| 悬赏条 | 显示积分与状态;采纳按钮在他人评论上;退款按钮按规则禁用并提示 — SSR `/post/:id/bounty/award|refund` |
|
||||||
| 抽奖卡 | 显示参与人数;开奖;中奖名单 |
|
| 抽奖卡 | 显示参与人数;开奖;中奖名单 |
|
||||||
| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除、回复/@ 通知 — SSR;嵌套树 UI 未做 |
|
| 评论 | 楼层列表、`reply_to` 引用、私密开关、点赞、举报、编辑(时限)、删除、回复/@ 通知 — SSR;嵌套树 UI 未做 |
|
||||||
| 修订 | 列表 + 单条快照 — SSR `/post/:id/revisions`(作者/管理员;无 diff) |
|
| 修订 | 列表 + 单条快照 — SSR `/post/:id/revisions`(作者/管理员;无 diff) |
|
||||||
|
|||||||
@@ -497,6 +497,21 @@ body.j13-body {
|
|||||||
border-radius: var(--j13-radius);
|
border-radius: var(--j13-radius);
|
||||||
}
|
}
|
||||||
.j13-poll-fields legend { padding: 0 0.35rem; font-weight: 600; }
|
.j13-poll-fields legend { padding: 0 0.35rem; font-weight: 600; }
|
||||||
|
.j13-bounty-fields {
|
||||||
|
margin: 0.75rem 0 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
border-radius: var(--j13-radius);
|
||||||
|
}
|
||||||
|
.j13-bounty-fields legend { padding: 0 0.35rem; font-weight: 600; }
|
||||||
|
.j13-bounty {
|
||||||
|
margin: 1rem 0 1.25rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
border-radius: var(--j13-radius);
|
||||||
|
background: var(--j13-surface, #fff);
|
||||||
|
}
|
||||||
|
.j13-bounty__title { margin: 0 0 0.35rem; font-size: 1.1rem; }
|
||||||
.j13-poll {
|
.j13-poll {
|
||||||
margin: 1rem 0 1.25rem;
|
margin: 1rem 0 1.25rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type composeData struct {
|
|||||||
PollMaxChoices int
|
PollMaxChoices int
|
||||||
PollEndsAt string
|
PollEndsAt string
|
||||||
PollOptions string
|
PollOptions string
|
||||||
|
BountyPoints int
|
||||||
Boards []BoardView
|
Boards []BoardView
|
||||||
TitleMax int
|
TitleMax int
|
||||||
TagsMax int
|
TagsMax int
|
||||||
@@ -66,15 +67,14 @@ 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 := normalizeComposePostType(form.PostType)
|
||||||
if postType != models.PostTypePoll && postType != models.PostTypeQuestion {
|
|
||||||
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())
|
||||||
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 || post.PostType == models.PostTypeBounty {
|
||||||
|
var extras services.PostCreateExtras
|
||||||
if post.PostType == models.PostTypePoll {
|
if post.PostType == models.PostTypePoll {
|
||||||
pollJSON, err := services.BuildPollOptionsJSON(form.PollMulti, form.PollMaxChoices, form.PollEndsAt, form.PollOptions)
|
pollJSON, err := services.BuildPollOptionsJSON(form.PollMulti, form.PollMaxChoices, form.PollEndsAt, form.PollOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,7 +82,10 @@ func (d Deps) ComposePost(c *gin.Context) {
|
|||||||
d.renderCompose(ctx, err.Error(), form, false, 0)
|
d.renderCompose(ctx, err.Error(), form, false, 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
extras := services.ParsePostExtrasFromForm(pollJSON, "", "")
|
extras = services.ParsePostExtrasFromForm(pollJSON, "", "")
|
||||||
|
} else {
|
||||||
|
extras = services.ParsePostExtrasFromForm("", strconv.Itoa(form.BountyPoints), "")
|
||||||
|
}
|
||||||
if err := services.FinalizeSpecialPostCreate(post, ctx.UserID(), extras); err != nil {
|
if err := services.FinalizeSpecialPostCreate(post, ctx.UserID(), extras); err != nil {
|
||||||
_ = d.Post.Delete(ctx.UserID(), post.ID, true)
|
_ = d.Post.Delete(ctx.UserID(), post.ID, true)
|
||||||
d.renderCompose(ctx, err.Error(), form, false, 0)
|
d.renderCompose(ctx, err.Error(), form, false, 0)
|
||||||
@@ -188,6 +191,16 @@ type composeForm struct {
|
|||||||
PollMaxChoices int
|
PollMaxChoices int
|
||||||
PollEndsAt string
|
PollEndsAt string
|
||||||
PollOptions string
|
PollOptions string
|
||||||
|
BountyPoints int
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeComposePostType(t string) string {
|
||||||
|
switch t {
|
||||||
|
case models.PostTypePoll, models.PostTypeQuestion, models.PostTypeBounty:
|
||||||
|
return t
|
||||||
|
default:
|
||||||
|
return models.PostTypeNormal
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func composeFormFrom(c *gin.Context) composeForm {
|
func composeFormFrom(c *gin.Context) composeForm {
|
||||||
@@ -196,20 +209,18 @@ func composeFormFrom(c *gin.Context) composeForm {
|
|||||||
if maxChoices < 1 {
|
if maxChoices < 1 {
|
||||||
maxChoices = 1
|
maxChoices = 1
|
||||||
}
|
}
|
||||||
postType := strings.TrimSpace(c.PostForm("post_type"))
|
bountyPoints, _ := strconv.Atoi(c.PostForm("bounty_points"))
|
||||||
if postType != models.PostTypePoll && postType != models.PostTypeQuestion {
|
|
||||||
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,
|
PostType: normalizeComposePostType(strings.TrimSpace(c.PostForm("post_type"))),
|
||||||
PollMulti: c.PostForm("poll_multi") == "1" || c.PostForm("poll_multi") == "on",
|
PollMulti: c.PostForm("poll_multi") == "1" || c.PostForm("poll_multi") == "on",
|
||||||
PollMaxChoices: maxChoices,
|
PollMaxChoices: maxChoices,
|
||||||
PollEndsAt: strings.TrimSpace(c.PostForm("poll_ends_at")),
|
PollEndsAt: strings.TrimSpace(c.PostForm("poll_ends_at")),
|
||||||
PollOptions: c.PostForm("poll_options"),
|
PollOptions: c.PostForm("poll_options"),
|
||||||
|
BountyPoints: bountyPoints,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +237,9 @@ func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm
|
|||||||
if form.PollMaxChoices < 1 {
|
if form.PollMaxChoices < 1 {
|
||||||
form.PollMaxChoices = 1
|
form.PollMaxChoices = 1
|
||||||
}
|
}
|
||||||
|
if form.BountyPoints < 1 {
|
||||||
|
form.BountyPoints = 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
|
||||||
@@ -243,6 +257,7 @@ func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm
|
|||||||
PollMaxChoices: form.PollMaxChoices,
|
PollMaxChoices: form.PollMaxChoices,
|
||||||
PollEndsAt: form.PollEndsAt,
|
PollEndsAt: form.PollEndsAt,
|
||||||
PollOptions: form.PollOptions,
|
PollOptions: form.PollOptions,
|
||||||
|
BountyPoints: form.BountyPoints,
|
||||||
Boards: chrome.Boards,
|
Boards: chrome.Boards,
|
||||||
TitleMax: d.Settings.PostTitleMax(),
|
TitleMax: d.Settings.PostTitleMax(),
|
||||||
TagsMax: d.Settings.PostTagsMax(),
|
TagsMax: d.Settings.PostTagsMax(),
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
|
|||||||
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/question/resolve", authMW.RequireAuth(), deps.PostQuestionResolvePost)
|
||||||
|
g.POST("/post/:id/bounty/award", authMW.RequireAuth(), deps.PostBountyAwardPost)
|
||||||
|
g.POST("/post/:id/bounty/refund", authMW.RequireAuth(), deps.PostBountyRefundPost)
|
||||||
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)
|
||||||
|
|||||||
@@ -49,9 +49,19 @@ type PostPageData struct {
|
|||||||
IsQuestion bool
|
IsQuestion bool
|
||||||
QuestionResolved bool
|
QuestionResolved bool
|
||||||
CanToggleQuestion bool
|
CanToggleQuestion bool
|
||||||
|
Bounty *postBountyView
|
||||||
Poll *postPollView
|
Poll *postPollView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type postBountyView struct {
|
||||||
|
Points int
|
||||||
|
Status string
|
||||||
|
StatusLabel string
|
||||||
|
CanRefund bool
|
||||||
|
RefundBlockReason string
|
||||||
|
AwardedCommentID uint
|
||||||
|
}
|
||||||
|
|
||||||
type postPollOptionView struct {
|
type postPollOptionView struct {
|
||||||
ID uint
|
ID uint
|
||||||
Text string
|
Text string
|
||||||
@@ -93,6 +103,8 @@ type CommentView struct {
|
|||||||
CanDelete bool
|
CanDelete bool
|
||||||
Status string
|
Status string
|
||||||
StatusLabel string
|
StatusLabel string
|
||||||
|
CanAwardBounty bool
|
||||||
|
IsBountyAward bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostView GET /post/:id
|
// PostView GET /post/:id
|
||||||
@@ -116,6 +128,10 @@ func (d Deps) PostView(c *gin.Context) {
|
|||||||
hasReplied := ctx.UserID() > 0 && d.Comment.HasUserReplied(uint(id), ctx.UserID())
|
hasReplied := ctx.UserID() > 0 && d.Comment.HasUserReplied(uint(id), ctx.UserID())
|
||||||
body := services.ApplyPostContentGates(post.Content, post, ctx.UserID(), ctx.IsAdmin(), hasReplied)
|
body := services.ApplyPostContentGates(post.Content, post, ctx.UserID(), ctx.IsAdmin(), hasReplied)
|
||||||
|
|
||||||
|
canAwardBounty := post.PostType == models.PostTypeBounty &&
|
||||||
|
post.BountyStatus == models.BountyStatusOpen &&
|
||||||
|
(ctx.IsAdmin() || post.UserID == ctx.UserID())
|
||||||
|
|
||||||
comments, _ := d.Comment.ListByPost(uint(id), ctx.UserID(), ctx.IsAdmin(), post.UserID, nil)
|
comments, _ := d.Comment.ListByPost(uint(id), ctx.UserID(), ctx.IsAdmin(), post.UserID, nil)
|
||||||
cv := make([]CommentView, 0, len(comments))
|
cv := make([]CommentView, 0, len(comments))
|
||||||
for _, cm := range comments {
|
for _, cm := range comments {
|
||||||
@@ -132,6 +148,9 @@ func (d Deps) PostView(c *gin.Context) {
|
|||||||
CanEdit: !cm.ContentHidden && d.Comment.CanUserEditComment(&cm, ctx.UserID(), ctx.IsAdmin()),
|
CanEdit: !cm.ContentHidden && d.Comment.CanUserEditComment(&cm, ctx.UserID(), ctx.IsAdmin()),
|
||||||
CanDelete: !cm.ContentHidden && d.Comment.CanUserDeleteComment(&cm, ctx.UserID(), ctx.IsAdmin()),
|
CanDelete: !cm.ContentHidden && d.Comment.CanUserDeleteComment(&cm, ctx.UserID(), ctx.IsAdmin()),
|
||||||
Status: cm.Status,
|
Status: cm.Status,
|
||||||
|
CanAwardBounty: canAwardBounty && !cm.ContentHidden &&
|
||||||
|
cm.Status == models.ContentStatusPublished && cm.UserID != post.UserID && cm.UserID > 0,
|
||||||
|
IsBountyAward: post.BountyCommentID > 0 && cm.ID == post.BountyCommentID,
|
||||||
}
|
}
|
||||||
if cm.Status == models.ContentStatusPending || cm.Status == models.ContentStatusRejected {
|
if cm.Status == models.ContentStatusPending || cm.Status == models.ContentStatusRejected {
|
||||||
if ctx.IsAdmin() || (cm.UserID > 0 && cm.UserID == ctx.UserID()) {
|
if ctx.IsAdmin() || (cm.UserID > 0 && cm.UserID == ctx.UserID()) {
|
||||||
@@ -183,6 +202,23 @@ func (d Deps) PostView(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var bountyView *postBountyView
|
||||||
|
if post.PostType == models.PostTypeBounty {
|
||||||
|
isOperator := ctx.IsAdmin() || post.UserID == ctx.UserID()
|
||||||
|
canRefund, blockReason := services.CanRefundBounty(post, ctx.IsAdmin())
|
||||||
|
bv := &postBountyView{
|
||||||
|
Points: post.BountyPoints,
|
||||||
|
Status: post.BountyStatus,
|
||||||
|
StatusLabel: bountyStatusLabel(post.BountyStatus),
|
||||||
|
CanRefund: isOperator && canRefund,
|
||||||
|
AwardedCommentID: post.BountyCommentID,
|
||||||
|
}
|
||||||
|
if isOperator && !canRefund {
|
||||||
|
bv.RefundBlockReason = blockReason
|
||||||
|
}
|
||||||
|
bountyView = bv
|
||||||
|
}
|
||||||
|
|
||||||
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)),
|
||||||
@@ -205,6 +241,7 @@ func (d Deps) PostView(c *gin.Context) {
|
|||||||
IsQuestion: post.PostType == models.PostTypeQuestion,
|
IsQuestion: post.PostType == models.PostTypeQuestion,
|
||||||
QuestionResolved: post.QuestionResolved,
|
QuestionResolved: post.QuestionResolved,
|
||||||
CanToggleQuestion: post.PostType == models.PostTypeQuestion && (ctx.IsAdmin() || post.UserID == ctx.UserID()),
|
CanToggleQuestion: post.PostType == models.PostTypeQuestion && (ctx.IsAdmin() || post.UserID == ctx.UserID()),
|
||||||
|
Bounty: bountyView,
|
||||||
Poll: pollView,
|
Poll: pollView,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -247,6 +284,19 @@ func contentStatusLabel(status string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func bountyStatusLabel(status string) string {
|
||||||
|
switch status {
|
||||||
|
case models.BountyStatusOpen:
|
||||||
|
return "进行中"
|
||||||
|
case models.BountyStatusAwarded:
|
||||||
|
return "已采纳"
|
||||||
|
case models.BountyStatusRefunded:
|
||||||
|
return "已退回"
|
||||||
|
default:
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func postTypeLabel(t string) string {
|
func postTypeLabel(t string) string {
|
||||||
switch t {
|
switch t {
|
||||||
case models.PostTypeQuestion:
|
case models.PostTypeQuestion:
|
||||||
@@ -501,6 +551,51 @@ func (d Deps) PostQuestionResolvePost(c *gin.Context) {
|
|||||||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PostBountyAwardPost 采纳评论并发放悬赏
|
||||||
|
func (d Deps) PostBountyAwardPost(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
|
||||||
|
}
|
||||||
|
cid, _ := strconv.ParseUint(c.PostForm("comment_id"), 10, 64)
|
||||||
|
if err := services.AwardBounty(id, ctx.UserID(), ctx.IsAdmin(), uint(cid)); err != nil {
|
||||||
|
ctx.SetFlash(err.Error())
|
||||||
|
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.SetFlash("已采纳并发放悬赏")
|
||||||
|
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostBountyRefundPost 取消悬赏并退回积分
|
||||||
|
func (d Deps) PostBountyRefundPost(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
|
||||||
|
}
|
||||||
|
if err := services.RefundBounty(id, ctx.UserID(), ctx.IsAdmin()); 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
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{{if .IsEdit}}
|
{{if .IsEdit}}
|
||||||
<p class="j13-muted">类型:{{if eq .PostType "poll"}}投票{{else if eq .PostType "question"}}问答{{else}}普通讨论{{end}}(不可更改)</p>
|
<p class="j13-muted">类型:{{if eq .PostType "poll"}}投票{{else if eq .PostType "question"}}问答{{else if eq .PostType "bounty"}}悬赏{{else}}普通讨论{{end}}(不可更改)</p>
|
||||||
<input type="hidden" name="post_type" value="{{.PostType}}"/>
|
<input type="hidden" name="post_type" value="{{.PostType}}"/>
|
||||||
{{else}}
|
{{else}}
|
||||||
<label>类型
|
<label>类型
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
<option value="normal"{{if or (eq .PostType "normal") (eq .PostType "")}} 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="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>
|
||||||
|
<option value="bounty"{{if eq .PostType "bounty"}} selected{{end}}>悬赏</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<p class="j13-muted" id="compose-question-hint"{{if ne .PostType "question"}} hidden{{end}}>问答帖无额外字段;解决状态可在帖详情由作者或管理员切换。</p>
|
<p class="j13-muted" id="compose-question-hint"{{if ne .PostType "question"}} hidden{{end}}>问答帖无额外字段;解决状态可在帖详情由作者或管理员切换。</p>
|
||||||
@@ -40,6 +41,13 @@
|
|||||||
<textarea name="poll_options" rows="6" placeholder="选项 A 选项 B 选项 C">{{.PollOptions}}</textarea>
|
<textarea name="poll_options" rows="6" placeholder="选项 A 选项 B 选项 C">{{.PollOptions}}</textarea>
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset class="j13-bounty-fields" id="compose-bounty-fields"{{if ne .PostType "bounty"}} hidden{{end}}>
|
||||||
|
<legend>悬赏设置</legend>
|
||||||
|
<p class="j13-muted">当前积分:{{.ViewerPoints}}。发帖时立即托管,采纳或退款前不可收回(有他人回复后作者不可自行退款)。</p>
|
||||||
|
<label>悬赏积分
|
||||||
|
<input name="bounty_points" type="number" min="1" value="{{.BountyPoints}}"/>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
{{end}}
|
{{end}}
|
||||||
<label>标题
|
<label>标题
|
||||||
<input name="title" required maxlength="{{.TitleMax}}" value="{{.Title}}"/>
|
<input name="title" required maxlength="{{.TitleMax}}" value="{{.Title}}"/>
|
||||||
@@ -70,10 +78,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');
|
||||||
|
var bounty = document.getElementById('compose-bounty-fields');
|
||||||
var qHint = document.getElementById('compose-question-hint');
|
var qHint = document.getElementById('compose-question-hint');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
function sync() {
|
function sync() {
|
||||||
if (box) box.hidden = sel.value !== 'poll';
|
if (box) box.hidden = sel.value !== 'poll';
|
||||||
|
if (bounty) bounty.hidden = sel.value !== 'bounty';
|
||||||
if (qHint) qHint.hidden = sel.value !== 'question';
|
if (qHint) qHint.hidden = sel.value !== 'question';
|
||||||
}
|
}
|
||||||
sel.addEventListener('change', sync);
|
sel.addEventListener('change', sync);
|
||||||
|
|||||||
@@ -104,6 +104,23 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</header>
|
</header>
|
||||||
|
{{if .Bounty}}
|
||||||
|
<section class="j13-bounty" aria-label="悬赏">
|
||||||
|
<h2 class="j13-bounty__title">悬赏</h2>
|
||||||
|
<p class="j13-muted">
|
||||||
|
{{if eq .Bounty.Status "refunded"}}已退回{{else}}{{.Bounty.Points}} 积分{{end}}
|
||||||
|
· {{.Bounty.StatusLabel}}
|
||||||
|
</p>
|
||||||
|
{{if .Bounty.CanRefund}}
|
||||||
|
<form method="post" action="/post/{{.PostID}}/bounty/refund" class="j13-inline-form" onsubmit="return confirm('确认取消悬赏并退回积分?');">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}"/>
|
||||||
|
<button type="submit" class="j13-btn-secondary">取消悬赏并退回</button>
|
||||||
|
</form>
|
||||||
|
{{else if .Bounty.RefundBlockReason}}
|
||||||
|
<p class="j13-muted">{{.Bounty.RefundBlockReason}}</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
{{if .Poll}}
|
{{if .Poll}}
|
||||||
<section class="j13-poll" aria-label="投票">
|
<section class="j13-poll" aria-label="投票">
|
||||||
<h2 class="j13-poll__title">投票</h2>
|
<h2 class="j13-poll__title">投票</h2>
|
||||||
@@ -173,6 +190,7 @@
|
|||||||
<span>{{.CreatedLabel}}</span>
|
<span>{{.CreatedLabel}}</span>
|
||||||
{{if .StatusLabel}}<span class="j13-tag j13-tag--{{.Status}}">{{.StatusLabel}}</span>{{end}}
|
{{if .StatusLabel}}<span class="j13-tag j13-tag--{{.Status}}">{{.StatusLabel}}</span>{{end}}
|
||||||
{{if .IsPrivate}}<span class="j13-tag">私密</span>{{end}}
|
{{if .IsPrivate}}<span class="j13-tag">私密</span>{{end}}
|
||||||
|
{{if .IsBountyAward}}<span class="j13-tag j13-tag--resolved">已采纳</span>{{end}}
|
||||||
{{if .ReplyToFloor}}
|
{{if .ReplyToFloor}}
|
||||||
<span class="j13-comment__reply-to">回复 <a href="#floor-{{.ReplyToFloor}}">#{{.ReplyToFloor}}</a>{{if .ReplyToAuthor}} {{.ReplyToAuthor}}{{end}}</span>
|
<span class="j13-comment__reply-to">回复 <a href="#floor-{{.ReplyToFloor}}">#{{.ReplyToFloor}}</a>{{if .ReplyToAuthor}} {{.ReplyToAuthor}}{{end}}</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -184,6 +202,13 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{if $.LoggedIn}}
|
{{if $.LoggedIn}}
|
||||||
<div class="j13-comment__actions">
|
<div class="j13-comment__actions">
|
||||||
|
{{if .CanAwardBounty}}
|
||||||
|
<form method="post" action="/post/{{$.PostID}}/bounty/award" class="j13-inline-form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
|
||||||
|
<input type="hidden" name="comment_id" value="{{.ID}}"/>
|
||||||
|
<button type="submit">采纳并发放悬赏</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
<form method="post" action="/post/{{$.PostID}}/comments/{{.ID}}/like" class="j13-inline-form">
|
<form method="post" action="/post/{{$.PostID}}/comments/{{.ID}}/like" class="j13-inline-form">
|
||||||
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
|
<input type="hidden" name="_csrf" value="{{$.CSRF}}"/>
|
||||||
<button type="submit" class="j13-linkbtn">{{if .Liked}}取消赞{{else}}赞{{end}} ({{.LikeCount}})</button>
|
<button type="submit" class="j13-linkbtn">{{if .Liked}}取消赞{{else}}赞{{end}} ({{.LikeCount}})</button>
|
||||||
|
|||||||
@@ -497,6 +497,21 @@ body.j13-body {
|
|||||||
border-radius: var(--j13-radius);
|
border-radius: var(--j13-radius);
|
||||||
}
|
}
|
||||||
.j13-poll-fields legend { padding: 0 0.35rem; font-weight: 600; }
|
.j13-poll-fields legend { padding: 0 0.35rem; font-weight: 600; }
|
||||||
|
.j13-bounty-fields {
|
||||||
|
margin: 0.75rem 0 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
border-radius: var(--j13-radius);
|
||||||
|
}
|
||||||
|
.j13-bounty-fields legend { padding: 0 0.35rem; font-weight: 600; }
|
||||||
|
.j13-bounty {
|
||||||
|
margin: 1rem 0 1.25rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
border-radius: var(--j13-radius);
|
||||||
|
background: var(--j13-surface, #fff);
|
||||||
|
}
|
||||||
|
.j13-bounty__title { margin: 0 0 0.35rem; font-size: 1.1rem; }
|
||||||
.j13-poll {
|
.j13-poll {
|
||||||
margin: 1rem 0 1.25rem;
|
margin: 1rem 0 1.25rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user