feat(posts): 类型帖截止时间、抽奖奖品表与悬赏发帖体验

统一投票/悬赏/抽奖截止与状态展示,抽奖支持多奖品行;发帖页悬赏积分对齐表格输入,并在余额不足时于底部就地提示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 10:21:05 +08:00
parent 6b772380c9
commit b2d3d6dd26
23 changed files with 2711 additions and 1007 deletions

View File

@@ -25,7 +25,7 @@ func main() {
// 过期/已吊销 refresh token 定期清理 // 过期/已吊销 refresh token 定期清理
service.StartRefreshTokenCleanup(model.DB) service.StartRefreshTokenCleanup(model.DB)
// 悬赏过期退回 / 抽奖到期开奖 // 悬赏过期退回 / 抽奖到期开奖
service.StartTypedPostSettler(service.NewPostService(model.DB)) service.StartTypedPostSettler(service.NewPostService(model.DB).WithDevMode(cfg.DevMode))
r, err := router.Setup(cfg) r, err := router.Setup(cfg)
if err != nil { if err != nil {

View File

@@ -91,6 +91,8 @@ func (h *Handlers) CreateComment(c *gin.Context) {
if authorID, e := h.Post.GetAuthorID(uint(id)); e == nil { if authorID, e := h.Post.GetAuthorID(uint(id)); e == nil {
h.Notification.Create(authorID, claims.ID, model.NotificationTypeComment, uint(id), comment.ID, req.Content) h.Notification.Create(authorID, claims.ID, model.NotificationTypeComment, uint(id), comment.ID, req.Content)
} }
// 抽奖帖:主评入池并记录客户端 IP(同 IP 去重)
h.Post.EnsureLotteryEntry(uint(id), claims.ID, comment.ID, c.ClientIP())
} else { } else {
// 子回复:通知父评论作者(Notification.Create 内部排除自我通知) // 子回复:通知父评论作者(Notification.Create 内部排除自我通知)
h.Notification.Create(parent.UserID, claims.ID, model.NotificationTypeReply, uint(id), comment.ID, req.Content) h.Notification.Create(parent.UserID, claims.ID, model.NotificationTypeReply, uint(id), comment.ID, req.Content)

View File

@@ -78,6 +78,12 @@ func (h *Handlers) AdminApproveComment(c *gin.Context) {
h.writeModerationError(c, err) h.writeModerationError(c, err)
return return
} }
// 抽奖帖主评通过审核后入池(无评论者 IP,回退其最近登录 IP)
var cm model.Comment
if e := model.DB.Select("id", "post_id", "user_id", "parent_id").First(&cm, uint(id)).Error; e == nil && cm.ParentID == nil {
ip := h.Post.LatestSuccessLoginIP(cm.UserID)
h.Post.EnsureLotteryEntry(cm.PostID, cm.UserID, cm.ID, ip)
}
h.broadcastFeedChanged("comment", postID, boardID, actor.ID) h.broadcastFeedChanged("comment", postID, boardID, actor.ID)
h.broadcastModerationChanged() h.broadcastModerationChanged()
c.JSON(http.StatusOK, gin.H{"message": "操作成功"}) c.JSON(http.StatusOK, gin.H{"message": "操作成功"})

View File

@@ -449,6 +449,9 @@ func respondPostModError(c *gin.Context, err error) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrPostForbidden): case errors.Is(err, service.ErrPostForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrPollOptionsLocked),
errors.Is(err, service.ErrLotteryPrizesLocked):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
default: default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
} }

View File

