feat(posts): 类型帖截止时间、抽奖奖品表与悬赏发帖体验
统一投票/悬赏/抽奖截止与状态展示,抽奖支持多奖品行;发帖页悬赏积分对齐表格输入,并在余额不足时于底部就地提示。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -25,7 +25,7 @@ func main() {
|
|||||||
// 过期/已吊销 refresh token 定期清理
|
// 过期/已吊销 refresh token 定期清理
|
||||||
service.StartRefreshTokenCleanup(model.DB)
|
service.StartRefreshTokenCleanup(model.DB)
|
||||||
// 悬赏过期退回 / 抽奖到期开奖
|
// 悬赏过期退回 / 抽奖到期开奖
|
||||||
service.StartTypedPostSettler(service.NewPostService(model.DB))
|
service.StartTypedPostSettler(service.NewPostService(model.DB).WithDevMode(cfg.DevMode))
|
||||||
|
|
||||||
r, err := router.Setup(cfg)
|
r, err := router.Setup(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ func (h *Handlers) CreateComment(c *gin.Context) {
|
|||||||
if authorID, e := h.Post.GetAuthorID(uint(id)); e == nil {
|
if authorID, e := h.Post.GetAuthorID(uint(id)); e == nil {
|
||||||
h.Notification.Create(authorID, claims.ID, model.NotificationTypeComment, uint(id), comment.ID, req.Content)
|
h.Notification.Create(authorID, claims.ID, model.NotificationTypeComment, uint(id), comment.ID, req.Content)
|
||||||
}
|
}
|
||||||
|
// 抽奖帖:主评入池并记录客户端 IP(同 IP 去重)
|
||||||
|
h.Post.EnsureLotteryEntry(uint(id), claims.ID, comment.ID, c.ClientIP())
|
||||||
} else {
|
} else {
|
||||||
// 子回复:通知父评论作者(Notification.Create 内部排除自我通知)
|
// 子回复:通知父评论作者(Notification.Create 内部排除自我通知)
|
||||||
h.Notification.Create(parent.UserID, claims.ID, model.NotificationTypeReply, uint(id), comment.ID, req.Content)
|
h.Notification.Create(parent.UserID, claims.ID, model.NotificationTypeReply, uint(id), comment.ID, req.Content)
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ func (h *Handlers) AdminApproveComment(c *gin.Context) {
|
|||||||
h.writeModerationError(c, err)
|
h.writeModerationError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 抽奖帖主评通过审核后入池(无评论者 IP,回退其最近登录 IP)
|
||||||
|
var cm model.Comment
|
||||||
|
if e := model.DB.Select("id", "post_id", "user_id", "parent_id").First(&cm, uint(id)).Error; e == nil && cm.ParentID == nil {
|
||||||
|
ip := h.Post.LatestSuccessLoginIP(cm.UserID)
|
||||||
|
h.Post.EnsureLotteryEntry(cm.PostID, cm.UserID, cm.ID, ip)
|
||||||
|
}
|
||||||
h.broadcastFeedChanged("comment", postID, boardID, actor.ID)
|
h.broadcastFeedChanged("comment", postID, boardID, actor.ID)
|
||||||
h.broadcastModerationChanged()
|
h.broadcastModerationChanged()
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "操作成功"})
|
c.JSON(http.StatusOK, gin.H{"message": "操作成功"})
|
||||||
|
|||||||
@@ -449,6 +449,9 @@ func respondPostModError(c *gin.Context, err error) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
case errors.Is(err, service.ErrPostForbidden):
|
case errors.Is(err, service.ErrPostForbidden):
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, service.ErrPollOptionsLocked),
|
||||||
|
errors.Is(err, service.ErrLotteryPrizesLocked):
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ func (h *Handlers) respondInteractError(c *gin.Context, err error) {
|
|||||||
errors.Is(err, service.ErrPollClosed),
|
errors.Is(err, service.ErrPollClosed),
|
||||||
errors.Is(err, service.ErrPollOptionsLocked),
|
errors.Is(err, service.ErrPollOptionsLocked),
|
||||||
errors.Is(err, service.ErrLotteryDrawn),
|
errors.Is(err, service.ErrLotteryDrawn),
|
||||||
errors.Is(err, service.ErrLotteryClosed),
|
errors.Is(err, service.ErrBountySettled):
|
||||||
errors.Is(err, service.ErrBountySettled),
|
|
||||||
errors.Is(err, service.ErrBountyExpired):
|
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -148,38 +146,6 @@ func (h *Handlers) AcceptBounty(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefundBounty 退回悬赏
|
|
||||||
func (h *Handlers) RefundBounty(c *gin.Context) {
|
|
||||||
claims := middleware.CurrentUser(c)
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
post, err := h.Post.RefundBounty(h.loadActor(claims.ID), claims.ID, uint(id))
|
|
||||||
if err != nil {
|
|
||||||
h.respondInteractError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloseLottery 截止抽奖
|
|
||||||
func (h *Handlers) CloseLottery(c *gin.Context) {
|
|
||||||
claims := middleware.CurrentUser(c)
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
post, err := h.Post.CloseLotteryEntries(h.loadActor(claims.ID), claims.ID, uint(id))
|
|
||||||
if err != nil {
|
|
||||||
h.respondInteractError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DrawLottery 开奖
|
// DrawLottery 开奖
|
||||||
func (h *Handlers) DrawLottery(c *gin.Context) {
|
func (h *Handlers) DrawLottery(c *gin.Context) {
|
||||||
claims := middleware.CurrentUser(c)
|
claims := middleware.CurrentUser(c)
|
||||||
|
|||||||
@@ -259,11 +259,13 @@ type PostPollVote struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostLotteryEntry 抽奖报名
|
// PostLotteryEntry 抽奖报名(主评入池时登记;同 IP 去重用 ClientIP)
|
||||||
type PostLotteryEntry struct {
|
type PostLotteryEntry struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
PostID uint `gorm:"uniqueIndex:idx_lottery_entry;not null" json:"post_id"`
|
PostID uint `gorm:"uniqueIndex:idx_lottery_entry;not null" json:"post_id"`
|
||||||
UserID uint `gorm:"uniqueIndex:idx_lottery_entry;not null" json:"user_id"`
|
UserID uint `gorm:"uniqueIndex:idx_lottery_entry;not null" json:"user_id"`
|
||||||
|
CommentID uint `gorm:"not null;default:0" json:"comment_id"` // 入池时的主评 ID
|
||||||
|
ClientIP string `gorm:"size:45;not null;default:''" json:"-"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
|
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
|
||||||
boardSvc := service.NewBoardService(model.DB)
|
boardSvc := service.NewBoardService(model.DB)
|
||||||
settingSvc := service.NewSettingService(model.DB)
|
settingSvc := service.NewSettingService(model.DB)
|
||||||
postSvc := service.NewPostService(model.DB).WithSetting(settingSvc)
|
postSvc := service.NewPostService(model.DB).WithSetting(settingSvc).WithDevMode(cfg.DevMode)
|
||||||
commentSvc := service.NewCommentService(model.DB)
|
commentSvc := service.NewCommentService(model.DB)
|
||||||
likeSvc := service.NewLikeService(model.DB)
|
likeSvc := service.NewLikeService(model.DB)
|
||||||
notifSvc := service.NewNotificationService(model.DB)
|
notifSvc := service.NewNotificationService(model.DB)
|
||||||
@@ -144,8 +144,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
api.POST("/posts/:id/question/solve", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.SolveQuestion)
|
api.POST("/posts/:id/question/solve", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.SolveQuestion)
|
||||||
api.POST("/posts/:id/question/reopen", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.ReopenQuestion)
|
api.POST("/posts/:id/question/reopen", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.ReopenQuestion)
|
||||||
api.POST("/posts/:id/bounty/accept", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.AcceptBounty)
|
api.POST("/posts/:id/bounty/accept", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.AcceptBounty)
|
||||||
api.POST("/posts/:id/bounty/refund", h.RefundBounty)
|
|
||||||
api.POST("/posts/:id/lottery/close", h.CloseLottery)
|
|
||||||
api.POST("/posts/:id/lottery/draw", h.DrawLottery)
|
api.POST("/posts/:id/lottery/draw", h.DrawLottery)
|
||||||
api.DELETE("/posts/:id", h.DeletePost)
|
api.DELETE("/posts/:id", h.DeletePost)
|
||||||
api.PUT("/posts/:id/pin", h.TogglePin)
|
api.PUT("/posts/:id/pin", h.TogglePin)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ var (
|
|||||||
type PostService struct {
|
type PostService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
setting *SettingService
|
setting *SettingService
|
||||||
|
devMode bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPostService(db *gorm.DB) *PostService {
|
func NewPostService(db *gorm.DB) *PostService {
|
||||||
@@ -34,6 +35,12 @@ func (s *PostService) WithSetting(setting *SettingService) *PostService {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithDevMode 开发态:抽奖同 IP 去重对回环地址放宽
|
||||||
|
func (s *PostService) WithDevMode(dev bool) *PostService {
|
||||||
|
s.devMode = dev
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// PostListQuery 帖子列表查询参数
|
// PostListQuery 帖子列表查询参数
|
||||||
type PostListQuery struct {
|
type PostListQuery struct {
|
||||||
BoardID uint
|
BoardID uint
|
||||||
@@ -445,6 +452,7 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto
|
|||||||
// 惰性结算:悬赏过期退回 / 抽奖到期开奖
|
// 惰性结算:悬赏过期退回 / 抽奖到期开奖
|
||||||
_ = s.settleExpiredBountyIfNeeded(&post)
|
_ = s.settleExpiredBountyIfNeeded(&post)
|
||||||
_ = s.settleDueLotteryIfNeeded(&post)
|
_ = s.settleDueLotteryIfNeeded(&post)
|
||||||
|
_ = s.settleDuePollIfNeeded(&post)
|
||||||
if post.TypeMeta != "" {
|
if post.TypeMeta != "" {
|
||||||
var fresh model.Post
|
var fresh model.Post
|
||||||
if err := s.db.Select("type_meta").First(&fresh, post.ID).Error; err == nil {
|
if err := s.db.Select("type_meta").First(&fresh, post.ID).Error; err == nil {
|
||||||
@@ -729,9 +737,8 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
|
|||||||
bm.Expired = false
|
bm.Expired = false
|
||||||
bm.AcceptedCommentID = 0
|
bm.AcceptedCommentID = 0
|
||||||
if status == model.ContentStatusPublished {
|
if status == model.ContentStatusPublished {
|
||||||
days := normalizeExpireDays(bm.ExpireDays, 7, []int{3, 7, 14, 30})
|
bm.EndsAt = resolveEndsAtOnPublish(bm.EndsAt, bm.ExpireDays, 7, []int{3, 7, 14, 30}, now)
|
||||||
bm.ExpireDays = 0
|
bm.ExpireDays = 0
|
||||||
bm.EndsAt = endsAtFromDays(days, now)
|
|
||||||
}
|
}
|
||||||
typeMeta, _ = encodeMeta(bm)
|
typeMeta, _ = encodeMeta(bm)
|
||||||
}
|
}
|
||||||
@@ -741,14 +748,26 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
|
|||||||
if lm.WinnerIDs == nil {
|
if lm.WinnerIDs == nil {
|
||||||
lm.WinnerIDs = []uint{}
|
lm.WinnerIDs = []uint{}
|
||||||
}
|
}
|
||||||
|
if lm.Prizes == nil {
|
||||||
|
lm.Prizes = []LotteryPrize{}
|
||||||
|
}
|
||||||
|
lm.Slots = lotterySlotsFromPrizes(lm.Prizes)
|
||||||
if status == model.ContentStatusPublished {
|
if status == model.ContentStatusPublished {
|
||||||
days := normalizeExpireDays(lm.ExpireDays, 7, []int{1, 3, 7, 14})
|
lm.EndsAt = resolveEndsAtOnPublish(lm.EndsAt, lm.ExpireDays, 7, []int{1, 3, 7, 14}, now)
|
||||||
lm.ExpireDays = 0
|
lm.ExpireDays = 0
|
||||||
lm.EndsAt = endsAtFromDays(days, now)
|
|
||||||
}
|
}
|
||||||
typeMeta, _ = encodeMeta(lm)
|
typeMeta, _ = encodeMeta(lm)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if postType == model.PostTypePoll {
|
||||||
|
pm, _ := parsePollMeta(typeMeta)
|
||||||
|
if status == model.ContentStatusPublished {
|
||||||
|
pm.EndsAt = resolveEndsAtOnPublish(pm.EndsAt, pm.ExpireDays, 7, []int{1, 3, 7, 14}, now)
|
||||||
|
pm.ExpireDays = 0
|
||||||
|
}
|
||||||
|
typeMeta, _ = encodeMeta(pm)
|
||||||
|
}
|
||||||
|
|
||||||
post := &model.Post{
|
post := &model.Post{
|
||||||
BoardID: in.BoardID, UserID: in.UserID,
|
BoardID: in.BoardID, UserID: in.UserID,
|
||||||
Title: title, Content: content, Tags: in.Tags,
|
Title: title, Content: content, Tags: in.Tags,
|
||||||
@@ -892,9 +911,15 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
|
|||||||
neu.Multi = old.Multi
|
neu.Multi = old.Multi
|
||||||
neu.Anonymous = old.Anonymous
|
neu.Anonymous = old.Anonymous
|
||||||
neu.Closed = old.Closed
|
neu.Closed = old.Closed
|
||||||
|
neu.EndsAt = old.EndsAt
|
||||||
|
neu.ClosedAt = old.ClosedAt
|
||||||
|
neu.ExpireDays = 0
|
||||||
meta, _ = encodeMeta(neu)
|
meta, _ = encodeMeta(neu)
|
||||||
} else {
|
} else {
|
||||||
neu.Closed = old.Closed
|
neu.Closed = old.Closed
|
||||||
|
neu.EndsAt = old.EndsAt
|
||||||
|
neu.ClosedAt = old.ClosedAt
|
||||||
|
neu.ExpireDays = 0
|
||||||
meta, _ = encodeMeta(neu)
|
meta, _ = encodeMeta(neu)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -907,8 +932,16 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
|
|||||||
neu.WinnerIDs = old.WinnerIDs
|
neu.WinnerIDs = old.WinnerIDs
|
||||||
neu.Closed = old.Closed
|
neu.Closed = old.Closed
|
||||||
neu.EndsAt = old.EndsAt
|
neu.EndsAt = old.EndsAt
|
||||||
if old.Drawn {
|
// 已有评论或已开奖:冻结奖品(名额由奖品合计推导)
|
||||||
|
locked := old.Drawn || post.CommentCount > 0
|
||||||
|
if locked {
|
||||||
|
if !lotteryPrizesEqual(neu.Prizes, old.Prizes) || neu.Slots != old.Slots {
|
||||||
|
return nil, ErrLotteryPrizesLocked
|
||||||
|
}
|
||||||
|
neu.Prizes = old.Prizes
|
||||||
neu.Slots = old.Slots
|
neu.Slots = old.Slots
|
||||||
|
} else {
|
||||||
|
neu.Slots = lotterySlotsFromPrizes(neu.Prizes)
|
||||||
}
|
}
|
||||||
meta, _ = encodeMeta(neu)
|
meta, _ = encodeMeta(neu)
|
||||||
}
|
}
|
||||||
@@ -920,6 +953,7 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
|
|||||||
// 解题态走专用 API,编辑帖子不覆盖
|
// 解题态走专用 API,编辑帖子不覆盖
|
||||||
neu.Solved = old.Solved
|
neu.Solved = old.Solved
|
||||||
neu.AcceptedCommentID = old.AcceptedCommentID
|
neu.AcceptedCommentID = old.AcceptedCommentID
|
||||||
|
neu.SolvedAt = old.SolvedAt
|
||||||
meta, _ = encodeMeta(neu)
|
meta, _ = encodeMeta(neu)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -16,15 +17,19 @@ import (
|
|||||||
// ---- TypeMeta 结构 ----
|
// ---- TypeMeta 结构 ----
|
||||||
|
|
||||||
type QuestionMeta struct {
|
type QuestionMeta struct {
|
||||||
Solved bool `json:"solved"`
|
Solved bool `json:"solved"`
|
||||||
AcceptedCommentID uint `json:"accepted_comment_id"`
|
AcceptedCommentID uint `json:"accepted_comment_id"`
|
||||||
|
SolvedAt string `json:"solved_at,omitempty"` // 解决时刻 RFC3339
|
||||||
}
|
}
|
||||||
|
|
||||||
type PollMeta struct {
|
type PollMeta struct {
|
||||||
Options []string `json:"options"`
|
Options []string `json:"options"`
|
||||||
Multi bool `json:"multi"`
|
Multi bool `json:"multi"`
|
||||||
Closed bool `json:"closed"`
|
Closed bool `json:"closed"`
|
||||||
Anonymous bool `json:"anonymous"` // 默认 true;缺省字段在 parse 时补 true
|
Anonymous bool `json:"anonymous"` // 默认 true;缺省字段在 parse 时补 true
|
||||||
|
EndsAt string `json:"ends_at,omitempty"`
|
||||||
|
ClosedAt string `json:"closed_at,omitempty"` // 实际结束时刻;提前结束时与 ends_at 可能不同
|
||||||
|
ExpireDays int `json:"expire_days,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BountyMeta struct {
|
type BountyMeta struct {
|
||||||
@@ -37,13 +42,19 @@ type BountyMeta struct {
|
|||||||
ExpireDays int `json:"expire_days,omitempty"` // 发帖入参,规范化后可清
|
ExpireDays int `json:"expire_days,omitempty"` // 发帖入参,规范化后可清
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LotteryPrize struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
type LotteryMeta struct {
|
type LotteryMeta struct {
|
||||||
Slots int `json:"slots"`
|
Prizes []LotteryPrize `json:"prizes"`
|
||||||
Drawn bool `json:"drawn"`
|
Slots int `json:"slots"` // 由 prizes 数量合计推导,兼容旧数据
|
||||||
WinnerIDs []uint `json:"winner_ids"`
|
Drawn bool `json:"drawn"`
|
||||||
Closed bool `json:"closed"`
|
WinnerIDs []uint `json:"winner_ids"`
|
||||||
EndsAt string `json:"ends_at"`
|
Closed bool `json:"closed"`
|
||||||
ExpireDays int `json:"expire_days,omitempty"`
|
EndsAt string `json:"ends_at"`
|
||||||
|
ExpireDays int `json:"expire_days,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -53,10 +64,9 @@ var (
|
|||||||
ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置")
|
ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置")
|
||||||
ErrBountySettled = errors.New("悬赏已结算")
|
ErrBountySettled = errors.New("悬赏已结算")
|
||||||
ErrBountyNotEscrow = errors.New("悬赏未托管")
|
ErrBountyNotEscrow = errors.New("悬赏未托管")
|
||||||
ErrBountyExpired = errors.New("悬赏已过期")
|
|
||||||
ErrLotteryDrawn = errors.New("已开奖")
|
ErrLotteryDrawn = errors.New("已开奖")
|
||||||
ErrLotteryClosed = errors.New("抽奖已截止")
|
|
||||||
ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖")
|
ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖")
|
||||||
|
ErrLotteryPrizesLocked = errors.New("已有评论,无法修改奖品设置")
|
||||||
ErrQuestionNotType = errors.New("本文不是问答帖")
|
ErrQuestionNotType = errors.New("本文不是问答帖")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -73,10 +83,13 @@ func parseQuestionMeta(raw string) (*QuestionMeta, error) {
|
|||||||
|
|
||||||
func parsePollMeta(raw string) (*PollMeta, error) {
|
func parsePollMeta(raw string) (*PollMeta, error) {
|
||||||
var wire struct {
|
var wire struct {
|
||||||
Options []string `json:"options"`
|
Options []string `json:"options"`
|
||||||
Multi bool `json:"multi"`
|
Multi bool `json:"multi"`
|
||||||
Closed bool `json:"closed"`
|
Closed bool `json:"closed"`
|
||||||
Anonymous *bool `json:"anonymous"`
|
Anonymous *bool `json:"anonymous"`
|
||||||
|
EndsAt string `json:"ends_at"`
|
||||||
|
ClosedAt string `json:"closed_at"`
|
||||||
|
ExpireDays int `json:"expire_days"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(raw), &wire); err != nil {
|
if err := json.Unmarshal([]byte(raw), &wire); err != nil {
|
||||||
return nil, errors.New("投票配置无效")
|
return nil, errors.New("投票配置无效")
|
||||||
@@ -86,10 +99,13 @@ func parsePollMeta(raw string) (*PollMeta, error) {
|
|||||||
anon = *wire.Anonymous
|
anon = *wire.Anonymous
|
||||||
}
|
}
|
||||||
return &PollMeta{
|
return &PollMeta{
|
||||||
Options: wire.Options,
|
Options: wire.Options,
|
||||||
Multi: wire.Multi,
|
Multi: wire.Multi,
|
||||||
Closed: wire.Closed,
|
Closed: wire.Closed,
|
||||||
Anonymous: anon,
|
Anonymous: anon,
|
||||||
|
EndsAt: strings.TrimSpace(wire.EndsAt),
|
||||||
|
ClosedAt: strings.TrimSpace(wire.ClosedAt),
|
||||||
|
ExpireDays: wire.ExpireDays,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,9 +122,49 @@ func parseLotteryMeta(raw string) (*LotteryMeta, error) {
|
|||||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||||
return nil, errors.New("抽奖配置无效")
|
return nil, errors.New("抽奖配置无效")
|
||||||
}
|
}
|
||||||
|
// 旧数据只有 slots:合成单一奖品项
|
||||||
|
if len(m.Prizes) == 0 && m.Slots > 0 {
|
||||||
|
m.Prizes = []LotteryPrize{{Name: "奖品", Count: m.Slots}}
|
||||||
|
}
|
||||||
|
if len(m.Prizes) > 0 {
|
||||||
|
m.Slots = lotterySlotsFromPrizes(m.Prizes)
|
||||||
|
}
|
||||||
return &m, nil
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lotterySlotsFromPrizes(prizes []LotteryPrize) int {
|
||||||
|
n := 0
|
||||||
|
for _, p := range prizes {
|
||||||
|
if p.Count > 0 {
|
||||||
|
n += p.Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func lotteryPrizesEqual(a, b []LotteryPrize) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i].Name != b[i].Name || a[i].Count != b[i].Count {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandLotteryPrizeNames 按奖项顺序展开为与名额等长的名称列表(开奖结果按序对应)
|
||||||
|
func expandLotteryPrizeNames(prizes []LotteryPrize) []string {
|
||||||
|
out := make([]string, 0, lotterySlotsFromPrizes(prizes))
|
||||||
|
for _, p := range prizes {
|
||||||
|
for i := 0; i < p.Count; i++ {
|
||||||
|
out = append(out, p.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func encodeMeta(v any) (string, error) {
|
func encodeMeta(v any) (string, error) {
|
||||||
b, err := json.Marshal(v)
|
b, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -145,6 +201,35 @@ func parseEndsAt(s string) (time.Time, bool) {
|
|||||||
return t, true
|
return t, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeOptionalEndsAt 校验自定义截止时刻:至少晚于现在 1 小时,最远 90 天。空串表示未自定义。
|
||||||
|
func normalizeOptionalEndsAt(endsAt string) (string, error) {
|
||||||
|
endsAt = strings.TrimSpace(endsAt)
|
||||||
|
if endsAt == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
t, ok := parseEndsAt(endsAt)
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New("截止时间格式无效")
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if !t.After(now.Add(time.Hour)) {
|
||||||
|
return "", errors.New("截止时间至少晚于当前 1 小时")
|
||||||
|
}
|
||||||
|
if t.After(now.Add(90 * 24 * time.Hour)) {
|
||||||
|
return "", errors.New("截止时间最远 90 天")
|
||||||
|
}
|
||||||
|
return t.UTC().Format(time.RFC3339), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveEndsAtOnPublish 发布时确定 ends_at:已有自定义时刻则保留,否则按天数推算。
|
||||||
|
func resolveEndsAtOnPublish(existing string, expireDays, def int, allowed []int, from time.Time) string {
|
||||||
|
if t, ok := parseEndsAt(existing); ok {
|
||||||
|
return t.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
days := normalizeExpireDays(expireDays, def, allowed)
|
||||||
|
return endsAtFromDays(days, from)
|
||||||
|
}
|
||||||
|
|
||||||
// ComputeTypeStatus 列表徽章状态
|
// ComputeTypeStatus 列表徽章状态
|
||||||
func ComputeTypeStatus(postType, typeMeta string) string {
|
func ComputeTypeStatus(postType, typeMeta string) string {
|
||||||
pt := model.NormalizePostType(postType)
|
pt := model.NormalizePostType(postType)
|
||||||
@@ -175,7 +260,10 @@ func ComputeTypeStatus(postType, typeMeta string) string {
|
|||||||
if m.AcceptedCommentID > 0 {
|
if m.AcceptedCommentID > 0 {
|
||||||
return "solved"
|
return "solved"
|
||||||
}
|
}
|
||||||
if m.Expired || m.Refunded {
|
if m.Expired {
|
||||||
|
return "expired"
|
||||||
|
}
|
||||||
|
if m.Refunded {
|
||||||
return "expired"
|
return "expired"
|
||||||
}
|
}
|
||||||
return "open"
|
return "open"
|
||||||
@@ -240,6 +328,14 @@ func NormalizeAndValidateTypeMeta(postType, meta string) (string, error) {
|
|||||||
return "", errors.New("最多 10 个投票选项")
|
return "", errors.New("最多 10 个投票选项")
|
||||||
}
|
}
|
||||||
m.Options = opts
|
m.Options = opts
|
||||||
|
if ends, err := normalizeOptionalEndsAt(m.EndsAt); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if ends != "" {
|
||||||
|
m.EndsAt = ends
|
||||||
|
m.ExpireDays = 0
|
||||||
|
} else {
|
||||||
|
m.ExpireDays = normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14})
|
||||||
|
}
|
||||||
return encodeMeta(m)
|
return encodeMeta(m)
|
||||||
case model.PostTypeBounty:
|
case model.PostTypeBounty:
|
||||||
if meta == "" {
|
if meta == "" {
|
||||||
@@ -255,50 +351,102 @@ func NormalizeAndValidateTypeMeta(postType, meta string) (string, error) {
|
|||||||
if m.Points > 100000 {
|
if m.Points > 100000 {
|
||||||
return "", errors.New("悬赏积分过高")
|
return "", errors.New("悬赏积分过高")
|
||||||
}
|
}
|
||||||
days := normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30})
|
if ends, err := normalizeOptionalEndsAt(m.EndsAt); err != nil {
|
||||||
m.ExpireDays = days
|
return "", err
|
||||||
// ends_at 在 Create/Approve 时按发布时间写入;编辑保留旧值
|
} else if ends != "" {
|
||||||
|
m.EndsAt = ends
|
||||||
|
m.ExpireDays = 0
|
||||||
|
} else {
|
||||||
|
m.ExpireDays = normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30})
|
||||||
|
}
|
||||||
|
// ends_at 若仍空,在 Create/Approve 时按发布时间写入
|
||||||
return encodeMeta(m)
|
return encodeMeta(m)
|
||||||
case model.PostTypeLottery:
|
case model.PostTypeLottery:
|
||||||
if meta == "" {
|
if meta == "" {
|
||||||
return "", errors.New("请设置中奖名额")
|
return "", errors.New("请设置奖品")
|
||||||
}
|
}
|
||||||
m, err := parseLotteryMeta(meta)
|
m, err := parseLotteryMeta(meta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if m.Slots < 1 {
|
prizes := make([]LotteryPrize, 0, len(m.Prizes))
|
||||||
return "", errors.New("中奖名额至少为 1")
|
for _, p := range m.Prizes {
|
||||||
|
name := strings.TrimSpace(p.Name)
|
||||||
|
if name == "" {
|
||||||
|
return "", errors.New("请填写奖品名称")
|
||||||
|
}
|
||||||
|
if 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 {
|
if len(prizes) < 1 {
|
||||||
return "", errors.New("中奖名额过多")
|
return "", errors.New("请至少添加一种奖品")
|
||||||
}
|
}
|
||||||
|
if len(prizes) > 10 {
|
||||||
|
return "", errors.New("最多 10 种奖品")
|
||||||
|
}
|
||||||
|
total := lotterySlotsFromPrizes(prizes)
|
||||||
|
if total < 1 {
|
||||||
|
return "", errors.New("奖品总名额至少为 1")
|
||||||
|
}
|
||||||
|
if total > 100 {
|
||||||
|
return "", errors.New("奖品总名额过多")
|
||||||
|
}
|
||||||
|
m.Prizes = prizes
|
||||||
|
m.Slots = total
|
||||||
if m.WinnerIDs == nil {
|
if m.WinnerIDs == nil {
|
||||||
m.WinnerIDs = []uint{}
|
m.WinnerIDs = []uint{}
|
||||||
}
|
}
|
||||||
days := normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14})
|
if ends, err := normalizeOptionalEndsAt(m.EndsAt); err != nil {
|
||||||
m.ExpireDays = days
|
return "", err
|
||||||
|
} else if ends != "" {
|
||||||
|
m.EndsAt = ends
|
||||||
|
m.ExpireDays = 0
|
||||||
|
} else {
|
||||||
|
m.ExpireDays = normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14})
|
||||||
|
}
|
||||||
return encodeMeta(m)
|
return encodeMeta(m)
|
||||||
default:
|
default:
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureDeadlineOnPublish 首次公开发布时补写 ends_at(悬赏/抽奖)
|
// EnsureDeadlineOnPublish 首次公开发布时补写 ends_at(投票 / 悬赏 / 抽奖)
|
||||||
func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (string, bool) {
|
func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (string, bool) {
|
||||||
pt := model.NormalizePostType(postType)
|
pt := model.NormalizePostType(postType)
|
||||||
switch pt {
|
switch pt {
|
||||||
|
case model.PostTypePoll:
|
||||||
|
m, err := parsePollMeta(typeMeta)
|
||||||
|
if err != nil {
|
||||||
|
return typeMeta, false
|
||||||
|
}
|
||||||
|
if _, ok := parseEndsAt(m.EndsAt); ok {
|
||||||
|
return typeMeta, false
|
||||||
|
}
|
||||||
|
m.EndsAt = resolveEndsAtOnPublish("", m.ExpireDays, 7, []int{1, 3, 7, 14}, publishedAt)
|
||||||
|
m.ExpireDays = 0
|
||||||
|
raw, err := encodeMeta(m)
|
||||||
|
if err != nil {
|
||||||
|
return typeMeta, false
|
||||||
|
}
|
||||||
|
return raw, true
|
||||||
case model.PostTypeBounty:
|
case model.PostTypeBounty:
|
||||||
m, err := parseBountyMeta(typeMeta)
|
m, err := parseBountyMeta(typeMeta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return typeMeta, false
|
return typeMeta, false
|
||||||
}
|
}
|
||||||
if m.EndsAt != "" {
|
if _, ok := parseEndsAt(m.EndsAt); ok {
|
||||||
return typeMeta, false
|
return typeMeta, false
|
||||||
}
|
}
|
||||||
days := normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30})
|
m.EndsAt = resolveEndsAtOnPublish("", m.ExpireDays, 7, []int{3, 7, 14, 30}, publishedAt)
|
||||||
m.ExpireDays = 0
|
m.ExpireDays = 0
|
||||||
m.EndsAt = endsAtFromDays(days, publishedAt)
|
|
||||||
raw, err := encodeMeta(m)
|
raw, err := encodeMeta(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return typeMeta, false
|
return typeMeta, false
|
||||||
@@ -309,12 +457,11 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return typeMeta, false
|
return typeMeta, false
|
||||||
}
|
}
|
||||||
if m.EndsAt != "" {
|
if _, ok := parseEndsAt(m.EndsAt); ok {
|
||||||
return typeMeta, false
|
return typeMeta, false
|
||||||
}
|
}
|
||||||
days := normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14})
|
m.EndsAt = resolveEndsAtOnPublish("", m.ExpireDays, 7, []int{1, 3, 7, 14}, publishedAt)
|
||||||
m.ExpireDays = 0
|
m.ExpireDays = 0
|
||||||
m.EndsAt = endsAtFromDays(days, publishedAt)
|
|
||||||
raw, err := encodeMeta(m)
|
raw, err := encodeMeta(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return typeMeta, false
|
return typeMeta, false
|
||||||
@@ -347,6 +494,7 @@ type QuestionState struct {
|
|||||||
AcceptedCommentID uint `json:"accepted_comment_id"`
|
AcceptedCommentID uint `json:"accepted_comment_id"`
|
||||||
AcceptedFloor int `json:"accepted_floor"`
|
AcceptedFloor int `json:"accepted_floor"`
|
||||||
AcceptedAnswer *AcceptedAnswer `json:"accepted_answer,omitempty"`
|
AcceptedAnswer *AcceptedAnswer `json:"accepted_answer,omitempty"`
|
||||||
|
SolvedAt string `json:"solved_at,omitempty"`
|
||||||
CanAccept bool `json:"can_accept"`
|
CanAccept bool `json:"can_accept"`
|
||||||
CanSolve bool `json:"can_solve"`
|
CanSolve bool `json:"can_solve"`
|
||||||
CanReopen bool `json:"can_reopen"`
|
CanReopen bool `json:"can_reopen"`
|
||||||
@@ -372,6 +520,8 @@ type PollState struct {
|
|||||||
Multi bool `json:"multi"`
|
Multi bool `json:"multi"`
|
||||||
Closed bool `json:"closed"`
|
Closed bool `json:"closed"`
|
||||||
Anonymous bool `json:"anonymous"`
|
Anonymous bool `json:"anonymous"`
|
||||||
|
EndsAt string `json:"ends_at,omitempty"`
|
||||||
|
ClosedAt string `json:"closed_at,omitempty"`
|
||||||
TotalVotes int `json:"total_votes"`
|
TotalVotes int `json:"total_votes"`
|
||||||
Voters int `json:"voters"`
|
Voters int `json:"voters"`
|
||||||
MyOptions []int `json:"my_options"`
|
MyOptions []int `json:"my_options"`
|
||||||
@@ -391,7 +541,6 @@ type BountyState struct {
|
|||||||
EndsAt string `json:"ends_at,omitempty"`
|
EndsAt string `json:"ends_at,omitempty"`
|
||||||
Settled bool `json:"settled"`
|
Settled bool `json:"settled"`
|
||||||
CanAccept bool `json:"can_accept"`
|
CanAccept bool `json:"can_accept"`
|
||||||
CanRefund bool `json:"can_refund"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LotteryEntrant struct {
|
type LotteryEntrant struct {
|
||||||
@@ -399,20 +548,23 @@ type LotteryEntrant struct {
|
|||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
CommentID uint `json:"comment_id"` // 中奖楼层(该用户最早主评),每人仅一条
|
CommentID uint `json:"comment_id"`
|
||||||
Floor int `json:"floor"`
|
Floor int `json:"floor"`
|
||||||
|
PrizeName string `json:"prize_name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LotteryState struct {
|
type LotteryState struct {
|
||||||
Slots int `json:"slots"`
|
Slots int `json:"slots"`
|
||||||
Drawn bool `json:"drawn"`
|
Prizes []LotteryPrize `json:"prizes"`
|
||||||
Closed bool `json:"closed"`
|
Drawn bool `json:"drawn"`
|
||||||
EndsAt string `json:"ends_at,omitempty"`
|
Closed bool `json:"closed"`
|
||||||
EntryCount int `json:"entry_count"` // 回帖参与人数
|
EndsAt string `json:"ends_at,omitempty"`
|
||||||
Eligible bool `json:"eligible"` // 当前用户是否在奖池
|
EntryCount int `json:"entry_count"`
|
||||||
CanDraw bool `json:"can_draw"`
|
Eligible bool `json:"eligible"`
|
||||||
CanClose bool `json:"can_close"`
|
CooldownHours int `json:"cooldown_hours"`
|
||||||
Winners []LotteryEntrant `json:"winners"`
|
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) {
|
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{}
|
m = &QuestionMeta{}
|
||||||
}
|
}
|
||||||
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
|
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
|
||||||
|
answer := s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor)
|
||||||
|
solvedAt := m.SolvedAt
|
||||||
|
if m.Solved && solvedAt == "" && answer != nil && !answer.CreatedAt.IsZero() {
|
||||||
|
solvedAt = answer.CreatedAt.UTC().Format(time.RFC3339) // 旧数据:无 solved_at 时用采纳评论时间
|
||||||
|
}
|
||||||
return &QuestionState{
|
return &QuestionState{
|
||||||
Solved: m.Solved,
|
Solved: m.Solved,
|
||||||
AcceptedCommentID: m.AcceptedCommentID,
|
AcceptedCommentID: m.AcceptedCommentID,
|
||||||
AcceptedFloor: floor,
|
AcceptedFloor: floor,
|
||||||
AcceptedAnswer: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor),
|
AcceptedAnswer: answer,
|
||||||
|
SolvedAt: solvedAt,
|
||||||
CanAccept: canManage && !m.Solved,
|
CanAccept: canManage && !m.Solved,
|
||||||
CanSolve: canManage && !m.Solved,
|
CanSolve: canManage && !m.Solved,
|
||||||
CanReopen: canManage && m.Solved,
|
// 已采纳答案后不可重新打开;仅「手动标已解决」可撤回
|
||||||
|
CanReopen: canManage && m.Solved && m.AcceptedCommentID == 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose bool) *PollState {
|
func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose bool) *PollState {
|
||||||
|
_ = s.settleDuePollIfNeeded(post)
|
||||||
|
s.db.Select("id", "type_meta", "post_type").First(post, post.ID)
|
||||||
|
|
||||||
m, err := parsePollMeta(post.TypeMeta)
|
m, err := parsePollMeta(post.TypeMeta)
|
||||||
if err != nil || len(m.Options) == 0 {
|
if err != nil || len(m.Options) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -531,11 +693,17 @@ func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose b
|
|||||||
opts[i] = stat
|
opts[i] = stat
|
||||||
}
|
}
|
||||||
voters := len(voterSet)
|
voters := len(voterSet)
|
||||||
|
closedAt := m.ClosedAt
|
||||||
|
if m.Closed && closedAt == "" {
|
||||||
|
closedAt = m.EndsAt // 旧数据:无 closed_at 时用截止时刻兜底
|
||||||
|
}
|
||||||
return &PollState{
|
return &PollState{
|
||||||
Options: opts,
|
Options: opts,
|
||||||
Multi: m.Multi,
|
Multi: m.Multi,
|
||||||
Closed: m.Closed,
|
Closed: m.Closed,
|
||||||
Anonymous: m.Anonymous,
|
Anonymous: m.Anonymous,
|
||||||
|
EndsAt: m.EndsAt,
|
||||||
|
ClosedAt: closedAt,
|
||||||
TotalVotes: total,
|
TotalVotes: total,
|
||||||
Voters: voters,
|
Voters: voters,
|
||||||
MyOptions: my,
|
MyOptions: my,
|
||||||
@@ -550,7 +718,7 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
settled := m.AcceptedCommentID > 0 || m.Refunded || m.Expired
|
accepted := m.AcceptedCommentID > 0
|
||||||
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
|
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
|
||||||
return &BountyState{
|
return &BountyState{
|
||||||
Points: m.Points,
|
Points: m.Points,
|
||||||
@@ -561,9 +729,9 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty
|
|||||||
Refunded: m.Refunded,
|
Refunded: m.Refunded,
|
||||||
Expired: m.Expired,
|
Expired: m.Expired,
|
||||||
EndsAt: m.EndsAt,
|
EndsAt: m.EndsAt,
|
||||||
Settled: settled,
|
Settled: accepted,
|
||||||
CanAccept: canManage && m.Escrowed && !settled && !m.Expired,
|
// 托管中可采纳;到期自动退回后仍可从余额再扣支付(不可撤回)
|
||||||
CanRefund: canManage && m.Escrowed && !settled && !m.Expired,
|
CanAccept: canManage && !accepted && (m.Escrowed || m.Refunded),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,14 +769,197 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int) *Acc
|
|||||||
return ans
|
return ans
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *PostService) lotteryCooldownHours() int {
|
||||||
|
hours := DefaultCooldownHours
|
||||||
|
if s.setting != nil {
|
||||||
|
if h, err := s.setting.PostCooldownHours(); err == nil {
|
||||||
|
hours = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hours < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return hours
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureLotteryEntry 主评公开发布后登记抽奖报名(幂等;楼主不入池)。
|
||||||
|
func (s *PostService) EnsureLotteryEntry(postID, userID, commentID uint, clientIP string) {
|
||||||
|
if postID == 0 || userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var post model.Post
|
||||||
|
if err := s.db.Select("id", "user_id", "post_type").First(&post, postID).Error; err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if model.NormalizePostType(post.PostType) != model.PostTypeLottery {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if post.UserID == userID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clientIP = normalizeLotteryIP(clientIP)
|
||||||
|
var n int64
|
||||||
|
s.db.Model(&model.PostLotteryEntry{}).
|
||||||
|
Where("post_id = ? AND user_id = ?", postID, userID).
|
||||||
|
Count(&n)
|
||||||
|
if n > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.db.Create(&model.PostLotteryEntry{
|
||||||
|
PostID: postID,
|
||||||
|
UserID: userID,
|
||||||
|
CommentID: commentID,
|
||||||
|
ClientIP: clientIP,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLotteryIP(ip string) string {
|
||||||
|
ip = strings.TrimSpace(ip)
|
||||||
|
if ip == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// 去掉 IPv6 zone(如 fe80::1%eth0)
|
||||||
|
if i := strings.IndexByte(ip, '%'); i >= 0 {
|
||||||
|
ip = ip[:i]
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
|
// lotteryEligibleIDs 奖池:已发布主评用户去重 − 楼主 − 新号冷静期 − 同 IP 仅留一人。
|
||||||
func (s *PostService) lotteryEligibleIDs(postID, authorID uint) ([]uint, error) {
|
func (s *PostService) lotteryEligibleIDs(postID, authorID uint) ([]uint, error) {
|
||||||
var ids []uint
|
hours := s.lotteryCooldownHours()
|
||||||
err := s.db.Model(&model.Comment{}).
|
q := s.db.Table("comments AS c").
|
||||||
Where("post_id = ? AND status = ? AND deleted_at IS NULL AND user_id <> ?",
|
Select("c.user_id").
|
||||||
|
Joins("INNER JOIN users u ON u.id = c.user_id AND u.deleted_at IS NULL").
|
||||||
|
Where("c.post_id = ? AND c.parent_id IS NULL AND c.status = ? AND c.deleted_at IS NULL AND c.user_id <> ?",
|
||||||
postID, model.ContentStatusPublished, authorID).
|
postID, model.ContentStatusPublished, authorID).
|
||||||
Distinct("user_id").
|
Group("c.user_id")
|
||||||
Pluck("user_id", &ids).Error
|
if hours > 0 {
|
||||||
return ids, err
|
cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||||
|
q = q.Where("u.created_at <= ?", cutoff)
|
||||||
|
}
|
||||||
|
var ids []uint
|
||||||
|
if err := q.Pluck("c.user_id", &ids).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.lotteryDedupByIP(postID, ids), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// latestSuccessLoginIPs 各用户最近一次成功登录 IP(作报名 IP 回退)
|
||||||
|
func (s *PostService) latestSuccessLoginIPs(userIDs []uint) map[uint]string {
|
||||||
|
out := map[uint]string{}
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
type row struct {
|
||||||
|
UserID uint
|
||||||
|
IP string
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
_ = s.db.Raw(`
|
||||||
|
SELECT DISTINCT ON (user_id) user_id, ip
|
||||||
|
FROM login_logs
|
||||||
|
WHERE user_id IN ? AND success = TRUE AND ip <> ''
|
||||||
|
ORDER BY user_id, created_at DESC
|
||||||
|
`, userIDs).Scan(&rows).Error
|
||||||
|
for _, r := range rows {
|
||||||
|
out[r.UserID] = normalizeLotteryIP(r.IP)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestSuccessLoginIP 单用户最近成功登录 IP
|
||||||
|
func (s *PostService) LatestSuccessLoginIP(userID uint) string {
|
||||||
|
if userID == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.latestSuccessLoginIPs([]uint{userID})[userID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// lotteryIPIgnoredForDedup 不参与同 IP 互斥:空地址;开发态下的回环地址(::1 / 127.0.0.1)
|
||||||
|
func (s *PostService) lotteryIPIgnoredForDedup(ip string) bool {
|
||||||
|
ip = normalizeLotteryIP(ip)
|
||||||
|
if ip == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !s.devMode {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parsed := net.ParseIP(ip)
|
||||||
|
return parsed != nil && parsed.IsLoopback()
|
||||||
|
}
|
||||||
|
|
||||||
|
// lotteryDedupByIP 同一非空 IP 只保留注册最早的一名;空 IP / 开发态回环不互斥。
|
||||||
|
func (s *PostService) lotteryDedupByIP(postID uint, ids []uint) []uint {
|
||||||
|
if len(ids) <= 1 {
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
var entries []model.PostLotteryEntry
|
||||||
|
_ = s.db.Where("post_id = ? AND user_id IN ?", postID, ids).Find(&entries).Error
|
||||||
|
ipByUser := map[uint]string{}
|
||||||
|
for _, e := range entries {
|
||||||
|
if ip := normalizeLotteryIP(e.ClientIP); ip != "" && !s.lotteryIPIgnoredForDedup(ip) {
|
||||||
|
ipByUser[e.UserID] = ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
missing := make([]uint, 0)
|
||||||
|
for _, id := range ids {
|
||||||
|
if ipByUser[id] == "" {
|
||||||
|
missing = append(missing, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id, ip := range s.latestSuccessLoginIPs(missing) {
|
||||||
|
if ip != "" && !s.lotteryIPIgnoredForDedup(ip) {
|
||||||
|
ipByUser[id] = ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type userRow struct {
|
||||||
|
ID uint
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
var users []userRow
|
||||||
|
_ = s.db.Model(&model.User{}).Select("id", "created_at").
|
||||||
|
Where("id IN ?", ids).Find(&users).Error
|
||||||
|
created := map[uint]time.Time{}
|
||||||
|
for _, u := range users {
|
||||||
|
created[u.ID] = u.CreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// ip -> 胜出 user(注册最早;同刻取更小 id)
|
||||||
|
bestByIP := map[string]uint{}
|
||||||
|
for _, id := range ids {
|
||||||
|
ip := ipByUser[id]
|
||||||
|
if ip == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cur, ok := bestByIP[ip]
|
||||||
|
if !ok {
|
||||||
|
bestByIP[ip] = id
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ct, okC := created[id]
|
||||||
|
cc, okCur := created[cur]
|
||||||
|
if !okC {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !okCur || ct.Before(cc) || (ct.Equal(cc) && id < cur) {
|
||||||
|
bestByIP[ip] = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chosenIP := map[uint]bool{}
|
||||||
|
for _, id := range bestByIP {
|
||||||
|
chosenIP[id] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]uint, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
ip := ipByUser[id]
|
||||||
|
if ip == "" || chosenIP[id] {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// lotteryWinningFloor 中奖用户的中奖楼层:其在本帖最早一条已发布主评论(每人仅一楼)。
|
// lotteryWinningFloor 中奖用户的中奖楼层:其在本帖最早一条已发布主评论(每人仅一楼)。
|
||||||
@@ -633,6 +984,7 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
cooldownHours := s.lotteryCooldownHours()
|
||||||
ids, _ := s.lotteryEligibleIDs(post.ID, post.UserID)
|
ids, _ := s.lotteryEligibleIDs(post.ID, post.UserID)
|
||||||
eligible := false
|
eligible := false
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
@@ -642,6 +994,7 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
winners := []LotteryEntrant{}
|
winners := []LotteryEntrant{}
|
||||||
|
prizeNames := expandLotteryPrizeNames(m.Prizes)
|
||||||
if len(m.WinnerIDs) > 0 {
|
if len(m.WinnerIDs) > 0 {
|
||||||
var users []model.User
|
var users []model.User
|
||||||
s.db.Select("id, username, nickname, avatar").Where("id IN ?", m.WinnerIDs).Find(&users)
|
s.db.Select("id, username, nickname, avatar").Where("id IN ?", m.WinnerIDs).Find(&users)
|
||||||
@@ -649,9 +1002,13 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
|
|||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
umap[u.ID] = u
|
umap[u.ID] = u
|
||||||
}
|
}
|
||||||
for _, id := range m.WinnerIDs {
|
for i, id := range m.WinnerIDs {
|
||||||
if u, ok := umap[id]; ok {
|
if u, ok := umap[id]; ok {
|
||||||
cid, floor := s.lotteryWinningFloor(post.ID, id)
|
cid, floor := s.lotteryWinningFloor(post.ID, id)
|
||||||
|
prize := ""
|
||||||
|
if i < len(prizeNames) {
|
||||||
|
prize = prizeNames[i]
|
||||||
|
}
|
||||||
winners = append(winners, LotteryEntrant{
|
winners = append(winners, LotteryEntrant{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
Nickname: u.Nickname,
|
Nickname: u.Nickname,
|
||||||
@@ -659,20 +1016,27 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
|
|||||||
Avatar: u.Avatar,
|
Avatar: u.Avatar,
|
||||||
CommentID: cid,
|
CommentID: cid,
|
||||||
Floor: floor,
|
Floor: floor,
|
||||||
|
PrizeName: prize,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
prizes := m.Prizes
|
||||||
|
if prizes == nil {
|
||||||
|
prizes = []LotteryPrize{}
|
||||||
|
}
|
||||||
return &LotteryState{
|
return &LotteryState{
|
||||||
Slots: m.Slots,
|
Slots: m.Slots,
|
||||||
Drawn: m.Drawn,
|
Prizes: prizes,
|
||||||
Closed: m.Closed,
|
Drawn: m.Drawn,
|
||||||
EndsAt: m.EndsAt,
|
Closed: m.Closed,
|
||||||
EntryCount: len(ids),
|
EndsAt: m.EndsAt,
|
||||||
Eligible: eligible,
|
EntryCount: len(ids),
|
||||||
CanDraw: canManage && !m.Drawn,
|
Eligible: eligible,
|
||||||
CanClose: canManage && !m.Drawn && !m.Closed,
|
CooldownHours: cooldownHours,
|
||||||
Winners: winners,
|
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 {
|
if post.Status != model.ContentStatusPublished {
|
||||||
return nil, errors.New("帖子未公开,无法投票")
|
return nil, errors.New("帖子未公开,无法投票")
|
||||||
}
|
}
|
||||||
|
_ = s.settleDuePollIfNeeded(&post)
|
||||||
|
s.db.First(&post, post.ID)
|
||||||
m, err := parsePollMeta(post.TypeMeta)
|
m, err := parsePollMeta(post.TypeMeta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -755,6 +1121,9 @@ func (s *PostService) ClosePoll(actor *Actor, userID, postID uint) (*PostDetail,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m.Closed = true
|
m.Closed = true
|
||||||
|
if strings.TrimSpace(m.ClosedAt) == "" {
|
||||||
|
m.ClosedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
raw, err := encodeMeta(m)
|
raw, err := encodeMeta(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -794,6 +1163,7 @@ func (s *PostService) AcceptQuestion(actor *Actor, userID, postID, commentID uin
|
|||||||
}
|
}
|
||||||
m.AcceptedCommentID = commentID
|
m.AcceptedCommentID = commentID
|
||||||
m.Solved = true
|
m.Solved = true
|
||||||
|
m.SolvedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
raw, err := encodeMeta(m)
|
raw, err := encodeMeta(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -819,6 +1189,7 @@ func (s *PostService) SolveQuestion(actor *Actor, userID, postID uint) (*PostDet
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m.Solved = true
|
m.Solved = true
|
||||||
|
m.SolvedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
raw, err := encodeMeta(m)
|
raw, err := encodeMeta(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -830,7 +1201,7 @@ func (s *PostService) SolveQuestion(actor *Actor, userID, postID uint) (*PostDet
|
|||||||
return s.detailAfterInteract(post, userID)
|
return s.detailAfterInteract(post, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReopenQuestion 重新打开
|
// ReopenQuestion 重新打开(仅允许撤回「手动标已解决」;已采纳答案不可重开)
|
||||||
func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDetail, error) {
|
func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDetail, error) {
|
||||||
post, err := s.loadManageablePost(actor, userID, postID)
|
post, err := s.loadManageablePost(actor, userID, postID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -839,7 +1210,14 @@ func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDe
|
|||||||
if model.NormalizePostType(post.PostType) != model.PostTypeQuestion {
|
if model.NormalizePostType(post.PostType) != model.PostTypeQuestion {
|
||||||
return nil, ErrQuestionNotType
|
return nil, ErrQuestionNotType
|
||||||
}
|
}
|
||||||
m := &QuestionMeta{Solved: false, AcceptedCommentID: 0}
|
old, err := parseQuestionMeta(post.TypeMeta)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if old != nil && old.AcceptedCommentID > 0 {
|
||||||
|
return nil, errors.New("已采纳答案,无法重新打开")
|
||||||
|
}
|
||||||
|
m := &QuestionMeta{Solved: false, AcceptedCommentID: 0, SolvedAt: ""}
|
||||||
raw, err := encodeMeta(m)
|
raw, err := encodeMeta(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -851,7 +1229,8 @@ func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDe
|
|||||||
return s.detailAfterInteract(post, userID)
|
return s.detailAfterInteract(post, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AcceptBounty 采纳评论并发放悬赏
|
// AcceptBounty 采纳评论并发放悬赏。
|
||||||
|
// 托管中:从托管直接发给答主;到期已退回后:从作者余额再扣再发放(不可撤回)。
|
||||||
func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint) (*PostDetail, error) {
|
func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint) (*PostDetail, error) {
|
||||||
post, err := s.loadManageablePost(actor, userID, postID)
|
post, err := s.loadManageablePost(actor, userID, postID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -867,15 +1246,17 @@ func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if m.Expired {
|
if m.AcceptedCommentID > 0 {
|
||||||
return nil, ErrBountyExpired
|
|
||||||
}
|
|
||||||
if !m.Escrowed {
|
|
||||||
return nil, ErrBountyNotEscrow
|
|
||||||
}
|
|
||||||
if m.AcceptedCommentID > 0 || m.Refunded {
|
|
||||||
return nil, ErrBountySettled
|
return nil, ErrBountySettled
|
||||||
}
|
}
|
||||||
|
payFromBalance := false
|
||||||
|
if m.Escrowed {
|
||||||
|
payFromBalance = false
|
||||||
|
} else if m.Refunded {
|
||||||
|
payFromBalance = true
|
||||||
|
} else {
|
||||||
|
return nil, ErrBountyNotEscrow
|
||||||
|
}
|
||||||
var cm model.Comment
|
var cm model.Comment
|
||||||
if err := s.db.First(&cm, commentID).Error; err != nil || cm.PostID != postID {
|
if err := s.db.First(&cm, commentID).Error; err != nil || cm.PostID != postID {
|
||||||
return nil, errors.New("评论不存在")
|
return nil, errors.New("评论不存在")
|
||||||
@@ -888,56 +1269,18 @@ func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint)
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if payFromBalance {
|
||||||
|
note := "悬赏截止后采纳扣款:" + post.Title
|
||||||
|
if _, err := DebitTx(tx, post.UserID, m.Points, model.PointReasonBountyEscrow, "post", postID, note); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if _, err := CreditTx(tx, cm.UserID, m.Points, model.PointReasonBountyAward, "post", postID, "悬赏采纳:"+post.Title); err != nil {
|
if _, err := CreditTx(tx, cm.UserID, m.Points, model.PointReasonBountyAward, "post", postID, "悬赏采纳:"+post.Title); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.AcceptedCommentID = commentID
|
m.AcceptedCommentID = commentID
|
||||||
m.Escrowed = false
|
m.Escrowed = false
|
||||||
raw, err := encodeMeta(m)
|
// 截止后退回再采纳:保留 Expired/Refunded 痕迹便于对账,但 Settled 以采纳为准
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Model(post).Update("type_meta", raw).Error
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.db.Preload("Board").Preload("User").First(post, post.ID)
|
|
||||||
return s.detailAfterInteract(post, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefundBounty 退回悬赏积分(未采纳时)
|
|
||||||
func (s *PostService) RefundBounty(actor *Actor, userID, postID uint) (*PostDetail, error) {
|
|
||||||
post, err := s.loadManageablePost(actor, userID, postID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
_ = s.settleExpiredBountyIfNeeded(post)
|
|
||||||
s.db.First(post, post.ID)
|
|
||||||
|
|
||||||
if model.NormalizePostType(post.PostType) != model.PostTypeBounty {
|
|
||||||
return nil, errors.New("本文不是悬赏帖")
|
|
||||||
}
|
|
||||||
m, err := parseBountyMeta(post.TypeMeta)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if m.Expired {
|
|
||||||
return nil, ErrBountyExpired
|
|
||||||
}
|
|
||||||
if !m.Escrowed {
|
|
||||||
return nil, ErrBountyNotEscrow
|
|
||||||
}
|
|
||||||
if m.AcceptedCommentID > 0 || m.Refunded {
|
|
||||||
return nil, ErrBountySettled
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if _, err := CreditTx(tx, post.UserID, m.Points, model.PointReasonBountyRefund, "post", postID, "悬赏退回:"+post.Title); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
m.Refunded = true
|
|
||||||
m.Escrowed = false
|
|
||||||
raw, err := encodeMeta(m)
|
raw, err := encodeMeta(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -996,36 +1339,12 @@ func (s *PostService) settleExpiredBountyIfNeeded(post *model.Post) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseLotteryEntries 截止抽奖(仍可手动开奖)
|
|
||||||
func (s *PostService) CloseLotteryEntries(actor *Actor, userID, postID uint) (*PostDetail, error) {
|
|
||||||
post, err := s.loadManageablePost(actor, userID, postID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
m, err := parseLotteryMeta(post.TypeMeta)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if m.Drawn {
|
|
||||||
return nil, ErrLotteryDrawn
|
|
||||||
}
|
|
||||||
m.Closed = true
|
|
||||||
raw, err := encodeMeta(m)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
post.TypeMeta = raw
|
|
||||||
return s.detailAfterInteract(post, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PostService) drawLotteryCore(post *model.Post, m *LotteryMeta) error {
|
func (s *PostService) drawLotteryCore(post *model.Post, m *LotteryMeta) error {
|
||||||
ids, err := s.lotteryEligibleIDs(post.ID, post.UserID)
|
ids, err := s.lotteryEligibleIDs(post.ID, post.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
m.Slots = lotterySlotsFromPrizes(m.Prizes)
|
||||||
nWin := m.Slots
|
nWin := m.Slots
|
||||||
if nWin > len(ids) {
|
if nWin > len(ids) {
|
||||||
nWin = len(ids)
|
nWin = len(ids)
|
||||||
@@ -1078,6 +1397,34 @@ func (s *PostService) DrawLottery(actor *Actor, userID, postID uint) (*PostDetai
|
|||||||
return s.detailAfterInteract(post, userID)
|
return s.detailAfterInteract(post, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *PostService) settleDuePollIfNeeded(post *model.Post) error {
|
||||||
|
if model.NormalizePostType(post.PostType) != model.PostTypePoll {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m, err := parsePollMeta(post.TypeMeta)
|
||||||
|
if err != nil || m.Closed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ends, ok := parseEndsAt(m.EndsAt)
|
||||||
|
if !ok || time.Now().UTC().Before(ends) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.Closed = true
|
||||||
|
if strings.TrimSpace(m.ClosedAt) == "" {
|
||||||
|
// 到期自动结束:结束时刻取截止时刻
|
||||||
|
m.ClosedAt = ends.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
raw, err := encodeMeta(m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
post.TypeMeta = raw
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PostService) settleDueLotteryIfNeeded(post *model.Post) error {
|
func (s *PostService) settleDueLotteryIfNeeded(post *model.Post) error {
|
||||||
if model.NormalizePostType(post.PostType) != model.PostTypeLottery {
|
if model.NormalizePostType(post.PostType) != model.PostTypeLottery {
|
||||||
return nil
|
return nil
|
||||||
@@ -1093,15 +1440,29 @@ func (s *PostService) settleDueLotteryIfNeeded(post *model.Post) error {
|
|||||||
return s.drawLotteryCore(post, m)
|
return s.drawLotteryCore(post, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettleDueTypedPosts 定时任务:悬赏过期退回 + 抽奖到期开奖
|
// SettleDueTypedPosts 定时任务:投票到期关闭 + 悬赏过期退回 + 抽奖到期开奖
|
||||||
func (s *PostService) SettleDueTypedPosts(limit int) {
|
func (s *PostService) SettleDueTypedPosts(limit int) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
|
n := 0
|
||||||
|
var polls []model.Post
|
||||||
|
s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypePoll).
|
||||||
|
Order("id ASC").Limit(limit * 3).Find(&polls)
|
||||||
|
for i := range polls {
|
||||||
|
if n >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
before := polls[i].TypeMeta
|
||||||
|
_ = s.settleDuePollIfNeeded(&polls[i])
|
||||||
|
if polls[i].TypeMeta != before {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var bounties []model.Post
|
var bounties []model.Post
|
||||||
s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty).
|
s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty).
|
||||||
Order("id ASC").Limit(limit * 3).Find(&bounties)
|
Order("id ASC").Limit(limit * 3).Find(&bounties)
|
||||||
n := 0
|
|
||||||
for i := range bounties {
|
for i := range bounties {
|
||||||
if n >= limit {
|
if n >= limit {
|
||||||
break
|
break
|
||||||
@@ -1116,7 +1477,6 @@ func (s *PostService) SettleDueTypedPosts(limit int) {
|
|||||||
var lotteries []model.Post
|
var lotteries []model.Post
|
||||||
s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeLottery).
|
s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeLottery).
|
||||||
Order("id ASC").Limit(limit * 3).Find(&lotteries)
|
Order("id ASC").Limit(limit * 3).Find(&lotteries)
|
||||||
n = 0
|
|
||||||
for i := range lotteries {
|
for i := range lotteries {
|
||||||
if n >= limit {
|
if n >= limit {
|
||||||
break
|
break
|
||||||
|
|||||||
9
frontend/AGENTS.md
Normal file
9
frontend/AGENTS.md
Normal 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
1
frontend/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -182,30 +182,106 @@ body {
|
|||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
.j13-compose-toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: color-mix(in srgb, var(--panel) 92%, var(--panel-2));
|
||||||
|
position: sticky;
|
||||||
|
top: 3.5rem;
|
||||||
|
z-index: 20;
|
||||||
|
border-radius: var(--r) var(--r) 0 0;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.j13-compose-toolbar {
|
||||||
|
padding: 0.85rem 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.j13-compose-toolbar-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.j13-compose-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.j13-compose-body {
|
.j13-compose-body {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 1.35rem 1.35rem 0.85rem;
|
padding: 1.15rem 1.15rem 0.75rem;
|
||||||
gap: 1.1rem;
|
gap: 0.85rem;
|
||||||
}
|
}
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.j13-compose-body {
|
.j13-compose-body {
|
||||||
padding: 1.5rem 1.75rem 0.9rem;
|
padding: 1.25rem 1.4rem 0.85rem;
|
||||||
gap: 1.15rem;
|
gap: 0.95rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* 发帖分区卡片:轻分隔,不过度割裂也不糊成一片 */
|
||||||
|
.j13-compose-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 1rem 1.1rem 1.1rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: color-mix(in srgb, var(--bg) 70%, var(--panel));
|
||||||
|
}
|
||||||
|
.j13-compose-section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.j13-compose-section-writing {
|
||||||
|
background: var(--panel);
|
||||||
|
border-color: color-mix(in srgb, var(--line) 85%, var(--accent) 15%);
|
||||||
|
box-shadow: 0 1px 0 color-mix(in srgb, var(--ink) 3.5%, transparent);
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
/* 类型面板已在分区内,去掉二次描边避免套娃 */
|
||||||
|
.j13-compose-section .j13-compose-typepanel {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
.j13-compose-actions {
|
.j13-compose-actions {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 0.75rem 1rem;
|
gap: 0.75rem 1rem;
|
||||||
padding: 1rem 1.5rem 1.25rem;
|
padding: 1rem 1.5rem 1.25rem;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
.j13-compose-actions-error {
|
||||||
|
flex: 1 1 12rem;
|
||||||
|
margin: 0;
|
||||||
|
max-width: min(100%, 28rem);
|
||||||
|
}
|
||||||
|
.j13-compose-actions-end {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.j13-compose-actions {
|
.j13-compose-actions {
|
||||||
padding-left: 2rem;
|
padding-left: 2rem;
|
||||||
@@ -271,6 +347,54 @@ body {
|
|||||||
border: 1px dashed color-mix(in srgb, var(--accent) 28%, var(--line));
|
border: 1px dashed color-mix(in srgb, var(--accent) 28%, var(--line));
|
||||||
background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel));
|
background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel));
|
||||||
}
|
}
|
||||||
|
.j13-lottery-prize-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.j13-lottery-prize-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.j13-lottery-prize-table th,
|
||||||
|
.j13-lottery-prize-table td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.j13-lottery-prize-table th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-2);
|
||||||
|
background: var(--panel-2);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.j13-lottery-prize-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.j13-lottery-prize-table .col-idx {
|
||||||
|
width: 2.5rem;
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.j13-lottery-prize-table .col-count {
|
||||||
|
width: 6.5rem;
|
||||||
|
}
|
||||||
|
.j13-lottery-prize-table .col-actions {
|
||||||
|
width: 4.5rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.j13-lottery-prize-table .j13-compose-field {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.j13-lottery-prize-table .j13-compose-field-count {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 5.5rem;
|
||||||
|
}
|
||||||
.j13-compose-attach-row {
|
.j13-compose-attach-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -286,6 +410,474 @@ body {
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 抽奖卡:对齐截图结构,落在站点石墨/令牌体系内 */
|
||||||
|
/* 类型帖交互卡(投票 / 悬赏 / 抽奖)共用壳 */
|
||||||
|
.j13-typed-card {
|
||||||
|
padding: 16px 18px 14px;
|
||||||
|
}
|
||||||
|
.j13-typed-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px 14px;
|
||||||
|
}
|
||||||
|
.j13-typed-head-main {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.j13-typed-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.j13-typed-title svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.j13-typed-title.is-question {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.j13-typed-title.is-poll {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.j13-typed-title.is-bounty {
|
||||||
|
color: var(--clay);
|
||||||
|
}
|
||||||
|
.j13-typed-title.is-lottery {
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
.j13-typed-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.j13-typed-actions-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.j13-typed-meta,
|
||||||
|
.j13-typed-empty {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.j13-typed-foot {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.j13-typed-foot-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
.j13-typed-foot-status svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.j13-typed-foot-time {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.j13-bounty-points {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--clay);
|
||||||
|
}
|
||||||
|
.j13-poll-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.j13-poll-option {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
.j13-poll-option:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.j13-poll-option:not(:disabled):hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 35%, var(--line));
|
||||||
|
}
|
||||||
|
.j13-poll-option.is-on {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.j13-poll-option-bar {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
opacity: 0.28;
|
||||||
|
background: var(--accent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.j13-poll-option-row {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.j13-poll-option-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.j13-poll-option-stat {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.j13-poll-voters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding: 0 2px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.j13-poll-voter {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 8px 2px 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.j13-poll-voter:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.j13-lottery-card {
|
||||||
|
padding: 16px 18px 14px;
|
||||||
|
}
|
||||||
|
.j13-lottery-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px 14px;
|
||||||
|
}
|
||||||
|
.j13-lottery-head-main {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.j13-lottery-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
.j13-lottery-title svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.j13-lottery-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.j13-lottery-meta,
|
||||||
|
.j13-lottery-empty {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
/* 截止倒计时:投票 / 悬赏 / 抽奖共用 */
|
||||||
|
.j13-deadline-countdown {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px 14px 11px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel));
|
||||||
|
}
|
||||||
|
.j13-deadline-countdown.is-expired {
|
||||||
|
border-color: color-mix(in srgb, var(--ink-3) 35%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--tint) 70%, var(--panel));
|
||||||
|
}
|
||||||
|
.j13-deadline-countdown-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.j13-deadline-countdown.is-expired .j13-deadline-countdown-label {
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.j13-deadline-countdown-digits {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.j13-deadline-countdown-unit {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 10px 5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.j13-deadline-countdown-unit.is-sec {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 28%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 45%, var(--panel));
|
||||||
|
}
|
||||||
|
.j13-deadline-countdown-num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: clamp(1.35rem, 3.2vw, 1.75rem);
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.j13-deadline-countdown-suf {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.j13-deadline-countdown-unit.is-sec .j13-deadline-countdown-num {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.j13-deadline-countdown-expired {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.j13-deadline-countdown-ends {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.dark .j13-deadline-countdown-unit {
|
||||||
|
background: var(--panel-2);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.j13-deadline-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.j13-deadline-picker-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.j13-deadline-picker-modes {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--tint);
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.j13-deadline-mode {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-2);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.j13-deadline-mode.is-on {
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.j13-deadline-mode:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.j13-deadline-presets {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.j13-deadline-custom {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 22rem;
|
||||||
|
}
|
||||||
|
.j13-deadline-datetime {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
/* 奖品 / 中奖者:横向排布,放不下换行 */
|
||||||
|
.j13-lottery-prize-row,
|
||||||
|
.j13-lottery-winner-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px 10px;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.j13-lottery-prize-row > li,
|
||||||
|
.j13-lottery-winner-row > li {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* 奖品胶囊:淡金底 + 深金字 + 奖杯徽标 */
|
||||||
|
.j13-lottery-prize-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: min(100%, 28rem);
|
||||||
|
padding: 6px 13px 6px 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.25;
|
||||||
|
background: var(--gold-soft);
|
||||||
|
color: var(--gold);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--gold) 28%, transparent);
|
||||||
|
}
|
||||||
|
.j13-lottery-prize-chip svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.j13-lottery-prize-chip-text {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.j13-lottery-prize-chip-count {
|
||||||
|
margin-left: 0.3em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.j13-lottery-winners-label {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.j13-lottery-winner-row {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
/* 中奖者:横向胶囊卡片,内容定宽、可换行 */
|
||||||
|
.j13-lottery-winner {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 10px;
|
||||||
|
width: min(100%, 15.5rem);
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 8px 14px 8px 8px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.j13-lottery-winner-avatar {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
.j13-lottery-winner-body {
|
||||||
|
grid-column: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.j13-lottery-winner-name {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.j13-lottery-winner-name:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.j13-lottery-winner-prize {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
.dark .j13-lottery-winner {
|
||||||
|
background: var(--panel-2);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.j13-lottery-foot {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.j13-lottery-foot-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 550;
|
||||||
|
}
|
||||||
|
.j13-lottery-foot-status svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.j13-lottery-foot-time {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
.j13-interact-card.is-unsolved {
|
.j13-interact-card.is-unsolved {
|
||||||
background: color-mix(in srgb, var(--danger) 7%, var(--panel));
|
background: color-mix(in srgb, var(--danger) 7%, var(--panel));
|
||||||
border-color: color-mix(in srgb, var(--danger) 22%, var(--line));
|
border-color: color-mix(in srgb, var(--danger) 22%, var(--line));
|
||||||
@@ -303,51 +895,41 @@ body {
|
|||||||
border-color: color-mix(in srgb, var(--ok) 30%, var(--line));
|
border-color: color-mix(in srgb, var(--ok) 30%, var(--line));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 正文下方已采纳答案:问答资料卡 */
|
/* 正文下方已采纳答案:与类型帖 typed-card 同框,配色克制 */
|
||||||
.j13-accepted-answer {
|
.j13-accepted-answer {
|
||||||
margin-top: 1.75rem;
|
margin-top: 1.75rem;
|
||||||
padding: 1.15rem 1.25rem 1.25rem;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--ok) 28%, var(--line));
|
|
||||||
background: color-mix(in srgb, var(--ok) 7%, var(--panel));
|
|
||||||
box-shadow: inset 3px 0 0 0 var(--ok);
|
|
||||||
}
|
}
|
||||||
.dark .j13-accepted-answer {
|
.j13-accepted-answer.is-question,
|
||||||
background: color-mix(in srgb, var(--ok) 12%, var(--panel));
|
.j13-accepted-answer.is-bounty {
|
||||||
border-color: color-mix(in srgb, var(--ok) 36%, var(--line));
|
/* 覆盖旧绿底/左边条;沿用 interact-card 面板 */
|
||||||
}
|
border-color: var(--line);
|
||||||
.j13-accepted-answer__head {
|
background: var(--panel);
|
||||||
display: flex;
|
box-shadow: none;
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px 16px;
|
|
||||||
margin-bottom: 0.9rem;
|
|
||||||
padding-bottom: 0.85rem;
|
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--ok) 18%, var(--line));
|
|
||||||
}
|
|
||||||
.j13-accepted-answer__meta {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.j13-accepted-answer__status {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ink);
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
}
|
||||||
.j13-accepted-answer__author {
|
.j13-accepted-answer__author {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.j13-accepted-answer__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--ok-soft);
|
||||||
|
color: var(--ok);
|
||||||
}
|
}
|
||||||
.j13-accepted-answer__body {
|
.j13-accepted-answer__body {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
/* 正文下方类型特殊块(投票/悬赏/抽奖/问答状态) */
|
/* 正文下方类型特殊块(投票/悬赏/抽奖/问答状态) */
|
||||||
.j13-interact-below {
|
.j13-interact-below {
|
||||||
@@ -382,6 +964,10 @@ body {
|
|||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
.j13-compose-field-invalid {
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 55%, var(--line));
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.j13-compose-title {
|
.j13-compose-title {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1129,18 +1715,11 @@ a.j13-mention-token:hover {
|
|||||||
background: color-mix(in srgb, #0284c7 12%, var(--panel));
|
background: color-mix(in srgb, #0284c7 12%, var(--panel));
|
||||||
color: #0369a1;
|
color: #0369a1;
|
||||||
}
|
}
|
||||||
|
/* 已结束 / 已过期 / 已开奖 / 已截止:统一灰胶囊(标识已到期) */
|
||||||
.badge-status-closed {
|
.badge-status-closed {
|
||||||
background: color-mix(in srgb, #5b6b8c 12%, var(--panel));
|
background: color-mix(in srgb, #5b6b8c 12%, var(--panel));
|
||||||
color: #5b6b8c;
|
color: #5b6b8c;
|
||||||
}
|
}
|
||||||
.badge-status-expired {
|
|
||||||
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
.badge-status-drawn {
|
|
||||||
background: color-mix(in srgb, #7c5cbf 14%, var(--panel));
|
|
||||||
color: #6a4aad;
|
|
||||||
}
|
|
||||||
.dark .badge-status-unsolved {
|
.dark .badge-status-unsolved {
|
||||||
background: rgba(255, 107, 96, 0.14);
|
background: rgba(255, 107, 96, 0.14);
|
||||||
color: #fca5a5;
|
color: #fca5a5;
|
||||||
@@ -1157,14 +1736,6 @@ a.j13-mention-token:hover {
|
|||||||
background: rgba(170, 182, 207, 0.14);
|
background: rgba(170, 182, 207, 0.14);
|
||||||
color: #aab6cf;
|
color: #aab6cf;
|
||||||
}
|
}
|
||||||
.dark .badge-status-expired {
|
|
||||||
background: color-mix(in srgb, var(--danger) 20%, transparent);
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
.dark .badge-status-drawn {
|
|
||||||
background: rgba(192, 168, 245, 0.16);
|
|
||||||
color: #c0a8f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 评论区成就徽标:实心底,比类型/状态浅底更醒目 */
|
/* 评论区成就徽标:实心底,比类型/状态浅底更醒目 */
|
||||||
.badge-accepted {
|
.badge-accepted {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Props = {
|
|||||||
initialTypeMeta?: string;
|
initialTypeMeta?: string;
|
||||||
initialAttachments?: PostAttachment[];
|
initialAttachments?: PostAttachment[];
|
||||||
canEditPollOptions?: boolean;
|
canEditPollOptions?: boolean;
|
||||||
|
canEditLotteryPrizes?: boolean;
|
||||||
boardName: string;
|
boardName: string;
|
||||||
boardId: number;
|
boardId: number;
|
||||||
boardColorIndex?: number;
|
boardColorIndex?: number;
|
||||||
@@ -30,6 +31,7 @@ export default function EditPostClient({
|
|||||||
initialTypeMeta,
|
initialTypeMeta,
|
||||||
initialAttachments,
|
initialAttachments,
|
||||||
canEditPollOptions,
|
canEditPollOptions,
|
||||||
|
canEditLotteryPrizes,
|
||||||
boardName,
|
boardName,
|
||||||
boardId,
|
boardId,
|
||||||
boardColorIndex = 0,
|
boardColorIndex = 0,
|
||||||
@@ -47,6 +49,7 @@ export default function EditPostClient({
|
|||||||
initialTypeMeta={initialTypeMeta}
|
initialTypeMeta={initialTypeMeta}
|
||||||
initialAttachments={initialAttachments}
|
initialAttachments={initialAttachments}
|
||||||
canEditPollOptions={canEditPollOptions}
|
canEditPollOptions={canEditPollOptions}
|
||||||
|
canEditLotteryPrizes={canEditLotteryPrizes}
|
||||||
boardName={boardName}
|
boardName={boardName}
|
||||||
boardId={boardId}
|
boardId={boardId}
|
||||||
boardColorIndex={boardColorIndex}
|
boardColorIndex={boardColorIndex}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export default async function EditPostPage({ params }: PageProps) {
|
|||||||
initialTypeMeta={post.type_meta}
|
initialTypeMeta={post.type_meta}
|
||||||
initialAttachments={post.attachments || []}
|
initialAttachments={post.attachments || []}
|
||||||
canEditPollOptions={post.poll?.can_edit_options !== false}
|
canEditPollOptions={post.poll?.can_edit_options !== false}
|
||||||
|
canEditLotteryPrizes={post.lottery?.can_edit_prizes !== false}
|
||||||
boardName={post.board?.name || "未知板块"}
|
boardName={post.board?.name || "未知板块"}
|
||||||
boardId={post.board_id}
|
boardId={post.board_id}
|
||||||
boardColorIndex={post.board?.color_index ?? 0}
|
boardColorIndex={post.board?.color_index ?? 0}
|
||||||
|
|||||||
@@ -714,8 +714,11 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
<AcceptedAnswerCard
|
<AcceptedAnswerCard
|
||||||
answer={acceptedAnswer}
|
answer={acceptedAnswer}
|
||||||
variant={acceptedAnswerVariant}
|
variant={acceptedAnswerVariant}
|
||||||
postId={post.id}
|
solvedAt={
|
||||||
canReopen={!!post.question?.can_reopen}
|
post.question?.solved_at ||
|
||||||
|
post.bounty?.accepted_answer?.created_at ||
|
||||||
|
acceptedAnswer.created_at
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : questionState ? (
|
) : questionState ? (
|
||||||
<QuestionStatusCard postId={post.id} state={questionState} />
|
<QuestionStatusCard postId={post.id} state={questionState} />
|
||||||
|
|||||||
@@ -1,76 +1,83 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CheckCircle2 } from "lucide-react";
|
import { CheckCircle2, CircleHelp, Coins } from "lucide-react";
|
||||||
import type { AcceptedAnswer } from "@/lib/api";
|
import type { AcceptedAnswer } from "@/lib/api";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import MarkdownBody from "@/components/MarkdownBody";
|
import MarkdownBody from "@/components/MarkdownBody";
|
||||||
import AcceptedAnswerReopen from "@/components/AcceptedAnswerReopen";
|
|
||||||
|
|
||||||
/** 正文下方的已采纳答案卡:头像 + 昵称 + 全文;问答已解决时并入状态与「重新打开」 */
|
/** 正文下方已采纳答案:对齐类型帖 typed-card;已采纳后不可重新打开 */
|
||||||
export default async function AcceptedAnswerCard({
|
export default async function AcceptedAnswerCard({
|
||||||
answer,
|
answer,
|
||||||
variant = "question",
|
variant = "question",
|
||||||
postId,
|
solvedAt,
|
||||||
canReopen = false,
|
|
||||||
}: {
|
}: {
|
||||||
answer: AcceptedAnswer;
|
answer: AcceptedAnswer;
|
||||||
/** 问答用「最佳答案」,悬赏用「已采纳」 */
|
/** 问答用「最佳答案」,悬赏用「已采纳」 */
|
||||||
variant?: "question" | "bounty";
|
variant?: "question" | "bounty";
|
||||||
postId?: number;
|
/** 解决/采纳时刻;缺省用答案创建时间 */
|
||||||
canReopen?: boolean;
|
solvedAt?: string;
|
||||||
}) {
|
}) {
|
||||||
const name = answer.user.nickname || answer.user.username || "用户";
|
const name = answer.user.nickname || answer.user.username || "用户";
|
||||||
const label = variant === "bounty" ? "已采纳" : "最佳答案";
|
const isQuestion = variant === "question";
|
||||||
const showReopen = variant === "question" && canReopen && !!postId;
|
const title = isQuestion ? "问答" : "悬赏";
|
||||||
|
const statusLabel = isQuestion ? "已解决" : "已采纳";
|
||||||
|
const answerLabel = isQuestion ? "最佳答案" : "采纳答案";
|
||||||
|
const endTime = solvedAt || answer.created_at;
|
||||||
|
const endLabel = formatEndsAt(endTime);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="j13-accepted-answer" aria-label={variant === "question" ? "问答 · 已解决" : label}>
|
<section
|
||||||
<header className="j13-accepted-answer__head">
|
className={`j13-interact-card j13-typed-card j13-accepted-answer${isQuestion ? " is-question" : " is-bounty"}`}
|
||||||
<div className="j13-accepted-answer__meta min-w-0">
|
aria-label={`${title} · ${statusLabel}`}
|
||||||
{variant === "question" && (
|
>
|
||||||
<p className="j13-accepted-answer__status">问答 · 已解决</p>
|
<div className="j13-typed-head">
|
||||||
)}
|
<div className="j13-typed-head-main">
|
||||||
<div className="j13-accepted-answer__author min-w-0">
|
<h2 className={`j13-typed-title ${isQuestion ? "is-question" : "is-bounty"}`}>
|
||||||
<span className="badge-accepted shrink-0">
|
{isQuestion ? (
|
||||||
<CheckCircle2 size={13} aria-hidden />
|
<CircleHelp size={16} strokeWidth={2.25} aria-hidden />
|
||||||
{label}
|
) : (
|
||||||
</span>
|
<Coins size={16} strokeWidth={2.25} aria-hidden />
|
||||||
<Link
|
)}
|
||||||
href={`/u/${answer.user.id}`}
|
{title}
|
||||||
className="shrink-0"
|
</h2>
|
||||||
aria-label={name}
|
<span className="badge-status badge-status-solved shrink-0">{statusLabel}</span>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
{showReopen && <AcceptedAnswerReopen postId={postId!} />}
|
</div>
|
||||||
</header>
|
|
||||||
|
<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">
|
<div className="j13-accepted-answer__body">
|
||||||
{answer.deleted ? (
|
{answer.deleted ? (
|
||||||
@@ -81,6 +88,25 @@ export default async function AcceptedAnswerCard({
|
|||||||
<MarkdownBody content={answer.content} className="prose-content prose-comment" />
|
<MarkdownBody content={answer.content} className="prose-content prose-comment" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="j13-typed-foot">
|
||||||
|
<span className="j13-typed-foot-status">
|
||||||
|
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
|
||||||
|
{isQuestion ? "已采纳答案" : "悬赏已发放"}
|
||||||
|
</span>
|
||||||
|
{endLabel ? (
|
||||||
|
<span className="j13-typed-foot-time">结束时间: {endLabel}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatEndsAt(iso?: string) {
|
||||||
|
if (!iso) return "";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString("zh-CN");
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
130
frontend/components/DeadlinePicker.tsx
Normal file
130
frontend/components/DeadlinePicker.tsx
Normal 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
@@ -1,13 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Gift, Trophy, CheckCircle2, Timer, BarChart3, Coins, CircleHelp } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
apiVotePoll,
|
apiVotePoll,
|
||||||
apiClosePoll,
|
apiClosePoll,
|
||||||
apiRefundBounty,
|
|
||||||
apiCloseLottery,
|
|
||||||
apiDrawLottery,
|
apiDrawLottery,
|
||||||
apiSolveQuestion,
|
apiSolveQuestion,
|
||||||
apiReopenQuestion,
|
apiReopenQuestion,
|
||||||
@@ -32,12 +31,13 @@ export default function PostInteractPanels({
|
|||||||
lottery?: LotteryState | null;
|
lottery?: LotteryState | null;
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
}) {
|
}) {
|
||||||
// 问答状态改在正文下方展示(未解决 / 已解决资料卡),此处不再放
|
// 问答 / 已采纳悬赏改在正文下方 AcceptedAnswerCard,此处不再放
|
||||||
if (!poll && !bounty && !lottery) return null;
|
const showBounty = !!bounty && !bounty.accepted_comment_id;
|
||||||
|
if (!poll && !showBounty && !lottery) return null;
|
||||||
return (
|
return (
|
||||||
<div className="j13-interact-below space-y-4">
|
<div className="j13-interact-below space-y-4">
|
||||||
{poll && <PollPanel postId={postId} state={poll} loggedIn={loggedIn} />}
|
{poll && <PollPanel postId={postId} state={poll} loggedIn={loggedIn} />}
|
||||||
{bounty && <BountyPanel postId={postId} state={bounty} />}
|
{showBounty && <BountyPanel postId={postId} state={bounty!} />}
|
||||||
{lottery && <LotteryPanel postId={postId} state={lottery} />}
|
{lottery && <LotteryPanel postId={postId} state={lottery} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -52,6 +52,129 @@ function formatEndsAt(iso?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 底栏右侧:进行中显示截止时间,结束后显示结束时间 */
|
||||||
|
function TypedFootTime({
|
||||||
|
endsAt,
|
||||||
|
endedAt,
|
||||||
|
ended,
|
||||||
|
}: {
|
||||||
|
endsAt?: string;
|
||||||
|
endedAt?: string;
|
||||||
|
ended?: boolean;
|
||||||
|
}) {
|
||||||
|
if (ended) {
|
||||||
|
const t = endedAt || endsAt;
|
||||||
|
if (!t) return null;
|
||||||
|
return <span className="j13-typed-foot-time">结束时间: {formatEndsAt(t)}</span>;
|
||||||
|
}
|
||||||
|
if (!endsAt) return null;
|
||||||
|
return <span className="j13-typed-foot-time">截止时间: {formatEndsAt(endsAt)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(n: number) {
|
||||||
|
return String(Math.max(0, n)).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainParts(endsAtMs: number, now: number) {
|
||||||
|
const ms = Math.max(0, endsAtMs - now);
|
||||||
|
const totalSec = Math.floor(ms / 1000);
|
||||||
|
return {
|
||||||
|
days: Math.floor(totalSec / 86400),
|
||||||
|
hours: Math.floor((totalSec % 86400) / 3600),
|
||||||
|
mins: Math.floor((totalSec % 3600) / 60),
|
||||||
|
secs: totalSec % 60,
|
||||||
|
expired: ms <= 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未截止倒计时:最小单位秒,每秒刷新 */
|
||||||
|
function DeadlineCountdown({
|
||||||
|
endsAt,
|
||||||
|
activeLabel = "距截止还剩",
|
||||||
|
expiredLabel = "已到截止时间",
|
||||||
|
expiredHint = "等待处理",
|
||||||
|
}: {
|
||||||
|
endsAt: string;
|
||||||
|
activeLabel?: string;
|
||||||
|
expiredLabel?: string;
|
||||||
|
expiredHint?: string;
|
||||||
|
}) {
|
||||||
|
const endsMs = Date.parse(endsAt);
|
||||||
|
const [now, setNow] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Number.isFinite(endsMs)) return;
|
||||||
|
const tick = () => setNow(Date.now());
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [endsMs]);
|
||||||
|
|
||||||
|
if (!Number.isFinite(endsMs)) return null;
|
||||||
|
|
||||||
|
if (now === null) {
|
||||||
|
return (
|
||||||
|
<div className="j13-deadline-countdown" aria-hidden="true">
|
||||||
|
<div className="j13-deadline-countdown-label">
|
||||||
|
<Timer size={15} strokeWidth={2.25} aria-hidden />
|
||||||
|
{activeLabel}
|
||||||
|
</div>
|
||||||
|
<div className="j13-deadline-countdown-digits">
|
||||||
|
{["天", "时", "分", "秒"].map((suf) => (
|
||||||
|
<span key={suf} className={`j13-deadline-countdown-unit${suf === "秒" ? " is-sec" : ""}`}>
|
||||||
|
<span className="j13-deadline-countdown-num tabular-nums">--</span>
|
||||||
|
<span className="j13-deadline-countdown-suf">{suf}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="j13-deadline-countdown-ends">截止于 {formatEndsAt(endsAt)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = remainParts(endsMs, now);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`j13-deadline-countdown${r.expired ? " is-expired" : ""}`}
|
||||||
|
role="timer"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
<div className="j13-deadline-countdown-label">
|
||||||
|
<Timer size={15} strokeWidth={2.25} aria-hidden />
|
||||||
|
{r.expired ? expiredLabel : activeLabel}
|
||||||
|
</div>
|
||||||
|
{r.expired ? (
|
||||||
|
<p className="j13-deadline-countdown-expired">{expiredHint}</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="j13-deadline-countdown-digits"
|
||||||
|
aria-label={`还剩 ${r.days} 天 ${r.hours} 时 ${r.mins} 分 ${r.secs} 秒`}
|
||||||
|
>
|
||||||
|
<span className="j13-deadline-countdown-unit">
|
||||||
|
<span className="j13-deadline-countdown-num tabular-nums">{pad2(r.days)}</span>
|
||||||
|
<span className="j13-deadline-countdown-suf">天</span>
|
||||||
|
</span>
|
||||||
|
<span className="j13-deadline-countdown-unit">
|
||||||
|
<span className="j13-deadline-countdown-num tabular-nums">{pad2(r.hours)}</span>
|
||||||
|
<span className="j13-deadline-countdown-suf">时</span>
|
||||||
|
</span>
|
||||||
|
<span className="j13-deadline-countdown-unit">
|
||||||
|
<span className="j13-deadline-countdown-num tabular-nums">{pad2(r.mins)}</span>
|
||||||
|
<span className="j13-deadline-countdown-suf">分</span>
|
||||||
|
</span>
|
||||||
|
<span className="j13-deadline-countdown-unit is-sec">
|
||||||
|
<span className="j13-deadline-countdown-num tabular-nums">{pad2(r.secs)}</span>
|
||||||
|
<span className="j13-deadline-countdown-suf">秒</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="j13-deadline-countdown-ends">截止于 {formatEndsAt(endsAt)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 正文下方的问答状态卡(未解决 / 仅标记已解决);有采纳答案时改用 AcceptedAnswerCard */
|
/** 正文下方的问答状态卡(未解决 / 仅标记已解决);有采纳答案时改用 AcceptedAnswerCard */
|
||||||
export function QuestionStatusCard({ postId, state }: { postId: number; state: QuestionState }) {
|
export function QuestionStatusCard({ postId, state }: { postId: number; state: QuestionState }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -70,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 (
|
return (
|
||||||
<section
|
<section className="j13-interact-card j13-typed-card" aria-label={`问答 · ${statusBadge.label}`}>
|
||||||
className={`j13-interact-card j13-question-below${state.solved ? " is-solved" : " is-unsolved"}`}
|
<div className="j13-typed-head">
|
||||||
aria-label={`问答 · ${state.solved ? "已解决" : "未解决"}`}
|
<div className="j13-typed-head-main">
|
||||||
>
|
<h2 className="j13-typed-title is-question">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<CircleHelp size={16} strokeWidth={2.25} aria-hidden />
|
||||||
<div>
|
问答
|
||||||
<h2 className="text-[15px] font-medium" style={{ color: "var(--ink)" }}>
|
|
||||||
问答 · {state.solved ? "已解决" : "未解决"}
|
|
||||||
</h2>
|
</h2>
|
||||||
<p className="meta text-[12.5px] mt-1">
|
<span className={statusBadge.cls}>{statusBadge.label}</span>
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -139,6 +285,7 @@ function PollPanel({
|
|||||||
const [sel, setSel] = useState<number[]>(state.my_options || []);
|
const [sel, setSel] = useState<number[]>(state.my_options || []);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const showResults = state.closed || (state.my_options && state.my_options.length > 0);
|
const showResults = state.closed || (state.my_options && state.my_options.length > 0);
|
||||||
|
const hasVoted = (state.my_options?.length ?? 0) > 0;
|
||||||
|
|
||||||
const toggle = (idx: number) => {
|
const toggle = (idx: number) => {
|
||||||
if (!state.can_vote) return;
|
if (!state.can_vote) return;
|
||||||
@@ -183,19 +330,56 @@ function PollPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const statusBadge = state.closed
|
||||||
|
? { cls: "badge-status badge-status-closed", label: "已结束" }
|
||||||
|
: { cls: "badge-status badge-status-open", label: "进行中" };
|
||||||
|
|
||||||
|
const footLeft = state.closed
|
||||||
|
? "已结束投票"
|
||||||
|
: hasVoted
|
||||||
|
? "你已完成投票"
|
||||||
|
: "投票进行中";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="j13-interact-card">
|
<section className="j13-interact-card j13-typed-card" aria-label="投票">
|
||||||
<div className="flex items-center justify-between gap-3 mb-3">
|
<div className="j13-typed-head">
|
||||||
<h2 className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>
|
<div className="j13-typed-head-main">
|
||||||
投票{state.multi ? "(多选)" : "(单选)"}
|
<h2 className="j13-typed-title is-poll">
|
||||||
{state.anonymous ? " · 匿名" : " · 实名"}
|
<BarChart3 size={16} strokeWidth={2.25} aria-hidden />
|
||||||
</h2>
|
投票
|
||||||
<span className="meta text-[12.5px]">
|
</h2>
|
||||||
{state.voters} 人参与 · {state.total_votes} 票
|
<span className={statusBadge.cls}>{statusBadge.label}</span>
|
||||||
{state.closed ? " · 已结束" : ""}
|
</div>
|
||||||
</span>
|
{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>
|
</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) => {
|
{state.options.map((o) => {
|
||||||
const checked = sel.includes(o.index);
|
const checked = sel.includes(o.index);
|
||||||
const namedVoters = !state.anonymous && showResults ? o.voters || [] : [];
|
const namedVoters = !state.anonymous && showResults ? o.voters || [] : [];
|
||||||
@@ -205,43 +389,32 @@ function PollPanel({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!state.can_vote || busy}
|
disabled={!state.can_vote || busy}
|
||||||
onClick={() => toggle(o.index)}
|
onClick={() => toggle(o.index)}
|
||||||
className="w-full text-left rounded-[12px] px-3 py-2.5 relative overflow-hidden transition-colors"
|
className={`j13-poll-option${checked ? " is-on" : ""}`}
|
||||||
style={{
|
|
||||||
border: `1px solid ${checked ? "var(--accent)" : "var(--line)"}`,
|
|
||||||
background: checked ? "var(--accent-soft)" : "var(--panel)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{showResults && (
|
{showResults && (
|
||||||
<span
|
<span
|
||||||
className="absolute inset-y-0 left-0 opacity-30"
|
className="j13-poll-option-bar"
|
||||||
style={{
|
style={{ width: `${o.percent}%` }}
|
||||||
width: `${o.percent}%`,
|
|
||||||
background: "var(--accent)",
|
|
||||||
}}
|
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="relative flex items-center justify-between gap-3">
|
<span className="j13-poll-option-row">
|
||||||
<span className="text-[14px]" style={{ color: "var(--ink)" }}>
|
<span className="j13-poll-option-text">
|
||||||
{state.can_vote ? (checked ? "✓ " : "") : ""}
|
{state.can_vote ? (checked ? "✓ " : "") : ""}
|
||||||
{o.text}
|
{o.text}
|
||||||
</span>
|
</span>
|
||||||
{showResults && (
|
{showResults && (
|
||||||
<span className="meta tabular-nums text-[12.5px] shrink-0">
|
<span className="j13-poll-option-stat tabular-nums">
|
||||||
{o.votes} · {o.percent}%
|
{o.votes} · {o.percent}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{namedVoters.length > 0 && (
|
{namedVoters.length > 0 && (
|
||||||
<ul className="mt-1.5 flex flex-wrap gap-1.5 px-1">
|
<ul className="j13-poll-voters">
|
||||||
{namedVoters.map((v) => (
|
{namedVoters.map((v) => (
|
||||||
<li key={v.id}>
|
<li key={v.id}>
|
||||||
<Link
|
<Link href={`/u/${v.id}`} className="j13-poll-voter">
|
||||||
href={`/u/${v.id}`}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[12px]"
|
|
||||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
<Avatar name={v.nickname || v.username} src={v.avatar || undefined} size={16} />
|
<Avatar name={v.nickname || v.username} src={v.avatar || undefined} size={16} />
|
||||||
{v.nickname || v.username}
|
{v.nickname || v.username}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -253,106 +426,121 @@ function PollPanel({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{state.can_vote && (
|
{(state.can_vote || (!loggedIn && !state.closed)) && (
|
||||||
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => void vote()}>
|
<div className="j13-typed-actions-row">
|
||||||
提交投票
|
{state.can_vote && (
|
||||||
</button>
|
<button
|
||||||
)}
|
type="button"
|
||||||
{!loggedIn && !state.closed && (
|
className="btn btn-primary btn-sm"
|
||||||
<Link href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`} className="btn btn-line btn-sm">
|
disabled={busy}
|
||||||
登录后投票
|
onClick={() => void vote()}
|
||||||
</Link>
|
>
|
||||||
)}
|
提交投票
|
||||||
{state.can_close && (
|
</button>
|
||||||
<button
|
)}
|
||||||
type="button"
|
{!loggedIn && !state.closed && (
|
||||||
className="btn btn-line btn-sm"
|
<Link
|
||||||
disabled={busy}
|
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`}
|
||||||
onClick={() => void close()}
|
className="btn btn-line btn-sm"
|
||||||
>
|
>
|
||||||
结束投票
|
登录后投票
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BountyPanel({ postId, state }: { postId: number; state: BountyState }) {
|
function BountyPanel({ state }: { postId: number; state: BountyState }) {
|
||||||
const router = useRouter();
|
const statusBadge = state.accepted_comment_id
|
||||||
const [busy, setBusy] = useState(false);
|
? { cls: "badge-status badge-status-solved", label: "已采纳" }
|
||||||
const [confirming, setConfirming] = useState(false);
|
: state.expired || state.refunded
|
||||||
|
? { cls: "badge-status badge-status-closed", label: "已过期" }
|
||||||
|
: { cls: "badge-status badge-status-open", label: "进行中" };
|
||||||
|
|
||||||
const refund = async () => {
|
const footLeft = state.accepted_comment_id
|
||||||
if (!confirming) {
|
? "悬赏已完成"
|
||||||
setConfirming(true);
|
: state.expired || state.refunded
|
||||||
window.setTimeout(() => setConfirming(false), 3000);
|
? state.can_accept
|
||||||
return;
|
? "已过期退回 · 仍可采纳支付"
|
||||||
}
|
: "悬赏已过期退回"
|
||||||
setBusy(true);
|
: "悬赏进行中";
|
||||||
try {
|
|
||||||
const res = await apiRefundBounty(String(postId));
|
|
||||||
if (res.post) {
|
|
||||||
toast("悬赏已退回", "ok");
|
|
||||||
router.refresh();
|
|
||||||
} else toast(res.error || "退回失败", "error");
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
setConfirming(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusText = state.expired
|
const bodyHint = state.accepted_answer
|
||||||
? "已过期,积分已自动退回"
|
? "悬赏积分已发放给采纳答案"
|
||||||
: state.refunded
|
: state.accepted_comment_id
|
||||||
? "已退回给发帖人"
|
? null
|
||||||
: state.accepted_comment_id
|
: state.expired || state.refunded
|
||||||
? null
|
? state.can_accept
|
||||||
|
? "到期未采纳已自动退回;采纳将从余额扣除,支付后不可撤回"
|
||||||
|
: "积分已自动退回发帖人"
|
||||||
: state.escrowed
|
: state.escrowed
|
||||||
? "积分已托管,采纳优质回答后发放"
|
? "积分已托管,采纳优质回答后发放;到期未采纳自动退回"
|
||||||
: "悬赏未托管";
|
: "悬赏未托管";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="j13-interact-card">
|
<section className="j13-interact-card j13-typed-card" aria-label="悬赏">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="j13-typed-head">
|
||||||
<div>
|
<div className="j13-typed-head-main">
|
||||||
<h2 className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>
|
<h2 className="j13-typed-title is-bounty">
|
||||||
悬赏 {state.points} 积分
|
<Coins size={16} strokeWidth={2.25} aria-hidden />
|
||||||
|
悬赏
|
||||||
</h2>
|
</h2>
|
||||||
<p className="meta text-[12.5px] mt-1">
|
<span className={statusBadge.cls}>{statusBadge.label}</span>
|
||||||
{state.accepted_answer
|
|
||||||
? "悬赏积分已发放给采纳答案"
|
|
||||||
: 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>
|
|
||||||
</div>
|
</div>
|
||||||
{state.can_refund && (
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
<p className="j13-typed-meta">
|
||||||
className={`btn btn-sm ${confirming ? "btn-primary" : "btn-line"}`}
|
<span className="j13-bounty-points tabular-nums">{state.points} 积分</span>
|
||||||
disabled={busy}
|
{bodyHint ? ` · ${bodyHint}` : null}
|
||||||
onClick={() => void refund()}
|
{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 ? "再点确认退回" : "退回悬赏"}
|
#{state.accepted_floor || "?"}
|
||||||
</button>
|
</a>
|
||||||
)}
|
</p>
|
||||||
{state.can_accept && (
|
)}
|
||||||
<p className="meta text-[12.5px]">在评论旁点击「采纳」发放积分</p>
|
|
||||||
)}
|
<div className="j13-typed-foot">
|
||||||
|
<span className="j13-typed-foot-status">
|
||||||
|
<CheckCircle2 size={14} strokeWidth={2.25} aria-hidden />
|
||||||
|
{footLeft}
|
||||||
|
</span>
|
||||||
|
<TypedFootTime
|
||||||
|
ended={!!state.accepted_comment_id || state.expired || state.refunded}
|
||||||
|
endsAt={state.ends_at}
|
||||||
|
endedAt={state.ends_at}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -381,35 +569,28 @@ function LotteryPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const prizes = state.prizes?.length
|
||||||
|
? state.prizes
|
||||||
|
: state.slots > 0
|
||||||
|
? [{ name: "奖品", count: state.slots }]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const statusBadge = state.drawn
|
||||||
|
? { cls: "badge-status badge-status-closed", label: "已开奖" }
|
||||||
|
: { cls: "badge-status badge-status-open", label: "进行中" };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="j13-interact-card">
|
<section className="j13-interact-card j13-typed-card" aria-label="抽奖">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
<div className="j13-typed-head">
|
||||||
<div>
|
<div className="j13-typed-head-main">
|
||||||
<h2 className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>
|
<h2 className="j13-typed-title is-lottery">
|
||||||
抽奖 · {state.slots} 个名额
|
<Gift size={16} strokeWidth={2.25} aria-hidden />
|
||||||
|
抽奖
|
||||||
</h2>
|
</h2>
|
||||||
<p className="meta text-[12.5px] mt-1">
|
<span className={statusBadge.cls}>{statusBadge.label}</span>
|
||||||
已有 {state.entry_count} 名回帖用户参与
|
|
||||||
{state.drawn ? " · 已开奖" : state.closed ? " · 已截止" : ""}
|
|
||||||
{state.eligible && !state.drawn ? " · 你已在奖池中" : ""}
|
|
||||||
{state.ends_at && !state.drawn ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""}
|
|
||||||
</p>
|
|
||||||
{!state.drawn && (
|
|
||||||
<p className="meta text-[12px] mt-1">对本帖发表评论即可参与(楼主除外)</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
{state.can_draw && (
|
||||||
{state.can_close && (
|
<div className="j13-typed-actions">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-line btn-sm"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => void act(() => apiCloseLottery(String(postId)), "已截止")}
|
|
||||||
>
|
|
||||||
提前截止
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{state.can_draw && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
@@ -418,38 +599,83 @@ function LotteryPanel({
|
|||||||
>
|
>
|
||||||
开奖
|
开奖
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(state.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 && (
|
{state.drawn && (
|
||||||
<div>
|
<>
|
||||||
<p className="font-semibold text-[13px] mb-2" style={{ color: "var(--ink)" }}>
|
<p className="j13-lottery-winners-label">中奖名单</p>
|
||||||
{state.winners.length > 0 ? "中奖名单" : "无人回帖,开奖结果为空"}
|
{state.winners.length > 0 ? (
|
||||||
</p>
|
<ul className="j13-lottery-winner-row">
|
||||||
{state.winners.length > 0 && (
|
|
||||||
<ul className="flex flex-wrap gap-3">
|
|
||||||
{state.winners.map((w) => (
|
{state.winners.map((w) => (
|
||||||
<li key={w.id}>
|
<li key={`${w.id}-${w.comment_id || 0}`}>
|
||||||
<span className="badge-winner inline-flex items-center gap-2 !py-1.5 !px-2.5 !text-[13px] !font-medium">
|
<div className="j13-lottery-winner">
|
||||||
<Link href={`/u/${w.id}`} className="inline-flex items-center gap-2 min-w-0">
|
<Avatar
|
||||||
<Avatar name={w.nickname || w.username} src={w.avatar || undefined} size={22} />
|
name={w.nickname || w.username}
|
||||||
<span className="truncate">{w.nickname || w.username}</span>
|
src={w.avatar || undefined}
|
||||||
</Link>
|
size={36}
|
||||||
{w.comment_id && w.floor ? (
|
className="j13-lottery-winner-avatar"
|
||||||
<a
|
/>
|
||||||
href={`#comment-${w.comment_id}`}
|
<div className="j13-lottery-winner-body">
|
||||||
className="opacity-90 hover:underline shrink-0"
|
<Link href={`/u/${w.id}`} className="j13-lottery-winner-name">
|
||||||
>
|
{w.nickname || w.username}
|
||||||
#{w.floor}
|
</Link>
|
||||||
</a>
|
{w.prize_name ? (
|
||||||
) : null}
|
<span className="j13-lottery-winner-prize">{w.prize_name}</span>
|
||||||
</span>
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ export interface QuestionState {
|
|||||||
accepted_comment_id: number;
|
accepted_comment_id: number;
|
||||||
accepted_floor: number;
|
accepted_floor: number;
|
||||||
accepted_answer?: AcceptedAnswer | null;
|
accepted_answer?: AcceptedAnswer | null;
|
||||||
|
solved_at?: string;
|
||||||
can_accept: boolean;
|
can_accept: boolean;
|
||||||
can_solve: boolean;
|
can_solve: boolean;
|
||||||
can_reopen: boolean;
|
can_reopen: boolean;
|
||||||
@@ -279,6 +280,8 @@ export interface PollState {
|
|||||||
multi: boolean;
|
multi: boolean;
|
||||||
closed: boolean;
|
closed: boolean;
|
||||||
anonymous: boolean;
|
anonymous: boolean;
|
||||||
|
ends_at?: string;
|
||||||
|
closed_at?: string;
|
||||||
total_votes: number;
|
total_votes: number;
|
||||||
voters: number;
|
voters: number;
|
||||||
my_options: number[];
|
my_options: number[];
|
||||||
@@ -298,7 +301,11 @@ export interface BountyState {
|
|||||||
ends_at?: string;
|
ends_at?: string;
|
||||||
settled: boolean;
|
settled: boolean;
|
||||||
can_accept: boolean;
|
can_accept: boolean;
|
||||||
can_refund: boolean;
|
}
|
||||||
|
|
||||||
|
export interface LotteryPrize {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LotteryEntrant {
|
export interface LotteryEntrant {
|
||||||
@@ -309,17 +316,21 @@ export interface LotteryEntrant {
|
|||||||
/** 中奖楼层评论 ID(该用户最早主评,每人仅一条) */
|
/** 中奖楼层评论 ID(该用户最早主评,每人仅一条) */
|
||||||
comment_id?: number;
|
comment_id?: number;
|
||||||
floor?: number;
|
floor?: number;
|
||||||
|
prize_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LotteryState {
|
export interface LotteryState {
|
||||||
slots: number;
|
slots: number;
|
||||||
|
prizes: LotteryPrize[];
|
||||||
drawn: boolean;
|
drawn: boolean;
|
||||||
closed: boolean;
|
closed: boolean;
|
||||||
ends_at?: string;
|
ends_at?: string;
|
||||||
entry_count: number;
|
entry_count: number;
|
||||||
eligible?: boolean;
|
eligible?: boolean;
|
||||||
|
/** 新号入池冷静期小时数(与发帖冷静期同源,0=关闭) */
|
||||||
|
cooldown_hours?: number;
|
||||||
can_draw: boolean;
|
can_draw: boolean;
|
||||||
can_close: boolean;
|
can_edit_prizes?: boolean;
|
||||||
winners: LotteryEntrant[];
|
winners: LotteryEntrant[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,14 +949,6 @@ export async function apiAcceptBounty(postId: string, commentId: number) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiRefundBounty(postId: string) {
|
|
||||||
const res = await fetchWithRefresh(`/api/posts/${postId}/bounty/refund`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: clientHeaders(),
|
|
||||||
});
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiAcceptQuestion(postId: string, commentId: number) {
|
export async function apiAcceptQuestion(postId: string, commentId: number) {
|
||||||
const res = await fetchWithRefresh(`/api/posts/${postId}/question/accept`, {
|
const res = await fetchWithRefresh(`/api/posts/${postId}/question/accept`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -971,14 +974,6 @@ export async function apiReopenQuestion(postId: string) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiCloseLottery(postId: string) {
|
|
||||||
const res = await fetchWithRefresh(`/api/posts/${postId}/lottery/close`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: clientHeaders(),
|
|
||||||
});
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function apiDrawLottery(postId: string) {
|
export async function apiDrawLottery(postId: string) {
|
||||||
const res = await fetchWithRefresh(`/api/posts/${postId}/lottery/draw`, {
|
const res = await fetchWithRefresh(`/api/posts/${postId}/lottery/draw`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const POST_TYPES = [
|
|||||||
{ value: "question", label: "问答", hint: "提问求解" },
|
{ value: "question", label: "问答", hint: "提问求解" },
|
||||||
{ value: "poll", label: "投票", hint: "发起投票,读者可参与" },
|
{ value: "poll", label: "投票", hint: "发起投票,读者可参与" },
|
||||||
{ value: "bounty", label: "悬赏", hint: "发布时托管积分,采纳后发放" },
|
{ value: "bounty", label: "悬赏", hint: "发布时托管积分,采纳后发放" },
|
||||||
{ value: "lottery", label: "抽奖", hint: "回帖即参与,截止自动开奖" },
|
{ value: "lottery", label: "抽奖", hint: "奖品类型+数量入池,评论后锁定奖品" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type PostTypeValue = (typeof POST_TYPES)[number]["value"];
|
export type PostTypeValue = (typeof POST_TYPES)[number]["value"];
|
||||||
@@ -21,6 +21,7 @@ export type ContentAccessValue = (typeof CONTENT_ACCESS)[number]["value"];
|
|||||||
|
|
||||||
export const BOUNTY_EXPIRE_DAYS = [3, 7, 14, 30] as const;
|
export const BOUNTY_EXPIRE_DAYS = [3, 7, 14, 30] as const;
|
||||||
export const LOTTERY_EXPIRE_DAYS = [1, 3, 7, 14] as const;
|
export const LOTTERY_EXPIRE_DAYS = [1, 3, 7, 14] as const;
|
||||||
|
export const POLL_EXPIRE_DAYS = [1, 3, 7, 14] as const;
|
||||||
|
|
||||||
export function normalizePostType(t?: string): PostTypeValue {
|
export function normalizePostType(t?: string): PostTypeValue {
|
||||||
if (!t) return "discussion";
|
if (!t) return "discussion";
|
||||||
@@ -74,12 +75,13 @@ export function typeStatusClass(postType?: string, typeStatus?: string): string
|
|||||||
break;
|
break;
|
||||||
case "bounty":
|
case "bounty":
|
||||||
if (s === "solved") kind = "solved";
|
if (s === "solved") kind = "solved";
|
||||||
else if (s === "expired") kind = "expired";
|
// 已过期:与投票「已结束」同色(灰胶囊,标识已到期)
|
||||||
|
else if (s === "expired") kind = "closed";
|
||||||
else kind = "open";
|
else kind = "open";
|
||||||
break;
|
break;
|
||||||
case "lottery":
|
case "lottery":
|
||||||
if (s === "drawn") kind = "drawn";
|
// 已开奖 / 已截止:与投票「已结束」同色(灰胶囊)
|
||||||
else if (s === "closed") kind = "closed";
|
if (s === "drawn" || s === "closed") kind = "closed";
|
||||||
else kind = "open";
|
else kind = "open";
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|||||||
4
frontend/next-env.d.ts
vendored
4
frontend/next-env.d.ts
vendored
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
import "./.next/types/root-params.d.ts";
|
import "./.next/dev/types/root-params.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
Reference in New Issue
Block a user