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

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

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

View File

@@ -25,7 +25,7 @@ func main() {
// 过期/已吊销 refresh token 定期清理
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 {

View File

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

View File

@@ -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": "操作成功"})

View File

@@ -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()})
}

View File

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

View File

@@ -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"`
}

View File

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

View File

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

View File

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

9
frontend/AGENTS.md Normal file
View File

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

1
frontend/CLAUDE.md Normal file
View File

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

View File

@@ -182,30 +182,106 @@ body {
box-shadow: var(--shadow);
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 {

View File

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

View File

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

View File

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

View File

@@ -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 (
<section className="j13-accepted-answer" aria-label={variant === "question" ? "问答 · 已解决" : label}>
<header className="j13-accepted-answer__head">
<div className="j13-accepted-answer__meta min-w-0">
{variant === "question" && (
<p className="j13-accepted-answer__status">问答 · 已解决</p>
)}
<div className="j13-accepted-answer__author min-w-0">
<span className="badge-accepted shrink-0">
<CheckCircle2 size={13} aria-hidden />
{label}
</span>
<Link
href={`/u/${answer.user.id}`}
className="shrink-0"
aria-label={name}
>
<Avatar name={name} src={answer.user.avatar || undefined} size={36} />
</Link>
<div className="min-w-0 flex flex-col gap-0.5">
<Link
href={`/u/${answer.user.id}`}
className="truncate text-[14px] font-semibold hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{name}
</Link>
<p className="meta text-[12px] flex items-center gap-1.5 flex-wrap">
<time dateTime={answer.created_at}>
{new Date(answer.created_at).toLocaleString("zh-CN")}
</time>
{answer.floor > 0 && (
<>
<span aria-hidden>·</span>
<a
href={`#comment-${answer.id}`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
#{answer.floor}
</a>
</>
)}
</p>
</div>
</div>
<section
className={`j13-interact-card j13-typed-card j13-accepted-answer${isQuestion ? " is-question" : " is-bounty"}`}
aria-label={`${title} · ${statusLabel}`}
>
<div className="j13-typed-head">
<div className="j13-typed-head-main">
<h2 className={`j13-typed-title ${isQuestion ? "is-question" : "is-bounty"}`}>
{isQuestion ? (
<CircleHelp size={16} strokeWidth={2.25} aria-hidden />
) : (
<Coins size={16} strokeWidth={2.25} aria-hidden />
)}
{title}
</h2>
<span className="badge-status badge-status-solved shrink-0">{statusLabel}</span>
</div>
{showReopen && <AcceptedAnswerReopen postId={postId!} />}
</header>
</div>
<div className="j13-accepted-answer__author">
<span className="j13-accepted-answer__chip">
<CheckCircle2 size={12} strokeWidth={2.25} aria-hidden />
{answerLabel}
</span>
<Link href={`/u/${answer.user.id}`} className="shrink-0" aria-label={name}>
<Avatar name={name} src={answer.user.avatar || undefined} size={32} />
</Link>
<div className="min-w-0 flex flex-col gap-0.5">
<Link
href={`/u/${answer.user.id}`}
className="truncate text-[13.5px] font-semibold hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{name}
</Link>
<p className="meta text-[12px] flex items-center gap-1.5 flex-wrap !mb-0">
<time dateTime={answer.created_at}>
{new Date(answer.created_at).toLocaleString("zh-CN")}
</time>
{answer.floor > 0 && (
<>
<span aria-hidden>·</span>
<a
href={`#comment-${answer.id}`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
#{answer.floor}
</a>
</>
)}
</p>
</div>
</div>
<div className="j13-accepted-answer__body">
{answer.deleted ? (
@@ -81,6 +88,25 @@ export default async function AcceptedAnswerCard({
<MarkdownBody content={answer.content} className="prose-content prose-comment" />
)}
</div>
<div className="j13-typed-foot">
<span className="j13-typed-foot-status">
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
{isQuestion ? "已采纳答案" : "悬赏已发放"}
</span>
{endLabel ? (
<span className="j13-typed-foot-time">结束时间: {endLabel}</span>
) : null}
</div>
</section>
);
}
function formatEndsAt(iso?: string) {
if (!iso) return "";
try {
return new Date(iso).toLocaleString("zh-CN");
} catch {
return iso;
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.