@@ -22,9 +22,7 @@ func (h *Handlers) respondInteractError(c *gin.Context, err error) {
errors.Is(err, service.ErrPollClosed), errors.Is(err, service.ErrPollClosed),
errors.Is(err, service.ErrPollOptionsLocked), errors.Is(err, service.ErrPollOptionsLocked),
errors.Is(err, service.ErrLotteryDrawn), errors.Is(err, service.ErrLotteryDrawn),
errors.Is(err, service.ErrLotteryClosed), errors.Is(err, service.ErrBountySettled):
errors.Is(err, service.ErrBountySettled),
errors.Is(err, service.ErrBountyExpired):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
default: default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -148,38 +146,6 @@ func (h *Handlers) AcceptBounty(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"post": post}) c.JSON(http.StatusOK, gin.H{"post": post})
} }
// RefundBounty 退回悬赏
func (h *Handlers) RefundBounty(c *gin.Context) {
claims := middleware.CurrentUser(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
post, err := h.Post.RefundBounty(h.loadActor(claims.ID), claims.ID, uint(id))
if err != nil {
h.respondInteractError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"post": post})
}
// CloseLottery 截止抽奖
func (h *Handlers) CloseLottery(c *gin.Context) {
claims := middleware.CurrentUser(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
post, err := h.Post.CloseLotteryEntries(h.loadActor(claims.ID), claims.ID, uint(id))
if err != nil {
h.respondInteractError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"post": post})
}
// DrawLottery 开奖 // DrawLottery 开奖
func (h *Handlers) DrawLottery(c *gin.Context) { func (h *Handlers) DrawLottery(c *gin.Context) {
claims := middleware.CurrentUser(c) claims := middleware.CurrentUser(c)

View File

@@ -259,11 +259,13 @@ type PostPollVote struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
// PostLotteryEntry 抽奖报名 // PostLotteryEntry 抽奖报名(主评入池时登记;同 IP 去重用 ClientIP)
type PostLotteryEntry struct { type PostLotteryEntry struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"uniqueIndex:idx_lottery_entry;not null" json:"post_id"` PostID uint `gorm:"uniqueIndex:idx_lottery_entry;not null" json:"post_id"`
UserID uint `gorm:"uniqueIndex:idx_lottery_entry;not null" json:"user_id"` UserID uint `gorm:"uniqueIndex:idx_lottery_entry;not null" json:"user_id"`
CommentID uint `gorm:"not null;default:0" json:"comment_id"` // 入池时的主评 ID
ClientIP string `gorm:"size:45;not null;default:''" json:"-"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }

View File

@@ -42,7 +42,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret) authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
boardSvc := service.NewBoardService(model.DB) boardSvc := service.NewBoardService(model.DB)
settingSvc := service.NewSettingService(model.DB) settingSvc := service.NewSettingService(model.DB)
postSvc := service.NewPostService(model.DB).WithSetting(settingSvc) postSvc := service.NewPostService(model.DB).WithSetting(settingSvc).WithDevMode(cfg.DevMode)
commentSvc := service.NewCommentService(model.DB) commentSvc := service.NewCommentService(model.DB)
likeSvc := service.NewLikeService(model.DB) likeSvc := service.NewLikeService(model.DB)
notifSvc := service.NewNotificationService(model.DB) notifSvc := service.NewNotificationService(model.DB)
@@ -144,8 +144,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.POST("/posts/:id/question/solve", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.SolveQuestion) api.POST("/posts/:id/question/solve", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.SolveQuestion)
api.POST("/posts/:id/question/reopen", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.ReopenQuestion) api.POST("/posts/:id/question/reopen", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.ReopenQuestion)
api.POST("/posts/:id/bounty/accept", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.AcceptBounty) api.POST("/posts/:id/bounty/accept", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.AcceptBounty)
api.POST("/posts/:id/bounty/refund", h.RefundBounty)
api.POST("/posts/:id/lottery/close", h.CloseLottery)
api.POST("/posts/:id/lottery/draw", h.DrawLottery) api.POST("/posts/:id/lottery/draw", h.DrawLottery)
api.DELETE("/posts/:id", h.DeletePost) api.DELETE("/posts/:id", h.DeletePost)
api.PUT("/posts/:id/pin", h.TogglePin) api.PUT("/posts/:id/pin", h.TogglePin)

View File

@@ -22,6 +22,7 @@ var (
type PostService struct { type PostService struct {
db *gorm.DB db *gorm.DB
setting *SettingService setting *SettingService
devMode bool
} }
func NewPostService(db *gorm.DB) *PostService { func NewPostService(db *gorm.DB) *PostService {
@@ -34,6 +35,12 @@ func (s *PostService) WithSetting(setting *SettingService) *PostService {
return s return s
} }
// WithDevMode 开发态:抽奖同 IP 去重对回环地址放宽
func (s *PostService) WithDevMode(dev bool) *PostService {
s.devMode = dev
return s
}
// PostListQuery 帖子列表查询参数 // PostListQuery 帖子列表查询参数
type PostListQuery struct { type PostListQuery struct {
BoardID uint BoardID uint
@@ -445,6 +452,7 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto
// 惰性结算:悬赏过期退回 / 抽奖到期开奖 // 惰性结算:悬赏过期退回 / 抽奖到期开奖
_ = s.settleExpiredBountyIfNeeded(&post) _ = s.settleExpiredBountyIfNeeded(&post)
_ = s.settleDueLotteryIfNeeded(&post) _ = s.settleDueLotteryIfNeeded(&post)
_ = s.settleDuePollIfNeeded(&post)
if post.TypeMeta != "" { if post.TypeMeta != "" {
var fresh model.Post var fresh model.Post
if err := s.db.Select("type_meta").First(&fresh, post.ID).Error; err == nil { if err := s.db.Select("type_meta").First(&fresh, post.ID).Error; err == nil {
@@ -729,9 +737,8 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
bm.Expired = false bm.Expired = false
bm.AcceptedCommentID = 0 bm.AcceptedCommentID = 0
if status == model.ContentStatusPublished { if status == model.ContentStatusPublished {
days := normalizeExpireDays(bm.ExpireDays, 7, []int{3, 7, 14, 30}) bm.EndsAt = resolveEndsAtOnPublish(bm.EndsAt, bm.ExpireDays, 7, []int{3, 7, 14, 30}, now)
bm.ExpireDays = 0 bm.ExpireDays = 0
bm.EndsAt = endsAtFromDays(days, now)
} }
typeMeta, _ = encodeMeta(bm) typeMeta, _ = encodeMeta(bm)
} }
@@ -741,14 +748,26 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
if lm.WinnerIDs == nil { if lm.WinnerIDs == nil {
lm.WinnerIDs = []uint{} lm.WinnerIDs = []uint{}
} }
if lm.Prizes == nil {
lm.Prizes = []LotteryPrize{}
}
lm.Slots = lotterySlotsFromPrizes(lm.Prizes)
if status == model.ContentStatusPublished { if status == model.ContentStatusPublished {
days := normalizeExpireDays(lm.ExpireDays, 7, []int{1, 3, 7, 14}) lm.EndsAt = resolveEndsAtOnPublish(lm.EndsAt, lm.ExpireDays, 7, []int{1, 3, 7, 14}, now)
lm.ExpireDays = 0 lm.ExpireDays = 0
lm.EndsAt = endsAtFromDays(days, now)
} }
typeMeta, _ = encodeMeta(lm) typeMeta, _ = encodeMeta(lm)
} }
if postType == model.PostTypePoll {
pm, _ := parsePollMeta(typeMeta)
if status == model.ContentStatusPublished {
pm.EndsAt = resolveEndsAtOnPublish(pm.EndsAt, pm.ExpireDays, 7, []int{1, 3, 7, 14}, now)
pm.ExpireDays = 0
}
typeMeta, _ = encodeMeta(pm)
}
post := &model.Post{ post := &model.Post{
BoardID: in.BoardID, UserID: in.UserID, BoardID: in.BoardID, UserID: in.UserID,
Title: title, Content: content, Tags: in.Tags, Title: title, Content: content, Tags: in.Tags,
@@ -892,9 +911,15 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
neu.Multi = old.Multi neu.Multi = old.Multi
neu.Anonymous = old.Anonymous neu.Anonymous = old.Anonymous
neu.Closed = old.Closed neu.Closed = old.Closed
neu.EndsAt = old.EndsAt
neu.ClosedAt = old.ClosedAt
neu.ExpireDays = 0
meta, _ = encodeMeta(neu) meta, _ = encodeMeta(neu)
} else { } else {
neu.Closed = old.Closed neu.Closed = old.Closed
neu.EndsAt = old.EndsAt
neu.ClosedAt = old.ClosedAt
neu.ExpireDays = 0
meta, _ = encodeMeta(neu) meta, _ = encodeMeta(neu)
} }
} }
@@ -907,8 +932,16 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
neu.WinnerIDs = old.WinnerIDs neu.WinnerIDs = old.WinnerIDs
neu.Closed = old.Closed neu.Closed = old.Closed
neu.EndsAt = old.EndsAt neu.EndsAt = old.EndsAt
if old.Drawn { // 已有评论或已开奖:冻结奖品(名额由奖品合计推导)
locked := old.Drawn || post.CommentCount > 0
if locked {
if !lotteryPrizesEqual(neu.Prizes, old.Prizes) || neu.Slots != old.Slots {
return nil, ErrLotteryPrizesLocked
}
neu.Prizes = old.Prizes
neu.Slots = old.Slots neu.Slots = old.Slots
} else {
neu.Slots = lotterySlotsFromPrizes(neu.Prizes)
} }
meta, _ = encodeMeta(neu) meta, _ = encodeMeta(neu)
} }
@@ -920,6 +953,7 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
// 解题态走专用 API,编辑帖子不覆盖 // 解题态走专用 API,编辑帖子不覆盖
neu.Solved = old.Solved neu.Solved = old.Solved
neu.AcceptedCommentID = old.AcceptedCommentID neu.AcceptedCommentID = old.AcceptedCommentID
neu.SolvedAt = old.SolvedAt
meta, _ = encodeMeta(neu) meta, _ = encodeMeta(neu)
} }
} }

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"math/big" "math/big"
"net"
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -18,6 +19,7 @@ import (
type QuestionMeta struct { type QuestionMeta struct {
Solved bool `json:"solved"` Solved bool `json:"solved"`
AcceptedCommentID uint `json:"accepted_comment_id"` AcceptedCommentID uint `json:"accepted_comment_id"`
SolvedAt string `json:"solved_at,omitempty"` // 解决时刻 RFC3339
} }
type PollMeta struct { type PollMeta struct {
@@ -25,6 +27,9 @@ type PollMeta struct {
Multi bool `json:"multi"` Multi bool `json:"multi"`
Closed bool `json:"closed"` Closed bool `json:"closed"`
Anonymous bool `json:"anonymous"` // 默认 true;缺省字段在 parse 时补 true Anonymous bool `json:"anonymous"` // 默认 true;缺省字段在 parse 时补 true
EndsAt string `json:"ends_at,omitempty"`
ClosedAt string `json:"closed_at,omitempty"` // 实际结束时刻;提前结束时与 ends_at 可能不同
ExpireDays int `json:"expire_days,omitempty"`
} }
type BountyMeta struct { type BountyMeta struct {
@@ -37,8 +42,14 @@ type BountyMeta struct {
ExpireDays int `json:"expire_days,omitempty"` // 发帖入参,规范化后可清 ExpireDays int `json:"expire_days,omitempty"` // 发帖入参,规范化后可清
} }
type LotteryPrize struct {
Name string `json:"name"`
Count int `json:"count"`
}
type LotteryMeta struct { type LotteryMeta struct {
Slots int `json:"slots"` Prizes []LotteryPrize `json:"prizes"`
Slots int `json:"slots"` // 由 prizes 数量合计推导,兼容旧数据
Drawn bool `json:"drawn"` Drawn bool `json:"drawn"`
WinnerIDs []uint `json:"winner_ids"` WinnerIDs []uint `json:"winner_ids"`
Closed bool `json:"closed"` Closed bool `json:"closed"`
@@ -53,10 +64,9 @@ var (
ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置") ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置")
ErrBountySettled = errors.New("悬赏已结算") ErrBountySettled = errors.New("悬赏已结算")
ErrBountyNotEscrow = errors.New("悬赏未托管") ErrBountyNotEscrow = errors.New("悬赏未托管")
ErrBountyExpired = errors.New("悬赏已过期")
ErrLotteryDrawn = errors.New("已开奖") ErrLotteryDrawn = errors.New("已开奖")
ErrLotteryClosed = errors.New("抽奖已截止")
ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖") ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖")
ErrLotteryPrizesLocked = errors.New("已有评论,无法修改奖品设置")
ErrQuestionNotType = errors.New("本文不是问答帖") ErrQuestionNotType = errors.New("本文不是问答帖")
) )
@@ -77,6 +87,9 @@ func parsePollMeta(raw string) (*PollMeta, error) {
Multi bool `json:"multi"` Multi bool `json:"multi"`
Closed bool `json:"closed"` Closed bool `json:"closed"`
Anonymous *bool `json:"anonymous"` Anonymous *bool `json:"anonymous"`
EndsAt string `json:"ends_at"`
ClosedAt string `json:"closed_at"`
ExpireDays int `json:"expire_days"`
} }
if err := json.Unmarshal([]byte(raw), &wire); err != nil { if err := json.Unmarshal([]byte(raw), &wire); err != nil {
return nil, errors.New("投票配置无效") return nil, errors.New("投票配置无效")
@@ -90,6 +103,9 @@ func parsePollMeta(raw string) (*PollMeta, error) {
Multi: wire.Multi, Multi: wire.Multi,
Closed: wire.Closed, Closed: wire.Closed,
Anonymous: anon, Anonymous: anon,
EndsAt: strings.TrimSpace(wire.EndsAt),
ClosedAt: strings.TrimSpace(wire.ClosedAt),
ExpireDays: wire.ExpireDays,
}, nil }, nil
} }
@@ -106,9 +122,49 @@ func parseLotteryMeta(raw string) (*LotteryMeta, error) {
if err := json.Unmarshal([]byte(raw), &m); err != nil { if err := json.Unmarshal([]byte(raw), &m); err != nil {
return nil, errors.New("抽奖配置无效") return nil, errors.New("抽奖配置无效")
} }
// 旧数据只有 slots:合成单一奖品项
if len(m.Prizes) == 0 && m.Slots > 0 {
m.Prizes = []LotteryPrize{{Name: "奖品", Count: m.Slots}}
}
if len(m.Prizes) > 0 {
m.Slots = lotterySlotsFromPrizes(m.Prizes)
}
return &m, nil return &m, nil
} }
func lotterySlotsFromPrizes(prizes []LotteryPrize) int {
n := 0
for _, p := range prizes {
if p.Count > 0 {
n += p.Count
}
}
return n
}
func lotteryPrizesEqual(a, b []LotteryPrize) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].Name != b[i].Name || a[i].Count != b[i].Count {
return false
}
}
return true
}
// expandLotteryPrizeNames 按奖项顺序展开为与名额等长的名称列表(开奖结果按序对应)
func expandLotteryPrizeNames(prizes []LotteryPrize) []string {
out := make([]string, 0, lotterySlotsFromPrizes(prizes))
for _, p := range prizes {
for i := 0; i < p.Count; i++ {
out = append(out, p.Name)
}
}
return out
}
func encodeMeta(v any) (string, error) { func encodeMeta(v any) (string, error) {
b, err := json.Marshal(v) b, err := json.Marshal(v)
if err != nil { if err != nil {
@@ -145,6 +201,35 @@ func parseEndsAt(s string) (time.Time, bool) {
return t, true return t, true
} }
// normalizeOptionalEndsAt 校验自定义截止时刻:至少晚于现在 1 小时,最远 90 天。空串表示未自定义。
func normalizeOptionalEndsAt(endsAt string) (string, error) {
endsAt = strings.TrimSpace(endsAt)
if endsAt == "" {
return "", nil
}
t, ok := parseEndsAt(endsAt)
if !ok {
return "", errors.New("截止时间格式无效")
}
now := time.Now().UTC()
if !t.After(now.Add(time.Hour)) {
return "", errors.New("截止时间至少晚于当前 1 小时")
}
if t.After(now.Add(90 * 24 * time.Hour)) {
return "", errors.New("截止时间最远 90 天")
}
return t.UTC().Format(time.RFC3339), nil
}
// resolveEndsAtOnPublish 发布时确定 ends_at:已有自定义时刻则保留,否则按天数推算。
func resolveEndsAtOnPublish(existing string, expireDays, def int, allowed []int, from time.Time) string {
if t, ok := parseEndsAt(existing); ok {
return t.UTC().Format(time.RFC3339)
}
days := normalizeExpireDays(expireDays, def, allowed)
return endsAtFromDays(days, from)
}
// ComputeTypeStatus 列表徽章状态 // ComputeTypeStatus 列表徽章状态
func ComputeTypeStatus(postType, typeMeta string) string { func ComputeTypeStatus(postType, typeMeta string) string {
pt := model.NormalizePostType(postType) pt := model.NormalizePostType(postType)
@@ -175,7 +260,10 @@ func ComputeTypeStatus(postType, typeMeta string) string {
if m.AcceptedCommentID > 0 { if m.AcceptedCommentID > 0 {
return "solved" return "solved"
} }
if m.Expired || m.Refunded { if m.Expired {
return "expired"
}
if m.Refunded {
return "expired" return "expired"
} }
return "open" return "open"
@@ -240,6 +328,14 @@ func NormalizeAndValidateTypeMeta(postType, meta string) (string, error) {
return "", errors.New("最多 10 个投票选项") return "", errors.New("最多 10 个投票选项")
} }
m.Options = opts m.Options = opts
if ends, err := normalizeOptionalEndsAt(m.EndsAt); err != nil {
return "", err
} else if ends != "" {
m.EndsAt = ends
m.ExpireDays = 0
} else {
m.ExpireDays = normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14})
}
return encodeMeta(m) return encodeMeta(m)
case model.PostTypeBounty: case model.PostTypeBounty:
if meta == "" { if meta == "" {
@@ -255,50 +351,102 @@ func NormalizeAndValidateTypeMeta(postType, meta string) (string, error) {
if m.Points > 100000 { if m.Points > 100000 {
return "", errors.New("悬赏积分过高") return "", errors.New("悬赏积分过高")
} }
days := normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30}) if ends, err := normalizeOptionalEndsAt(m.EndsAt); err != nil {
m.ExpireDays = days return "", err
// ends_at 在 Create/Approve 时按发布时间写入;编辑保留旧值 } else if ends != "" {
m.EndsAt = ends
m.ExpireDays = 0
} else {
m.ExpireDays = normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30})
}
// ends_at 若仍空,在 Create/Approve 时按发布时间写入
return encodeMeta(m) return encodeMeta(m)
case model.PostTypeLottery: case model.PostTypeLottery:
if meta == "" { if meta == "" {
return "", errors.New("请设置中奖名额") return "", errors.New("请设置奖品")
} }
m, err := parseLotteryMeta(meta) m, err := parseLotteryMeta(meta)
if err != nil { if err != nil {
return "", err return "", err
} }
if m.Slots < 1 { prizes := make([]LotteryPrize, 0, len(m.Prizes))
return "", errors.New("中奖名额至少为 1") for _, p := range m.Prizes {
name := strings.TrimSpace(p.Name)
if name == "" {
return "", errors.New("请填写奖品名称")
} }
if m.Slots > 100 { if utf8.RuneCountInString(name) > 32 {
return "", errors.New("中奖名额过多") return "", errors.New("奖品名称最多 32 字")
} }
if p.Count < 1 {
return "", errors.New("请填写每个奖品的数量")
}
if p.Count > 100 {
return "", errors.New("单个奖品数量过多")
}
prizes = append(prizes, LotteryPrize{Name: name, Count: p.Count})
}
if len(prizes) < 1 {
return "", errors.New("请至少添加一种奖品")
}
if len(prizes) > 10 {
return "", errors.New("最多 10 种奖品")
}
total := lotterySlotsFromPrizes(prizes)
if total < 1 {
return "", errors.New("奖品总名额至少为 1")
}
if total > 100 {
return "", errors.New("奖品总名额过多")
}
m.Prizes = prizes
m.Slots = total
if m.WinnerIDs == nil { if m.WinnerIDs == nil {
m.WinnerIDs = []uint{} m.WinnerIDs = []uint{}
} }
days := normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14}) if ends, err := normalizeOptionalEndsAt(m.EndsAt); err != nil {
m.ExpireDays = days return "", err
} else if ends != "" {
m.EndsAt = ends
m.ExpireDays = 0
} else {
m.ExpireDays = normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14})
}
return encodeMeta(m) return encodeMeta(m)
default: default:
return "", nil return "", nil
} }
} }
// EnsureDeadlineOnPublish 首次公开发布时补写 ends_at(悬赏/抽奖) // EnsureDeadlineOnPublish 首次公开发布时补写 ends_at(投票 / 悬赏 / 抽奖)
func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (string, bool) { func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (string, bool) {
pt := model.NormalizePostType(postType) pt := model.NormalizePostType(postType)
switch pt { switch pt {
case model.PostTypePoll:
m, err := parsePollMeta(typeMeta)
if err != nil {
return typeMeta, false
}
if _, ok := parseEndsAt(m.EndsAt); ok {
return typeMeta, false
}
m.EndsAt = resolveEndsAtOnPublish("", m.ExpireDays, 7, []int{1, 3, 7, 14}, publishedAt)
m.ExpireDays = 0
raw, err := encodeMeta(m)
if err != nil {
return typeMeta, false
}
return raw, true
case model.PostTypeBounty: case model.PostTypeBounty:
m, err := parseBountyMeta(typeMeta) m, err := parseBountyMeta(typeMeta)
if err != nil { if err != nil {
return typeMeta, false return typeMeta, false
} }
if m.EndsAt != "" { if _, ok := parseEndsAt(m.EndsAt); ok {
return typeMeta, false return typeMeta, false
} }
days := normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30}) m.EndsAt = resolveEndsAtOnPublish("", m.ExpireDays, 7, []int{3, 7, 14, 30}, publishedAt)
m.ExpireDays = 0 m.ExpireDays = 0
m.EndsAt = endsAtFromDays(days, publishedAt)
raw, err := encodeMeta(m) raw, err := encodeMeta(m)
if err != nil { if err != nil {
return typeMeta, false return typeMeta, false
@@ -309,12 +457,11 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (
if err != nil { if err != nil {
return typeMeta, false return typeMeta, false
} }
if m.EndsAt != "" { if _, ok := parseEndsAt(m.EndsAt); ok {
return typeMeta, false return typeMeta, false
} }
days := normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14}) m.EndsAt = resolveEndsAtOnPublish("", m.ExpireDays, 7, []int{1, 3, 7, 14}, publishedAt)
m.ExpireDays = 0 m.ExpireDays = 0
m.EndsAt = endsAtFromDays(days, publishedAt)
raw, err := encodeMeta(m) raw, err := encodeMeta(m)
if err != nil { if err != nil {
return typeMeta, false return typeMeta, false
@@ -347,6 +494,7 @@ type QuestionState struct {
AcceptedCommentID uint `json:"accepted_comment_id"` AcceptedCommentID uint `json:"accepted_comment_id"`
AcceptedFloor int `json:"accepted_floor"` AcceptedFloor int `json:"accepted_floor"`
AcceptedAnswer *AcceptedAnswer `json:"accepted_answer,omitempty"` AcceptedAnswer *AcceptedAnswer `json:"accepted_answer,omitempty"`
SolvedAt string `json:"solved_at,omitempty"`
CanAccept bool `json:"can_accept"` CanAccept bool `json:"can_accept"`
CanSolve bool `json:"can_solve"` CanSolve bool `json:"can_solve"`
CanReopen bool `json:"can_reopen"` CanReopen bool `json:"can_reopen"`
@@ -372,6 +520,8 @@ type PollState struct {
Multi bool `json:"multi"` Multi bool `json:"multi"`
Closed bool `json:"closed"` Closed bool `json:"closed"`
Anonymous bool `json:"anonymous"` Anonymous bool `json:"anonymous"`
EndsAt string `json:"ends_at,omitempty"`
ClosedAt string `json:"closed_at,omitempty"`
TotalVotes int `json:"total_votes"` TotalVotes int `json:"total_votes"`
Voters int `json:"voters"` Voters int `json:"voters"`
MyOptions []int `json:"my_options"` MyOptions []int `json:"my_options"`
@@ -391,7 +541,6 @@ type BountyState struct {
EndsAt string `json:"ends_at,omitempty"` EndsAt string `json:"ends_at,omitempty"`
Settled bool `json:"settled"` Settled bool `json:"settled"`
CanAccept bool `json:"can_accept"` CanAccept bool `json:"can_accept"`
CanRefund bool `json:"can_refund"`
} }
type LotteryEntrant struct { type LotteryEntrant struct {
@@ -399,19 +548,22 @@ type LotteryEntrant struct {
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Username string `json:"username"` Username string `json:"username"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
CommentID uint `json:"comment_id"` // 中奖楼层(该用户最早主评),每人仅一条 CommentID uint `json:"comment_id"`
Floor int `json:"floor"` Floor int `json:"floor"`
PrizeName string `json:"prize_name,omitempty"`
} }
type LotteryState struct { type LotteryState struct {
Slots int `json:"slots"` Slots int `json:"slots"`
Prizes []LotteryPrize `json:"prizes"`
Drawn bool `json:"drawn"` Drawn bool `json:"drawn"`
Closed bool `json:"closed"` Closed bool `json:"closed"`
EndsAt string `json:"ends_at,omitempty"` EndsAt string `json:"ends_at,omitempty"`
EntryCount int `json:"entry_count"` // 回帖参与人数 EntryCount int `json:"entry_count"`
Eligible bool `json:"eligible"` // 当前用户是否在奖池 Eligible bool `json:"eligible"`
CooldownHours int `json:"cooldown_hours"`
CanDraw bool `json:"can_draw"` CanDraw bool `json:"can_draw"`
CanClose bool `json:"can_close"` CanEditPrizes bool `json:"can_edit_prizes"`
Winners []LotteryEntrant `json:"winners"` Winners []LotteryEntrant `json:"winners"`
} }
@@ -444,18 +596,28 @@ func (s *PostService) buildQuestionState(post *model.Post, canManage bool) *Ques
m = &QuestionMeta{} m = &QuestionMeta{}
} }
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID) floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
answer := s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor)
solvedAt := m.SolvedAt
if m.Solved && solvedAt == "" && answer != nil && !answer.CreatedAt.IsZero() {
solvedAt = answer.CreatedAt.UTC().Format(time.RFC3339) // 旧数据:无 solved_at 时用采纳评论时间
}
return &QuestionState{ return &QuestionState{
Solved: m.Solved, Solved: m.Solved,
AcceptedCommentID: m.AcceptedCommentID, AcceptedCommentID: m.AcceptedCommentID,
AcceptedFloor: floor, AcceptedFloor: floor,
AcceptedAnswer: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor), AcceptedAnswer: answer,
SolvedAt: solvedAt,
CanAccept: canManage && !m.Solved, CanAccept: canManage && !m.Solved,
CanSolve: canManage && !m.Solved, CanSolve: canManage && !m.Solved,
CanReopen: canManage && m.Solved, // 已采纳答案后不可重新打开;仅「手动标已解决」可撤回
CanReopen: canManage && m.Solved && m.AcceptedCommentID == 0,
} }
} }
func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose bool) *PollState { func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose bool) *PollState {
_ = s.settleDuePollIfNeeded(post)
s.db.Select("id", "type_meta", "post_type").First(post, post.ID)
m, err := parsePollMeta(post.TypeMeta) m, err := parsePollMeta(post.TypeMeta)
if err != nil || len(m.Options) == 0 { if err != nil || len(m.Options) == 0 {
return nil return nil
@@ -531,11 +693,17 @@ func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose b
opts[i] = stat opts[i] = stat
} }
voters := len(voterSet) voters := len(voterSet)
closedAt := m.ClosedAt
if m.Closed && closedAt == "" {
closedAt = m.EndsAt // 旧数据:无 closed_at 时用截止时刻兜底
}
return &PollState{ return &PollState{
Options: opts, Options: opts,
Multi: m.Multi, Multi: m.Multi,
Closed: m.Closed, Closed: m.Closed,
Anonymous: m.Anonymous, Anonymous: m.Anonymous,
EndsAt: m.EndsAt,
ClosedAt: closedAt,
TotalVotes: total, TotalVotes: total,
Voters: voters, Voters: voters,
MyOptions: my, MyOptions: my,
@@ -550,7 +718,7 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty
if err != nil { if err != nil {
return nil return nil
} }
settled := m.AcceptedCommentID > 0 || m.Refunded || m.Expired accepted := m.AcceptedCommentID > 0
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID) floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
return &BountyState{ return &BountyState{
Points: m.Points, Points: m.Points,
@@ -561,9 +729,9 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty
Refunded: m.Refunded, Refunded: m.Refunded,
Expired: m.Expired, Expired: m.Expired,
EndsAt: m.EndsAt, EndsAt: m.EndsAt,
Settled: settled, Settled: accepted,
CanAccept: canManage && m.Escrowed && !settled && !m.Expired, // 托管中可采纳;到期自动退回后仍可从余额再扣支付(不可撤回)
CanRefund: canManage && m.Escrowed && !settled && !m.Expired, CanAccept: canManage && !accepted && (m.Escrowed || m.Refunded),
} }
} }
@@ -601,14 +769,197 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int) *Acc
return ans return ans
} }
func (s *PostService) lotteryCooldownHours() int {
hours := DefaultCooldownHours
if s.setting != nil {
if h, err := s.setting.PostCooldownHours(); err == nil {
hours = h
}
}
if hours < 0 {
return 0
}
return hours
}
// EnsureLotteryEntry 主评公开发布后登记抽奖报名(幂等;楼主不入池)。
func (s *PostService) EnsureLotteryEntry(postID, userID, commentID uint, clientIP string) {
if postID == 0 || userID == 0 {
return
}
var post model.Post
if err := s.db.Select("id", "user_id", "post_type").First(&post, postID).Error; err != nil {
return
}
if model.NormalizePostType(post.PostType) != model.PostTypeLottery {
return
}
if post.UserID == userID {
return
}
clientIP = normalizeLotteryIP(clientIP)
var n int64
s.db.Model(&model.PostLotteryEntry{}).
Where("post_id = ? AND user_id = ?", postID, userID).
Count(&n)
if n > 0 {
return
}
_ = s.db.Create(&model.PostLotteryEntry{
PostID: postID,
UserID: userID,
CommentID: commentID,
ClientIP: clientIP,
}).Error
}
func normalizeLotteryIP(ip string) string {
ip = strings.TrimSpace(ip)
if ip == "" {
return ""
}
// 去掉 IPv6 zone(如 fe80::1%eth0)
if i := strings.IndexByte(ip, '%'); i >= 0 {
ip = ip[:i]
}
return ip
}
// lotteryEligibleIDs 奖池:已发布主评用户去重 − 楼主 − 新号冷静期 − 同 IP 仅留一人。
func (s *PostService) lotteryEligibleIDs(postID, authorID uint) ([]uint, error) { func (s *PostService) lotteryEligibleIDs(postID, authorID uint) ([]uint, error) {
var ids []uint hours := s.lotteryCooldownHours()
err := s.db.Model(&model.Comment{}). q := s.db.Table("comments AS c").
Where("post_id = ? AND status = ? AND deleted_at IS NULL AND user_id <> ?", Select("c.user_id").
Joins("INNER JOIN users u ON u.id = c.user_id AND u.deleted_at IS NULL").
Where("c.post_id = ? AND c.parent_id IS NULL AND c.status = ? AND c.deleted_at IS NULL AND c.user_id <> ?",
postID, model.ContentStatusPublished, authorID). postID, model.ContentStatusPublished, authorID).
Distinct("user_id"). Group("c.user_id")
Pluck("user_id", &ids).Error if hours > 0 {
return ids, err cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
q = q.Where("u.created_at <= ?", cutoff)
}
var ids []uint
if err := q.Pluck("c.user_id", &ids).Error; err != nil {
return nil, err
}
return s.lotteryDedupByIP(postID, ids), nil
}
// latestSuccessLoginIPs 各用户最近一次成功登录 IP(作报名 IP 回退)
func (s *PostService) latestSuccessLoginIPs(userIDs []uint) map[uint]string {
out := map[uint]string{}
if len(userIDs) == 0 {
return out
}
type row struct {
UserID uint
IP string
}
var rows []row
_ = s.db.Raw(`
SELECT DISTINCT ON (user_id) user_id, ip
FROM login_logs
WHERE user_id IN ? AND success = TRUE AND ip <> ''
ORDER BY user_id, created_at DESC
`, userIDs).Scan(&rows).Error
for _, r := range rows {
out[r.UserID] = normalizeLotteryIP(r.IP)
}
return out
}
// LatestSuccessLoginIP 单用户最近成功登录 IP
func (s *PostService) LatestSuccessLoginIP(userID uint) string {
if userID == 0 {
return ""
}
return s.latestSuccessLoginIPs([]uint{userID})[userID]
}
// lotteryIPIgnoredForDedup 不参与同 IP 互斥:空地址;开发态下的回环地址(::1 / 127.0.0.1)
func (s *PostService) lotteryIPIgnoredForDedup(ip string) bool {
ip = normalizeLotteryIP(ip)
if ip == "" {
return true
}
if !s.devMode {
return false
}
parsed := net.ParseIP(ip)
return parsed != nil && parsed.IsLoopback()
}
// lotteryDedupByIP 同一非空 IP 只保留注册最早的一名;空 IP / 开发态回环不互斥。
func (s *PostService) lotteryDedupByIP(postID uint, ids []uint) []uint {
if len(ids) <= 1 {
return ids
}
var entries []model.PostLotteryEntry
_ = s.db.Where("post_id = ? AND user_id IN ?", postID, ids).Find(&entries).Error
ipByUser := map[uint]string{}
for _, e := range entries {
if ip := normalizeLotteryIP(e.ClientIP); ip != "" && !s.lotteryIPIgnoredForDedup(ip) {
ipByUser[e.UserID] = ip
}
}
missing := make([]uint, 0)
for _, id := range ids {
if ipByUser[id] == "" {
missing = append(missing, id)
}
}
for id, ip := range s.latestSuccessLoginIPs(missing) {
if ip != "" && !s.lotteryIPIgnoredForDedup(ip) {
ipByUser[id] = ip
}
}
type userRow struct {
ID uint
CreatedAt time.Time
}
var users []userRow
_ = s.db.Model(&model.User{}).Select("id", "created_at").
Where("id IN ?", ids).Find(&users).Error
created := map[uint]time.Time{}
for _, u := range users {
created[u.ID] = u.CreatedAt
}
// ip -> 胜出 user(注册最早;同刻取更小 id)
bestByIP := map[string]uint{}
for _, id := range ids {
ip := ipByUser[id]
if ip == "" {
continue
}
cur, ok := bestByIP[ip]
if !ok {
bestByIP[ip] = id
continue
}
ct, okC := created[id]
cc, okCur := created[cur]
if !okC {
continue
}
if !okCur || ct.Before(cc) || (ct.Equal(cc) && id < cur) {
bestByIP[ip] = id
}
}
chosenIP := map[uint]bool{}
for _, id := range bestByIP {
chosenIP[id] = true
}
out := make([]uint, 0, len(ids))
for _, id := range ids {
ip := ipByUser[id]
if ip == "" || chosenIP[id] {
out = append(out, id)
}
}
return out
} }
// lotteryWinningFloor 中奖用户的中奖楼层:其在本帖最早一条已发布主评论(每人仅一楼)。 // lotteryWinningFloor 中奖用户的中奖楼层:其在本帖最早一条已发布主评论(每人仅一楼)。
@@ -633,6 +984,7 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
if err != nil { if err != nil {
return nil return nil
} }
cooldownHours := s.lotteryCooldownHours()
ids, _ := s.lotteryEligibleIDs(post.ID, post.UserID) ids, _ := s.lotteryEligibleIDs(post.ID, post.UserID)
eligible := false eligible := false
for _, id := range ids { for _, id := range ids {
@@ -642,6 +994,7 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
} }
} }
winners := []LotteryEntrant{} winners := []LotteryEntrant{}
prizeNames := expandLotteryPrizeNames(m.Prizes)
if len(m.WinnerIDs) > 0 { if len(m.WinnerIDs) > 0 {
var users []model.User var users []model.User
s.db.Select("id, username, nickname, avatar").Where("id IN ?", m.WinnerIDs).Find(&users) s.db.Select("id, username, nickname, avatar").Where("id IN ?", m.WinnerIDs).Find(&users)
@@ -649,9 +1002,13 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
for _, u := range users { for _, u := range users {
umap[u.ID] = u umap[u.ID] = u
} }
for _, id := range m.WinnerIDs { for i, id := range m.WinnerIDs {
if u, ok := umap[id]; ok { if u, ok := umap[id]; ok {
cid, floor := s.lotteryWinningFloor(post.ID, id) cid, floor := s.lotteryWinningFloor(post.ID, id)
prize := ""
if i < len(prizeNames) {
prize = prizeNames[i]
}
winners = append(winners, LotteryEntrant{ winners = append(winners, LotteryEntrant{
ID: u.ID, ID: u.ID,
Nickname: u.Nickname, Nickname: u.Nickname,
@@ -659,19 +1016,26 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
Avatar: u.Avatar, Avatar: u.Avatar,
CommentID: cid, CommentID: cid,
Floor: floor, Floor: floor,
PrizeName: prize,
}) })
} }
} }
} }
prizes := m.Prizes
if prizes == nil {
prizes = []LotteryPrize{}
}
return &LotteryState{ return &LotteryState{
Slots: m.Slots, Slots: m.Slots,
Prizes: prizes,
Drawn: m.Drawn, Drawn: m.Drawn,
Closed: m.Closed, Closed: m.Closed,
EndsAt: m.EndsAt, EndsAt: m.EndsAt,
EntryCount: len(ids), EntryCount: len(ids),
Eligible: eligible, Eligible: eligible,
CooldownHours: cooldownHours,
CanDraw: canManage && !m.Drawn, CanDraw: canManage && !m.Drawn,
CanClose: canManage && !m.Drawn && !m.Closed, CanEditPrizes: !m.Drawn && post.CommentCount == 0,
Winners: winners, Winners: winners,
} }
} }
@@ -688,6 +1052,8 @@ func (s *PostService) VotePoll(userID, postID uint, optionIndexes []int) (*PostD
if post.Status != model.ContentStatusPublished { if post.Status != model.ContentStatusPublished {
return nil, errors.New("帖子未公开,无法投票") return nil, errors.New("帖子未公开,无法投票")
} }
_ = s.settleDuePollIfNeeded(&post)
s.db.First(&post, post.ID)
m, err := parsePollMeta(post.TypeMeta) m, err := parsePollMeta(post.TypeMeta)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -755,6 +1121,9 @@ func (s *PostService) ClosePoll(actor *Actor, userID, postID uint) (*PostDetail,
return nil, err return nil, err
} }
m.Closed = true m.Closed = true
if strings.TrimSpace(m.ClosedAt) == "" {
m.ClosedAt = time.Now().UTC().Format(time.RFC3339)
}
raw, err := encodeMeta(m) raw, err := encodeMeta(m)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -794,6 +1163,7 @@ func (s *PostService) AcceptQuestion(actor *Actor, userID, postID, commentID uin
} }
m.AcceptedCommentID = commentID m.AcceptedCommentID = commentID
m.Solved = true m.Solved = true
m.SolvedAt = time.Now().UTC().Format(time.RFC3339)
raw, err := encodeMeta(m) raw, err := encodeMeta(m)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -819,6 +1189,7 @@ func (s *PostService) SolveQuestion(actor *Actor, userID, postID uint) (*PostDet
return nil, err return nil, err
} }
m.Solved = true m.Solved = true
m.SolvedAt = time.Now().UTC().Format(time.RFC3339)
raw, err := encodeMeta(m) raw, err := encodeMeta(m)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -830,7 +1201,7 @@ func (s *PostService) SolveQuestion(actor *Actor, userID, postID uint) (*PostDet
return s.detailAfterInteract(post, userID) return s.detailAfterInteract(post, userID)
} }
// ReopenQuestion 重新打开 // ReopenQuestion 重新打开(仅允许撤回「手动标已解决」;已采纳答案不可重开)
func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDetail, error) { func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDetail, error) {
post, err := s.loadManageablePost(actor, userID, postID) post, err := s.loadManageablePost(actor, userID, postID)
if err != nil { if err != nil {
@@ -839,7 +1210,14 @@ func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDe
if model.NormalizePostType(post.PostType) != model.PostTypeQuestion { if model.NormalizePostType(post.PostType) != model.PostTypeQuestion {
return nil, ErrQuestionNotType return nil, ErrQuestionNotType
} }
m := &QuestionMeta{Solved: false, AcceptedCommentID: 0} old, err := parseQuestionMeta(post.TypeMeta)
if err != nil {
return nil, err
}
if old != nil && old.AcceptedCommentID > 0 {
return nil, errors.New("已采纳答案,无法重新打开")
}
m := &QuestionMeta{Solved: false, AcceptedCommentID: 0, SolvedAt: ""}
raw, err := encodeMeta(m) raw, err := encodeMeta(m)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -851,7 +1229,8 @@ func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDe
return s.detailAfterInteract(post, userID) return s.detailAfterInteract(post, userID)
} }
// AcceptBounty 采纳评论并发放悬赏 // AcceptBounty 采纳评论并发放悬赏。
// 托管中:从托管直接发给答主;到期已退回后:从作者余额再扣再发放(不可撤回)。
func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint) (*PostDetail, error) { func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint) (*PostDetail, error) {
post, err := s.loadManageablePost(actor, userID, postID) post, err := s.loadManageablePost(actor, userID, postID)
if err != nil { if err != nil {
@@ -867,15 +1246,17 @@ func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if m.Expired { if m.AcceptedCommentID > 0 {
return nil, ErrBountyExpired
}
if !m.Escrowed {
return nil, ErrBountyNotEscrow
}
if m.AcceptedCommentID > 0 || m.Refunded {
return nil, ErrBountySettled return nil, ErrBountySettled
} }
payFromBalance := false
if m.Escrowed {
payFromBalance = false
} else if m.Refunded {
payFromBalance = true
} else {
return nil, ErrBountyNotEscrow
}
var cm model.Comment var cm model.Comment
if err := s.db.First(&cm, commentID).Error; err != nil || cm.PostID != postID { if err := s.db.First(&cm, commentID).Error; err != nil || cm.PostID != postID {
return nil, errors.New("评论不存在") return nil, errors.New("评论不存在")
@@ -888,56 +1269,18 @@ func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint)
} }
err = s.db.Transaction(func(tx *gorm.DB) error { err = s.db.Transaction(func(tx *gorm.DB) error {
if payFromBalance {
note := "悬赏截止后采纳扣款:" + post.Title
if _, err := DebitTx(tx, post.UserID, m.Points, model.PointReasonBountyEscrow, "post", postID, note); err != nil {
return err
}
}
if _, err := CreditTx(tx, cm.UserID, m.Points, model.PointReasonBountyAward, "post", postID, "悬赏采纳:"+post.Title); err != nil { if _, err := CreditTx(tx, cm.UserID, m.Points, model.PointReasonBountyAward, "post", postID, "悬赏采纳:"+post.Title); err != nil {
return err return err
} }
m.AcceptedCommentID = commentID m.AcceptedCommentID = commentID
m.Escrowed = false m.Escrowed = false
raw, err := encodeMeta(m) // 截止后退回再采纳:保留 Expired/Refunded 痕迹便于对账,但 Settled 以采纳为准
if err != nil {
return err
}
return tx.Model(post).Update("type_meta", raw).Error
})
if err != nil {
return nil, err
}
s.db.Preload("Board").Preload("User").First(post, post.ID)
return s.detailAfterInteract(post, userID)
}
// RefundBounty 退回悬赏积分(未采纳时)
func (s *PostService) RefundBounty(actor *Actor, userID, postID uint) (*PostDetail, error) {
post, err := s.loadManageablePost(actor, userID, postID)
if err != nil {
return nil, err
}
_ = s.settleExpiredBountyIfNeeded(post)
s.db.First(post, post.ID)
if model.NormalizePostType(post.PostType) != model.PostTypeBounty {
return nil, errors.New("本文不是悬赏帖")
}
m, err := parseBountyMeta(post.TypeMeta)
if err != nil {
return nil, err
}
if m.Expired {
return nil, ErrBountyExpired
}
if !m.Escrowed {
return nil, ErrBountyNotEscrow
}
if m.AcceptedCommentID > 0 || m.Refunded {
return nil, ErrBountySettled
}
err = s.db.Transaction(func(tx *gorm.DB) error {
if _, err := CreditTx(tx, post.UserID, m.Points, model.PointReasonBountyRefund, "post", postID, "悬赏退回:"+post.Title); err != nil {
return err
}
m.Refunded = true
m.Escrowed = false
raw, err := encodeMeta(m) raw, err := encodeMeta(m)
if err != nil { if err != nil {
return err return err
@@ -996,36 +1339,12 @@ func (s *PostService) settleExpiredBountyIfNeeded(post *model.Post) error {
}) })
} }
// CloseLotteryEntries 截止抽奖(仍可手动开奖)
func (s *PostService) CloseLotteryEntries(actor *Actor, userID, postID uint) (*PostDetail, error) {
post, err := s.loadManageablePost(actor, userID, postID)
if err != nil {
return nil, err
}
m, err := parseLotteryMeta(post.TypeMeta)
if err != nil {
return nil, err
}
if m.Drawn {
return nil, ErrLotteryDrawn
}
m.Closed = true
raw, err := encodeMeta(m)
if err != nil {
return nil, err
}
if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil {
return nil, err
}
post.TypeMeta = raw
return s.detailAfterInteract(post, userID)
}
func (s *PostService) drawLotteryCore(post *model.Post, m *LotteryMeta) error { func (s *PostService) drawLotteryCore(post *model.Post, m *LotteryMeta) error {
ids, err := s.lotteryEligibleIDs(post.ID, post.UserID) ids, err := s.lotteryEligibleIDs(post.ID, post.UserID)
if err != nil { if err != nil {
return err return err
} }
m.Slots = lotterySlotsFromPrizes(m.Prizes)
nWin := m.Slots nWin := m.Slots
if nWin > len(ids) { if nWin > len(ids) {
nWin = len(ids) nWin = len(ids)
@@ -1078,6 +1397,34 @@ func (s *PostService) DrawLottery(actor *Actor, userID, postID uint) (*PostDetai
return s.detailAfterInteract(post, userID) return s.detailAfterInteract(post, userID)
} }
func (s *PostService) settleDuePollIfNeeded(post *model.Post) error {
if model.NormalizePostType(post.PostType) != model.PostTypePoll {
return nil
}
m, err := parsePollMeta(post.TypeMeta)
if err != nil || m.Closed {
return nil
}
ends, ok := parseEndsAt(m.EndsAt)
if !ok || time.Now().UTC().Before(ends) {
return nil
}
m.Closed = true
if strings.TrimSpace(m.ClosedAt) == "" {
// 到期自动结束:结束时刻取截止时刻
m.ClosedAt = ends.UTC().Format(time.RFC3339)
}
raw, err := encodeMeta(m)
if err != nil {
return err
}
if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil {
return err
}
post.TypeMeta = raw
return nil
}
func (s *PostService) settleDueLotteryIfNeeded(post *model.Post) error { func (s *PostService) settleDueLotteryIfNeeded(post *model.Post) error {
if model.NormalizePostType(post.PostType) != model.PostTypeLottery { if model.NormalizePostType(post.PostType) != model.PostTypeLottery {
return nil return nil
@@ -1093,15 +1440,29 @@ func (s *PostService) settleDueLotteryIfNeeded(post *model.Post) error {
return s.drawLotteryCore(post, m) return s.drawLotteryCore(post, m)
} }
// SettleDueTypedPosts 定时任务:悬赏过期退回 + 抽奖到期开奖 // SettleDueTypedPosts 定时任务:投票到期关闭 + 悬赏过期退回 + 抽奖到期开奖
func (s *PostService) SettleDueTypedPosts(limit int) { func (s *PostService) SettleDueTypedPosts(limit int) {
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
} }
n := 0
var polls []model.Post
s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypePoll).
Order("id ASC").Limit(limit * 3).Find(&polls)
for i := range polls {
if n >= limit {
break
}
before := polls[i].TypeMeta
_ = s.settleDuePollIfNeeded(&polls[i])
if polls[i].TypeMeta != before {
n++
}
}
var bounties []model.Post var bounties []model.Post
s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty). s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty).
Order("id ASC").Limit(limit * 3).Find(&bounties) Order("id ASC").Limit(limit * 3).Find(&bounties)
n := 0
for i := range bounties { for i := range bounties {
if n >= limit { if n >= limit {
break break
@@ -1116,7 +1477,6 @@ func (s *PostService) SettleDueTypedPosts(limit int) {
var lotteries []model.Post var lotteries []model.Post
s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeLottery). s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeLottery).
Order("id ASC").Limit(limit * 3).Find(&lotteries) Order("id ASC").Limit(limit * 3).Find(&lotteries)
n = 0
for i := range lotteries { for i := range lotteries {
if n >= limit { if n >= limit {
break break

9
frontend/AGENTS.md Normal file
View File

@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

1
frontend/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -182,30 +182,106 @@ body {
box-shadow: var(--shadow); box-shadow: var(--shadow);
overflow: visible; overflow: visible;
} }
.j13-compose-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--panel) 92%, var(--panel-2));
position: sticky;
top: 3.5rem;
z-index: 20;
border-radius: var(--r) var(--r) 0 0;
}
@media (min-width: 640px) {
.j13-compose-toolbar {
padding: 0.85rem 1.75rem;
}
}
.j13-compose-toolbar-title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: var(--ink);
letter-spacing: -0.01em;
}
.j13-compose-toolbar-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.j13-compose-body { .j13-compose-body {
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
padding: 1.35rem 1.35rem 0.85rem; padding: 1.15rem 1.15rem 0.75rem;
gap: 1.1rem; gap: 0.85rem;
} }
@media (min-width: 640px) { @media (min-width: 640px) {
.j13-compose-body { .j13-compose-body {
padding: 1.5rem 1.75rem 0.9rem; padding: 1.25rem 1.4rem 0.85rem;
gap: 1.15rem; gap: 0.95rem;
} }
} }
/* 发帖分区卡片:轻分隔,不过度割裂也不糊成一片 */
.j13-compose-section {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1rem 1.1rem 1.1rem;
border-radius: 14px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--bg) 70%, var(--panel));
}
.j13-compose-section-title {
margin: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
color: var(--ink-3);
}
.j13-compose-section-writing {
background: var(--panel);
border-color: color-mix(in srgb, var(--line) 85%, var(--accent) 15%);
box-shadow: 0 1px 0 color-mix(in srgb, var(--ink) 3.5%, transparent);
flex: 1 1 auto;
min-height: 0;
}
/* 类型面板已在分区内,去掉二次描边避免套娃 */
.j13-compose-section .j13-compose-typepanel {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.j13-compose-actions { .j13-compose-actions {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between;
gap: 0.75rem 1rem; gap: 0.75rem 1rem;
padding: 1rem 1.5rem 1.25rem; padding: 1rem 1.5rem 1.25rem;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
background: var(--panel); background: var(--panel);
} }
.j13-compose-actions-error {
flex: 1 1 12rem;
margin: 0;
max-width: min(100%, 28rem);
}
.j13-compose-actions-end {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.65rem;
margin-left: auto;
}
@media (min-width: 640px) { @media (min-width: 640px) {
.j13-compose-actions { .j13-compose-actions {
padding-left: 2rem; padding-left: 2rem;
@@ -271,6 +347,54 @@ body {
border: 1px dashed color-mix(in srgb, var(--accent) 28%, var(--line)); border: 1px dashed color-mix(in srgb, var(--accent) 28%, var(--line));
background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel)); background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel));
} }
.j13-lottery-prize-table-wrap {
overflow-x: auto;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--panel);
}
.j13-lottery-prize-table {
width: 100%;
border-collapse: collapse;
font-size: 13.5px;
}
.j13-lottery-prize-table th,
.j13-lottery-prize-table td {
padding: 8px 10px;
text-align: left;
vertical-align: middle;
border-bottom: 1px solid var(--line);
}
.j13-lottery-prize-table th {
font-weight: 600;
color: var(--ink-2);
background: var(--panel-2);
white-space: nowrap;
}
.j13-lottery-prize-table tbody tr:last-child td {
border-bottom: none;
}
.j13-lottery-prize-table .col-idx {
width: 2.5rem;
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
.j13-lottery-prize-table .col-count {
width: 6.5rem;
}
.j13-lottery-prize-table .col-actions {
width: 4.5rem;
text-align: right;
}
.j13-lottery-prize-table .j13-compose-field {
width: 100%;
padding: 7px 10px;
font-size: 13.5px;
}
.j13-lottery-prize-table .j13-compose-field-count {
width: 100%;
max-width: 5.5rem;
}
.j13-compose-attach-row { .j13-compose-attach-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -286,6 +410,474 @@ body {
border: 1px solid var(--line); border: 1px solid var(--line);
background: var(--panel); background: var(--panel);
} }
/* 抽奖卡:对齐截图结构,落在站点石墨/令牌体系内 */
/* 类型帖交互卡(投票 / 悬赏 / 抽奖)共用壳 */
.j13-typed-card {
padding: 16px 18px 14px;
}
.j13-typed-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 14px;
}
.j13-typed-head-main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
min-width: 0;
}
.j13-typed-title {
display: inline-flex;
align-items: center;
gap: 7px;
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--ink);
}
.j13-typed-title svg {
flex-shrink: 0;
}
.j13-typed-title.is-question {
color: var(--ink);
}
.j13-typed-title.is-poll {
color: var(--accent);
}
.j13-typed-title.is-bounty {
color: var(--clay);
}
.j13-typed-title.is-lottery {
color: var(--gold);
}
.j13-typed-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.j13-typed-actions-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.j13-typed-meta,
.j13-typed-empty {
margin: 8px 0 0;
font-size: 12.5px;
line-height: 1.5;
color: var(--ink-3);
}
.j13-typed-foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 16px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--line);
font-size: 12.5px;
color: var(--ink-3);
}
.j13-typed-foot-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 550;
}
.j13-typed-foot-status svg {
flex-shrink: 0;
color: var(--ink-2);
}
.j13-typed-foot-time {
font-variant-numeric: tabular-nums;
}
.j13-bounty-points {
font-weight: 700;
color: var(--clay);
}
.j13-poll-options {
display: flex;
flex-direction: column;
gap: 8px;
margin: 12px 0 0;
padding: 0;
list-style: none;
}
.j13-poll-option {
position: relative;
display: block;
width: 100%;
text-align: left;
overflow: hidden;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--panel);
transition: border-color 0.15s ease, background 0.15s ease;
}
.j13-poll-option:disabled {
cursor: default;
}
.j13-poll-option:not(:disabled):hover {
border-color: color-mix(in srgb, var(--accent) 35%, var(--line));
}
.j13-poll-option.is-on {
border-color: var(--accent);
background: var(--accent-soft);
}
.j13-poll-option-bar {
position: absolute;
inset: 0 auto 0 0;
opacity: 0.28;
background: var(--accent);
pointer-events: none;
}
.j13-poll-option-row {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.j13-poll-option-text {
font-size: 14px;
color: var(--ink);
}
.j13-poll-option-stat {
flex-shrink: 0;
font-size: 12.5px;
color: var(--ink-3);
}
.j13-poll-voters {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 6px 0 0;
padding: 0 2px;
list-style: none;
}
.j13-poll-voter {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px 2px 3px;
border-radius: 999px;
font-size: 12px;
background: var(--accent-soft);
color: var(--accent);
text-decoration: none;
}
.j13-poll-voter:hover {
text-decoration: underline;
}
.j13-lottery-card {
padding: 16px 18px 14px;
}
.j13-lottery-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 14px;
}
.j13-lottery-head-main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
min-width: 0;
}
.j13-lottery-title {
display: inline-flex;
align-items: center;
gap: 7px;
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--gold);
}
.j13-lottery-title svg {
flex-shrink: 0;
}
.j13-lottery-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.j13-lottery-meta,
.j13-lottery-empty {
margin: 8px 0 0;
font-size: 12.5px;
line-height: 1.5;
color: var(--ink-3);
}
/* 截止倒计时:投票 / 悬赏 / 抽奖共用 */
.j13-deadline-countdown {
margin-top: 12px;
padding: 12px 14px 11px;
border-radius: 14px;
border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--line));
background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel));
}
.j13-deadline-countdown.is-expired {
border-color: color-mix(in srgb, var(--ink-3) 35%, var(--line));
background: color-mix(in srgb, var(--tint) 70%, var(--panel));
}
.j13-deadline-countdown-label {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0 0 8px;
font-size: 12.5px;
font-weight: 650;
color: var(--accent);
}
.j13-deadline-countdown.is-expired .j13-deadline-countdown-label {
color: var(--ink-2);
}
.j13-deadline-countdown-digits {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 8px;
}
.j13-deadline-countdown-unit {
display: inline-flex;
align-items: baseline;
gap: 4px;
padding: 6px 10px 5px;
border-radius: 10px;
background: var(--panel);
border: 1px solid var(--line);
box-shadow: var(--shadow-sm);
}
.j13-deadline-countdown-unit.is-sec {
border-color: color-mix(in srgb, var(--accent) 28%, var(--line));
background: color-mix(in srgb, var(--accent-soft) 45%, var(--panel));
}
.j13-deadline-countdown-num {
font-family: var(--font-mono);
font-size: clamp(1.35rem, 3.2vw, 1.75rem);
font-weight: 750;
line-height: 1;
letter-spacing: -0.02em;
color: var(--ink);
}
.j13-deadline-countdown-suf {
font-size: 12px;
font-weight: 650;
color: var(--ink-3);
}
.j13-deadline-countdown-unit.is-sec .j13-deadline-countdown-num {
color: var(--accent);
}
.j13-deadline-countdown-expired {
margin: 0;
font-size: 1.15rem;
font-weight: 750;
letter-spacing: -0.01em;
color: var(--ink);
}
.j13-deadline-countdown-ends {
margin: 8px 0 0;
font-size: 12px;
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
.dark .j13-deadline-countdown-unit {
background: var(--panel-2);
box-shadow: none;
}
.j13-deadline-picker {
display: flex;
flex-direction: column;
gap: 8px;
}
.j13-deadline-picker-label {
font-size: 13px;
font-weight: 650;
color: var(--ink);
}
.j13-deadline-picker-modes {
display: inline-flex;
gap: 4px;
padding: 3px;
border-radius: 10px;
background: var(--tint);
align-self: flex-start;
}
.j13-deadline-mode {
border: 0;
background: transparent;
color: var(--ink-2);
font-size: 12.5px;
font-weight: 600;
padding: 5px 10px;
border-radius: 8px;
cursor: pointer;
}
.j13-deadline-mode.is-on {
background: var(--panel);
color: var(--ink);
box-shadow: var(--shadow-sm);
}
.j13-deadline-mode:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.j13-deadline-presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.j13-deadline-custom {
display: flex;
flex-direction: column;
gap: 6px;
max-width: 22rem;
}
.j13-deadline-datetime {
width: 100%;
}
/* 奖品 / 中奖者:横向排布,放不下换行 */
.j13-lottery-prize-row,
.j13-lottery-winner-row {
display: flex;
flex-wrap: wrap;
align-items: stretch;
gap: 8px 10px;
margin: 12px 0 0;
padding: 0;
list-style: none;
}
.j13-lottery-prize-row > li,
.j13-lottery-winner-row > li {
min-width: 0;
}
/* 奖品胶囊:淡金底 + 深金字 + 奖杯徽标 */
.j13-lottery-prize-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: min(100%, 28rem);
padding: 6px 13px 6px 11px;
border-radius: 999px;
font-size: 13px;
font-weight: 650;
line-height: 1.25;
background: var(--gold-soft);
color: var(--gold);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--gold) 28%, transparent);
}
.j13-lottery-prize-chip svg {
flex-shrink: 0;
}
.j13-lottery-prize-chip-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.j13-lottery-prize-chip-count {
margin-left: 0.3em;
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.j13-lottery-winners-label {
margin: 14px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--ink-3);
}
.j13-lottery-winner-row {
margin-top: 8px;
}
/* 中奖者:横向胶囊卡片,内容定宽、可换行 */
.j13-lottery-winner {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
column-gap: 10px;
width: min(100%, 15.5rem);
min-height: 52px;
padding: 8px 14px 8px 8px;
border-radius: 14px;
border: 1px solid var(--line);
background: var(--panel);
box-shadow: var(--shadow-sm);
}
.j13-lottery-winner-avatar {
grid-column: 1;
}
.j13-lottery-winner-body {
grid-column: 2;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.j13-lottery-winner-name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 650;
line-height: 1.25;
color: var(--ink);
text-decoration: none;
}
.j13-lottery-winner-name:hover {
color: var(--accent);
}
.j13-lottery-winner-prize {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11.5px;
font-weight: 600;
color: var(--gold);
}
.dark .j13-lottery-winner {
background: var(--panel-2);
box-shadow: none;
}
.j13-lottery-foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 16px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--line);
font-size: 12.5px;
color: var(--ink-3);
}
.j13-lottery-foot-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 550;
}
.j13-lottery-foot-status svg {
flex-shrink: 0;
color: var(--ink-2);
}
.j13-lottery-foot-time {
font-variant-numeric: tabular-nums;
}
.j13-interact-card.is-unsolved { .j13-interact-card.is-unsolved {
background: color-mix(in srgb, var(--danger) 7%, var(--panel)); background: color-mix(in srgb, var(--danger) 7%, var(--panel));
border-color: color-mix(in srgb, var(--danger) 22%, var(--line)); border-color: color-mix(in srgb, var(--danger) 22%, var(--line));
@@ -303,51 +895,41 @@ body {
border-color: color-mix(in srgb, var(--ok) 30%, var(--line)); border-color: color-mix(in srgb, var(--ok) 30%, var(--line));
} }
/* 正文下方已采纳答案:问答资料卡 */ /* 正文下方已采纳答案:与类型帖 typed-card 同框,配色克制 */
.j13-accepted-answer { .j13-accepted-answer {
margin-top: 1.75rem; margin-top: 1.75rem;
padding: 1.15rem 1.25rem 1.25rem;
border-radius: 14px;
border: 1px solid color-mix(in srgb, var(--ok) 28%, var(--line));
background: color-mix(in srgb, var(--ok) 7%, var(--panel));
box-shadow: inset 3px 0 0 0 var(--ok);
} }
.dark .j13-accepted-answer { .j13-accepted-answer.is-question,
background: color-mix(in srgb, var(--ok) 12%, var(--panel)); .j13-accepted-answer.is-bounty {
border-color: color-mix(in srgb, var(--ok) 36%, var(--line)); /* 覆盖旧绿底/左边条;沿用 interact-card 面板 */
} border-color: var(--line);
.j13-accepted-answer__head { background: var(--panel);
display: flex; box-shadow: none;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px 16px;
margin-bottom: 0.9rem;
padding-bottom: 0.85rem;
border-bottom: 1px solid color-mix(in srgb, var(--ok) 18%, var(--line));
}
.j13-accepted-answer__meta {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
flex: 1;
}
.j13-accepted-answer__status {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--ink);
line-height: 1.3;
} }
.j13-accepted-answer__author { .j13-accepted-answer__author {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
min-width: 0; min-width: 0;
margin-top: 12px;
}
.j13-accepted-answer__chip {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: var(--ok-soft);
color: var(--ok);
} }
.j13-accepted-answer__body { .j13-accepted-answer__body {
min-width: 0; min-width: 0;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--line);
} }
/* 正文下方类型特殊块(投票/悬赏/抽奖/问答状态) */ /* 正文下方类型特殊块(投票/悬赏/抽奖/问答状态) */
.j13-interact-below { .j13-interact-below {
@@ -382,6 +964,10 @@ body {
opacity: 0.65; opacity: 0.65;
cursor: not-allowed; cursor: not-allowed;
} }
.j13-compose-field-invalid {
border-color: color-mix(in srgb, var(--danger) 55%, var(--line));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent);
}
.j13-compose-title { .j13-compose-title {
width: 100%; width: 100%;
@@ -1129,18 +1715,11 @@ a.j13-mention-token:hover {
background: color-mix(in srgb, #0284c7 12%, var(--panel)); background: color-mix(in srgb, #0284c7 12%, var(--panel));
color: #0369a1; color: #0369a1;
} }
/* 已结束 / 已过期 / 已开奖 / 已截止:统一灰胶囊(标识已到期) */
.badge-status-closed { .badge-status-closed {
background: color-mix(in srgb, #5b6b8c 12%, var(--panel)); background: color-mix(in srgb, #5b6b8c 12%, var(--panel));
color: #5b6b8c; color: #5b6b8c;
} }
.badge-status-expired {
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
color: var(--danger);
}
.badge-status-drawn {
background: color-mix(in srgb, #7c5cbf 14%, var(--panel));
color: #6a4aad;
}
.dark .badge-status-unsolved { .dark .badge-status-unsolved {
background: rgba(255, 107, 96, 0.14); background: rgba(255, 107, 96, 0.14);
color: #fca5a5; color: #fca5a5;
@@ -1157,14 +1736,6 @@ a.j13-mention-token:hover {
background: rgba(170, 182, 207, 0.14); background: rgba(170, 182, 207, 0.14);
color: #aab6cf; color: #aab6cf;
} }
.dark .badge-status-expired {
background: color-mix(in srgb, var(--danger) 20%, transparent);
color: var(--danger);
}
.dark .badge-status-drawn {
background: rgba(192, 168, 245, 0.16);
color: #c0a8f5;
}
/* 评论区成就徽标:实心底,比类型/状态浅底更醒目 */ /* 评论区成就徽标:实心底,比类型/状态浅底更醒目 */
.badge-accepted { .badge-accepted {

View File

@@ -14,6 +14,7 @@ type Props = {
initialTypeMeta?: string; initialTypeMeta?: string;
initialAttachments?: PostAttachment[]; initialAttachments?: PostAttachment[];
canEditPollOptions?: boolean; canEditPollOptions?: boolean;
canEditLotteryPrizes?: boolean;
boardName: string; boardName: string;
boardId: number; boardId: number;
boardColorIndex?: number; boardColorIndex?: number;
@@ -30,6 +31,7 @@ export default function EditPostClient({
initialTypeMeta, initialTypeMeta,
initialAttachments, initialAttachments,
canEditPollOptions, canEditPollOptions,
canEditLotteryPrizes,
boardName, boardName,
boardId, boardId,
boardColorIndex = 0, boardColorIndex = 0,
@@ -47,6 +49,7 @@ export default function EditPostClient({
initialTypeMeta={initialTypeMeta} initialTypeMeta={initialTypeMeta}
initialAttachments={initialAttachments} initialAttachments={initialAttachments}
canEditPollOptions={canEditPollOptions} canEditPollOptions={canEditPollOptions}
canEditLotteryPrizes={canEditLotteryPrizes}
boardName={boardName} boardName={boardName}
boardId={boardId} boardId={boardId}
boardColorIndex={boardColorIndex} boardColorIndex={boardColorIndex}

View File

@@ -65,6 +65,7 @@ export default async function EditPostPage({ params }: PageProps) {
initialTypeMeta={post.type_meta} initialTypeMeta={post.type_meta}
initialAttachments={post.attachments || []} initialAttachments={post.attachments || []}
canEditPollOptions={post.poll?.can_edit_options !== false} canEditPollOptions={post.poll?.can_edit_options !== false}
canEditLotteryPrizes={post.lottery?.can_edit_prizes !== false}
boardName={post.board?.name || "未知板块"} boardName={post.board?.name || "未知板块"}
boardId={post.board_id} boardId={post.board_id}
boardColorIndex={post.board?.color_index ?? 0} boardColorIndex={post.board?.color_index ?? 0}

View File

@@ -714,8 +714,11 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
<AcceptedAnswerCard <AcceptedAnswerCard
answer={acceptedAnswer} answer={acceptedAnswer}
variant={acceptedAnswerVariant} variant={acceptedAnswerVariant}
postId={post.id} solvedAt={
canReopen={!!post.question?.can_reopen} post.question?.solved_at ||
post.bounty?.accepted_answer?.created_at ||
acceptedAnswer.created_at
}
/> />
) : questionState ? ( ) : questionState ? (
<QuestionStatusCard postId={post.id} state={questionState} /> <QuestionStatusCard postId={post.id} state={questionState} />

View File

@@ -1,55 +1,65 @@
import Link from "next/link"; import Link from "next/link";
import { CheckCircle2 } from "lucide-react"; import { CheckCircle2, CircleHelp, Coins } from "lucide-react";
import type { AcceptedAnswer } from "@/lib/api"; import type { AcceptedAnswer } from "@/lib/api";
import Avatar from "@/components/Avatar"; import Avatar from "@/components/Avatar";
import MarkdownBody from "@/components/MarkdownBody"; import MarkdownBody from "@/components/MarkdownBody";
import AcceptedAnswerReopen from "@/components/AcceptedAnswerReopen";
/** 正文下方的已采纳答案卡:头像 + 昵称 + 全文;问答已解决时并入状态与「重新打开」 */ /** 正文下方已采纳答案:对齐类型帖 typed-card;已采纳后不可重新打开 */
export default async function AcceptedAnswerCard({ export default async function AcceptedAnswerCard({
answer, answer,
variant = "question", variant = "question",
postId, solvedAt,
canReopen = false,
}: { }: {
answer: AcceptedAnswer; answer: AcceptedAnswer;
/** 问答用「最佳答案」,悬赏用「已采纳」 */ /** 问答用「最佳答案」,悬赏用「已采纳」 */
variant?: "question" | "bounty"; variant?: "question" | "bounty";
postId?: number; /** 解决/采纳时刻;缺省用答案创建时间 */
canReopen?: boolean; solvedAt?: string;
}) { }) {
const name = answer.user.nickname || answer.user.username || "用户"; const name = answer.user.nickname || answer.user.username || "用户";
const label = variant === "bounty" ? "已采纳" : "最佳答案"; const isQuestion = variant === "question";
const showReopen = variant === "question" && canReopen && !!postId; const title = isQuestion ? "问答" : "悬赏";
const statusLabel = isQuestion ? "已解决" : "已采纳";
const answerLabel = isQuestion ? "最佳答案" : "采纳答案";
const endTime = solvedAt || answer.created_at;
const endLabel = formatEndsAt(endTime);
return ( return (
<section className="j13-accepted-answer" aria-label={variant === "question" ? "问答 · 已解决" : label}> <section
<header className="j13-accepted-answer__head"> className={`j13-interact-card j13-typed-card j13-accepted-answer${isQuestion ? " is-question" : " is-bounty"}`}
<div className="j13-accepted-answer__meta min-w-0"> aria-label={`${title} · ${statusLabel}`}
{variant === "question" && (
<p className="j13-accepted-answer__status">问答 · 已解决</p>
)}
<div className="j13-accepted-answer__author min-w-0">
<span className="badge-accepted shrink-0">
<CheckCircle2 size={13} aria-hidden />
{label}
</span>
<Link
href={`/u/${answer.user.id}`}
className="shrink-0"
aria-label={name}
> >
<Avatar name={name} src={answer.user.avatar || undefined} size={36} /> <div className="j13-typed-head">
<div className="j13-typed-head-main">
<h2 className={`j13-typed-title ${isQuestion ? "is-question" : "is-bounty"}`}>
{isQuestion ? (
<CircleHelp size={16} strokeWidth={2.25} aria-hidden />
) : (
<Coins size={16} strokeWidth={2.25} aria-hidden />
)}
{title}
</h2>
<span className="badge-status badge-status-solved shrink-0">{statusLabel}</span>
</div>
</div>
<div className="j13-accepted-answer__author">
<span className="j13-accepted-answer__chip">
<CheckCircle2 size={12} strokeWidth={2.25} aria-hidden />
{answerLabel}
</span>
<Link href={`/u/${answer.user.id}`} className="shrink-0" aria-label={name}>
<Avatar name={name} src={answer.user.avatar || undefined} size={32} />
</Link> </Link>
<div className="min-w-0 flex flex-col gap-0.5"> <div className="min-w-0 flex flex-col gap-0.5">
<Link <Link
href={`/u/${answer.user.id}`} href={`/u/${answer.user.id}`}
className="truncate text-[14px] font-semibold hover:text-[var(--accent)] transition-colors" className="truncate text-[13.5px] font-semibold hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }} style={{ color: "var(--ink)" }}
> >
{name} {name}
</Link> </Link>
<p className="meta text-[12px] flex items-center gap-1.5 flex-wrap"> <p className="meta text-[12px] flex items-center gap-1.5 flex-wrap !mb-0">
<time dateTime={answer.created_at}> <time dateTime={answer.created_at}>
{new Date(answer.created_at).toLocaleString("zh-CN")} {new Date(answer.created_at).toLocaleString("zh-CN")}
</time> </time>
@@ -68,9 +78,6 @@ export default async function AcceptedAnswerCard({
</p> </p>
</div> </div>
</div> </div>
</div>
{showReopen && <AcceptedAnswerReopen postId={postId!} />}
</header>
<div className="j13-accepted-answer__body"> <div className="j13-accepted-answer__body">
{answer.deleted ? ( {answer.deleted ? (
@@ -81,6 +88,25 @@ export default async function AcceptedAnswerCard({
<MarkdownBody content={answer.content} className="prose-content prose-comment" /> <MarkdownBody content={answer.content} className="prose-content prose-comment" />
)} )}
</div> </div>
<div className="j13-typed-foot">
<span className="j13-typed-foot-status">
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
{isQuestion ? "已采纳答案" : "悬赏已发放"}
</span>
{endLabel ? (
<span className="j13-typed-foot-time">结束时间: {endLabel}</span>
) : null}
</div>
</section> </section>
); );
} }
function formatEndsAt(iso?: string) {
if (!iso) return "";
try {
return new Date(iso).toLocaleString("zh-CN");
} catch {
return iso;
}
}

View File

@@ -1,36 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { apiReopenQuestion } from "@/lib/api";
import { toast } from "@/lib/toast";
/** 已采纳答案卡上的「重新打开」——并入正文下方资料卡,避免与上方状态条重复 */
export default function AcceptedAnswerReopen({ postId }: { postId: number }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const reopen = async () => {
setBusy(true);
try {
const res = await apiReopenQuestion(String(postId));
if (res.post) {
toast("已重新打开", "ok");
router.refresh();
} else toast(res.error || "操作失败", "error");
} finally {
setBusy(false);
}
};
return (
<button
type="button"
className="btn btn-line btn-sm shrink-0 ml-auto"
disabled={busy}
onClick={() => void reopen()}
>
{busy ? "处理中…" : "重新打开"}
</button>
);
}

View File

@@ -0,0 +1,130 @@
"use client";
/** 发帖截止时间:快捷天数 + 自定义时刻 */
export type DeadlineMode = "preset" | "custom";
function pad2(n: number) {
return String(n).padStart(2, "0");
}
/** datetime-local 值(本地时区) */
export function toLocalInputValue(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
/** 默认自定义:从现在起 +N 天 */
export function defaultCustomLocal(days = 7): string {
const d = new Date();
d.setDate(d.getDate() + days);
d.setSeconds(0, 0);
return toLocalInputValue(d);
}
/** 本地 datetime-local → RFC3339(UTC) */
export function localInputToRFC3339(local: string): string | null {
const t = Date.parse(local);
if (!Number.isFinite(t)) return null;
return new Date(t).toISOString().replace(/\.\d{3}Z$/, "Z");
}
export function formatLocalPreview(local: string): string {
const t = Date.parse(local);
if (!Number.isFinite(t)) return "";
try {
return new Date(t).toLocaleString("zh-CN");
} catch {
return local;
}
}
export default function DeadlinePicker({
label = "截止时间",
presets,
mode,
onModeChange,
days,
onDaysChange,
customLocal,
onCustomLocalChange,
disabled,
hint,
}: {
label?: string;
presets: readonly number[];
mode: DeadlineMode;
onModeChange: (m: DeadlineMode) => void;
days: number;
onDaysChange: (d: number) => void;
customLocal: string;
onCustomLocalChange: (v: string) => void;
disabled?: boolean;
hint?: string;
}) {
const minLocal = toLocalInputValue(new Date(Date.now() + 60 * 60 * 1000));
const maxLocal = toLocalInputValue(new Date(Date.now() + 90 * 24 * 60 * 60 * 1000));
return (
<div className="j13-deadline-picker">
<div className="j13-deadline-picker-label">{label}</div>
<div className="j13-deadline-picker-modes" role="tablist" aria-label={label}>
<button
type="button"
role="tab"
aria-selected={mode === "preset"}
className={`j13-deadline-mode${mode === "preset" ? " is-on" : ""}`}
disabled={disabled}
onClick={() => onModeChange("preset")}
>
快捷时长
</button>
<button
type="button"
role="tab"
aria-selected={mode === "custom"}
className={`j13-deadline-mode${mode === "custom" ? " is-on" : ""}`}
disabled={disabled}
onClick={() => {
onModeChange("custom");
if (!customLocal) onCustomLocalChange(defaultCustomLocal(days || 7));
}}
>
指定时刻
</button>
</div>
{mode === "preset" ? (
<div className="j13-deadline-presets">
{presets.map((d) => (
<button
key={d}
type="button"
className={`btn btn-sm ${days === d ? "btn-primary" : "btn-line"}`}
disabled={disabled}
onClick={() => onDaysChange(d)}
>
{d} 天
</button>
))}
</div>
) : (
<div className="j13-deadline-custom">
<input
type="datetime-local"
className="j13-compose-field j13-deadline-datetime"
value={customLocal}
min={minLocal}
max={maxLocal}
disabled={disabled}
onChange={(e) => onCustomLocalChange(e.target.value)}
aria-label="指定截止时刻"
/>
{customLocal ? (
<p className="meta text-[12.5px] !mb-0">将于 {formatLocalPreview(customLocal)} 截止</p>
) : null}
</div>
)}
{hint ? <p className="meta text-[12px] !mb-0">{hint}</p> : null}
</div>
);
}

View File

@@ -10,6 +10,7 @@ import {
apiUploadPostFile, apiUploadPostFile,
apiDeletePostFile, apiDeletePostFile,
apiUpdatePostFilePrice, apiUpdatePostFilePrice,
apiGetPoints,
fetchBoards, fetchBoards,
type Board, type Board,
type PostAttachment, type PostAttachment,
@@ -19,6 +20,7 @@ import {
CONTENT_ACCESS, CONTENT_ACCESS,
BOUNTY_EXPIRE_DAYS, BOUNTY_EXPIRE_DAYS,
LOTTERY_EXPIRE_DAYS, LOTTERY_EXPIRE_DAYS,
POLL_EXPIRE_DAYS,
normalizePostType, normalizePostType,
type PostTypeValue, type PostTypeValue,
type ContentAccessValue, type ContentAccessValue,
@@ -26,6 +28,11 @@ import {
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import MarkdownEditor from "@/components/MarkdownEditor"; import MarkdownEditor from "@/components/MarkdownEditor";
import BoardPicker, { BoardBadge } from "@/components/BoardPicker"; import BoardPicker, { BoardBadge } from "@/components/BoardPicker";
import DeadlinePicker, {
type DeadlineMode,
defaultCustomLocal,
localInputToRFC3339,
} from "@/components/DeadlinePicker";
const TITLE_MAX = 256; const TITLE_MAX = 256;
const DRAFT_KEY = "j13-compose-draft"; const DRAFT_KEY = "j13-compose-draft";
@@ -127,6 +134,8 @@ type EditProps = {
initialAttachments?: PostAttachment[]; initialAttachments?: PostAttachment[];
/** 投票帖:有票后禁止改选项 */ /** 投票帖:有票后禁止改选项 */
canEditPollOptions?: boolean; canEditPollOptions?: boolean;
/** 抽奖帖:有评论后禁止改奖品 */
canEditLotteryPrizes?: boolean;
boardName?: string; boardName?: string;
boardId?: number; boardId?: number;
boardColorIndex?: number; boardColorIndex?: number;
@@ -192,20 +201,36 @@ export default function PostComposer(props: PostComposerProps) {
return true; return true;
} }
}); });
const [bountyPoints, setBountyPoints] = useState(() => { const [bountyPoints, setBountyPoints] = useState<number | "">(() => {
// 新建:空值 + placeholder(与抽奖数量输入一致);编辑回填已托管金额
if (isCreate) return "";
try { try {
const p = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").points); const p = Number(JSON.parse(props.initialTypeMeta || "{}").points);
return p > 0 ? p : 20; return p > 0 ? p : "";
} catch { } catch {
return 20; return "";
} }
}); });
const [lotterySlots, setLotterySlots] = useState(() => { const [lotteryPrizes, setLotteryPrizes] = useState<{ name: string; count: number | "" }[]>(() => {
// 新建:空行 + placeholder;编辑才回填已保存奖品
if (isCreate) return [{ name: "", count: "" }];
try { try {
const s = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").slots); const raw = JSON.parse(props.initialTypeMeta || "{}") as {
return s > 0 ? s : 1; prizes?: { name?: string; count?: number }[];
slots?: number;
};
if (Array.isArray(raw.prizes) && raw.prizes.length > 0) {
return raw.prizes.map((p) => ({
name: String(p.name || "").slice(0, 32),
count: Math.max(1, Number(p.count) || 1),
}));
}
// 旧数据仅有 slots:数量可回填,名称留空让作者补全
const slots = Number(raw.slots);
if (slots > 0) return [{ name: "", count: slots }];
return [{ name: "", count: "" }];
} catch { } catch {
return 1; return [{ name: "", count: "" }];
} }
}); });
const [bountyExpireDays, setBountyExpireDays] = useState(() => { const [bountyExpireDays, setBountyExpireDays] = useState(() => {
@@ -224,19 +249,43 @@ export default function PostComposer(props: PostComposerProps) {
return 7; return 7;
} }
}); });
const [pollExpireDays, setPollExpireDays] = useState(7);
const [deadlineMode, setDeadlineMode] = useState<DeadlineMode>("preset");
const [customEndsLocal, setCustomEndsLocal] = useState(() => defaultCustomLocal(7));
const canEditPoll = const canEditPoll =
isCreate || (props.mode === "edit" && props.canEditPollOptions !== false); isCreate || (props.mode === "edit" && props.canEditPollOptions !== false);
const canEditLotteryPrizes =
isCreate || (props.mode === "edit" && props.canEditLotteryPrizes !== false);
const lotterySlotTotal = lotteryPrizes.reduce((sum, p) => {
if (typeof p.count !== "number") return sum;
return sum + Math.max(0, Math.floor(p.count));
}, 0);
/** 每一行都需填写名称与数量,否则不可发布 */
const lotteryPrizesReady =
lotteryPrizes.length >= 1 &&
lotteryPrizes.every(
(p) =>
p.name.trim().length > 0 &&
typeof p.count === "number" &&
p.count >= 1 &&
p.count <= 100
);
const [attachments, setAttachments] = useState<PostAttachment[]>( const [attachments, setAttachments] = useState<PostAttachment[]>(
isCreate ? [] : props.initialAttachments || [] isCreate ? [] : props.initialAttachments || []
); );
const [fileBusy, setFileBusy] = useState(false); const [fileBusy, setFileBusy] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
/** 发帖页余额:仅新建悬赏时拉取,用于就地提示积分不足 */
const [pointsBalance, setPointsBalance] = useState<number | null>(null);
const [draftBanner, setDraftBanner] = useState<ComposeDraft | null>(null); const [draftBanner, setDraftBanner] = useState<ComposeDraft | null>(null);
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const skipDraftSave = useRef(true); const skipDraftSave = useRef(true);
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const actionsErrorRef = useRef<HTMLDivElement>(null);
const bountyFieldRef = useRef<HTMLDivElement>(null);
const pendingErrorFocus = useRef<"actions" | "bounty" | null>(null);
const preferredBoardId = isCreate ? props.initialBoardId || 0 : 0; const preferredBoardId = isCreate ? props.initialBoardId || 0 : 0;
@@ -280,6 +329,27 @@ export default function PostComposer(props: PostComposerProps) {
}; };
}, [isCreate, preferredBoardId]); }, [isCreate, preferredBoardId]);
// 新建悬赏:拉取余额,便于在积分输入旁提前提示不足
useEffect(() => {
if (!isCreate || postType !== "bounty") {
setPointsBalance(null);
return;
}
let cancelled = false;
apiGetPoints()
.then((res) => {
if (cancelled) return;
const n = Number(res.points);
setPointsBalance(Number.isFinite(n) ? Math.max(0, Math.floor(n)) : null);
})
.catch(() => {
if (!cancelled) setPointsBalance(null);
});
return () => {
cancelled = true;
};
}, [isCreate, postType]);
useEffect(() => { useEffect(() => {
if (!isCreate || skipDraftSave.current) return; if (!isCreate || skipDraftSave.current) return;
if (draftTimer.current) clearTimeout(draftTimer.current); if (draftTimer.current) clearTimeout(draftTimer.current);
@@ -359,6 +429,15 @@ export default function PostComposer(props: PostComposerProps) {
setTagList((prev) => prev.filter((x) => x !== t)); setTagList((prev) => prev.filter((x) => x !== t));
}; };
const buildDeadlineFields = (presetDays: number) => {
if (deadlineMode === "custom") {
const iso = localInputToRFC3339(customEndsLocal);
if (!iso) return { expire_days: presetDays };
return { ends_at: iso, expire_days: 0 };
}
return { expire_days: presetDays };
};
const buildTypeMeta = (): string => { const buildTypeMeta = (): string => {
if (postType === "discussion") return ""; if (postType === "discussion") return "";
if (postType === "question") { if (postType === "question") {
@@ -370,25 +449,33 @@ export default function PostComposer(props: PostComposerProps) {
multi: pollMulti, multi: pollMulti,
anonymous: pollAnonymous, anonymous: pollAnonymous,
closed: false, closed: false,
...buildDeadlineFields(pollExpireDays),
}); });
} }
if (postType === "bounty") { if (postType === "bounty") {
return JSON.stringify({ return JSON.stringify({
points: Math.max(1, bountyPoints || 1), points: typeof bountyPoints === "number" ? Math.max(1, Math.floor(bountyPoints)) : 1,
accepted_comment_id: 0, accepted_comment_id: 0,
escrowed: false, escrowed: false,
refunded: false, refunded: false,
expired: false, expired: false,
expire_days: bountyExpireDays, ...buildDeadlineFields(bountyExpireDays),
}); });
} }
if (postType === "lottery") { if (postType === "lottery") {
const prizes = lotteryPrizes
.map((p) => ({
name: p.name.trim().slice(0, 32),
count: typeof p.count === "number" ? Math.floor(p.count) : 0,
}))
.filter((p) => p.name.length > 0 && p.count >= 1);
return JSON.stringify({ return JSON.stringify({
slots: Math.max(1, lotterySlots || 1), prizes,
slots: prizes.reduce((s, p) => s + p.count, 0),
drawn: false, drawn: false,
winner_ids: [], winner_ids: [],
closed: false, closed: false,
expire_days: lotteryExpireDays, ...buildDeadlineFields(lotteryExpireDays),
}); });
} }
return typeMeta.trim(); return typeMeta.trim();
@@ -434,6 +521,13 @@ export default function PostComposer(props: PostComposerProps) {
await apiUpdatePostFilePrice(id, p); await apiUpdatePostFilePrice(id, p);
}; };
const bountyShort =
isCreate &&
postType === "bounty" &&
pointsBalance !== null &&
typeof bountyPoints === "number" &&
bountyPoints > pointsBalance;
const canSubmit = const canSubmit =
!loading && !loading &&
title.trim().length > 0 && title.trim().length > 0 &&
@@ -442,11 +536,65 @@ export default function PostComposer(props: PostComposerProps) {
(contentAccess !== "points" || accessPoints > 0) && (contentAccess !== "points" || accessPoints > 0) &&
(postType !== "poll" || (postType !== "poll" ||
pollOptions.map((o) => o.trim()).filter(Boolean).length >= 2) && pollOptions.map((o) => o.trim()).filter(Boolean).length >= 2) &&
(postType !== "bounty" || bountyPoints > 0) && (postType !== "bounty" ||
(postType !== "lottery" || lotterySlots > 0); (typeof bountyPoints === "number" && bountyPoints > 0)) &&
(postType !== "lottery" || lotteryPrizesReady);
/** 提交失败:底部操作区可见 + Toast;积分类滚到悬赏输入 */
const reportSubmitError = useCallback((msg: string, focus: "actions" | "bounty" = "actions") => {
pendingErrorFocus.current = focus;
setError(msg);
toast(msg, "error");
}, []);
useEffect(() => {
if (!error) return;
const focus = pendingErrorFocus.current ?? "actions";
pendingErrorFocus.current = null;
const el =
focus === "bounty" && bountyFieldRef.current
? bountyFieldRef.current
: actionsErrorRef.current;
el?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, [error]);
const handleSubmit = async (e?: React.FormEvent) => { const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault(); e?.preventDefault();
if (
isCreate &&
deadlineMode === "custom" &&
(postType === "poll" || postType === "bounty" || postType === "lottery")
) {
const iso = localInputToRFC3339(customEndsLocal);
if (!iso) {
reportSubmitError("请选择有效的截止时间");
return;
}
const t = Date.parse(iso);
if (!Number.isFinite(t) || t < Date.now() + 60 * 60 * 1000) {
reportSubmitError("截止时间至少晚于当前 1 小时");
return;
}
}
if (postType === "lottery" && !lotteryPrizesReady) {
reportSubmitError("请填写奖品名称与数量后再发布");
return;
}
if (
isCreate &&
postType === "bounty" &&
(typeof bountyPoints !== "number" || bountyPoints < 1)
) {
reportSubmitError("请填写悬赏积分后再发布", "bounty");
return;
}
if (bountyShort) {
reportSubmitError(
`积分不足(余额 ${pointsBalance},需要 ${bountyPoints})`,
"bounty"
);
return;
}
if (!canSubmit) return; if (!canSubmit) return;
setLoading(true); setLoading(true);
setError(""); setError("");
@@ -475,7 +623,11 @@ export default function PostComposer(props: PostComposerProps) {
} }
router.push(`/post/${res.post.id}`); router.push(`/post/${res.post.id}`);
} else { } else {
setError(mapPostError(res.error || "发帖失败")); const mapped = mapPostError(res.error || "发帖失败");
reportSubmitError(
mapped,
mapped.includes("积分") ? "bounty" : "actions"
);
} }
} else { } else {
const res = await apiUpdatePost(String(props.postId), { const res = await apiUpdatePost(String(props.postId), {
@@ -491,20 +643,56 @@ export default function PostComposer(props: PostComposerProps) {
toast("已保存", "ok"); toast("已保存", "ok");
router.push(props.cancelHref); router.push(props.cancelHref);
} else { } else {
setError(mapPostError(res.error || "保存失败")); reportSubmitError(mapPostError(res.error || "保存失败"));
} }
} }
} catch { } catch {
setError("网络错误,请稍后重试"); reportSubmitError("网络错误,请稍后重试");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const cancelHref = isCreate ? props.cancelHref || "/" : props.cancelHref; const cancelHref = isCreate ? props.cancelHref || "/" : props.cancelHref;
const submitLabel = loading
? isCreate
? "发布中…"
: "保存中…"
: isCreate
? "发布"
: "保存";
const cancelControl = isCreate ? (
<Link href={cancelHref} className="btn btn-line btn-sm">
取消
</Link>
) : (
<button
type="button"
onClick={() => router.push(cancelHref)}
className="btn btn-line btn-sm"
>
取消
</button>
);
return ( return (
<form ref={formRef} onSubmit={handleSubmit} className="j13-compose-stage"> <form ref={formRef} onSubmit={handleSubmit} className="j13-compose-stage">
<header className="j13-compose-toolbar">
<h1 className="j13-compose-toolbar-title">
{isCreate ? "写新帖" : "编辑帖子"}
</h1>
<div className="j13-compose-toolbar-actions">
{cancelControl}
<button
type="submit"
disabled={!canSubmit}
className="btn btn-primary btn-sm min-w-[4.5rem]"
>
{submitLabel}
</button>
</div>
</header>
<div className="j13-compose-body"> <div className="j13-compose-body">
{draftBanner && ( {draftBanner && (
<div <div
@@ -533,7 +721,9 @@ export default function PostComposer(props: PostComposerProps) {
</div> </div>
)} )}
{error && <div className="alert-error shrink-0">{error}</div>} {/* 基本信息:类型 / 板块 / 标签 / 可见性 */}
<section className="j13-compose-section" aria-label="基本信息">
<h2 className="j13-compose-section-title">基本信息</h2>
{/* 帖子类型 */} {/* 帖子类型 */}
<div className="shrink-0"> <div className="shrink-0">
@@ -666,7 +856,12 @@ export default function PostComposer(props: PostComposerProps) {
</div> </div>
</div> </div>
{/* 类型专属面板 */} </section>
{/* 类型扩展(投票 / 悬赏 / 抽奖) */}
{(postType === "poll" || postType === "bounty" || postType === "lottery") && (
<section className="j13-compose-section" aria-label="类型设置">
<h2 className="j13-compose-section-title">类型设置</h2>
{postType === "poll" && ( {postType === "poll" && (
<div className="j13-compose-typepanel shrink-0 space-y-3"> <div className="j13-compose-typepanel shrink-0 space-y-3">
<p className="font-semibold text-[14px]" style={{ color: "var(--ink)" }}> <p className="font-semibold text-[14px]" style={{ color: "var(--ink)" }}>
@@ -732,6 +927,19 @@ export default function PostComposer(props: PostComposerProps) {
{!pollAnonymous && ( {!pollAnonymous && (
<p className="meta text-[12px]">非匿名:投票结果公开后可看到每位投票人</p> <p className="meta text-[12px]">非匿名:投票结果公开后可看到每位投票人</p>
)} )}
{isCreate && (
<DeadlinePicker
presets={POLL_EXPIRE_DAYS}
mode={deadlineMode}
onModeChange={setDeadlineMode}
days={pollExpireDays}
onDaysChange={setPollExpireDays}
customLocal={customEndsLocal}
onCustomLocalChange={setCustomEndsLocal}
disabled={loading}
hint="到期后自动结束投票;也可由作者提前结束"
/>
)}
</div> </div>
)} )}
{postType === "bounty" && ( {postType === "bounty" && (
@@ -740,38 +948,117 @@ export default function PostComposer(props: PostComposerProps) {
悬赏积分 悬赏积分
</p> </p>
<p className="meta text-[12.5px]"> <p className="meta text-[12.5px]">
发布时从你的积分余额托管;到期未采纳将自动退回。采纳回答后发给对方。 发布时从余额托管;到期未采纳自动退回。截止后仍可采纳,将从余额扣款支付且不可撤回。
</p> </p>
<div className="flex flex-wrap items-center gap-2"> <div ref={bountyFieldRef} className="j13-lottery-prize-table-wrap">
<table className="j13-lottery-prize-table">
<thead>
<tr>
<th scope="col">悬赏积分</th>
<th scope="col" className="col-count">
余额
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<input <input
type="number" type="number"
min={1} min={1}
max={100000} max={100000}
value={bountyPoints} value={bountyPoints}
placeholder="如:20"
disabled={loading || !isCreate} disabled={loading || !isCreate}
onChange={(e) => setBountyPoints(Number(e.target.value) || 0)} aria-invalid={bountyShort || undefined}
className="j13-compose-field w-36" aria-label="悬赏积分"
required={postType === "bounty"}
onChange={(e) => {
const raw = e.target.value;
if (error.includes("积分")) setError("");
if (raw === "") {
setBountyPoints("");
return;
}
const n = Math.max(
1,
Math.min(100000, Number(raw) || 1)
);
setBountyPoints(n);
}}
className={`j13-compose-field${bountyShort ? " j13-compose-field-invalid" : ""}`}
/> />
<span className="meta text-[13px]">积分</span> </td>
{!isCreate && ( <td className="col-count">
<span className="meta text-[12.5px]">编辑不可更改已托管金额</span> {isCreate ? (
<span
className="meta text-[13px] tabular-nums"
style={
bountyShort
? { color: "var(--danger)", fontWeight: 600 }
: undefined
}
>
{pointsBalance === null
? "—"
: pointsBalance.toLocaleString("zh-CN")}
</span>
) : (
<span className="meta text-[12px]">已托管</span>
)} )}
</td>
</tr>
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
{bountyShort ? (
<p
className="meta text-[12.5px] m-0"
style={{ color: "var(--danger)" }}
role="status"
>
积分不足,请下调悬赏金额后再发布
</p>
) : isCreate && typeof bountyPoints !== "number" ? (
<p
className="meta text-[12.5px] m-0"
style={{ color: "var(--danger)" }}
>
请填写悬赏积分,否则无法发布
</p>
) : !isCreate ? (
<p className="meta text-[12.5px] m-0">编辑不可更改已托管金额</p>
) : (
<span />
)}
<span
className="meta text-[12.5px] tabular-nums ml-auto"
style={
bountyShort
? { color: "var(--danger)", fontWeight: 600 }
: undefined
}
>
{typeof bountyPoints === "number"
? `托管 ${bountyPoints.toLocaleString("zh-CN")} 积分`
: "托管 —"}
{isCreate && pointsBalance !== null
? ` · 余额 ${pointsBalance.toLocaleString("zh-CN")}`
: ""}
</span>
</div> </div>
{isCreate && ( {isCreate && (
<div className="flex flex-wrap items-center gap-2"> <DeadlinePicker
<span className="meta text-[13px]">过期天数</span> presets={BOUNTY_EXPIRE_DAYS}
{BOUNTY_EXPIRE_DAYS.map((d) => ( mode={deadlineMode}
<button onModeChange={setDeadlineMode}
key={d} days={bountyExpireDays}
type="button" onDaysChange={setBountyExpireDays}
className={`btn btn-sm ${bountyExpireDays === d ? "btn-primary" : "btn-line"}`} customLocal={customEndsLocal}
onCustomLocalChange={setCustomEndsLocal}
disabled={loading} disabled={loading}
onClick={() => setBountyExpireDays(d)} />
>
{d} 天
</button>
))}
</div>
)} )}
</div> </div>
)} )}
@@ -781,52 +1068,156 @@ export default function PostComposer(props: PostComposerProps) {
抽奖设置 抽奖设置
</p> </p>
<p className="meta text-[12.5px]"> <p className="meta text-[12.5px]">
对本帖发表评论的用户自动进入奖池(楼主除外);截止时可自动开奖。 填写奖品名称与数量,中奖名额自动合计。有评论后不可再改奖品。
</p> </p>
<div className="flex items-center gap-2"> {!canEditLotteryPrizes && (
<label className="meta text-[13px]" htmlFor="lottery-slots"> <p className="meta text-[12.5px]" style={{ color: "var(--gold)" }}>
中奖名额 已有评论,奖品设置已锁定
</label> </p>
)}
<div className="j13-lottery-prize-table-wrap">
<table className="j13-lottery-prize-table">
<thead>
<tr>
<th scope="col" className="col-idx">
#
</th>
<th scope="col">奖品名称</th>
<th scope="col" className="col-count">
数量
</th>
{canEditLotteryPrizes && (
<th scope="col" className="col-actions">
操作
</th>
)}
</tr>
</thead>
<tbody>
{lotteryPrizes.map((p, idx) => (
<tr key={idx}>
<td className="col-idx">{idx + 1}</td>
<td>
<input
type="text"
value={p.name}
maxLength={32}
placeholder="如:一等奖 / 周边礼包"
disabled={loading || !canEditLotteryPrizes}
onChange={(e) => {
const v = e.target.value.slice(0, 32);
setLotteryPrizes((prev) =>
prev.map((row, i) => (i === idx ? { ...row, name: v } : row))
);
}}
className="j13-compose-field"
aria-label={`奖品 ${idx + 1} 名称`}
required={postType === "lottery"}
/>
</td>
<td className="col-count">
<input <input
id="lottery-slots"
type="number" type="number"
min={1} min={1}
max={100} max={100}
value={lotterySlots} value={p.count}
disabled={loading || !isCreate} placeholder="数量"
onChange={(e) => setLotterySlots(Number(e.target.value) || 1)} disabled={loading || !canEditLotteryPrizes}
className="j13-compose-field w-28" onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
setLotteryPrizes((prev) =>
prev.map((row, i) => (i === idx ? { ...row, count: "" } : row))
);
return;
}
const n = Math.max(1, Math.min(100, Number(raw) || 1));
setLotteryPrizes((prev) =>
prev.map((row, i) => (i === idx ? { ...row, count: n } : row))
);
}}
className="j13-compose-field j13-compose-field-count"
aria-label={`奖品 ${idx + 1} 数量`}
required={postType === "lottery"}
/> />
</div> </td>
{isCreate && ( {canEditLotteryPrizes && (
<div className="flex flex-wrap items-center gap-2"> <td className="col-actions">
<span className="meta text-[13px]">截止天数</span> {lotteryPrizes.length > 1 ? (
{LOTTERY_EXPIRE_DAYS.map((d) => (
<button <button
key={d}
type="button" type="button"
className={`btn btn-sm ${lotteryExpireDays === d ? "btn-primary" : "btn-line"}`} className="btn btn-line btn-sm"
disabled={loading} disabled={loading}
onClick={() => setLotteryExpireDays(d)} onClick={() =>
setLotteryPrizes((prev) => prev.filter((_, i) => i !== idx))
}
> >
{d} 天 删除
</button> </button>
) : (
<span className="meta text-[12px]">—</span>
)}
</td>
)}
</tr>
))} ))}
</tbody>
</table>
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-2">
{canEditLotteryPrizes && lotteryPrizes.length < 10 ? (
<button
type="button"
className="btn btn-line btn-sm"
disabled={loading}
onClick={() =>
setLotteryPrizes((prev) => [...prev, { name: "", count: "" }])
}
>
添加一行
</button>
) : (
<span />
)}
<span className="meta text-[12.5px] tabular-nums">
合计名额 {lotteryPrizesReady ? lotterySlotTotal : "—"}
</span>
</div>
{canEditLotteryPrizes && !lotteryPrizesReady && (
<p className="meta text-[12.5px]" style={{ color: "var(--danger)" }}>
请填写每一行的奖品名称与数量,否则无法发布
</p>
)}
{isCreate && (
<DeadlinePicker
presets={LOTTERY_EXPIRE_DAYS}
mode={deadlineMode}
onModeChange={setDeadlineMode}
days={lotteryExpireDays}
onDaysChange={setLotteryExpireDays}
customLocal={customEndsLocal}
onCustomLocalChange={setCustomEndsLocal}
disabled={loading}
hint="到期后自动开奖"
/>
)} )}
</div> </div>
)} )}
</section>
)}
{/* 大标题 */} {/* 标题与正文 */}
<div className="shrink-0"> <section className="j13-compose-section j13-compose-section-writing" aria-label="标题与正文">
<div className="flex items-end justify-between gap-3 mb-1"> <div className="flex items-center justify-between gap-3">
<label className="sr-only" htmlFor="compose-title"> <h2 className="j13-compose-section-title !mb-0">标题与正文</h2>
标题 <span className="meta text-[12px] tabular-nums">
</label>
<span className="meta text-[12px] tabular-nums ml-auto">
{title.length}/{TITLE_MAX} {title.length}/{TITLE_MAX}
</span> </span>
</div> </div>
<div className="shrink-0">
<label className="sr-only" htmlFor="compose-title">
标题
</label>
<input <input
id="compose-title" id="compose-title"
type="text" type="text"
@@ -839,8 +1230,6 @@ export default function PostComposer(props: PostComposerProps) {
disabled={loading} disabled={loading}
/> />
</div> </div>
{/* 正文 */}
<div className="j13-compose-canvas"> <div className="j13-compose-canvas">
<label className="sr-only" htmlFor="compose-content"> <label className="sr-only" htmlFor="compose-content">
正文 正文
@@ -859,14 +1248,17 @@ export default function PostComposer(props: PostComposerProps) {
}} }}
/> />
</div> </div>
</section>
{/* 附件 */} {/* 附件 */}
<section className="j13-compose-section" aria-label="附件">
<h2 className="j13-compose-section-title">附件</h2>
<div className="j13-compose-attach shrink-0"> <div className="j13-compose-attach shrink-0">
<div className="flex items-center justify-between gap-3 mb-2"> <div className="flex items-center justify-between gap-3 mb-2">
<div className="field-label !mb-0">附件</div> <p className="meta text-[12.5px] !mb-0">单文件 ≤20MB,最多 10 个;可设免费或积分下载</p>
<button <button
type="button" type="button"
className="btn btn-line btn-sm inline-flex items-center gap-1.5" className="btn btn-line btn-sm inline-flex items-center gap-1.5 shrink-0"
disabled={loading || fileBusy || attachments.length >= 10} disabled={loading || fileBusy || attachments.length >= 10}
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
> >
@@ -880,9 +1272,6 @@ export default function PostComposer(props: PostComposerProps) {
onChange={(e) => void onPickFile(e.target.files?.[0] ?? null)} onChange={(e) => void onPickFile(e.target.files?.[0] ?? null)}
/> />
</div> </div>
<p className="meta text-[12.5px] mb-2">
单文件 ≤20MB,最多 10 个;可设免费或积分下载
</p>
{attachments.length === 0 ? ( {attachments.length === 0 ? (
<p className="meta text-[13px] py-2">暂无附件</p> <p className="meta text-[13px] py-2">暂无附件</p>
) : ( ) : (
@@ -921,22 +1310,26 @@ export default function PostComposer(props: PostComposerProps) {
</ul> </ul>
)} )}
</div> </div>
</section>
</div> </div>
<div className="j13-compose-actions"> <div className="j13-compose-actions">
<button {error ? (
type="submit" <div
disabled={!canSubmit} ref={actionsErrorRef}
className="btn btn-primary min-w-[8.5rem] !px-7 !py-3 !text-[15px]" className="alert-error j13-compose-actions-error"
role="alert"
> >
{loading {error}
? isCreate </div>
? "发布中…" ) : (
: "保存中…" <p className="meta text-[12.5px] max-w-md">
: isCreate {isCreate
? "发布帖子" ? "普通用户发帖需审核 · Ctrl/⌘+Enter 快捷发布"
: "保存修改"} : "保存后立即生效 · 类型不可更改"}
</button> </p>
)}
<div className="j13-compose-actions-end">
{isCreate ? ( {isCreate ? (
<Link href={cancelHref} className="btn btn-line !py-3"> <Link href={cancelHref} className="btn btn-line !py-3">
取消 取消
@@ -950,11 +1343,20 @@ export default function PostComposer(props: PostComposerProps) {
取消 取消
</button> </button>
)} )}
<span className="meta text-[12.5px] sm:ml-auto max-w-md"> <button
{isCreate type="submit"
? "普通用户发帖需审核 · Ctrl/⌘+Enter 提交 · Esc 退出全屏" disabled={!canSubmit}
: "保存后立即生效 · 类型不可更改"} className="btn btn-primary min-w-[8.5rem] !px-7 !py-3 !text-[15px]"
</span> >
{loading
? isCreate
? "发布中…"
: "保存中…"
: isCreate
? "发布帖子"
: "保存修改"}
</button>
</div>
</div> </div>
</form> </form>
); );

View File

@@ -1,13 +1,12 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Gift, Trophy, CheckCircle2, Timer, BarChart3, Coins, CircleHelp } from "lucide-react";
import { import {
apiVotePoll, apiVotePoll,
apiClosePoll, apiClosePoll,
apiRefundBounty,
apiCloseLottery,
apiDrawLottery, apiDrawLottery,
apiSolveQuestion, apiSolveQuestion,
apiReopenQuestion, apiReopenQuestion,
@@ -32,12 +31,13 @@ export default function PostInteractPanels({
lottery?: LotteryState | null; lottery?: LotteryState | null;
loggedIn: boolean; loggedIn: boolean;
}) { }) {
// 问答状态改在正文下方展示(未解决 / 已解决资料卡),此处不再放 // 问答 / 已采纳悬赏改在正文下方 AcceptedAnswerCard,此处不再放
if (!poll && !bounty && !lottery) return null; const showBounty = !!bounty && !bounty.accepted_comment_id;
if (!poll && !showBounty && !lottery) return null;
return ( return (
<div className="j13-interact-below space-y-4"> <div className="j13-interact-below space-y-4">
{poll && <PollPanel postId={postId} state={poll} loggedIn={loggedIn} />} {poll && <PollPanel postId={postId} state={poll} loggedIn={loggedIn} />}
{bounty && <BountyPanel postId={postId} state={bounty} />} {showBounty && <BountyPanel postId={postId} state={bounty!} />}
{lottery && <LotteryPanel postId={postId} state={lottery} />} {lottery && <LotteryPanel postId={postId} state={lottery} />}
</div> </div>
); );
@@ -52,6 +52,129 @@ function formatEndsAt(iso?: string) {
} }
} }
/** 底栏右侧:进行中显示截止时间,结束后显示结束时间 */
function TypedFootTime({
endsAt,
endedAt,
ended,
}: {
endsAt?: string;
endedAt?: string;
ended?: boolean;
}) {
if (ended) {
const t = endedAt || endsAt;
if (!t) return null;
return <span className="j13-typed-foot-time">结束时间: {formatEndsAt(t)}</span>;
}
if (!endsAt) return null;
return <span className="j13-typed-foot-time">截止时间: {formatEndsAt(endsAt)}</span>;
}
function pad2(n: number) {
return String(Math.max(0, n)).padStart(2, "0");
}
function remainParts(endsAtMs: number, now: number) {
const ms = Math.max(0, endsAtMs - now);
const totalSec = Math.floor(ms / 1000);
return {
days: Math.floor(totalSec / 86400),
hours: Math.floor((totalSec % 86400) / 3600),
mins: Math.floor((totalSec % 3600) / 60),
secs: totalSec % 60,
expired: ms <= 0,
};
}
/** 未截止倒计时:最小单位秒,每秒刷新 */
function DeadlineCountdown({
endsAt,
activeLabel = "距截止还剩",
expiredLabel = "已到截止时间",
expiredHint = "等待处理",
}: {
endsAt: string;
activeLabel?: string;
expiredLabel?: string;
expiredHint?: string;
}) {
const endsMs = Date.parse(endsAt);
const [now, setNow] = useState<number | null>(null);
useEffect(() => {
if (!Number.isFinite(endsMs)) return;
const tick = () => setNow(Date.now());
tick();
const id = window.setInterval(tick, 1000);
return () => window.clearInterval(id);
}, [endsMs]);
if (!Number.isFinite(endsMs)) return null;
if (now === null) {
return (
<div className="j13-deadline-countdown" aria-hidden="true">
<div className="j13-deadline-countdown-label">
<Timer size={15} strokeWidth={2.25} aria-hidden />
{activeLabel}
</div>
<div className="j13-deadline-countdown-digits">
{["天", "时", "分", "秒"].map((suf) => (
<span key={suf} className={`j13-deadline-countdown-unit${suf === "秒" ? " is-sec" : ""}`}>
<span className="j13-deadline-countdown-num tabular-nums">--</span>
<span className="j13-deadline-countdown-suf">{suf}</span>
</span>
))}
</div>
<p className="j13-deadline-countdown-ends">截止于 {formatEndsAt(endsAt)}</p>
</div>
);
}
const r = remainParts(endsMs, now);
return (
<div
className={`j13-deadline-countdown${r.expired ? " is-expired" : ""}`}
role="timer"
aria-live="polite"
aria-atomic="true"
>
<div className="j13-deadline-countdown-label">
<Timer size={15} strokeWidth={2.25} aria-hidden />
{r.expired ? expiredLabel : activeLabel}
</div>
{r.expired ? (
<p className="j13-deadline-countdown-expired">{expiredHint}</p>
) : (
<div
className="j13-deadline-countdown-digits"
aria-label={`还剩 ${r.days} 天 ${r.hours} 时 ${r.mins} 分 ${r.secs} 秒`}
>
<span className="j13-deadline-countdown-unit">
<span className="j13-deadline-countdown-num tabular-nums">{pad2(r.days)}</span>
<span className="j13-deadline-countdown-suf">天</span>
</span>
<span className="j13-deadline-countdown-unit">
<span className="j13-deadline-countdown-num tabular-nums">{pad2(r.hours)}</span>
<span className="j13-deadline-countdown-suf">时</span>
</span>
<span className="j13-deadline-countdown-unit">
<span className="j13-deadline-countdown-num tabular-nums">{pad2(r.mins)}</span>
<span className="j13-deadline-countdown-suf">分</span>
</span>
<span className="j13-deadline-countdown-unit is-sec">
<span className="j13-deadline-countdown-num tabular-nums">{pad2(r.secs)}</span>
<span className="j13-deadline-countdown-suf">秒</span>
</span>
</div>
)}
<p className="j13-deadline-countdown-ends">截止于 {formatEndsAt(endsAt)}</p>
</div>
);
}
/** 正文下方的问答状态卡(未解决 / 仅标记已解决);有采纳答案时改用 AcceptedAnswerCard */ /** 正文下方的问答状态卡(未解决 / 仅标记已解决);有采纳答案时改用 AcceptedAnswerCard */
export function QuestionStatusCard({ postId, state }: { postId: number; state: QuestionState }) { export function QuestionStatusCard({ postId, state }: { postId: number; state: QuestionState }) {
const router = useRouter(); const router = useRouter();
@@ -70,36 +193,28 @@ export function QuestionStatusCard({ postId, state }: { postId: number; state: Q
} }
}; };
return ( const statusBadge = state.solved
<section ? { cls: "badge-status badge-status-solved", label: "已解决" }
className={`j13-interact-card j13-question-below${state.solved ? " is-solved" : " is-unsolved"}`} : { cls: "badge-status badge-status-unsolved", label: "未解决" };
aria-label={`问答 · ${state.solved ? "已解决" : "未解决"}`}
> const footLeft = state.solved
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-[15px] font-medium" style={{ color: "var(--ink)" }}>
问答 · {state.solved ? "已解决" : "未解决"}
</h2>
<p className="meta text-[12.5px] mt-1">
{state.solved
? state.accepted_comment_id ? state.accepted_comment_id
? ( ? "已采纳答案"
<> : "已标记解决"
已采纳{" "} : "等待解答";
<a
href={`#comment-${state.accepted_comment_id}`} return (
className="hover:underline" <section className="j13-interact-card j13-typed-card" aria-label={`问答 · ${statusBadge.label}`}>
style={{ color: "var(--accent)" }} <div className="j13-typed-head">
> <div className="j13-typed-head-main">
#{state.accepted_floor || "?"} <h2 className="j13-typed-title is-question">
</a> <CircleHelp size={16} strokeWidth={2.25} aria-hidden />
</> 问答
) </h2>
: "作者已标记为已解决" <span className={statusBadge.cls}>{statusBadge.label}</span>
: "可在评论旁采纳答案,或手动标记已解决"}
</p>
</div> </div>
<div className="flex flex-wrap gap-2"> {(state.can_solve || state.can_reopen) && (
<div className="j13-typed-actions">
{state.can_solve && ( {state.can_solve && (
<button <button
type="button" type="button"
@@ -121,6 +236,37 @@ export function QuestionStatusCard({ postId, state }: { postId: number; state: Q
</button> </button>
)} )}
</div> </div>
)}
</div>
<p className="j13-typed-meta">
{state.solved
? state.accepted_comment_id
? (
<>
已采纳{" "}
<a
href={`#comment-${state.accepted_comment_id}`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
#{state.accepted_floor || "?"}
</a>
</>
)
: "作者已标记为已解决"
: "可在评论旁采纳答案,或手动标记已解决"}
</p>
<div className="j13-typed-foot">
<span className="j13-typed-foot-status">
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
{footLeft}
</span>
<TypedFootTime
ended={state.solved}
endedAt={state.solved_at || state.accepted_answer?.created_at}
/>
</div> </div>
</section> </section>
); );
@@ -139,6 +285,7 @@ function PollPanel({
const [sel, setSel] = useState<number[]>(state.my_options || []); const [sel, setSel] = useState<number[]>(state.my_options || []);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const showResults = state.closed || (state.my_options && state.my_options.length > 0); const showResults = state.closed || (state.my_options && state.my_options.length > 0);
const hasVoted = (state.my_options?.length ?? 0) > 0;
const toggle = (idx: number) => { const toggle = (idx: number) => {
if (!state.can_vote) return; if (!state.can_vote) return;
@@ -183,19 +330,56 @@ function PollPanel({
} }
}; };
const statusBadge = state.closed
? { cls: "badge-status badge-status-closed", label: "已结束" }
: { cls: "badge-status badge-status-open", label: "进行中" };
const footLeft = state.closed
? "已结束投票"
: hasVoted
? "你已完成投票"
: "投票进行中";
return ( return (
<section className="j13-interact-card"> <section className="j13-interact-card j13-typed-card" aria-label="投票">
<div className="flex items-center justify-between gap-3 mb-3"> <div className="j13-typed-head">
<h2 className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}> <div className="j13-typed-head-main">
投票{state.multi ? "(多选)" : "(单选)"} <h2 className="j13-typed-title is-poll">
{state.anonymous ? " · 匿名" : " · 实名"} <BarChart3 size={16} strokeWidth={2.25} aria-hidden />
投票
</h2> </h2>
<span className="meta text-[12.5px]"> <span className={statusBadge.cls}>{statusBadge.label}</span>
{state.voters} 人参与 · {state.total_votes} 票
{state.closed ? " · 已结束" : ""}
</span>
</div> </div>
<ul className="space-y-2"> {state.can_close && (
<div className="j13-typed-actions">
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => void close()}
>
结束投票
</button>
</div>
)}
</div>
<p className="j13-typed-meta">
{state.multi ? "多选" : "单选"}
{state.anonymous ? " · 匿名" : " · 实名"}
{" · "}
{state.voters} 人参与 · {state.total_votes} 票
</p>
{!state.closed && state.ends_at ? (
<DeadlineCountdown
endsAt={state.ends_at}
expiredLabel="已到截止时间"
expiredHint="投票即将关闭"
/>
) : null}
<ul className="j13-poll-options">
{state.options.map((o) => { {state.options.map((o) => {
const checked = sel.includes(o.index); const checked = sel.includes(o.index);
const namedVoters = !state.anonymous && showResults ? o.voters || [] : []; const namedVoters = !state.anonymous && showResults ? o.voters || [] : [];
@@ -205,43 +389,32 @@ function PollPanel({
type="button" type="button"
disabled={!state.can_vote || busy} disabled={!state.can_vote || busy}
onClick={() => toggle(o.index)} onClick={() => toggle(o.index)}
className="w-full text-left rounded-[12px] px-3 py-2.5 relative overflow-hidden transition-colors" className={`j13-poll-option${checked ? " is-on" : ""}`}
style={{
border: `1px solid ${checked ? "var(--accent)" : "var(--line)"}`,
background: checked ? "var(--accent-soft)" : "var(--panel)",
}}
> >
{showResults && ( {showResults && (
<span <span
className="absolute inset-y-0 left-0 opacity-30" className="j13-poll-option-bar"
style={{ style={{ width: `${o.percent}%` }}
width: `${o.percent}%`,
background: "var(--accent)",
}}
aria-hidden aria-hidden
/> />
)} )}
<span className="relative flex items-center justify-between gap-3"> <span className="j13-poll-option-row">
<span className="text-[14px]" style={{ color: "var(--ink)" }}> <span className="j13-poll-option-text">
{state.can_vote ? (checked ? "✓ " : "") : ""} {state.can_vote ? (checked ? "✓ " : "") : ""}
{o.text} {o.text}
</span> </span>
{showResults && ( {showResults && (
<span className="meta tabular-nums text-[12.5px] shrink-0"> <span className="j13-poll-option-stat tabular-nums">
{o.votes} · {o.percent}% {o.votes} · {o.percent}%
</span> </span>
)} )}
</span> </span>
</button> </button>
{namedVoters.length > 0 && ( {namedVoters.length > 0 && (
<ul className="mt-1.5 flex flex-wrap gap-1.5 px-1"> <ul className="j13-poll-voters">
{namedVoters.map((v) => ( {namedVoters.map((v) => (
<li key={v.id}> <li key={v.id}>
<Link <Link href={`/u/${v.id}`} className="j13-poll-voter">
href={`/u/${v.id}`}
className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[12px]"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Avatar name={v.nickname || v.username} src={v.avatar || undefined} size={16} /> <Avatar name={v.nickname || v.username} src={v.avatar || undefined} size={16} />
{v.nickname || v.username} {v.nickname || v.username}
</Link> </Link>
@@ -253,79 +426,100 @@ function PollPanel({
); );
})} })}
</ul> </ul>
<div className="mt-3 flex flex-wrap gap-2">
{(state.can_vote || (!loggedIn && !state.closed)) && (
<div className="j13-typed-actions-row">
{state.can_vote && ( {state.can_vote && (
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => void vote()}> <button
type="button"
className="btn btn-primary btn-sm"
disabled={busy}
onClick={() => void vote()}
>
提交投票 提交投票
</button> </button>
)} )}
{!loggedIn && !state.closed && ( {!loggedIn && !state.closed && (
<Link href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`} className="btn btn-line btn-sm"> <Link
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`}
className="btn btn-line btn-sm"
>
登录后投票 登录后投票
</Link> </Link>
)} )}
{state.can_close && ( </div>
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => void close()}
>
结束投票
</button>
)} )}
<div className="j13-typed-foot">
<span className="j13-typed-foot-status">
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
{footLeft}
</span>
<TypedFootTime
ended={state.closed}
endsAt={state.ends_at}
endedAt={state.closed_at}
/>
</div> </div>
</section> </section>
); );
} }
function BountyPanel({ postId, state }: { postId: number; state: BountyState }) { function BountyPanel({ state }: { postId: number; state: BountyState }) {
const router = useRouter(); const statusBadge = state.accepted_comment_id
const [busy, setBusy] = useState(false); ? { cls: "badge-status badge-status-solved", label: "已采纳" }
const [confirming, setConfirming] = useState(false); : state.expired || state.refunded
? { cls: "badge-status badge-status-closed", label: "已过期" }
: { cls: "badge-status badge-status-open", label: "进行中" };
const refund = async () => { const footLeft = state.accepted_comment_id
if (!confirming) { ? "悬赏已完成"
setConfirming(true); : state.expired || state.refunded
window.setTimeout(() => setConfirming(false), 3000); ? state.can_accept
return; ? "已过期退回 · 仍可采纳支付"
} : "悬赏已过期退回"
setBusy(true); : "悬赏进行中";
try {
const res = await apiRefundBounty(String(postId));
if (res.post) {
toast("悬赏已退回", "ok");
router.refresh();
} else toast(res.error || "退回失败", "error");
} finally {
setBusy(false);
setConfirming(false);
}
};
const statusText = state.expired const bodyHint = state.accepted_answer
? "已过期,积分已自动退回" ? "悬赏积分已发放给采纳答案"
: state.refunded
? "已退回给发帖人"
: state.accepted_comment_id : state.accepted_comment_id
? null ? null
: state.expired || state.refunded
? state.can_accept
? "到期未采纳已自动退回;采纳将从余额扣除,支付后不可撤回"
: "积分已自动退回发帖人"
: state.escrowed : state.escrowed
? "积分已托管,采纳优质回答后发放" ? "积分已托管,采纳优质回答后发放;到期未采纳自动退回"
: "悬赏未托管"; : "悬赏未托管";
return ( return (
<section className="j13-interact-card"> <section className="j13-interact-card j13-typed-card" aria-label="悬赏">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="j13-typed-head">
<div> <div className="j13-typed-head-main">
<h2 className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}> <h2 className="j13-typed-title is-bounty">
悬赏 {state.points} 积分 <Coins size={16} strokeWidth={2.25} aria-hidden />
悬赏
</h2> </h2>
<p className="meta text-[12.5px] mt-1"> <span className={statusBadge.cls}>{statusBadge.label}</span>
{state.accepted_answer </div>
? "悬赏积分已发放给采纳答案" </div>
: state.accepted_comment_id
? ( <p className="j13-typed-meta">
<> <span className="j13-bounty-points tabular-nums">{state.points} 积分</span>
{bodyHint ? ` · ${bodyHint}` : null}
{state.can_accept ? " · 在评论旁点击「采纳」发放积分" : null}
</p>
{!state.accepted_comment_id && state.ends_at ? (
<DeadlineCountdown
endsAt={state.ends_at}
expiredLabel="已到截止时间"
expiredHint={state.can_accept ? "已自动退回,仍可采纳并支付" : "等待处理"}
/>
) : null}
{state.accepted_comment_id > 0 && (
<p className="j13-typed-meta">
已采纳{" "} 已采纳{" "}
<a <a
href={`#comment-${state.accepted_comment_id}`} href={`#comment-${state.accepted_comment_id}`}
@@ -334,25 +528,19 @@ function BountyPanel({ postId, state }: { postId: number; state: BountyState })
> >
#{state.accepted_floor || "?"} #{state.accepted_floor || "?"}
</a> </a>
</>
)
: statusText}
{state.ends_at && !state.settled ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""}
</p> </p>
</div>
{state.can_refund && (
<button
type="button"
className={`btn btn-sm ${confirming ? "btn-primary" : "btn-line"}`}
disabled={busy}
onClick={() => void refund()}
>
{confirming ? "再点确认退回" : "退回悬赏"}
</button>
)}
{state.can_accept && (
<p className="meta text-[12.5px]">在评论旁点击「采纳」发放积分</p>
)} )}
<div className="j13-typed-foot">
<span className="j13-typed-foot-status">
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
{footLeft}
</span>
<TypedFootTime
ended={!!state.accepted_comment_id || state.expired || state.refunded}
endsAt={state.ends_at}
endedAt={state.ends_at}
/>
</div> </div>
</section> </section>
); );
@@ -381,35 +569,28 @@ function LotteryPanel({
} }
}; };
const prizes = state.prizes?.length
? state.prizes
: state.slots > 0
? [{ name: "奖品", count: state.slots }]
: [];
const statusBadge = state.drawn
? { cls: "badge-status badge-status-closed", label: "已开奖" }
: { cls: "badge-status badge-status-open", label: "进行中" };
return ( return (
<section className="j13-interact-card"> <section className="j13-interact-card j13-typed-card" aria-label="抽奖">
<div className="flex flex-wrap items-center justify-between gap-3 mb-3"> <div className="j13-typed-head">
<div> <div className="j13-typed-head-main">
<h2 className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}> <h2 className="j13-typed-title is-lottery">
抽奖 · {state.slots} 个名额 <Gift size={16} strokeWidth={2.25} aria-hidden />
抽奖
</h2> </h2>
<p className="meta text-[12.5px] mt-1"> <span className={statusBadge.cls}>{statusBadge.label}</span>
已有 {state.entry_count} 名回帖用户参与
{state.drawn ? " · 已开奖" : state.closed ? " · 已截止" : ""}
{state.eligible && !state.drawn ? " · 你已在奖池中" : ""}
{state.ends_at && !state.drawn ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""}
</p>
{!state.drawn && (
<p className="meta text-[12px] mt-1">对本帖发表评论即可参与(楼主除外)</p>
)}
</div> </div>
<div className="flex flex-wrap gap-2">
{state.can_close && (
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => void act(() => apiCloseLottery(String(postId)), "已截止")}
>
提前截止
</button>
)}
{state.can_draw && ( {state.can_draw && (
<div className="j13-typed-actions">
<button <button
type="button" type="button"
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
@@ -418,38 +599,83 @@ function LotteryPanel({
> >
开奖 开奖
</button> </button>
</div>
)} )}
</div> </div>
</div>
{state.drawn && ( {(state.can_draw || (state.eligible && !state.drawn)) && (
<div> <p className="j13-typed-meta">
<p className="font-semibold text-[13px] mb-2" style={{ color: "var(--ink)" }}> {state.can_draw ? `已有 ${state.entry_count} 名主评用户入池` : null}
{state.winners.length > 0 ? "中奖名单" : "无人回帖,开奖结果为空"} {state.can_draw && state.eligible && !state.drawn ? " · " : null}
{state.eligible && !state.drawn ? "你已在奖池中" : null}
</p> </p>
{state.winners.length > 0 && ( )}
<ul className="flex flex-wrap gap-3">
{state.winners.map((w) => ( {!state.drawn && state.ends_at ? (
<li key={w.id}> <DeadlineCountdown
<span className="badge-winner inline-flex items-center gap-2 !py-1.5 !px-2.5 !text-[13px] !font-medium"> endsAt={state.ends_at}
<Link href={`/u/${w.id}`} className="inline-flex items-center gap-2 min-w-0"> expiredLabel="已到截止时间"
<Avatar name={w.nickname || w.username} src={w.avatar || undefined} size={22} /> expiredHint="等待开奖"
<span className="truncate">{w.nickname || w.username}</span> />
</Link>
{w.comment_id && w.floor ? (
<a
href={`#comment-${w.comment_id}`}
className="opacity-90 hover:underline shrink-0"
>
#{w.floor}
</a>
) : null} ) : null}
{prizes.length > 0 && (
<ul className="j13-lottery-prize-row" aria-label="奖品">
{prizes.map((prize, i) => (
<li key={`${prize.name}-${i}`}>
<div className="j13-lottery-prize-chip" title={`${prize.name} ×${prize.count}`}>
<Trophy size={13} strokeWidth={2.25} aria-hidden />
<span className="j13-lottery-prize-chip-text">
{prize.name}
<span className="j13-lottery-prize-chip-count">×{prize.count}</span>
</span> </span>
</div>
</li> </li>
))} ))}
</ul> </ul>
)} )}
{state.drawn && (
<>
<p className="j13-lottery-winners-label">中奖名单</p>
{state.winners.length > 0 ? (
<ul className="j13-lottery-winner-row">
{state.winners.map((w) => (
<li key={`${w.id}-${w.comment_id || 0}`}>
<div className="j13-lottery-winner">
<Avatar
name={w.nickname || w.username}
src={w.avatar || undefined}
size={36}
className="j13-lottery-winner-avatar"
/>
<div className="j13-lottery-winner-body">
<Link href={`/u/${w.id}`} className="j13-lottery-winner-name">
{w.nickname || w.username}
</Link>
{w.prize_name ? (
<span className="j13-lottery-winner-prize">{w.prize_name}</span>
) : null}
</div> </div>
</div>
</li>
))}
</ul>
) : (
<p className="j13-typed-empty">无人回帖,开奖结果为空</p>
)} )}
</>
)}
<div className="j13-typed-foot">
<span className="j13-typed-foot-status">
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
{state.drawn ? "开奖完成" : "抽奖进行中"}
</span>
{state.ends_at ? (
<span className="j13-typed-foot-time">截止时间: {formatEndsAt(state.ends_at)}</span>
) : null}
</div>
</section> </section>
); );
} }

View File

@@ -254,6 +254,7 @@ export interface QuestionState {
accepted_comment_id: number; accepted_comment_id: number;
accepted_floor: number; accepted_floor: number;
accepted_answer?: AcceptedAnswer | null; accepted_answer?: AcceptedAnswer | null;
solved_at?: string;
can_accept: boolean; can_accept: boolean;
can_solve: boolean; can_solve: boolean;
can_reopen: boolean; can_reopen: boolean;
@@ -279,6 +280,8 @@ export interface PollState {
multi: boolean; multi: boolean;
closed: boolean; closed: boolean;
anonymous: boolean; anonymous: boolean;
ends_at?: string;
closed_at?: string;
total_votes: number; total_votes: number;
voters: number; voters: number;
my_options: number[]; my_options: number[];
@@ -298,7 +301,11 @@ export interface BountyState {
ends_at?: string; ends_at?: string;
settled: boolean; settled: boolean;
can_accept: boolean; can_accept: boolean;
can_refund: boolean; }
export interface LotteryPrize {
name: string;
count: number;
} }
export interface LotteryEntrant { export interface LotteryEntrant {
@@ -309,17 +316,21 @@ export interface LotteryEntrant {
/** 中奖楼层评论 ID(该用户最早主评,每人仅一条) */ /** 中奖楼层评论 ID(该用户最早主评,每人仅一条) */
comment_id?: number; comment_id?: number;
floor?: number; floor?: number;
prize_name?: string;
} }
export interface LotteryState { export interface LotteryState {
slots: number; slots: number;
prizes: LotteryPrize[];
drawn: boolean; drawn: boolean;
closed: boolean; closed: boolean;
ends_at?: string; ends_at?: string;
entry_count: number; entry_count: number;
eligible?: boolean; eligible?: boolean;
/** 新号入池冷静期小时数(与发帖冷静期同源,0=关闭) */
cooldown_hours?: number;
can_draw: boolean; can_draw: boolean;
can_close: boolean; can_edit_prizes?: boolean;
winners: LotteryEntrant[]; winners: LotteryEntrant[];
} }
@@ -938,14 +949,6 @@ export async function apiAcceptBounty(postId: string, commentId: number) {
return res.json(); return res.json();
} }
export async function apiRefundBounty(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/bounty/refund`, {
method: "POST",
headers: clientHeaders(),
});
return res.json();
}
export async function apiAcceptQuestion(postId: string, commentId: number) { export async function apiAcceptQuestion(postId: string, commentId: number) {
const res = await fetchWithRefresh(`/api/posts/${postId}/question/accept`, { const res = await fetchWithRefresh(`/api/posts/${postId}/question/accept`, {
method: "POST", method: "POST",
@@ -971,14 +974,6 @@ export async function apiReopenQuestion(postId: string) {
return res.json(); return res.json();
} }
export async function apiCloseLottery(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/lottery/close`, {
method: "POST",
headers: clientHeaders(),
});
return res.json();
}
export async function apiDrawLottery(postId: string) { export async function apiDrawLottery(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/lottery/draw`, { const res = await fetchWithRefresh(`/api/posts/${postId}/lottery/draw`, {
method: "POST", method: "POST",

View File

@@ -5,7 +5,7 @@ export const POST_TYPES = [
{ value: "question", label: "问答", hint: "提问求解" }, { value: "question", label: "问答", hint: "提问求解" },
{ value: "poll", label: "投票", hint: "发起投票,读者可参与" }, { value: "poll", label: "投票", hint: "发起投票,读者可参与" },
{ value: "bounty", label: "悬赏", hint: "发布时托管积分,采纳后发放" }, { value: "bounty", label: "悬赏", hint: "发布时托管积分,采纳后发放" },
{ value: "lottery", label: "抽奖", hint: "回帖即参与,截止自动开奖" }, { value: "lottery", label: "抽奖", hint: "奖品类型+数量入池,评论后锁定奖品" },
] as const; ] as const;
export type PostTypeValue = (typeof POST_TYPES)[number]["value"]; export type PostTypeValue = (typeof POST_TYPES)[number]["value"];
@@ -21,6 +21,7 @@ export type ContentAccessValue = (typeof CONTENT_ACCESS)[number]["value"];
export const BOUNTY_EXPIRE_DAYS = [3, 7, 14, 30] as const; export const BOUNTY_EXPIRE_DAYS = [3, 7, 14, 30] as const;
export const LOTTERY_EXPIRE_DAYS = [1, 3, 7, 14] as const; export const LOTTERY_EXPIRE_DAYS = [1, 3, 7, 14] as const;
export const POLL_EXPIRE_DAYS = [1, 3, 7, 14] as const;
export function normalizePostType(t?: string): PostTypeValue { export function normalizePostType(t?: string): PostTypeValue {
if (!t) return "discussion"; if (!t) return "discussion";
@@ -74,12 +75,13 @@ export function typeStatusClass(postType?: string, typeStatus?: string): string
break; break;
case "bounty": case "bounty":
if (s === "solved") kind = "solved"; if (s === "solved") kind = "solved";
else if (s === "expired") kind = "expired"; // 已过期:与投票「已结束」同色(灰胶囊,标识已到期)
else if (s === "expired") kind = "closed";
else kind = "open"; else kind = "open";
break; break;
case "lottery": case "lottery":
if (s === "drawn") kind = "drawn"; // 已开奖 / 已截止:与投票「已结束」同色(灰胶囊)
else if (s === "closed") kind = "closed"; if (s === "drawn" || s === "closed") kind = "closed";
else kind = "open"; else kind = "open";
break; break;
default: default:

View File

@@ -1,7 +1,7 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
import "./.next/types/root-params.d.ts"; import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.