diff --git a/backend/cmd/jiang13/main.go b/backend/cmd/jiang13/main.go index 521685a..0bffc7e 100644 --- a/backend/cmd/jiang13/main.go +++ b/backend/cmd/jiang13/main.go @@ -25,7 +25,7 @@ func main() { // 过期/已吊销 refresh token 定期清理 service.StartRefreshTokenCleanup(model.DB) // 悬赏过期退回 / 抽奖到期开奖 - service.StartTypedPostSettler(service.NewPostService(model.DB)) + service.StartTypedPostSettler(service.NewPostService(model.DB).WithDevMode(cfg.DevMode)) r, err := router.Setup(cfg) if err != nil { diff --git a/backend/handler/comment.go b/backend/handler/comment.go index caa2e2b..fa8383b 100644 --- a/backend/handler/comment.go +++ b/backend/handler/comment.go @@ -91,6 +91,8 @@ func (h *Handlers) CreateComment(c *gin.Context) { if authorID, e := h.Post.GetAuthorID(uint(id)); e == nil { 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 { // 子回复:通知父评论作者(Notification.Create 内部排除自我通知) h.Notification.Create(parent.UserID, claims.ID, model.NotificationTypeReply, uint(id), comment.ID, req.Content) diff --git a/backend/handler/moderation.go b/backend/handler/moderation.go index cffab5e..e018a7b 100644 --- a/backend/handler/moderation.go +++ b/backend/handler/moderation.go @@ -78,6 +78,12 @@ func (h *Handlers) AdminApproveComment(c *gin.Context) { h.writeModerationError(c, err) 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.broadcastModerationChanged() c.JSON(http.StatusOK, gin.H{"message": "操作成功"}) diff --git a/backend/handler/post.go b/backend/handler/post.go index 0818122..46b984d 100644 --- a/backend/handler/post.go +++ b/backend/handler/post.go @@ -449,6 +449,9 @@ func respondPostModError(c *gin.Context, err error) { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.Is(err, service.ErrPostForbidden): 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: c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) } diff --git a/backend/handler/post_interact.go b/backend/handler/post_interact.go index 3533710..1e61775 100644 --- a/backend/handler/post_interact.go +++ b/backend/handler/post_interact.go @@ -22,9 +22,7 @@ func (h *Handlers) respondInteractError(c *gin.Context, err error) { errors.Is(err, service.ErrPollClosed), errors.Is(err, service.ErrPollOptionsLocked), errors.Is(err, service.ErrLotteryDrawn), - errors.Is(err, service.ErrLotteryClosed), - errors.Is(err, service.ErrBountySettled), - errors.Is(err, service.ErrBountyExpired): + errors.Is(err, service.ErrBountySettled): c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) default: 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}) } -// 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 开奖 func (h *Handlers) DrawLottery(c *gin.Context) { claims := middleware.CurrentUser(c) diff --git a/backend/model/models.go b/backend/model/models.go index fee7b87..f8a4815 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -259,11 +259,13 @@ type PostPollVote struct { CreatedAt time.Time `json:"created_at"` } -// PostLotteryEntry 抽奖报名 +// PostLotteryEntry 抽奖报名(主评入池时登记;同 IP 去重用 ClientIP) type PostLotteryEntry struct { ID uint `gorm:"primaryKey" json:"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"` + 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"` } diff --git a/backend/router/router.go b/backend/router/router.go index 5fda795..c03d68d 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -42,7 +42,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { authSvc := service.NewAuthService(model.DB, cfg.JWTSecret) boardSvc := service.NewBoardService(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) likeSvc := service.NewLikeService(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/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/refund", h.RefundBounty) - api.POST("/posts/:id/lottery/close", h.CloseLottery) api.POST("/posts/:id/lottery/draw", h.DrawLottery) api.DELETE("/posts/:id", h.DeletePost) api.PUT("/posts/:id/pin", h.TogglePin) diff --git a/backend/service/post.go b/backend/service/post.go index 25c2e7a..ec3870d 100644 --- a/backend/service/post.go +++ b/backend/service/post.go @@ -22,6 +22,7 @@ var ( type PostService struct { db *gorm.DB setting *SettingService + devMode bool } func NewPostService(db *gorm.DB) *PostService { @@ -34,6 +35,12 @@ func (s *PostService) WithSetting(setting *SettingService) *PostService { return s } +// WithDevMode 开发态:抽奖同 IP 去重对回环地址放宽 +func (s *PostService) WithDevMode(dev bool) *PostService { + s.devMode = dev + return s +} + // PostListQuery 帖子列表查询参数 type PostListQuery struct { BoardID uint @@ -445,6 +452,7 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto // 惰性结算:悬赏过期退回 / 抽奖到期开奖 _ = s.settleExpiredBountyIfNeeded(&post) _ = s.settleDueLotteryIfNeeded(&post) + _ = s.settleDuePollIfNeeded(&post) if post.TypeMeta != "" { var fresh model.Post 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.AcceptedCommentID = 0 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.EndsAt = endsAtFromDays(days, now) } typeMeta, _ = encodeMeta(bm) } @@ -741,14 +748,26 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) { if lm.WinnerIDs == nil { lm.WinnerIDs = []uint{} } + if lm.Prizes == nil { + lm.Prizes = []LotteryPrize{} + } + lm.Slots = lotterySlotsFromPrizes(lm.Prizes) 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.EndsAt = endsAtFromDays(days, now) } 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{ BoardID: in.BoardID, UserID: in.UserID, 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.Anonymous = old.Anonymous neu.Closed = old.Closed + neu.EndsAt = old.EndsAt + neu.ClosedAt = old.ClosedAt + neu.ExpireDays = 0 meta, _ = encodeMeta(neu) } else { neu.Closed = old.Closed + neu.EndsAt = old.EndsAt + neu.ClosedAt = old.ClosedAt + neu.ExpireDays = 0 meta, _ = encodeMeta(neu) } } @@ -907,8 +932,16 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp neu.WinnerIDs = old.WinnerIDs neu.Closed = old.Closed 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 + } else { + neu.Slots = lotterySlotsFromPrizes(neu.Prizes) } meta, _ = encodeMeta(neu) } @@ -920,6 +953,7 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp // 解题态走专用 API,编辑帖子不覆盖 neu.Solved = old.Solved neu.AcceptedCommentID = old.AcceptedCommentID + neu.SolvedAt = old.SolvedAt meta, _ = encodeMeta(neu) } } diff --git a/backend/service/post_interact.go b/backend/service/post_interact.go index b603da2..dc10c41 100644 --- a/backend/service/post_interact.go +++ b/backend/service/post_interact.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "math/big" + "net" "strings" "time" "unicode/utf8" @@ -16,15 +17,19 @@ import ( // ---- TypeMeta 结构 ---- type QuestionMeta struct { - Solved bool `json:"solved"` - AcceptedCommentID uint `json:"accepted_comment_id"` + Solved bool `json:"solved"` + AcceptedCommentID uint `json:"accepted_comment_id"` + SolvedAt string `json:"solved_at,omitempty"` // 解决时刻 RFC3339 } type PollMeta struct { - Options []string `json:"options"` - Multi bool `json:"multi"` - Closed bool `json:"closed"` - Anonymous bool `json:"anonymous"` // 默认 true;缺省字段在 parse 时补 true + Options []string `json:"options"` + Multi bool `json:"multi"` + Closed bool `json:"closed"` + 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 { @@ -37,13 +42,19 @@ type BountyMeta struct { ExpireDays int `json:"expire_days,omitempty"` // 发帖入参,规范化后可清 } +type LotteryPrize struct { + Name string `json:"name"` + Count int `json:"count"` +} + type LotteryMeta struct { - Slots int `json:"slots"` - Drawn bool `json:"drawn"` - WinnerIDs []uint `json:"winner_ids"` - Closed bool `json:"closed"` - EndsAt string `json:"ends_at"` - ExpireDays int `json:"expire_days,omitempty"` + Prizes []LotteryPrize `json:"prizes"` + Slots int `json:"slots"` // 由 prizes 数量合计推导,兼容旧数据 + Drawn bool `json:"drawn"` + WinnerIDs []uint `json:"winner_ids"` + Closed bool `json:"closed"` + EndsAt string `json:"ends_at"` + ExpireDays int `json:"expire_days,omitempty"` } var ( @@ -53,10 +64,9 @@ var ( ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置") ErrBountySettled = errors.New("悬赏已结算") ErrBountyNotEscrow = errors.New("悬赏未托管") - ErrBountyExpired = errors.New("悬赏已过期") ErrLotteryDrawn = errors.New("已开奖") - ErrLotteryClosed = errors.New("抽奖已截止") ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖") + ErrLotteryPrizesLocked = errors.New("已有评论,无法修改奖品设置") ErrQuestionNotType = errors.New("本文不是问答帖") ) @@ -73,10 +83,13 @@ func parseQuestionMeta(raw string) (*QuestionMeta, error) { func parsePollMeta(raw string) (*PollMeta, error) { var wire struct { - Options []string `json:"options"` - Multi bool `json:"multi"` - Closed bool `json:"closed"` - Anonymous *bool `json:"anonymous"` + Options []string `json:"options"` + Multi bool `json:"multi"` + Closed bool `json:"closed"` + 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 { return nil, errors.New("投票配置无效") @@ -86,10 +99,13 @@ func parsePollMeta(raw string) (*PollMeta, error) { anon = *wire.Anonymous } return &PollMeta{ - Options: wire.Options, - Multi: wire.Multi, - Closed: wire.Closed, - Anonymous: anon, + Options: wire.Options, + Multi: wire.Multi, + Closed: wire.Closed, + Anonymous: anon, + EndsAt: strings.TrimSpace(wire.EndsAt), + ClosedAt: strings.TrimSpace(wire.ClosedAt), + ExpireDays: wire.ExpireDays, }, nil } @@ -106,9 +122,49 @@ func parseLotteryMeta(raw string) (*LotteryMeta, error) { if err := json.Unmarshal([]byte(raw), &m); err != nil { 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 } +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) { b, err := json.Marshal(v) if err != nil { @@ -145,6 +201,35 @@ func parseEndsAt(s string) (time.Time, bool) { 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 列表徽章状态 func ComputeTypeStatus(postType, typeMeta string) string { pt := model.NormalizePostType(postType) @@ -175,7 +260,10 @@ func ComputeTypeStatus(postType, typeMeta string) string { if m.AcceptedCommentID > 0 { return "solved" } - if m.Expired || m.Refunded { + if m.Expired { + return "expired" + } + if m.Refunded { return "expired" } return "open" @@ -240,6 +328,14 @@ func NormalizeAndValidateTypeMeta(postType, meta string) (string, error) { return "", errors.New("最多 10 个投票选项") } 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) case model.PostTypeBounty: if meta == "" { @@ -255,50 +351,102 @@ func NormalizeAndValidateTypeMeta(postType, meta string) (string, error) { if m.Points > 100000 { return "", errors.New("悬赏积分过高") } - days := normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30}) - m.ExpireDays = days - // ends_at 在 Create/Approve 时按发布时间写入;编辑保留旧值 + 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{3, 7, 14, 30}) + } + // ends_at 若仍空,在 Create/Approve 时按发布时间写入 return encodeMeta(m) case model.PostTypeLottery: if meta == "" { - return "", errors.New("请设置中奖名额") + return "", errors.New("请设置奖品") } m, err := parseLotteryMeta(meta) if err != nil { return "", err } - if m.Slots < 1 { - return "", errors.New("中奖名额至少为 1") + prizes := make([]LotteryPrize, 0, len(m.Prizes)) + for _, p := range m.Prizes { + name := strings.TrimSpace(p.Name) + if name == "" { + return "", errors.New("请填写奖品名称") + } + if utf8.RuneCountInString(name) > 32 { + 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 m.Slots > 100 { - return "", errors.New("中奖名额过多") + 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 { m.WinnerIDs = []uint{} } - days := normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14}) - m.ExpireDays = days + 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) default: return "", nil } } -// EnsureDeadlineOnPublish 首次公开发布时补写 ends_at(悬赏/抽奖) +// EnsureDeadlineOnPublish 首次公开发布时补写 ends_at(投票 / 悬赏 / 抽奖) func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (string, bool) { pt := model.NormalizePostType(postType) 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: m, err := parseBountyMeta(typeMeta) if err != nil { return typeMeta, false } - if m.EndsAt != "" { + if _, ok := parseEndsAt(m.EndsAt); ok { 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.EndsAt = endsAtFromDays(days, publishedAt) raw, err := encodeMeta(m) if err != nil { return typeMeta, false @@ -309,12 +457,11 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) ( if err != nil { return typeMeta, false } - if m.EndsAt != "" { + if _, ok := parseEndsAt(m.EndsAt); ok { 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.EndsAt = endsAtFromDays(days, publishedAt) raw, err := encodeMeta(m) if err != nil { return typeMeta, false @@ -347,6 +494,7 @@ type QuestionState struct { AcceptedCommentID uint `json:"accepted_comment_id"` AcceptedFloor int `json:"accepted_floor"` AcceptedAnswer *AcceptedAnswer `json:"accepted_answer,omitempty"` + SolvedAt string `json:"solved_at,omitempty"` CanAccept bool `json:"can_accept"` CanSolve bool `json:"can_solve"` CanReopen bool `json:"can_reopen"` @@ -372,6 +520,8 @@ type PollState struct { Multi bool `json:"multi"` Closed bool `json:"closed"` Anonymous bool `json:"anonymous"` + EndsAt string `json:"ends_at,omitempty"` + ClosedAt string `json:"closed_at,omitempty"` TotalVotes int `json:"total_votes"` Voters int `json:"voters"` MyOptions []int `json:"my_options"` @@ -391,7 +541,6 @@ type BountyState struct { EndsAt string `json:"ends_at,omitempty"` Settled bool `json:"settled"` CanAccept bool `json:"can_accept"` - CanRefund bool `json:"can_refund"` } type LotteryEntrant struct { @@ -399,20 +548,23 @@ type LotteryEntrant struct { Nickname string `json:"nickname"` Username string `json:"username"` Avatar string `json:"avatar"` - CommentID uint `json:"comment_id"` // 中奖楼层(该用户最早主评),每人仅一条 + CommentID uint `json:"comment_id"` Floor int `json:"floor"` + PrizeName string `json:"prize_name,omitempty"` } type LotteryState struct { - Slots int `json:"slots"` - Drawn bool `json:"drawn"` - Closed bool `json:"closed"` - EndsAt string `json:"ends_at,omitempty"` - EntryCount int `json:"entry_count"` // 回帖参与人数 - Eligible bool `json:"eligible"` // 当前用户是否在奖池 - CanDraw bool `json:"can_draw"` - CanClose bool `json:"can_close"` - Winners []LotteryEntrant `json:"winners"` + Slots int `json:"slots"` + Prizes []LotteryPrize `json:"prizes"` + Drawn bool `json:"drawn"` + Closed bool `json:"closed"` + EndsAt string `json:"ends_at,omitempty"` + EntryCount int `json:"entry_count"` + Eligible bool `json:"eligible"` + CooldownHours int `json:"cooldown_hours"` + CanDraw bool `json:"can_draw"` + CanEditPrizes bool `json:"can_edit_prizes"` + Winners []LotteryEntrant `json:"winners"` } func (s *PostService) fillInteractState(detail *PostDetail, post *model.Post, viewerID uint, loadActor func() *Actor) { @@ -444,18 +596,28 @@ func (s *PostService) buildQuestionState(post *model.Post, canManage bool) *Ques m = &QuestionMeta{} } 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{ Solved: m.Solved, AcceptedCommentID: m.AcceptedCommentID, AcceptedFloor: floor, - AcceptedAnswer: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor), + AcceptedAnswer: answer, + SolvedAt: solvedAt, CanAccept: 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 { + _ = s.settleDuePollIfNeeded(post) + s.db.Select("id", "type_meta", "post_type").First(post, post.ID) + m, err := parsePollMeta(post.TypeMeta) if err != nil || len(m.Options) == 0 { return nil @@ -531,11 +693,17 @@ func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose b opts[i] = stat } voters := len(voterSet) + closedAt := m.ClosedAt + if m.Closed && closedAt == "" { + closedAt = m.EndsAt // 旧数据:无 closed_at 时用截止时刻兜底 + } return &PollState{ Options: opts, Multi: m.Multi, Closed: m.Closed, Anonymous: m.Anonymous, + EndsAt: m.EndsAt, + ClosedAt: closedAt, TotalVotes: total, Voters: voters, MyOptions: my, @@ -550,7 +718,7 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty if err != nil { return nil } - settled := m.AcceptedCommentID > 0 || m.Refunded || m.Expired + accepted := m.AcceptedCommentID > 0 floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID) return &BountyState{ Points: m.Points, @@ -561,9 +729,9 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty Refunded: m.Refunded, Expired: m.Expired, EndsAt: m.EndsAt, - Settled: settled, - CanAccept: canManage && m.Escrowed && !settled && !m.Expired, - CanRefund: canManage && m.Escrowed && !settled && !m.Expired, + Settled: accepted, + // 托管中可采纳;到期自动退回后仍可从余额再扣支付(不可撤回) + CanAccept: canManage && !accepted && (m.Escrowed || m.Refunded), } } @@ -601,14 +769,197 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int) *Acc 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) { - var ids []uint - err := s.db.Model(&model.Comment{}). - Where("post_id = ? AND status = ? AND deleted_at IS NULL AND user_id <> ?", + hours := s.lotteryCooldownHours() + q := s.db.Table("comments AS c"). + 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). - Distinct("user_id"). - Pluck("user_id", &ids).Error - return ids, err + Group("c.user_id") + if hours > 0 { + 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 中奖用户的中奖楼层:其在本帖最早一条已发布主评论(每人仅一楼)。 @@ -633,6 +984,7 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana if err != nil { return nil } + cooldownHours := s.lotteryCooldownHours() ids, _ := s.lotteryEligibleIDs(post.ID, post.UserID) eligible := false for _, id := range ids { @@ -642,6 +994,7 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana } } winners := []LotteryEntrant{} + prizeNames := expandLotteryPrizeNames(m.Prizes) if len(m.WinnerIDs) > 0 { var users []model.User 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 { umap[u.ID] = u } - for _, id := range m.WinnerIDs { + for i, id := range m.WinnerIDs { if u, ok := umap[id]; ok { cid, floor := s.lotteryWinningFloor(post.ID, id) + prize := "" + if i < len(prizeNames) { + prize = prizeNames[i] + } winners = append(winners, LotteryEntrant{ ID: u.ID, Nickname: u.Nickname, @@ -659,20 +1016,27 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana Avatar: u.Avatar, CommentID: cid, Floor: floor, + PrizeName: prize, }) } } } + prizes := m.Prizes + if prizes == nil { + prizes = []LotteryPrize{} + } return &LotteryState{ - Slots: m.Slots, - Drawn: m.Drawn, - Closed: m.Closed, - EndsAt: m.EndsAt, - EntryCount: len(ids), - Eligible: eligible, - CanDraw: canManage && !m.Drawn, - CanClose: canManage && !m.Drawn && !m.Closed, - Winners: winners, + Slots: m.Slots, + Prizes: prizes, + Drawn: m.Drawn, + Closed: m.Closed, + EndsAt: m.EndsAt, + EntryCount: len(ids), + Eligible: eligible, + CooldownHours: cooldownHours, + CanDraw: canManage && !m.Drawn, + CanEditPrizes: !m.Drawn && post.CommentCount == 0, + Winners: winners, } } @@ -688,6 +1052,8 @@ func (s *PostService) VotePoll(userID, postID uint, optionIndexes []int) (*PostD if post.Status != model.ContentStatusPublished { return nil, errors.New("帖子未公开,无法投票") } + _ = s.settleDuePollIfNeeded(&post) + s.db.First(&post, post.ID) m, err := parsePollMeta(post.TypeMeta) if err != nil { return nil, err @@ -755,6 +1121,9 @@ func (s *PostService) ClosePoll(actor *Actor, userID, postID uint) (*PostDetail, return nil, err } m.Closed = true + if strings.TrimSpace(m.ClosedAt) == "" { + m.ClosedAt = time.Now().UTC().Format(time.RFC3339) + } raw, err := encodeMeta(m) if err != nil { return nil, err @@ -794,6 +1163,7 @@ func (s *PostService) AcceptQuestion(actor *Actor, userID, postID, commentID uin } m.AcceptedCommentID = commentID m.Solved = true + m.SolvedAt = time.Now().UTC().Format(time.RFC3339) raw, err := encodeMeta(m) if err != nil { return nil, err @@ -819,6 +1189,7 @@ func (s *PostService) SolveQuestion(actor *Actor, userID, postID uint) (*PostDet return nil, err } m.Solved = true + m.SolvedAt = time.Now().UTC().Format(time.RFC3339) raw, err := encodeMeta(m) if err != nil { return nil, err @@ -830,7 +1201,7 @@ func (s *PostService) SolveQuestion(actor *Actor, userID, postID uint) (*PostDet return s.detailAfterInteract(post, userID) } -// ReopenQuestion 重新打开 +// ReopenQuestion 重新打开(仅允许撤回「手动标已解决」;已采纳答案不可重开) func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDetail, error) { post, err := s.loadManageablePost(actor, userID, postID) if err != nil { @@ -839,7 +1210,14 @@ func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDe if model.NormalizePostType(post.PostType) != model.PostTypeQuestion { 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) if err != nil { return nil, err @@ -851,7 +1229,8 @@ func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDe return s.detailAfterInteract(post, userID) } -// AcceptBounty 采纳评论并发放悬赏 +// AcceptBounty 采纳评论并发放悬赏。 +// 托管中:从托管直接发给答主;到期已退回后:从作者余额再扣再发放(不可撤回)。 func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint) (*PostDetail, error) { post, err := s.loadManageablePost(actor, userID, postID) if err != nil { @@ -867,15 +1246,17 @@ func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint) if err != nil { return nil, err } - if m.Expired { - return nil, ErrBountyExpired - } - if !m.Escrowed { - return nil, ErrBountyNotEscrow - } - if m.AcceptedCommentID > 0 || m.Refunded { + if m.AcceptedCommentID > 0 { return nil, ErrBountySettled } + payFromBalance := false + if m.Escrowed { + payFromBalance = false + } else if m.Refunded { + payFromBalance = true + } else { + return nil, ErrBountyNotEscrow + } var cm model.Comment if err := s.db.First(&cm, commentID).Error; err != nil || cm.PostID != postID { 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 { + 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 { return err } m.AcceptedCommentID = commentID m.Escrowed = false - raw, err := encodeMeta(m) - 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 + // 截止后退回再采纳:保留 Expired/Refunded 痕迹便于对账,但 Settled 以采纳为准 raw, err := encodeMeta(m) if err != nil { 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 { ids, err := s.lotteryEligibleIDs(post.ID, post.UserID) if err != nil { return err } + m.Slots = lotterySlotsFromPrizes(m.Prizes) nWin := m.Slots if 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) } +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 { if model.NormalizePostType(post.PostType) != model.PostTypeLottery { return nil @@ -1093,15 +1440,29 @@ func (s *PostService) settleDueLotteryIfNeeded(post *model.Post) error { return s.drawLotteryCore(post, m) } -// SettleDueTypedPosts 定时任务:悬赏过期退回 + 抽奖到期开奖 +// SettleDueTypedPosts 定时任务:投票到期关闭 + 悬赏过期退回 + 抽奖到期开奖 func (s *PostService) SettleDueTypedPosts(limit int) { if limit <= 0 { 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 s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty). Order("id ASC").Limit(limit * 3).Find(&bounties) - n := 0 for i := range bounties { if n >= limit { break @@ -1116,7 +1477,6 @@ func (s *PostService) SettleDueTypedPosts(limit int) { var lotteries []model.Post s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeLottery). Order("id ASC").Limit(limit * 3).Find(&lotteries) - n = 0 for i := range lotteries { if n >= limit { break diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,9 @@ + + +# 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. + + diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 120ff4f..cc61445 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -182,30 +182,106 @@ body { box-shadow: var(--shadow); 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 { flex: 1 1 auto; display: flex; flex-direction: column; min-height: 0; - padding: 1.35rem 1.35rem 0.85rem; - gap: 1.1rem; + padding: 1.15rem 1.15rem 0.75rem; + gap: 0.85rem; } @media (min-width: 640px) { .j13-compose-body { - padding: 1.5rem 1.75rem 0.9rem; - gap: 1.15rem; + padding: 1.25rem 1.4rem 0.85rem; + 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 { flex-shrink: 0; display: flex; flex-wrap: wrap; align-items: center; + justify-content: space-between; gap: 0.75rem 1rem; padding: 1rem 1.5rem 1.25rem; border-top: 1px solid var(--line); 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) { .j13-compose-actions { padding-left: 2rem; @@ -271,6 +347,54 @@ body { border: 1px dashed color-mix(in srgb, var(--accent) 28%, var(--line)); 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 { display: flex; align-items: center; @@ -286,6 +410,474 @@ body { border: 1px solid var(--line); 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 { background: color-mix(in srgb, var(--danger) 7%, var(--panel)); 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)); } -/* 正文下方已采纳答案:问答资料卡 */ +/* 正文下方已采纳答案:与类型帖 typed-card 同框,配色克制 */ .j13-accepted-answer { 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 { - background: color-mix(in srgb, var(--ok) 12%, var(--panel)); - border-color: color-mix(in srgb, var(--ok) 36%, var(--line)); -} -.j13-accepted-answer__head { - display: flex; - 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.is-question, +.j13-accepted-answer.is-bounty { + /* 覆盖旧绿底/左边条;沿用 interact-card 面板 */ + border-color: var(--line); + background: var(--panel); + box-shadow: none; } .j13-accepted-answer__author { display: flex; align-items: center; gap: 10px; 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 { min-width: 0; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--line); } /* 正文下方类型特殊块(投票/悬赏/抽奖/问答状态) */ .j13-interact-below { @@ -382,6 +964,10 @@ body { opacity: 0.65; 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 { width: 100%; @@ -1129,18 +1715,11 @@ a.j13-mention-token:hover { background: color-mix(in srgb, #0284c7 12%, var(--panel)); color: #0369a1; } + /* 已结束 / 已过期 / 已开奖 / 已截止:统一灰胶囊(标识已到期) */ .badge-status-closed { background: color-mix(in srgb, #5b6b8c 12%, var(--panel)); 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 { background: rgba(255, 107, 96, 0.14); color: #fca5a5; @@ -1157,14 +1736,6 @@ a.j13-mention-token:hover { background: rgba(170, 182, 207, 0.14); 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 { diff --git a/frontend/app/post/[id]/edit/EditPostClient.tsx b/frontend/app/post/[id]/edit/EditPostClient.tsx index 80b5a72..942d506 100644 --- a/frontend/app/post/[id]/edit/EditPostClient.tsx +++ b/frontend/app/post/[id]/edit/EditPostClient.tsx @@ -14,6 +14,7 @@ type Props = { initialTypeMeta?: string; initialAttachments?: PostAttachment[]; canEditPollOptions?: boolean; + canEditLotteryPrizes?: boolean; boardName: string; boardId: number; boardColorIndex?: number; @@ -30,6 +31,7 @@ export default function EditPostClient({ initialTypeMeta, initialAttachments, canEditPollOptions, + canEditLotteryPrizes, boardName, boardId, boardColorIndex = 0, @@ -47,6 +49,7 @@ export default function EditPostClient({ initialTypeMeta={initialTypeMeta} initialAttachments={initialAttachments} canEditPollOptions={canEditPollOptions} + canEditLotteryPrizes={canEditLotteryPrizes} boardName={boardName} boardId={boardId} boardColorIndex={boardColorIndex} diff --git a/frontend/app/post/[id]/edit/page.tsx b/frontend/app/post/[id]/edit/page.tsx index a324599..07d7e50 100644 --- a/frontend/app/post/[id]/edit/page.tsx +++ b/frontend/app/post/[id]/edit/page.tsx @@ -65,6 +65,7 @@ export default async function EditPostPage({ params }: PageProps) { initialTypeMeta={post.type_meta} initialAttachments={post.attachments || []} canEditPollOptions={post.poll?.can_edit_options !== false} + canEditLotteryPrizes={post.lottery?.can_edit_prizes !== false} boardName={post.board?.name || "未知板块"} boardId={post.board_id} boardColorIndex={post.board?.color_index ?? 0} diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx index d2743dd..4c5e5a8 100644 --- a/frontend/app/post/[id]/page.tsx +++ b/frontend/app/post/[id]/page.tsx @@ -714,8 +714,11 @@ export default async function PostDetailPage({ params, searchParams }: PageProps ) : questionState ? ( diff --git a/frontend/components/AcceptedAnswerCard.tsx b/frontend/components/AcceptedAnswerCard.tsx index e5b0c87..6daa847 100644 --- a/frontend/components/AcceptedAnswerCard.tsx +++ b/frontend/components/AcceptedAnswerCard.tsx @@ -1,76 +1,83 @@ import Link from "next/link"; -import { CheckCircle2 } from "lucide-react"; +import { CheckCircle2, CircleHelp, Coins } from "lucide-react"; import type { AcceptedAnswer } from "@/lib/api"; import Avatar from "@/components/Avatar"; import MarkdownBody from "@/components/MarkdownBody"; -import AcceptedAnswerReopen from "@/components/AcceptedAnswerReopen"; -/** 正文下方的已采纳答案卡:头像 + 昵称 + 全文;问答已解决时并入状态与「重新打开」 */ +/** 正文下方已采纳答案:对齐类型帖 typed-card;已采纳后不可重新打开 */ export default async function AcceptedAnswerCard({ answer, variant = "question", - postId, - canReopen = false, + solvedAt, }: { answer: AcceptedAnswer; /** 问答用「最佳答案」,悬赏用「已采纳」 */ variant?: "question" | "bounty"; - postId?: number; - canReopen?: boolean; + /** 解决/采纳时刻;缺省用答案创建时间 */ + solvedAt?: string; }) { const name = answer.user.nickname || answer.user.username || "用户"; - const label = variant === "bounty" ? "已采纳" : "最佳答案"; - const showReopen = variant === "question" && canReopen && !!postId; + const isQuestion = variant === "question"; + const title = isQuestion ? "问答" : "悬赏"; + const statusLabel = isQuestion ? "已解决" : "已采纳"; + const answerLabel = isQuestion ? "最佳答案" : "采纳答案"; + const endTime = solvedAt || answer.created_at; + const endLabel = formatEndsAt(endTime); return ( -
-
-
- {variant === "question" && ( -

问答 · 已解决

- )} -
- - - {label} - - - - -
- - {name} - -

- - {answer.floor > 0 && ( - <> - · - - #{answer.floor} - - - )} -

-
-
+
+
+
+

+ {isQuestion ? ( + + ) : ( + + )} + {title} +

+ {statusLabel}
- {showReopen && } -
+ + +
+ + + {answerLabel} + + + + +
+ + {name} + +

+ + {answer.floor > 0 && ( + <> + · + + #{answer.floor} + + + )} +

+
+
{answer.deleted ? ( @@ -81,6 +88,25 @@ export default async function AcceptedAnswerCard({ )}
+ +
+ + + {isQuestion ? "已采纳答案" : "悬赏已发放"} + + {endLabel ? ( + 结束时间: {endLabel} + ) : null} +
); } + +function formatEndsAt(iso?: string) { + if (!iso) return ""; + try { + return new Date(iso).toLocaleString("zh-CN"); + } catch { + return iso; + } +} diff --git a/frontend/components/AcceptedAnswerReopen.tsx b/frontend/components/AcceptedAnswerReopen.tsx deleted file mode 100644 index 5189442..0000000 --- a/frontend/components/AcceptedAnswerReopen.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/frontend/components/DeadlinePicker.tsx b/frontend/components/DeadlinePicker.tsx new file mode 100644 index 0000000..97a78b6 --- /dev/null +++ b/frontend/components/DeadlinePicker.tsx @@ -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 ( +
+
{label}
+
+ + +
+ + {mode === "preset" ? ( +
+ {presets.map((d) => ( + + ))} +
+ ) : ( +
+ onCustomLocalChange(e.target.value)} + aria-label="指定截止时刻" + /> + {customLocal ? ( +

将于 {formatLocalPreview(customLocal)} 截止

+ ) : null} +
+ )} + {hint ?

{hint}

: null} +
+ ); +} diff --git a/frontend/components/PostComposer.tsx b/frontend/components/PostComposer.tsx index 9f0965d..48df7bb 100644 --- a/frontend/components/PostComposer.tsx +++ b/frontend/components/PostComposer.tsx @@ -10,6 +10,7 @@ import { apiUploadPostFile, apiDeletePostFile, apiUpdatePostFilePrice, + apiGetPoints, fetchBoards, type Board, type PostAttachment, @@ -19,6 +20,7 @@ import { CONTENT_ACCESS, BOUNTY_EXPIRE_DAYS, LOTTERY_EXPIRE_DAYS, + POLL_EXPIRE_DAYS, normalizePostType, type PostTypeValue, type ContentAccessValue, @@ -26,6 +28,11 @@ import { import { toast } from "@/lib/toast"; import MarkdownEditor from "@/components/MarkdownEditor"; import BoardPicker, { BoardBadge } from "@/components/BoardPicker"; +import DeadlinePicker, { + type DeadlineMode, + defaultCustomLocal, + localInputToRFC3339, +} from "@/components/DeadlinePicker"; const TITLE_MAX = 256; const DRAFT_KEY = "j13-compose-draft"; @@ -127,6 +134,8 @@ type EditProps = { initialAttachments?: PostAttachment[]; /** 投票帖:有票后禁止改选项 */ canEditPollOptions?: boolean; + /** 抽奖帖:有评论后禁止改奖品 */ + canEditLotteryPrizes?: boolean; boardName?: string; boardId?: number; boardColorIndex?: number; @@ -192,20 +201,36 @@ export default function PostComposer(props: PostComposerProps) { return true; } }); - const [bountyPoints, setBountyPoints] = useState(() => { + const [bountyPoints, setBountyPoints] = useState(() => { + // 新建:空值 + placeholder(与抽奖数量输入一致);编辑回填已托管金额 + if (isCreate) return ""; try { - const p = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").points); - return p > 0 ? p : 20; + const p = Number(JSON.parse(props.initialTypeMeta || "{}").points); + return p > 0 ? p : ""; } catch { - return 20; + return ""; } }); - const [lotterySlots, setLotterySlots] = useState(() => { + const [lotteryPrizes, setLotteryPrizes] = useState<{ name: string; count: number | "" }[]>(() => { + // 新建:空行 + placeholder;编辑才回填已保存奖品 + if (isCreate) return [{ name: "", count: "" }]; try { - const s = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").slots); - return s > 0 ? s : 1; + const raw = JSON.parse(props.initialTypeMeta || "{}") as { + 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 { - return 1; + return [{ name: "", count: "" }]; } }); const [bountyExpireDays, setBountyExpireDays] = useState(() => { @@ -224,19 +249,43 @@ export default function PostComposer(props: PostComposerProps) { return 7; } }); + const [pollExpireDays, setPollExpireDays] = useState(7); + const [deadlineMode, setDeadlineMode] = useState("preset"); + const [customEndsLocal, setCustomEndsLocal] = useState(() => defaultCustomLocal(7)); const canEditPoll = 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( isCreate ? [] : props.initialAttachments || [] ); const [fileBusy, setFileBusy] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + /** 发帖页余额:仅新建悬赏时拉取,用于就地提示积分不足 */ + const [pointsBalance, setPointsBalance] = useState(null); const [draftBanner, setDraftBanner] = useState(null); const draftTimer = useRef | null>(null); const skipDraftSave = useRef(true); const formRef = useRef(null); const fileRef = useRef(null); + const actionsErrorRef = useRef(null); + const bountyFieldRef = useRef(null); + const pendingErrorFocus = useRef<"actions" | "bounty" | null>(null); const preferredBoardId = isCreate ? props.initialBoardId || 0 : 0; @@ -280,6 +329,27 @@ export default function PostComposer(props: PostComposerProps) { }; }, [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(() => { if (!isCreate || skipDraftSave.current) return; if (draftTimer.current) clearTimeout(draftTimer.current); @@ -359,6 +429,15 @@ export default function PostComposer(props: PostComposerProps) { 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 => { if (postType === "discussion") return ""; if (postType === "question") { @@ -370,25 +449,33 @@ export default function PostComposer(props: PostComposerProps) { multi: pollMulti, anonymous: pollAnonymous, closed: false, + ...buildDeadlineFields(pollExpireDays), }); } if (postType === "bounty") { return JSON.stringify({ - points: Math.max(1, bountyPoints || 1), + points: typeof bountyPoints === "number" ? Math.max(1, Math.floor(bountyPoints)) : 1, accepted_comment_id: 0, escrowed: false, refunded: false, expired: false, - expire_days: bountyExpireDays, + ...buildDeadlineFields(bountyExpireDays), }); } 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({ - slots: Math.max(1, lotterySlots || 1), + prizes, + slots: prizes.reduce((s, p) => s + p.count, 0), drawn: false, winner_ids: [], closed: false, - expire_days: lotteryExpireDays, + ...buildDeadlineFields(lotteryExpireDays), }); } return typeMeta.trim(); @@ -434,6 +521,13 @@ export default function PostComposer(props: PostComposerProps) { await apiUpdatePostFilePrice(id, p); }; + const bountyShort = + isCreate && + postType === "bounty" && + pointsBalance !== null && + typeof bountyPoints === "number" && + bountyPoints > pointsBalance; + const canSubmit = !loading && title.trim().length > 0 && @@ -442,11 +536,65 @@ export default function PostComposer(props: PostComposerProps) { (contentAccess !== "points" || accessPoints > 0) && (postType !== "poll" || pollOptions.map((o) => o.trim()).filter(Boolean).length >= 2) && - (postType !== "bounty" || bountyPoints > 0) && - (postType !== "lottery" || lotterySlots > 0); + (postType !== "bounty" || + (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) => { 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; setLoading(true); setError(""); @@ -475,7 +623,11 @@ export default function PostComposer(props: PostComposerProps) { } router.push(`/post/${res.post.id}`); } else { - setError(mapPostError(res.error || "发帖失败")); + const mapped = mapPostError(res.error || "发帖失败"); + reportSubmitError( + mapped, + mapped.includes("积分") ? "bounty" : "actions" + ); } } else { const res = await apiUpdatePost(String(props.postId), { @@ -491,20 +643,56 @@ export default function PostComposer(props: PostComposerProps) { toast("已保存", "ok"); router.push(props.cancelHref); } else { - setError(mapPostError(res.error || "保存失败")); + reportSubmitError(mapPostError(res.error || "保存失败")); } } } catch { - setError("网络错误,请稍后重试"); + reportSubmitError("网络错误,请稍后重试"); } finally { setLoading(false); } }; const cancelHref = isCreate ? props.cancelHref || "/" : props.cancelHref; + const submitLabel = loading + ? isCreate + ? "发布中…" + : "保存中…" + : isCreate + ? "发布" + : "保存"; + const cancelControl = isCreate ? ( + + 取消 + + ) : ( + + ); return (
+
+

+ {isCreate ? "写新帖" : "编辑帖子"} +

+
+ {cancelControl} + +
+
+
{draftBanner && (
)} - {error &&
{error}
} + {/* 基本信息:类型 / 板块 / 标签 / 可见性 */} +
+

基本信息

- {/* 帖子类型 */} -
-
帖子类型
-
- {POST_TYPES.map((t) => ( - - ))} -
-
- - {/* 元信息:板块 + 标签(标签 flex 占满) + 可见性 */} -
-
- - {isCreate ? ( - - ) : ( - - )} -
- -
- -
document.getElementById("compose-tag-input")?.focus()} - > - {tagList.map((t) => ( - - # {t} - - + {/* 帖子类型 */} +
+
帖子类型
+
+ {POST_TYPES.map((t) => ( + ))} - = 12} - onChange={(e) => setTagInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === "," || e.key === ",") { - e.preventDefault(); - commitTagInput(); - } else if (e.key === "Backspace" && !tagInput && tagList.length > 0) { - removeTag(tagList[tagList.length - 1]); - } - }} - onBlur={commitTagInput} - placeholder={tagList.length === 0 ? "输入后回车添加" : ""} - className="j13-tag-field-input flex-1 min-w-[8rem] self-stretch h-8 text-[13px]" - style={{ color: "var(--ink)" }} - />
-
- - - {contentAccess === "points" && ( -
-
- - {/* 类型专属面板 */} - {postType === "poll" && ( -
-

- 投票选项 -

- {!canEditPoll && ( -

已有人投票,无法修改选项、单/多选或匿名设置

- )} - {pollOptions.map((opt, i) => ( -
- { - const next = [...pollOptions]; - next[i] = e.target.value; - setPollOptions(next); - }} - /> - {pollOptions.length > 2 && canEditPoll && ( - + {isCreate ? ( + + ) : ( + )}
- ))} -
- {pollOptions.length < 10 && canEditPoll && ( - - )} - - -
- {!pollAnonymous && ( -

非匿名:投票结果公开后可看到每位投票人

- )} -
- )} - {postType === "bounty" && ( -
-

- 悬赏积分 -

-

- 发布时从你的积分余额托管;到期未采纳将自动退回。采纳回答后发给对方。 -

-
- setBountyPoints(Number(e.target.value) || 0)} - className="j13-compose-field w-36" - /> - 积分 - {!isCreate && ( - 编辑不可更改已托管金额 - )} -
- {isCreate && ( -
- 过期天数 - {BOUNTY_EXPIRE_DAYS.map((d) => ( - - ))} + {tagList.map((t) => ( + + # {t} + + + ))} + = 12} + onChange={(e) => setTagInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === "," || e.key === ",") { + e.preventDefault(); + commitTagInput(); + } else if (e.key === "Backspace" && !tagInput && tagList.length > 0) { + removeTag(tagList[tagList.length - 1]); + } + }} + onBlur={commitTagInput} + placeholder={tagList.length === 0 ? "输入后回车添加" : ""} + className="j13-tag-field-input flex-1 min-w-[8rem] self-stretch h-8 text-[13px]" + style={{ color: "var(--ink)" }} + /> +
- )} -
- )} - {postType === "lottery" && ( -
-

- 抽奖设置 -

-

- 对本帖发表评论的用户自动进入奖池(楼主除外);截止时可自动开奖。 -

-
- - setLotterySlots(Number(e.target.value) || 1)} - className="j13-compose-field w-28" - /> -
- {isCreate && ( -
- 截止天数 - {LOTTERY_EXPIRE_DAYS.map((d) => ( - - ))} + +
+ + + {contentAccess === "points" && ( +
+ + setAccessPoints(Number(e.target.value) || 0)} + className="j13-compose-field !py-1.5 w-28" + /> +
+ )}
- )} -
+
+ +
+ + {/* 类型扩展(投票 / 悬赏 / 抽奖) */} + {(postType === "poll" || postType === "bounty" || postType === "lottery") && ( +
+

类型设置

+ {postType === "poll" && ( +
+

+ 投票选项 +

+ {!canEditPoll && ( +

已有人投票,无法修改选项、单/多选或匿名设置

+ )} + {pollOptions.map((opt, i) => ( +
+ { + const next = [...pollOptions]; + next[i] = e.target.value; + setPollOptions(next); + }} + /> + {pollOptions.length > 2 && canEditPoll && ( + + )} +
+ ))} +
+ {pollOptions.length < 10 && canEditPoll && ( + + )} + + +
+ {!pollAnonymous && ( +

非匿名:投票结果公开后可看到每位投票人

+ )} + {isCreate && ( + + )} +
+ )} + {postType === "bounty" && ( +
+

+ 悬赏积分 +

+

+ 发布时从余额托管;到期未采纳自动退回。截止后仍可采纳,将从余额扣款支付且不可撤回。 +

+
+ + + + + + + + + + + + + +
悬赏积分 + 余额 +
+ { + 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" : ""}`} + /> + + {isCreate ? ( + + {pointsBalance === null + ? "—" + : pointsBalance.toLocaleString("zh-CN")} + + ) : ( + 已托管 + )} +
+
+
+ {bountyShort ? ( +

+ 积分不足,请下调悬赏金额后再发布 +

+ ) : isCreate && typeof bountyPoints !== "number" ? ( +

+ 请填写悬赏积分,否则无法发布 +

+ ) : !isCreate ? ( +

编辑不可更改已托管金额

+ ) : ( + + )} + + {typeof bountyPoints === "number" + ? `托管 ${bountyPoints.toLocaleString("zh-CN")} 积分` + : "托管 —"} + {isCreate && pointsBalance !== null + ? ` · 余额 ${pointsBalance.toLocaleString("zh-CN")}` + : ""} + +
+ {isCreate && ( + + )} +
+ )} + {postType === "lottery" && ( +
+

+ 抽奖设置 +

+

+ 填写奖品名称与数量,中奖名额自动合计。有评论后不可再改奖品。 +

+ {!canEditLotteryPrizes && ( +

+ 已有评论,奖品设置已锁定 +

+ )} +
+ + + + + + + {canEditLotteryPrizes && ( + + )} + + + + {lotteryPrizes.map((p, idx) => ( + + + + + {canEditLotteryPrizes && ( + + )} + + ))} + +
+ # + 奖品名称 + 数量 + + 操作 +
{idx + 1} + { + 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"} + /> + + { + 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"} + /> + + {lotteryPrizes.length > 1 ? ( + + ) : ( + — + )} +
+
+
+ {canEditLotteryPrizes && lotteryPrizes.length < 10 ? ( + + ) : ( + + )} + + 合计名额 {lotteryPrizesReady ? lotterySlotTotal : "—"} + +
+ {canEditLotteryPrizes && !lotteryPrizesReady && ( +

+ 请填写每一行的奖品名称与数量,否则无法发布 +

+ )} + {isCreate && ( + + )} +
+ )} +
)} - {/* 大标题 */} -
-
- - + {/* 标题与正文 */} +
+
+

标题与正文

+ {title.length}/{TITLE_MAX}
- setTitle(e.target.value.slice(0, TITLE_MAX))} - required - maxLength={TITLE_MAX} - placeholder="一句话说清楚你要讨论什么" - className="j13-compose-title" - disabled={loading} - /> -
- - {/* 正文 */} -
- - { - if (canSubmit) void handleSubmit(); - }} - /> -
- - {/* 附件 */} -
-
-
附件
- +
+ void onPickFile(e.target.files?.[0] ?? null)} + id="compose-title" + type="text" + value={title} + onChange={(e) => setTitle(e.target.value.slice(0, TITLE_MAX))} + required + maxLength={TITLE_MAX} + placeholder="一句话说清楚你要讨论什么" + className="j13-compose-title" + disabled={loading} />
-

- 单文件 ≤20MB,最多 10 个;可设免费或积分下载 -

- {attachments.length === 0 ? ( -

暂无附件

- ) : ( -
    - {attachments.map((a) => ( -
  • - -
    -
    {a.name}
    -
    {formatSize(a.size)}
    -
    - - -
  • - ))} -
- )} -
+
+ + { + if (canSubmit) void handleSubmit(); + }} + /> +
+ + + {/* 附件 */} +
+

附件

+
+
+

单文件 ≤20MB,最多 10 个;可设免费或积分下载

+ + void onPickFile(e.target.files?.[0] ?? null)} + /> +
+ {attachments.length === 0 ? ( +

暂无附件

+ ) : ( +
    + {attachments.map((a) => ( +
  • + +
    +
    {a.name}
    +
    {formatSize(a.size)}
    +
    + + +
  • + ))} +
+ )} +
+
- - {isCreate ? ( - - 取消 - - ) : ( - + {error} +
+ ) : ( +

+ {isCreate + ? "普通用户发帖需审核 · Ctrl/⌘+Enter 快捷发布" + : "保存后立即生效 · 类型不可更改"} +

)} - - {isCreate - ? "普通用户发帖需审核 · Ctrl/⌘+Enter 提交 · Esc 退出全屏" - : "保存后立即生效 · 类型不可更改"} - +
+ {isCreate ? ( + + 取消 + + ) : ( + + )} + +
); diff --git a/frontend/components/PostInteractPanels.tsx b/frontend/components/PostInteractPanels.tsx index 8780a4e..eb174dc 100644 --- a/frontend/components/PostInteractPanels.tsx +++ b/frontend/components/PostInteractPanels.tsx @@ -1,13 +1,12 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; +import { Gift, Trophy, CheckCircle2, Timer, BarChart3, Coins, CircleHelp } from "lucide-react"; import { apiVotePoll, apiClosePoll, - apiRefundBounty, - apiCloseLottery, apiDrawLottery, apiSolveQuestion, apiReopenQuestion, @@ -32,12 +31,13 @@ export default function PostInteractPanels({ lottery?: LotteryState | null; loggedIn: boolean; }) { - // 问答状态改在正文下方展示(未解决 / 已解决资料卡),此处不再放 - if (!poll && !bounty && !lottery) return null; + // 问答 / 已采纳悬赏改在正文下方 AcceptedAnswerCard,此处不再放 + const showBounty = !!bounty && !bounty.accepted_comment_id; + if (!poll && !showBounty && !lottery) return null; return (
{poll && } - {bounty && } + {showBounty && } {lottery && }
); @@ -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 结束时间: {formatEndsAt(t)}; + } + if (!endsAt) return null; + return 截止时间: {formatEndsAt(endsAt)}; +} + +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(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 ( + + ); + } + + const r = remainParts(endsMs, now); + + return ( +
+
+ + {r.expired ? expiredLabel : activeLabel} +
+ {r.expired ? ( +

{expiredHint}

+ ) : ( +
+ + {pad2(r.days)} + 天 + + + {pad2(r.hours)} + 时 + + + {pad2(r.mins)} + 分 + + + {pad2(r.secs)} + 秒 + +
+ )} +

截止于 {formatEndsAt(endsAt)}

+
+ ); +} + /** 正文下方的问答状态卡(未解决 / 仅标记已解决);有采纳答案时改用 AcceptedAnswerCard */ export function QuestionStatusCard({ postId, state }: { postId: number; state: QuestionState }) { const router = useRouter(); @@ -70,57 +193,80 @@ export function QuestionStatusCard({ postId, state }: { postId: number; state: Q } }; + const statusBadge = state.solved + ? { cls: "badge-status badge-status-solved", label: "已解决" } + : { cls: "badge-status badge-status-unsolved", label: "未解决" }; + + const footLeft = state.solved + ? state.accepted_comment_id + ? "已采纳答案" + : "已标记解决" + : "等待解答"; + return ( -
-
-
-

- 问答 · {state.solved ? "已解决" : "未解决"} +
+
+
+

+ + 问答

-

- {state.solved - ? state.accepted_comment_id - ? ( - <> - 已采纳{" "} - - #{state.accepted_floor || "?"} - - - ) - : "作者已标记为已解决" - : "可在评论旁采纳答案,或手动标记已解决"} -

-
-
- {state.can_solve && ( - - )} - {state.can_reopen && ( - - )} + {statusBadge.label}
+ {(state.can_solve || state.can_reopen) && ( +
+ {state.can_solve && ( + + )} + {state.can_reopen && ( + + )} +
+ )} +
+ +

+ {state.solved + ? state.accepted_comment_id + ? ( + <> + 已采纳{" "} + + #{state.accepted_floor || "?"} + + + ) + : "作者已标记为已解决" + : "可在评论旁采纳答案,或手动标记已解决"} +

+ +
+ + + {footLeft} + +
); @@ -139,6 +285,7 @@ function PollPanel({ const [sel, setSel] = useState(state.my_options || []); const [busy, setBusy] = useState(false); const showResults = state.closed || (state.my_options && state.my_options.length > 0); + const hasVoted = (state.my_options?.length ?? 0) > 0; const toggle = (idx: number) => { 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 ( -
-
-

- 投票{state.multi ? "(多选)" : "(单选)"} - {state.anonymous ? " · 匿名" : " · 实名"} -

- - {state.voters} 人参与 · {state.total_votes} 票 - {state.closed ? " · 已结束" : ""} - +
+
+
+

+ + 投票 +

+ {statusBadge.label} +
+ {state.can_close && ( +
+ +
+ )}
-
    + +

    + {state.multi ? "多选" : "单选"} + {state.anonymous ? " · 匿名" : " · 实名"} + {" · "} + {state.voters} 人参与 · {state.total_votes} 票 +

    + + {!state.closed && state.ends_at ? ( + + ) : null} + +
      {state.options.map((o) => { const checked = sel.includes(o.index); const namedVoters = !state.anonymous && showResults ? o.voters || [] : []; @@ -205,43 +389,32 @@ function PollPanel({ type="button" disabled={!state.can_vote || busy} onClick={() => toggle(o.index)} - className="w-full text-left rounded-[12px] px-3 py-2.5 relative overflow-hidden transition-colors" - style={{ - border: `1px solid ${checked ? "var(--accent)" : "var(--line)"}`, - background: checked ? "var(--accent-soft)" : "var(--panel)", - }} + className={`j13-poll-option${checked ? " is-on" : ""}`} > {showResults && ( )} - - + + {state.can_vote ? (checked ? "✓ " : "") : ""} {o.text} {showResults && ( - + {o.votes} · {o.percent}% )} {namedVoters.length > 0 && ( -
        +
          {namedVoters.map((v) => (
        • - + {v.nickname || v.username} @@ -253,106 +426,121 @@ function PollPanel({ ); })}
        -
        - {state.can_vote && ( - - )} - {!loggedIn && !state.closed && ( - - 登录后投票 - - )} - {state.can_close && ( - - )} + + {(state.can_vote || (!loggedIn && !state.closed)) && ( +
        + {state.can_vote && ( + + )} + {!loggedIn && !state.closed && ( + + 登录后投票 + + )} +
        + )} + +
        + + + {footLeft} + +
); } -function BountyPanel({ postId, state }: { postId: number; state: BountyState }) { - const router = useRouter(); - const [busy, setBusy] = useState(false); - const [confirming, setConfirming] = useState(false); +function BountyPanel({ state }: { postId: number; state: BountyState }) { + const statusBadge = state.accepted_comment_id + ? { cls: "badge-status badge-status-solved", label: "已采纳" } + : state.expired || state.refunded + ? { cls: "badge-status badge-status-closed", label: "已过期" } + : { cls: "badge-status badge-status-open", label: "进行中" }; - const refund = async () => { - if (!confirming) { - setConfirming(true); - window.setTimeout(() => setConfirming(false), 3000); - 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 footLeft = state.accepted_comment_id + ? "悬赏已完成" + : state.expired || state.refunded + ? state.can_accept + ? "已过期退回 · 仍可采纳支付" + : "悬赏已过期退回" + : "悬赏进行中"; - const statusText = state.expired - ? "已过期,积分已自动退回" - : state.refunded - ? "已退回给发帖人" - : state.accepted_comment_id - ? null + const bodyHint = state.accepted_answer + ? "悬赏积分已发放给采纳答案" + : state.accepted_comment_id + ? null + : state.expired || state.refunded + ? state.can_accept + ? "到期未采纳已自动退回;采纳将从余额扣除,支付后不可撤回" + : "积分已自动退回发帖人" : state.escrowed - ? "积分已托管,采纳优质回答后发放" + ? "积分已托管,采纳优质回答后发放;到期未采纳自动退回" : "悬赏未托管"; return ( -
-
-
-

- 悬赏 {state.points} 积分 +
+
+
+

+ + 悬赏

-

- {state.accepted_answer - ? "悬赏积分已发放给采纳答案" - : state.accepted_comment_id - ? ( - <> - 已采纳{" "} - - #{state.accepted_floor || "?"} - - - ) - : statusText} - {state.ends_at && !state.settled ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""} -

+ {statusBadge.label}
- {state.can_refund && ( -
+ +

+ {state.points} 积分 + {bodyHint ? ` · ${bodyHint}` : null} + {state.can_accept ? " · 在评论旁点击「采纳」发放积分" : null} +

+ + {!state.accepted_comment_id && state.ends_at ? ( + + ) : null} + + {state.accepted_comment_id > 0 && ( +

+ 已采纳{" "} + - {confirming ? "再点确认退回" : "退回悬赏"} - - )} - {state.can_accept && ( -

在评论旁点击「采纳」发放积分

- )} + #{state.accepted_floor || "?"} + +

+ )} + +
+ + + {footLeft} + +
); @@ -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 ( -
-
-
-

- 抽奖 · {state.slots} 个名额 +
+
+
+

+ + 抽奖

-

- 已有 {state.entry_count} 名回帖用户参与 - {state.drawn ? " · 已开奖" : state.closed ? " · 已截止" : ""} - {state.eligible && !state.drawn ? " · 你已在奖池中" : ""} - {state.ends_at && !state.drawn ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""} -

- {!state.drawn && ( -

对本帖发表评论即可参与(楼主除外)

- )} + {statusBadge.label}
-
- {state.can_close && ( - - )} - {state.can_draw && ( + {state.can_draw && ( +
- )} -
+
+ )}
+ + {(state.can_draw || (state.eligible && !state.drawn)) && ( +

+ {state.can_draw ? `已有 ${state.entry_count} 名主评用户入池` : null} + {state.can_draw && state.eligible && !state.drawn ? " · " : null} + {state.eligible && !state.drawn ? "你已在奖池中" : null} +

+ )} + + {!state.drawn && state.ends_at ? ( + + ) : null} + + {prizes.length > 0 && ( +
    + {prizes.map((prize, i) => ( +
  • +
    + + + {prize.name} + ×{prize.count} + +
    +
  • + ))} +
+ )} + {state.drawn && ( -
-

- {state.winners.length > 0 ? "中奖名单" : "无人回帖,开奖结果为空"} -

- {state.winners.length > 0 && ( -
    + <> +

    中奖名单

    + {state.winners.length > 0 ? ( +
      {state.winners.map((w) => ( -
    • - - - - {w.nickname || w.username} - - {w.comment_id && w.floor ? ( - - #{w.floor} - - ) : null} - +
    • +
      + +
      + + {w.nickname || w.username} + + {w.prize_name ? ( + {w.prize_name} + ) : null} +
      +
    • ))}
    + ) : ( +

    无人回帖,开奖结果为空

    )} -
+ )} + +
+ + + {state.drawn ? "开奖完成" : "抽奖进行中"} + + {state.ends_at ? ( + 截止时间: {formatEndsAt(state.ends_at)} + ) : null} +
); } diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 1175159..699d800 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -254,6 +254,7 @@ export interface QuestionState { accepted_comment_id: number; accepted_floor: number; accepted_answer?: AcceptedAnswer | null; + solved_at?: string; can_accept: boolean; can_solve: boolean; can_reopen: boolean; @@ -279,6 +280,8 @@ export interface PollState { multi: boolean; closed: boolean; anonymous: boolean; + ends_at?: string; + closed_at?: string; total_votes: number; voters: number; my_options: number[]; @@ -298,7 +301,11 @@ export interface BountyState { ends_at?: string; settled: boolean; can_accept: boolean; - can_refund: boolean; +} + +export interface LotteryPrize { + name: string; + count: number; } export interface LotteryEntrant { @@ -309,17 +316,21 @@ export interface LotteryEntrant { /** 中奖楼层评论 ID(该用户最早主评,每人仅一条) */ comment_id?: number; floor?: number; + prize_name?: string; } export interface LotteryState { slots: number; + prizes: LotteryPrize[]; drawn: boolean; closed: boolean; ends_at?: string; entry_count: number; eligible?: boolean; + /** 新号入池冷静期小时数(与发帖冷静期同源,0=关闭) */ + cooldown_hours?: number; can_draw: boolean; - can_close: boolean; + can_edit_prizes?: boolean; winners: LotteryEntrant[]; } @@ -938,14 +949,6 @@ export async function apiAcceptBounty(postId: string, commentId: number) { 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) { const res = await fetchWithRefresh(`/api/posts/${postId}/question/accept`, { method: "POST", @@ -971,14 +974,6 @@ export async function apiReopenQuestion(postId: string) { 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) { const res = await fetchWithRefresh(`/api/posts/${postId}/lottery/draw`, { method: "POST", diff --git a/frontend/lib/postMeta.ts b/frontend/lib/postMeta.ts index 441cd25..a97dec4 100644 --- a/frontend/lib/postMeta.ts +++ b/frontend/lib/postMeta.ts @@ -5,7 +5,7 @@ export const POST_TYPES = [ { value: "question", label: "问答", hint: "提问求解" }, { value: "poll", label: "投票", hint: "发起投票,读者可参与" }, { value: "bounty", label: "悬赏", hint: "发布时托管积分,采纳后发放" }, - { value: "lottery", label: "抽奖", hint: "回帖即参与,截止自动开奖" }, + { value: "lottery", label: "抽奖", hint: "奖品类型+数量入池,评论后锁定奖品" }, ] as const; 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 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 { if (!t) return "discussion"; @@ -74,12 +75,13 @@ export function typeStatusClass(postType?: string, typeStatus?: string): string break; case "bounty": if (s === "solved") kind = "solved"; - else if (s === "expired") kind = "expired"; + // 已过期:与投票「已结束」同色(灰胶囊,标识已到期) + else if (s === "expired") kind = "closed"; else kind = "open"; break; case "lottery": - if (s === "drawn") kind = "drawn"; - else if (s === "closed") kind = "closed"; + // 已开奖 / 已截止:与投票「已结束」同色(灰胶囊) + if (s === "drawn" || s === "closed") kind = "closed"; else kind = "open"; break; default: diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index ce4e94a..a419cbe 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.