From b9ba8cb8c95378923f9dce7c9f35f960e0e4b14e Mon Sep 17 00:00:00 2001
From: freefire
Date: Wed, 16 Sep 2026 03:17:07 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E7=B1=BB=E5=9E=8B=E5=B8=96=E4=BA=92?=
=?UTF-8?q?=E5=8A=A8=E3=80=81=E7=A7=AF=E5=88=86=E7=BB=8F=E6=B5=8E=E4=B8=8E?=
=?UTF-8?q?=E5=8F=91=E5=B8=96=E4=BD=93=E9=AA=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。
Co-authored-by: Cursor
---
backend/cmd/jiang13/main.go | 2 +
backend/handler/handlers.go | 2 +
backend/handler/post.go | 249 +++-
backend/handler/post_interact.go | 197 +++
backend/handler/upload.go | 40 +
backend/middleware/ratelimit.go | 20 +-
backend/model/db.go | 53 +
backend/model/models.go | 170 ++-
backend/router/router.go | 31 +-
backend/service/checkin.go | 12 +-
backend/service/moderation.go | 9 +-
backend/service/points.go | 231 ++++
backend/service/post.go | 695 +++++++++--
backend/service/post_file.go | 283 +++++
backend/service/post_interact.go | 1082 +++++++++++++++++
backend/service/ratelimit.go | 4 +
backend/service/upload.go | 95 +-
frontend/app/admin/AdminNav.tsx | 7 +
.../admin/announcements/AnnouncementAdmin.tsx | 10 +-
.../app/admin/appearance/AppearanceAdmin.tsx | 4 +-
frontend/app/admin/economy/page.tsx | 176 +++
frontend/app/admin/page.tsx | 18 +-
frontend/app/admin/users/UsersAdmin.tsx | 26 +-
frontend/app/board/[id]/page.tsx | 4 +-
frontend/app/chat/ChatShell.tsx | 4 +-
frontend/app/chat/[id]/ChatRoomClient.tsx | 12 +-
frontend/app/compose/layout.tsx | 18 +
frontend/app/compose/page.tsx | 173 +--
frontend/app/globals.css | 513 ++++++++
frontend/app/layout.tsx | 2 +
frontend/app/points/PointsClient.tsx | 130 ++
frontend/app/points/page.tsx | 20 +
.../app/post/[id]/edit/EditPostClient.tsx | 56 +
frontend/app/post/[id]/edit/page.tsx | 186 +--
frontend/app/post/[id]/page.tsx | 127 +-
frontend/app/u/[id]/page.tsx | 15 +-
frontend/components/AvatarCropModal.tsx | 4 +-
frontend/components/AvatarPicker.tsx | 2 +-
frontend/components/BoardPicker.tsx | 192 +++
frontend/components/CommentSection.tsx | 74 +-
frontend/components/ContentLockGate.tsx | 92 ++
frontend/components/Header.tsx | 2 +
frontend/components/LikeButton.tsx | 3 +-
frontend/components/MarkdownBody.tsx | 20 +
frontend/components/MarkdownEditor.tsx | 320 +++++
frontend/components/NotificationBell.tsx | 1 +
frontend/components/PostAttachments.tsx | 66 +
frontend/components/PostComposer.tsx | 957 +++++++++++++++
frontend/components/PostInteractPanels.tsx | 421 +++++++
frontend/components/PostRow.tsx | 44 +-
frontend/components/RoleBadge.tsx | 2 +-
frontend/components/TipHost.tsx | 135 ++
frontend/components/home/CheckinCard.tsx | 10 +-
frontend/components/home/CommunityPanels.tsx | 12 +-
frontend/components/home/WorkspaceNav.tsx | 12 +-
frontend/lib/api.ts | 299 ++++-
frontend/lib/points.ts | 14 +
frontend/lib/postMeta.ts | 61 +
58 files changed, 6913 insertions(+), 506 deletions(-)
create mode 100644 backend/handler/post_interact.go
create mode 100644 backend/service/points.go
create mode 100644 backend/service/post_file.go
create mode 100644 backend/service/post_interact.go
create mode 100644 frontend/app/admin/economy/page.tsx
create mode 100644 frontend/app/compose/layout.tsx
create mode 100644 frontend/app/points/PointsClient.tsx
create mode 100644 frontend/app/points/page.tsx
create mode 100644 frontend/app/post/[id]/edit/EditPostClient.tsx
create mode 100644 frontend/components/BoardPicker.tsx
create mode 100644 frontend/components/ContentLockGate.tsx
create mode 100644 frontend/components/MarkdownBody.tsx
create mode 100644 frontend/components/MarkdownEditor.tsx
create mode 100644 frontend/components/PostAttachments.tsx
create mode 100644 frontend/components/PostComposer.tsx
create mode 100644 frontend/components/PostInteractPanels.tsx
create mode 100644 frontend/components/TipHost.tsx
create mode 100644 frontend/lib/points.ts
create mode 100644 frontend/lib/postMeta.ts
diff --git a/backend/cmd/jiang13/main.go b/backend/cmd/jiang13/main.go
index 9375c79..2fac6e2 100644
--- a/backend/cmd/jiang13/main.go
+++ b/backend/cmd/jiang13/main.go
@@ -24,6 +24,8 @@ func main() {
service.ConfigureCookieNames(!cfg.DevMode)
// 过期/已吊销 refresh token 定期清理
service.StartRefreshTokenCleanup(model.DB)
+ // 悬赏过期退回 / 抽奖到期开奖
+ service.StartTypedPostSettler(service.NewPostService(model.DB))
r, err := router.Setup(cfg)
if err != nil {
diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go
index 67efaa4..9205ffa 100644
--- a/backend/handler/handlers.go
+++ b/backend/handler/handlers.go
@@ -22,6 +22,8 @@ type Handlers struct {
Checkin *service.CheckinService
Announcement *service.AnnouncementService
Upload *service.UploadService
+ PostFile *service.PostFileService
+ Points *service.PointsService
Setting *service.SettingService
AdminUser *service.AdminUserService
Moderation *service.ModerationService
diff --git a/backend/handler/post.go b/backend/handler/post.go
index 7a7416d..fe49381 100644
--- a/backend/handler/post.go
+++ b/backend/handler/post.go
@@ -2,7 +2,10 @@ package handler
import (
"errors"
+ "io"
"net/http"
+ "net/url"
+ "path/filepath"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
@@ -43,7 +46,6 @@ func (h *Handlers) Posts(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
- // 填充点赞状态(仅登录用户)
if claims := middleware.CurrentUser(c); claims != nil {
ids := make([]uint, 0, len(items))
for _, p := range items {
@@ -74,14 +76,13 @@ func (h *Handlers) PostDetail(c *gin.Context) {
var loadActor func() *service.Actor
if claims != nil {
viewerID = claims.ID
- loadActor = h.actorLoader(claims.ID) // 懒加载:仅非已发布帖才查 DB
+ loadActor = h.actorLoader(claims.ID)
}
post, err := h.Post.GetByIDForViewer(uint(id), viewerID, loadActor)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
- // 填充点赞状态(仅登录用户)
if claims != nil {
post.Liked = h.Like.HasLiked(post.ID, claims.ID)
}
@@ -90,11 +91,15 @@ func (h *Handlers) PostDetail(c *gin.Context) {
// CreatePostRequest 发帖请求
type CreatePostRequest struct {
- BoardID uint `json:"board_id" binding:"required"`
- Title string `json:"title" binding:"required,min=1,max=256"`
- Content string `json:"content" binding:"required,min=1"`
- Tags string `json:"tags"`
- PostType string `json:"post_type"`
+ BoardID uint `json:"board_id" binding:"required"`
+ Title string `json:"title" binding:"required,min=1,max=256"`
+ Content string `json:"content" binding:"required,min=1"`
+ Tags string `json:"tags"`
+ PostType string `json:"post_type"`
+ ContentAccess string `json:"content_access"`
+ AccessPoints int `json:"access_points"`
+ TypeMeta string `json:"type_meta"`
+ AttachmentIDs []uint `json:"attachment_ids"`
}
// CreatePost 创建帖子
@@ -105,17 +110,23 @@ func (h *Handlers) CreatePost(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- postType := req.PostType
- if postType == "" {
- postType = "normal"
- }
- // 管理团队成员发帖免审直发;普通用户进入待审核队列。
- // 角色变更会强制 JWT 失效(token_version 递增),claims.Role 可视为实时值
status := model.ContentStatusPending
if model.IsStaff(model.Role(claims.Role)) {
status = model.ContentStatusPublished
}
- post, err := h.Post.Create(claims.ID, req.BoardID, req.Title, req.Content, req.Tags, postType, status)
+ post, err := h.Post.Create(service.CreatePostInput{
+ UserID: claims.ID,
+ BoardID: req.BoardID,
+ Title: req.Title,
+ Content: req.Content,
+ Tags: req.Tags,
+ PostType: req.PostType,
+ ContentAccess: req.ContentAccess,
+ AccessPoints: req.AccessPoints,
+ TypeMeta: req.TypeMeta,
+ Status: status,
+ AttachmentIDs: req.AttachmentIDs,
+ })
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -130,9 +141,13 @@ func (h *Handlers) CreatePost(c *gin.Context) {
// UpdatePostRequest 更新帖子请求
type UpdatePostRequest struct {
- Title string `json:"title" binding:"omitempty,min=1,max=256"`
- Content string `json:"content" binding:"omitempty,min=1"`
- Tags string `json:"tags"`
+ Title string `json:"title" binding:"omitempty,min=1,max=256"`
+ Content string `json:"content" binding:"omitempty,min=1"`
+ Tags string `json:"tags"`
+ ContentAccess *string `json:"content_access"`
+ AccessPoints *int `json:"access_points"`
+ TypeMeta *string `json:"type_meta"`
+ AttachmentIDs *[]uint `json:"attachment_ids"`
}
// UpdatePost 编辑帖子
@@ -149,7 +164,15 @@ func (h *Handlers) UpdatePost(c *gin.Context) {
return
}
actor := h.loadActor(claims.ID)
- post, err := h.Post.Update(actor, uint(id), claims.ID, req.Title, req.Content, req.Tags)
+ post, err := h.Post.Update(actor, uint(id), claims.ID, service.UpdatePostInput{
+ Title: req.Title,
+ Content: req.Content,
+ Tags: req.Tags,
+ ContentAccess: req.ContentAccess,
+ AccessPoints: req.AccessPoints,
+ TypeMeta: req.TypeMeta,
+ AttachmentIDs: req.AttachmentIDs,
+ })
if err != nil {
respondPostModError(c, err)
return
@@ -157,6 +180,190 @@ func (h *Handlers) UpdatePost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"post": post})
}
+// UnlockPostContent 积分解锁正文
+func (h *Handlers) UnlockPostContent(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.UnlockContent(claims.ID, uint(id))
+ if err != nil {
+ if errors.Is(err, service.ErrInsufficientPoints) {
+ c.JSON(http.StatusPaymentRequired, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"post": post})
+}
+
+// GetPointsBalance 当前用户积分余额
+func (h *Handlers) GetPointsBalance(c *gin.Context) {
+ claims := middleware.CurrentUser(c)
+ bal, err := h.Points.Balance(claims.ID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"points": bal})
+}
+
+// GetPointsLedger 本人积分流水
+func (h *Handlers) GetPointsLedger(c *gin.Context) {
+ claims := middleware.CurrentUser(c)
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
+ items, total, err := h.Points.Ledger(claims.ID, page, size)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "items": nonNilSlice(items),
+ "total": total,
+ "page": page,
+ "size": size,
+ })
+}
+
+// AdminPointsStats 管理端积分与类型帖看板
+func (h *Handlers) AdminPointsStats(c *gin.Context) {
+ st, err := h.Points.AdminEconomyStats()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, st)
+}
+
+// UploadPostFile 上传帖子附件(草稿态)
+func (h *Handlers) UploadPostFile(c *gin.Context) {
+ claims := middleware.CurrentUser(c)
+ file, err := c.FormFile("file")
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请选择文件"})
+ return
+ }
+ price, _ := strconv.Atoi(c.DefaultPostForm("price_points", "0"))
+ f, err := file.Open()
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无法读取文件"})
+ return
+ }
+ defer f.Close()
+ data, err := io.ReadAll(io.LimitReader(f, service.FileMaxBytes+1))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "读取文件失败"})
+ return
+ }
+ att, err := h.PostFile.SaveDraftFile(claims.ID, file.Filename, data, price)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "attachment": gin.H{
+ "id": att.ID,
+ "name": att.Name,
+ "size": att.Size,
+ "mime": att.MIME,
+ "price_points": att.PricePoints,
+ "download_count": att.DownloadCount,
+ "unlocked": true,
+ },
+ })
+}
+
+// DeletePostFile 删除本人附件草稿
+func (h *Handlers) DeletePostFile(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
+ }
+ if err := h.PostFile.DeleteOwn(claims.ID, uint(id)); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
+// UpdatePostFilePrice 更新附件积分定价
+func (h *Handlers) UpdatePostFilePrice(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
+ }
+ var req struct {
+ PricePoints int `json:"price_points"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ if err := h.PostFile.UpdatePrice(claims.ID, uint(id), req.PricePoints); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
+// DownloadPostAttachment 下载帖子附件(鉴权 + 积分)
+func (h *Handlers) DownloadPostAttachment(c *gin.Context) {
+ postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
+ return
+ }
+ attID, err := strconv.ParseUint(c.Param("aid"), 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无效的附件 ID"})
+ return
+ }
+ claims := middleware.CurrentUser(c)
+ var viewerID uint
+ var loadActor func() *service.Actor
+ if claims != nil {
+ viewerID = claims.ID
+ loadActor = h.actorLoader(claims.ID)
+ }
+ if err := h.Post.EnsurePostVisible(uint(postID), viewerID, loadActor); err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
+ return
+ }
+ att, err := h.PostFile.Get(uint(attID))
+ if err != nil || att.PostID != uint(postID) {
+ c.JSON(http.StatusNotFound, gin.H{"error": "附件不存在"})
+ return
+ }
+ if att.PricePoints > 0 {
+ if claims == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
+ return
+ }
+ if err := h.PostFile.EnsureAttachmentUnlocked(claims.ID, att); err != nil {
+ if errors.Is(err, service.ErrInsufficientPoints) {
+ c.JSON(http.StatusPaymentRequired, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ }
+ path := h.PostFile.FilePath(att)
+ h.PostFile.IncDownload(att.ID)
+ c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(att.Name))
+ c.Header("Content-Type", att.MIME)
+ c.File(path)
+ _ = filepath.Base(path)
+}
+
// DeletePost 删除帖子
func (h *Handlers) DeletePost(c *gin.Context) {
claims := middleware.CurrentUser(c)
@@ -200,12 +407,12 @@ func (h *Handlers) ToggleRecommend(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
- recommended, err := h.Post.ToggleRecommend(uint(id))
+ rec, err := h.Post.ToggleRecommend(uint(id))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- c.JSON(http.StatusOK, gin.H{"recommended": recommended})
+ c.JSON(http.StatusOK, gin.H{"recommended": rec})
}
// requireAdminOrAbove 置顶/加精仅管理员及以上可用;校验失败已写响应,返回 false
diff --git a/backend/handler/post_interact.go b/backend/handler/post_interact.go
new file mode 100644
index 0000000..3533710
--- /dev/null
+++ b/backend/handler/post_interact.go
@@ -0,0 +1,197 @@
+package handler
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+
+ "github.com/freefire/jiang13-bbs/middleware"
+ "github.com/freefire/jiang13-bbs/service"
+ "github.com/gin-gonic/gin"
+)
+
+func (h *Handlers) respondInteractError(c *gin.Context, err error) {
+ switch {
+ case errors.Is(err, service.ErrPostNotFound):
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ case errors.Is(err, service.ErrPostForbidden):
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ case errors.Is(err, service.ErrInsufficientPoints):
+ c.JSON(http.StatusPaymentRequired, gin.H{"error": err.Error()})
+ case errors.Is(err, service.ErrAlreadyVoted),
+ errors.Is(err, service.ErrPollClosed),
+ errors.Is(err, service.ErrPollOptionsLocked),
+ errors.Is(err, service.ErrLotteryDrawn),
+ errors.Is(err, service.ErrLotteryClosed),
+ errors.Is(err, service.ErrBountySettled),
+ errors.Is(err, service.ErrBountyExpired):
+ c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
+ default:
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ }
+}
+
+// VotePoll 投票
+func (h *Handlers) VotePoll(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
+ }
+ var req struct {
+ Options []int `json:"options"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
+ return
+ }
+ post, err := h.Post.VotePoll(claims.ID, uint(id), req.Options)
+ if err != nil {
+ h.respondInteractError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"post": post})
+}
+
+// ClosePoll 结束投票
+func (h *Handlers) ClosePoll(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.ClosePoll(h.loadActor(claims.ID), claims.ID, uint(id))
+ if err != nil {
+ h.respondInteractError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"post": post})
+}
+
+// AcceptQuestion 采纳问答答案
+func (h *Handlers) AcceptQuestion(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
+ }
+ var req struct {
+ CommentID uint `json:"comment_id" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请指定评论"})
+ return
+ }
+ post, err := h.Post.AcceptQuestion(h.loadActor(claims.ID), claims.ID, uint(id), req.CommentID)
+ if err != nil {
+ h.respondInteractError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"post": post})
+}
+
+// SolveQuestion 手动标已解决
+func (h *Handlers) SolveQuestion(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.SolveQuestion(h.loadActor(claims.ID), claims.ID, uint(id))
+ if err != nil {
+ h.respondInteractError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"post": post})
+}
+
+// ReopenQuestion 重新打开问答
+func (h *Handlers) ReopenQuestion(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.ReopenQuestion(h.loadActor(claims.ID), claims.ID, uint(id))
+ if err != nil {
+ h.respondInteractError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"post": post})
+}
+
+// AcceptBounty 采纳评论并发放悬赏
+func (h *Handlers) AcceptBounty(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
+ }
+ var req struct {
+ CommentID uint `json:"comment_id" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请指定评论"})
+ return
+ }
+ post, err := h.Post.AcceptBounty(h.loadActor(claims.ID), claims.ID, uint(id), req.CommentID)
+ if err != nil {
+ h.respondInteractError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"post": post})
+}
+
+// RefundBounty 退回悬赏
+func (h *Handlers) RefundBounty(c *gin.Context) {
+ claims := middleware.CurrentUser(c)
+ id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
+ return
+ }
+ post, err := h.Post.RefundBounty(h.loadActor(claims.ID), claims.ID, uint(id))
+ if err != nil {
+ h.respondInteractError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"post": post})
+}
+
+// CloseLottery 截止抽奖
+func (h *Handlers) CloseLottery(c *gin.Context) {
+ claims := middleware.CurrentUser(c)
+ id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
+ return
+ }
+ post, err := h.Post.CloseLotteryEntries(h.loadActor(claims.ID), claims.ID, uint(id))
+ if err != nil {
+ h.respondInteractError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"post": post})
+}
+
+// DrawLottery 开奖
+func (h *Handlers) DrawLottery(c *gin.Context) {
+ claims := middleware.CurrentUser(c)
+ 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.DrawLottery(h.loadActor(claims.ID), claims.ID, uint(id))
+ if err != nil {
+ h.respondInteractError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"post": post})
+}
diff --git a/backend/handler/upload.go b/backend/handler/upload.go
index 98246b0..6b6cb3e 100644
--- a/backend/handler/upload.go
+++ b/backend/handler/upload.go
@@ -49,6 +49,46 @@ func (h *Handlers) UploadAvatar(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
}
+// UploadImage 上传帖子插图(multipart 字段 file:JPEG / PNG / WebP)
+func (h *Handlers) UploadImage(c *gin.Context) {
+ claims := middleware.CurrentUser(c)
+
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.ImageMaxBytes+4096)
+
+ fh, err := c.FormFile("file")
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大(不能超过 5MB)或格式不正确"})
+ return
+ }
+ if fh.Size > service.ImageMaxBytes {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 5MB"})
+ return
+ }
+ f, err := fh.Open()
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
+ return
+ }
+ defer f.Close()
+
+ data, err := io.ReadAll(io.LimitReader(f, service.ImageMaxBytes+1))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
+ return
+ }
+ if len(data) > service.ImageMaxBytes {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 5MB"})
+ return
+ }
+
+ att, err := h.Upload.SaveImage(claims.ID, data)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
+}
+
// UseAvatarRequest 选用历史头像请求
type UseAvatarRequest struct {
URL string `json:"url"`
diff --git a/backend/middleware/ratelimit.go b/backend/middleware/ratelimit.go
index f183a59..ebfac66 100644
--- a/backend/middleware/ratelimit.go
+++ b/backend/middleware/ratelimit.go
@@ -2,12 +2,13 @@ package middleware
import (
"net/http"
+ "strconv"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
-// RateLimitMiddleware 速率限制中间件
+// RateLimitMiddleware 速率限制中间件(按 IP)
func RateLimitMiddleware(rl *service.RateLimiter, rateType string) gin.HandlerFunc {
return func(c *gin.Context) {
key := rateType + ":" + c.ClientIP()
@@ -18,3 +19,20 @@ func RateLimitMiddleware(rl *service.RateLimiter, rateType string) gin.HandlerFu
c.Next()
}
}
+
+// RateLimitUserMiddleware 按登录用户限流(需挂在 RequireAuth 之后)
+func RateLimitUserMiddleware(rl *service.RateLimiter, rateType string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ claims := CurrentUser(c)
+ id := "anon"
+ if claims != nil {
+ id = strconv.FormatUint(uint64(claims.ID), 10)
+ }
+ key := rateType + ":u:" + id
+ if !rl.Allow(key) {
+ c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "操作过于频繁,请稍后再试"})
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/backend/model/db.go b/backend/model/db.go
index 4280bb7..6ac4b4c 100644
--- a/backend/model/db.go
+++ b/backend/model/db.go
@@ -39,10 +39,23 @@ func InitDB(dsn string) error {
&User{}, &Board{}, &Post{}, &Comment{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
&Announcement{}, &SiteSetting{}, &Attachment{}, &UserBoard{}, &LoginLog{},
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
+ &PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
+ &PostPollVote{}, &PostLotteryEntry{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}
+ // 旧 post_type=normal → discussion
+ if err := db.Exec(`UPDATE posts SET post_type = ? WHERE post_type = ? OR post_type = '' OR post_type IS NULL`,
+ PostTypeDiscussion, PostTypeNormal).Error; err != nil {
+ return fmt.Errorf("帖子类型归一失败: %w", err)
+ }
+
+ // 一次性:用签到累计回填 User.Points(仅余额仍为 0 且有签到积分的用户)
+ if err := backfillPointsFromCheckin(db); err != nil {
+ return fmt.Errorf("积分余额回填失败: %w", err)
+ }
+
// RBAC:把初始管理员(id 最小的 admin,通常即首个注册账号)升级为站长;
// 已存在 owner 时不动数据,保证幂等
if err := ensureOwnerRole(db); err != nil {
@@ -213,6 +226,46 @@ func ensureOwnerRole(db *gorm.DB) error {
return nil
}
+// backfillPointsFromCheckin 把历史签到积分写入 User.Points(幂等:仅 points=0 且有签到)
+func backfillPointsFromCheckin(db *gorm.DB) error {
+ type row struct {
+ UserID uint
+ Total int
+ }
+ var rows []row
+ if err := db.Raw(`
+ SELECT c.user_id, COALESCE(SUM(c.points), 0)::int AS total
+ FROM checkins c
+ INNER JOIN users u ON u.id = c.user_id AND u.deleted_at IS NULL AND u.points = 0
+ GROUP BY c.user_id
+ HAVING COALESCE(SUM(c.points), 0) > 0
+ `).Scan(&rows).Error; err != nil {
+ return err
+ }
+ for _, r := range rows {
+ err := db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Model(&User{}).Where("id = ? AND points = 0", r.UserID).
+ Update("points", r.Total).Error; err != nil {
+ return err
+ }
+ return tx.Create(&PointLedger{
+ UserID: r.UserID,
+ Delta: r.Total,
+ Balance: r.Total,
+ Reason: PointReasonMigrateCheckin,
+ Note: "历史签到积分回填",
+ }).Error
+ })
+ if err != nil {
+ return err
+ }
+ }
+ if len(rows) > 0 {
+ log.Printf("[model] 已回填 %d 名用户的签到积分余额", len(rows))
+ }
+ return nil
+}
+
// seedDefaultBoards 写入默认板块
func seedDefaultBoards(db *gorm.DB) {
defaults := []Board{
diff --git a/backend/model/models.go b/backend/model/models.go
index 6ba33f4..3e9fac1 100644
--- a/backend/model/models.go
+++ b/backend/model/models.go
@@ -54,12 +54,64 @@ const (
ContentStatusRejected = "rejected"
)
-// 帖子类型
+// 帖子类型(discussion 为默认;兼容旧值 normal → discussion)
const (
- PostTypeNormal = "normal"
- PostTypeQuestion = "question"
+ PostTypeDiscussion = "discussion" // 讨论(默认)
+ PostTypeQuestion = "question" // 问答
+ PostTypePoll = "poll" // 投票
+ PostTypeBounty = "bounty" // 悬赏
+ PostTypeLottery = "lottery" // 抽奖
+ PostTypeNormal = "normal" // 旧值,读取时归一为 discussion
)
+// ValidPostType 发帖类型白名单
+func ValidPostType(t string) bool {
+ switch t {
+ case PostTypeDiscussion, PostTypeQuestion, PostTypePoll, PostTypeBounty, PostTypeLottery:
+ return true
+ }
+ return false
+}
+
+// NormalizePostType 归一化类型(空/旧 normal → discussion)
+func NormalizePostType(t string) string {
+ if t == "" || t == PostTypeNormal {
+ return PostTypeDiscussion
+ }
+ if ValidPostType(t) {
+ return t
+ }
+ return PostTypeDiscussion
+}
+
+// 正文可见性
+const (
+ ContentAccessPublic = "public" // 公开
+ ContentAccessLogin = "login" // 登录可见
+ ContentAccessReply = "reply" // 回复可见
+ ContentAccessPoints = "points" // 积分购买可见
+)
+
+// ValidContentAccess 可见性白名单
+func ValidContentAccess(a string) bool {
+ switch a {
+ case ContentAccessPublic, ContentAccessLogin, ContentAccessReply, ContentAccessPoints:
+ return true
+ }
+ return false
+}
+
+// NormalizeContentAccess 归一化可见性
+func NormalizeContentAccess(a string) string {
+ if a == "" {
+ return ContentAccessPublic
+ }
+ if ValidContentAccess(a) {
+ return a
+ }
+ return ContentAccessPublic
+}
+
// User 用户表
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
@@ -71,6 +123,7 @@ type User struct {
Signature string `gorm:"size:255;default:''" json:"signature"` // 个性签名
Role Role `gorm:"size:16;default:user" json:"role"`
Banned bool `gorm:"default:false" json:"banned"`
+ Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新)
CreatedAt time.Time `json:"created_at"`
@@ -111,28 +164,105 @@ type Board struct {
// Post 帖子
type Post struct {
- ID uint `gorm:"primaryKey" json:"id"`
- BoardID uint `gorm:"index;not null" json:"board_id"`
- UserID uint `gorm:"index;not null" json:"user_id"`
- Title string `gorm:"size:256;not null" json:"title"`
- Content string `gorm:"type:text;not null" json:"content"`
- Tags string `gorm:"size:256" json:"tags"`
- PostType string `gorm:"size:16;default:normal;index" json:"post_type"`
- Pinned int `gorm:"default:0" json:"pinned"`
- Recommended bool `gorm:"default:false;index" json:"recommended"`
- Status string `gorm:"size:16;default:published;index" json:"status"`
- LikeCount int `gorm:"default:0" json:"like_count"`
- ViewCount int `gorm:"default:0" json:"view_count"`
- CommentCount int `gorm:"default:0" json:"comment_count"`
- Liked bool `gorm:"-" json:"liked"` // 当前用户是否已点赞(展示字段,不入库)
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ ID uint `gorm:"primaryKey" json:"id"`
+ BoardID uint `gorm:"index;not null" json:"board_id"`
+ UserID uint `gorm:"index;not null" json:"user_id"`
+ Title string `gorm:"size:256;not null" json:"title"`
+ Content string `gorm:"type:text;not null" json:"content"`
+ Tags string `gorm:"size:256" json:"tags"`
+ PostType string `gorm:"size:16;default:discussion;index" json:"post_type"`
+ ContentAccess string `gorm:"size:16;default:public;index" json:"content_access"` // public|login|reply|points
+ AccessPoints int `gorm:"not null;default:0" json:"access_points"` // points 可见时所需积分
+ TypeMeta string `gorm:"type:text;default:''" json:"type_meta"` // 类型扩展 JSON(投票/悬赏/抽奖壳)
+ Pinned int `gorm:"default:0" json:"pinned"`
+ Recommended bool `gorm:"default:false;index" json:"recommended"`
+ Status string `gorm:"size:16;default:published;index" json:"status"`
+ LikeCount int `gorm:"default:0" json:"like_count"`
+ ViewCount int `gorm:"default:0" json:"view_count"`
+ CommentCount int `gorm:"default:0" json:"comment_count"`
+ Liked bool `gorm:"-" json:"liked"` // 当前用户是否已点赞(展示字段,不入库)
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Board Board `gorm:"foreignKey:BoardID" json:"board,omitempty"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
+// PointLedger 积分流水(余额以 User.Points 为准,本表可审计)
+type PointLedger struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ UserID uint `gorm:"index;not null" json:"user_id"`
+ Delta int `gorm:"not null" json:"delta"` // 正入负出
+ Balance int `gorm:"not null" json:"balance"` // 变动后余额
+ Reason string `gorm:"size:32;not null;index" json:"reason"`
+ RefType string `gorm:"size:32;not null;default:''" json:"ref_type"`
+ RefID uint `gorm:"not null;default:0" json:"ref_id"`
+ Note string `gorm:"size:256;not null;default:''" json:"note"`
+ CreatedAt time.Time `gorm:"index" json:"created_at"`
+}
+
+// 积分流水原因
+const (
+ PointReasonCheckin = "checkin"
+ PointReasonUnlockPost = "unlock_post"
+ PointReasonDownloadFile = "download_file"
+ PointReasonMigrateCheckin = "migrate_checkin"
+ PointReasonBountyEscrow = "bounty_escrow"
+ PointReasonBountyRefund = "bounty_refund"
+ PointReasonBountyAward = "bounty_award"
+)
+
+// PostPollVote 投票记录(多选时同一用户多行)
+type PostPollVote struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ PostID uint `gorm:"uniqueIndex:idx_poll_vote;not null" json:"post_id"`
+ UserID uint `gorm:"uniqueIndex:idx_poll_vote;not null" json:"user_id"`
+ OptionIndex int `gorm:"uniqueIndex:idx_poll_vote;not null" json:"option_index"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// PostLotteryEntry 抽奖报名
+type PostLotteryEntry struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ PostID uint `gorm:"uniqueIndex:idx_lottery_entry;not null" json:"post_id"`
+ UserID uint `gorm:"uniqueIndex:idx_lottery_entry;not null" json:"user_id"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// PostContentUnlock 积分购买正文解锁记录
+type PostContentUnlock struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ PostID uint `gorm:"uniqueIndex:idx_post_unlock_user;not null" json:"post_id"`
+ UserID uint `gorm:"uniqueIndex:idx_post_unlock_user;not null" json:"user_id"`
+ Points int `gorm:"not null;default:0" json:"points"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// PostAttachment 帖子文件附件(不走公开静态目录,经 API 鉴权下载)
+type PostAttachment struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定
+ UserID uint `gorm:"index;not null" json:"user_id"`
+ Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
+ StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名
+ MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"`
+ Size int `gorm:"not null;default:0" json:"size"`
+ PricePoints int `gorm:"not null;default:0" json:"price_points"` // 0=免费
+ DownloadCount int `gorm:"not null;default:0" json:"download_count"`
+ CreatedAt time.Time `json:"created_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+}
+
+// PostAttachmentUnlock 积分附件下载解锁(按附件计费一次)
+type PostAttachmentUnlock struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ AttachmentID uint `gorm:"uniqueIndex:idx_att_unlock_user;not null" json:"attachment_id"`
+ UserID uint `gorm:"uniqueIndex:idx_att_unlock_user;not null" json:"user_id"`
+ Points int `gorm:"not null;default:0" json:"points"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
// Comment 评论(主评论 = 楼层,ParentID 为空;子评论挂 root_id 对应楼层下)
type Comment struct {
ID uint `gorm:"primaryKey" json:"id"`
diff --git a/backend/router/router.go b/backend/router/router.go
index 15d6d77..2f66c79 100644
--- a/backend/router/router.go
+++ b/backend/router/router.go
@@ -49,6 +49,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
checkinSvc := service.NewCheckinService(model.DB)
announcementSvc := service.NewAnnouncementService(model.DB)
uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
+ postFileSvc := service.NewPostFileService(model.DB, filepath.Join(cfg.DataDir, "private"))
+ pointsSvc := service.NewPointsService(model.DB)
settingSvc := service.NewSettingService(model.DB)
adminUserSvc := service.NewAdminUserService(model.DB)
moderationSvc := service.NewModerationService(model.DB, notifSvc)
@@ -56,6 +58,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
if err := uploadSvc.EnsureDir(); err != nil {
return nil, err
}
+ if err := postFileSvc.EnsureDir(); err != nil {
+ return nil, err
+ }
limiter := service.DefaultRateLimiter()
h := &handler.Handlers{
@@ -71,6 +76,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
Checkin: checkinSvc,
Announcement: announcementSvc,
Upload: uploadSvc,
+ PostFile: postFileSvc,
+ Points: pointsSvc,
Setting: settingSvc,
AdminUser: adminUserSvc,
Moderation: moderationSvc,
@@ -107,6 +114,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/overview", h.Overview)
pubAPI.GET("/posts", h.Posts)
pubAPI.GET("/posts/:id", h.PostDetail)
+ pubAPI.GET("/posts/:id/attachments/:aid/download", h.DownloadPostAttachment)
pubAPI.GET("/posts/:id/comments", h.PostComments)
pubAPI.GET("/users/:id", h.UserProfile)
pubAPI.GET("/users/:id/comments", h.UserComments)
@@ -128,6 +136,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.PUT("/profile", h.UpdateProfile)
api.POST("/posts", middleware.RateLimitMiddleware(limiter, service.RatePost), h.CreatePost)
api.PUT("/posts/:id", h.UpdatePost)
+ api.POST("/posts/:id/unlock", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.UnlockPostContent)
+ api.POST("/posts/:id/poll/vote", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.VotePoll)
+ api.POST("/posts/:id/poll/close", h.ClosePoll)
+ api.POST("/posts/:id/question/accept", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.AcceptQuestion)
+ api.POST("/posts/:id/question/solve", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.SolveQuestion)
+ api.POST("/posts/:id/question/reopen", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.ReopenQuestion)
+ api.POST("/posts/:id/bounty/accept", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.AcceptBounty)
+ api.POST("/posts/:id/bounty/refund", h.RefundBounty)
+ api.POST("/posts/:id/lottery/close", h.CloseLottery)
+ api.POST("/posts/:id/lottery/draw", h.DrawLottery)
api.DELETE("/posts/:id", h.DeletePost)
api.PUT("/posts/:id/pin", h.TogglePin)
api.PUT("/posts/:id/recommend", h.ToggleRecommend)
@@ -139,11 +157,17 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.GET("/notifications/unread-count", h.UnreadCount)
api.PUT("/notifications/:id/read", h.MarkRead)
api.PUT("/notifications/read-all", h.MarkAllRead)
- // 每日签到
+ // 每日签到 / 积分
api.GET("/checkin", h.GetCheckin)
api.POST("/checkin", middleware.RateLimitMiddleware(limiter, service.RateComment), h.DoCheckin)
- // 头像上传(裁剪后的 WebP)/ 媒体库 / 附件删除
+ api.GET("/points", h.GetPointsBalance)
+ api.GET("/points/ledger", h.GetPointsLedger)
+ // 头像上传(裁剪后的 WebP)/ 帖子插图 / 媒体库 / 附件删除
api.POST("/upload/avatar", h.UploadAvatar)
+ api.POST("/upload/image", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadImage)
+ api.POST("/upload/file", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadPostFile)
+ api.PUT("/upload/file/:id/price", h.UpdatePostFilePrice)
+ api.DELETE("/upload/file/:id", h.DeletePostFile)
api.PUT("/avatar/use", h.UseAvatar)
api.GET("/my/media", h.MyMedia)
api.DELETE("/my/attachments/:id", h.DeleteAttachment)
@@ -186,6 +210,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
staffAPI.PUT("/moderation/comments/:id/approve", h.AdminApproveComment)
staffAPI.PUT("/moderation/comments/:id/reject", h.AdminRejectComment)
+ // 积分与类型帖概览(管理员及以上)
+ staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats)
+
// 站点公告文章管理(管理员及以上)
announceAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
announceAPI.GET("/announcements", h.AdminListAnnouncements)
diff --git a/backend/service/checkin.go b/backend/service/checkin.go
index 61780b2..423b053 100644
--- a/backend/service/checkin.go
+++ b/backend/service/checkin.go
@@ -21,7 +21,7 @@ var ErrAlreadyCheckedIn = errors.New("今日已签到")
type CheckinStatus struct {
CheckedToday bool `json:"checked_today"`
Streak int `json:"streak"` // 连续签到天数
- TotalPoints int `json:"total_points"` // 累计积分(签到所得)
+ TotalPoints int `json:"total_points"` // 累计积分(可用余额)
TodayPoints int `json:"today_points"` // 今日签到可得积分
}
@@ -85,9 +85,9 @@ func (s *CheckinService) Status(userID uint) (*CheckinStatus, error) {
}
var total int64
- if err := s.db.Model(&model.Checkin{}).
- Where("user_id = ?", userID).
- Select("COALESCE(SUM(points), 0)").Scan(&total).Error; err != nil {
+ if err := s.db.Model(&model.User{}).
+ Where("id = ?", userID).
+ Select("points").Scan(&total).Error; err != nil {
return nil, err
}
@@ -122,6 +122,10 @@ func (s *CheckinService) CheckIn(userID uint) (*CheckinStatus, error) {
if err := tx.Create(&row).Error; err != nil {
return err
}
+ // 同步入账到用户积分余额
+ if _, err := CreditTx(tx, userID, DailyCheckinPoints, model.PointReasonCheckin, "checkin", row.ID, "每日签到"); err != nil {
+ return err
+ }
return nil
})
if err != nil {
diff --git a/backend/service/moderation.go b/backend/service/moderation.go
index e1f724b..549de01 100644
--- a/backend/service/moderation.go
+++ b/backend/service/moderation.go
@@ -171,7 +171,14 @@ func (s *ModerationService) ApprovePost(actor *Actor, id uint) (boardID uint, er
if post.Status != model.ContentStatusPending {
return ErrNotPending
}
- if err := tx.Model(&post).Update("status", model.ContentStatusPublished).Error; err != nil {
+ updates := map[string]interface{}{
+ "status": model.ContentStatusPublished,
+ }
+ // 悬赏/抽奖:首次公开发布时补写 ends_at
+ if raw, ok := EnsureDeadlineOnPublish(post.TypeMeta, post.PostType, time.Now().UTC()); ok {
+ updates["type_meta"] = raw
+ }
+ if err := tx.Model(&post).Updates(updates).Error; err != nil {
return err
}
boardID = post.BoardID
diff --git a/backend/service/points.go b/backend/service/points.go
new file mode 100644
index 0000000..c843267
--- /dev/null
+++ b/backend/service/points.go
@@ -0,0 +1,231 @@
+package service
+
+import (
+ "errors"
+ "time"
+
+ "github.com/freefire/jiang13-bbs/model"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+var (
+ ErrInsufficientPoints = errors.New("积分不足")
+ ErrInvalidPoints = errors.New("积分数量无效")
+)
+
+// PointsService 用户积分账户
+type PointsService struct {
+ db *gorm.DB
+}
+
+func NewPointsService(db *gorm.DB) *PointsService {
+ return &PointsService{db: db}
+}
+
+// Balance 查询可用积分
+func (s *PointsService) Balance(userID uint) (int, error) {
+ var u model.User
+ if err := s.db.Select("id", "points").First(&u, userID).Error; err != nil {
+ return 0, err
+ }
+ return u.Points, nil
+}
+
+// CreditTx 在事务内入账
+func CreditTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
+ if delta <= 0 {
+ return 0, ErrInvalidPoints
+ }
+ var u model.User
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Select("id", "points").First(&u, userID).Error; err != nil {
+ return 0, err
+ }
+ bal := u.Points + delta
+ if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("points", bal).Error; err != nil {
+ return 0, err
+ }
+ if err := tx.Create(&model.PointLedger{
+ UserID: userID,
+ Delta: delta,
+ Balance: bal,
+ Reason: reason,
+ RefType: refType,
+ RefID: refID,
+ Note: note,
+ }).Error; err != nil {
+ return 0, err
+ }
+ return bal, nil
+}
+
+// DebitTx 在事务内扣款
+func DebitTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
+ if delta <= 0 {
+ return 0, ErrInvalidPoints
+ }
+ var u model.User
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Select("id", "points").First(&u, userID).Error; err != nil {
+ return 0, err
+ }
+ if u.Points < delta {
+ return 0, ErrInsufficientPoints
+ }
+ bal := u.Points - delta
+ if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("points", bal).Error; err != nil {
+ return 0, err
+ }
+ if err := tx.Create(&model.PointLedger{
+ UserID: userID,
+ Delta: -delta,
+ Balance: bal,
+ Reason: reason,
+ RefType: refType,
+ RefID: refID,
+ Note: note,
+ }).Error; err != nil {
+ return 0, err
+ }
+ return bal, nil
+}
+
+// Credit 入账
+func (s *PointsService) Credit(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
+ var bal int
+ err := s.db.Transaction(func(tx *gorm.DB) error {
+ var e error
+ bal, e = CreditTx(tx, userID, delta, reason, refType, refID, note)
+ return e
+ })
+ return bal, err
+}
+
+// Debit 扣款
+func (s *PointsService) Debit(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
+ var bal int
+ err := s.db.Transaction(func(tx *gorm.DB) error {
+ var e error
+ bal, e = DebitTx(tx, userID, delta, reason, refType, refID, note)
+ return e
+ })
+ return bal, err
+}
+
+// LedgerItem 流水展示项
+type LedgerItem struct {
+ ID uint `json:"id"`
+ Delta int `json:"delta"`
+ Balance int `json:"balance"`
+ Reason string `json:"reason"`
+ RefType string `json:"ref_type"`
+ RefID uint `json:"ref_id"`
+ Note string `json:"note"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// Ledger 分页查询本人积分流水
+func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64, error) {
+ if page < 1 {
+ page = 1
+ }
+ if size < 1 || size > 50 {
+ size = 20
+ }
+ var total int64
+ q := s.db.Model(&model.PointLedger{}).Where("user_id = ?", userID)
+ if err := q.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+ var rows []model.PointLedger
+ if err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
+ return nil, 0, err
+ }
+ out := make([]LedgerItem, 0, len(rows))
+ for _, r := range rows {
+ out = append(out, LedgerItem{
+ ID: r.ID, Delta: r.Delta, Balance: r.Balance, Reason: r.Reason,
+ RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt,
+ })
+ }
+ return out, total, nil
+}
+
+// PointsEconomyStats 全站积分与类型帖概览(管理端)
+type PointsEconomyStats struct {
+ TotalBalance int64 `json:"total_balance"` // 用户余额合计
+ LedgerCount int64 `json:"ledger_count"` // 流水条数
+ CheckinToday int64 `json:"checkin_today"` // 今日签到人数
+ OpenBounties int64 `json:"open_bounties"` // 未结算悬赏帖
+ EscrowedPoints int64 `json:"escrowed_points"` // 托管中悬赏积分(估算)
+ PostsByType map[string]int64 `json:"posts_by_type"` // 各类型帖数量
+ RecentLedger []LedgerItem `json:"recent_ledger"` // 最近全局流水(脱敏 note)
+}
+
+// AdminEconomyStats 管理端经济看板
+func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
+ st := &PointsEconomyStats{PostsByType: map[string]int64{}}
+ if err := s.db.Model(&model.User{}).Where("deleted_at IS NULL").
+ Select("COALESCE(SUM(points),0)").Scan(&st.TotalBalance).Error; err != nil {
+ return nil, err
+ }
+ if err := s.db.Model(&model.PointLedger{}).Count(&st.LedgerCount).Error; err != nil {
+ return nil, err
+ }
+ today := time.Now().Truncate(24 * time.Hour)
+ // 用日期字符串更稳妥(与 checkin 一致用 date)
+ if err := s.db.Model(&model.Checkin{}).
+ Where("checkin_date::date = CURRENT_DATE").
+ Count(&st.CheckinToday).Error; err != nil {
+ return nil, err
+ }
+ _ = today
+
+ type row struct {
+ PostType string
+ Cnt int64
+ }
+ var rows []row
+ if err := s.db.Model(&model.Post{}).
+ Select("post_type, count(*) as cnt").
+ Where("deleted_at IS NULL").
+ Group("post_type").Scan(&rows).Error; err != nil {
+ return nil, err
+ }
+ for _, r := range rows {
+ pt := model.NormalizePostType(r.PostType)
+ st.PostsByType[pt] += r.Cnt
+ }
+
+ // 未结算悬赏:type_meta 含 escrowed true 且未 accepted/refunded —— 用简易扫描估算
+ var bountyPosts []model.Post
+ if err := s.db.Select("id, type_meta").
+ Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty).
+ Find(&bountyPosts).Error; err != nil {
+ return nil, err
+ }
+ for _, p := range bountyPosts {
+ m, err := parseBountyMeta(p.TypeMeta)
+ if err != nil {
+ continue
+ }
+ if m.Escrowed && m.AcceptedCommentID == 0 && !m.Refunded && !m.Expired {
+ st.OpenBounties++
+ st.EscrowedPoints += int64(m.Points)
+ }
+ }
+
+ var recent []model.PointLedger
+ if err := s.db.Order("id DESC").Limit(15).Find(&recent).Error; err != nil {
+ return nil, err
+ }
+ st.RecentLedger = make([]LedgerItem, 0, len(recent))
+ for _, r := range recent {
+ st.RecentLedger = append(st.RecentLedger, LedgerItem{
+ ID: r.ID, Delta: r.Delta, Balance: r.Balance, Reason: r.Reason,
+ RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt,
+ })
+ }
+ return st, nil
+}
diff --git a/backend/service/post.go b/backend/service/post.go
index 499d9b1..3f987b0 100644
--- a/backend/service/post.go
+++ b/backend/service/post.go
@@ -40,9 +40,13 @@ type PostListQuery struct {
func toPostListItems(posts []model.Post) []PostListItem {
items := make([]PostListItem, 0, len(posts))
for _, p := range posts {
+ pt := model.NormalizePostType(p.PostType)
items = append(items, PostListItem{
ID: p.ID, BoardID: p.BoardID, UserID: p.UserID,
- Title: p.Title, Tags: p.Tags, PostType: p.PostType,
+ Title: p.Title, Tags: p.Tags, PostType: pt,
+ TypeStatus: ComputeTypeStatus(pt, p.TypeMeta),
+ ContentAccess: model.NormalizeContentAccess(p.ContentAccess),
+ AccessPoints: p.AccessPoints,
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
CommentCount: p.CommentCount, Status: p.Status, CreatedAt: p.CreatedAt,
Board: p.Board, User: p.User,
@@ -67,23 +71,26 @@ type LastReplyInfo struct {
// PostListItem 帖子列表项(不含正文)
type PostListItem struct {
- ID uint `json:"id"`
- BoardID uint `json:"board_id"`
- UserID uint `json:"user_id"`
- Title string `json:"title"`
- Tags string `json:"tags"`
- PostType string `json:"post_type"`
- Pinned int `json:"pinned"`
- Recommended bool `json:"recommended"`
- LikeCount int `json:"like_count"`
- ViewCount int `json:"view_count"`
- CommentCount int `json:"comment_count"`
- Status string `json:"status"`
- Liked bool `json:"liked"`
- CreatedAt time.Time `json:"created_at"`
- LastReply *LastReplyInfo `json:"last_reply,omitempty"`
- Board model.Board `json:"board"`
- User model.User `json:"user"`
+ ID uint `json:"id"`
+ BoardID uint `json:"board_id"`
+ UserID uint `json:"user_id"`
+ Title string `json:"title"`
+ Tags string `json:"tags"`
+ PostType string `json:"post_type"`
+ TypeStatus string `json:"type_status,omitempty"` // unsolved|solved|open|closed|expired|drawn
+ ContentAccess string `json:"content_access"`
+ AccessPoints int `json:"access_points"`
+ Pinned int `json:"pinned"`
+ Recommended bool `json:"recommended"`
+ LikeCount int `json:"like_count"`
+ ViewCount int `json:"view_count"`
+ CommentCount int `json:"comment_count"`
+ Status string `json:"status"`
+ Liked bool `json:"liked"`
+ CreatedAt time.Time `json:"created_at"`
+ LastReply *LastReplyInfo `json:"last_reply,omitempty"`
+ Board model.Board `json:"board"`
+ User model.User `json:"user"`
}
// fillLastReply 批量填充每帖最后一条已发布评论(发帖人+时间),
@@ -327,9 +334,78 @@ func visibleToPost(post *model.Post, viewerID uint, loadActor func() *Actor) boo
return loadActor().CanModerateBoard(post.BoardID)
}
-// GetByIDForViewer 获取帖子详情(带可见性校验);通过校验才计入浏览量。
-// viewerID 为当前登录用户(未登录传 0),loadActor 可传 nil
-func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor) (*model.Post, error) {
+// PostAttachmentDTO 附件对外字段
+type PostAttachmentDTO struct {
+ ID uint `json:"id"`
+ Name string `json:"name"`
+ Size int `json:"size"`
+ MIME string `json:"mime"`
+ PricePoints int `json:"price_points"`
+ DownloadCount int `json:"download_count"`
+ Unlocked bool `json:"unlocked"` // 当前用户是否可直接下载(免费/已购/作者)
+}
+
+// PostDetail 帖子详情(含可见性裁剪与附件)
+type PostDetail struct {
+ ID uint `json:"id"`
+ BoardID uint `json:"board_id"`
+ UserID uint `json:"user_id"`
+ Title string `json:"title"`
+ Content string `json:"content"`
+ Tags string `json:"tags"`
+ PostType string `json:"post_type"`
+ ContentAccess string `json:"content_access"`
+ AccessPoints int `json:"access_points"`
+ TypeMeta string `json:"type_meta"`
+ TypeStatus string `json:"type_status,omitempty"`
+ Pinned int `json:"pinned"`
+ Recommended bool `json:"recommended"`
+ Status string `json:"status"`
+ LikeCount int `json:"like_count"`
+ ViewCount int `json:"view_count"`
+ CommentCount int `json:"comment_count"`
+ Liked bool `json:"liked"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Board model.Board `json:"board"`
+ User model.User `json:"user"`
+ ContentLocked bool `json:"content_locked"`
+ AccessHint string `json:"access_hint,omitempty"`
+ Attachments []PostAttachmentDTO `json:"attachments"`
+ Question *QuestionState `json:"question,omitempty"`
+ Poll *PollState `json:"poll,omitempty"`
+ Bounty *BountyState `json:"bounty,omitempty"`
+ Lottery *LotteryState `json:"lottery,omitempty"`
+}
+
+// CreatePostInput 发帖入参
+type CreatePostInput struct {
+ UserID uint
+ BoardID uint
+ Title string
+ Content string
+ Tags string
+ PostType string
+ ContentAccess string
+ AccessPoints int
+ TypeMeta string
+ Status string
+ AttachmentIDs []uint
+}
+
+// UpdatePostInput 编辑入参
+type UpdatePostInput struct {
+ Title string
+ Content string
+ Tags string
+ ContentAccess *string
+ AccessPoints *int
+ TypeMeta *string
+ AttachmentIDs *[]uint // nil=不改附件;非 nil=替换列表
+}
+
+// GetByIDForViewer 获取帖子详情(带状态可见性 + 正文访问控制)
+func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor) (*PostDetail, error) {
var post model.Post
if err := s.db.Preload("Board").Preload("User").First(&post, id).Error; err != nil {
return nil, ErrPostNotFound
@@ -337,9 +413,504 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto
if !visibleToPost(&post, viewerID, loadActor) {
return nil, ErrPostNotFound
}
- // 增加浏览量(待审/被拒内容不计)
+ // 惰性结算:悬赏过期退回 / 抽奖到期开奖
+ _ = s.settleExpiredBountyIfNeeded(&post)
+ _ = s.settleDueLotteryIfNeeded(&post)
+ if post.TypeMeta != "" {
+ var fresh model.Post
+ if err := s.db.Select("type_meta").First(&fresh, post.ID).Error; err == nil {
+ post.TypeMeta = fresh.TypeMeta
+ }
+ }
+
s.db.Model(&post).UpdateColumn("view_count", gorm.Expr("view_count + 1"))
- return &post, nil
+ post.ViewCount++
+
+ detail := buildPostDetail(&post)
+ locked, hint := s.evalContentAccess(&post, viewerID, loadActor)
+ detail.ContentLocked = locked
+ detail.AccessHint = hint
+ if locked {
+ detail.Content = ""
+ }
+
+ atts, _ := s.listAttachmentDTOs(post.ID, viewerID, post.UserID)
+ detail.Attachments = atts
+ s.fillInteractState(detail, &post, viewerID, loadActor)
+ return detail, nil
+}
+
+func buildPostDetail(post *model.Post) *PostDetail {
+ return &PostDetail{
+ ID: post.ID, BoardID: post.BoardID, UserID: post.UserID,
+ Title: post.Title, Content: post.Content, Tags: post.Tags,
+ PostType: model.NormalizePostType(post.PostType),
+ ContentAccess: model.NormalizeContentAccess(post.ContentAccess),
+ AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta,
+ TypeStatus: ComputeTypeStatus(post.PostType, post.TypeMeta),
+ Pinned: post.Pinned, Recommended: post.Recommended, Status: post.Status,
+ LikeCount: post.LikeCount, ViewCount: post.ViewCount, CommentCount: post.CommentCount,
+ Liked: post.Liked, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt,
+ Board: post.Board, User: post.User,
+ Attachments: []PostAttachmentDTO{},
+ }
+}
+
+func (s *PostService) evalContentAccess(post *model.Post, viewerID uint, loadActor func() *Actor) (locked bool, hint string) {
+ access := model.NormalizeContentAccess(post.ContentAccess)
+ if access == model.ContentAccessPublic {
+ return false, ""
+ }
+ // 作者与版主始终可见
+ if viewerID > 0 && post.UserID == viewerID {
+ return false, ""
+ }
+ if loadActor != nil && loadActor().CanModerateBoard(post.BoardID) {
+ return false, ""
+ }
+
+ switch access {
+ case model.ContentAccessLogin:
+ if viewerID == 0 {
+ return true, "登录后可见全文"
+ }
+ return false, ""
+ case model.ContentAccessReply:
+ if viewerID == 0 {
+ return true, "回复本帖后可见全文"
+ }
+ var n int64
+ s.db.Model(&model.Comment{}).
+ Where("post_id = ? AND user_id = ? AND status = ? AND deleted_at IS NULL",
+ post.ID, viewerID, model.ContentStatusPublished).
+ Count(&n)
+ if n == 0 {
+ return true, "回复本帖后可见全文"
+ }
+ return false, ""
+ case model.ContentAccessPoints:
+ need := post.AccessPoints
+ if need <= 0 {
+ need = 1
+ }
+ if viewerID == 0 {
+ return true, "支付积分后可见全文"
+ }
+ var n int64
+ s.db.Model(&model.PostContentUnlock{}).
+ Where("post_id = ? AND user_id = ?", post.ID, viewerID).Count(&n)
+ if n > 0 {
+ return false, ""
+ }
+ return true, "支付积分后可见全文"
+ default:
+ return false, ""
+ }
+}
+
+func (s *PostService) listAttachmentDTOs(postID, viewerID, authorID uint) ([]PostAttachmentDTO, error) {
+ var rows []model.PostAttachment
+ if err := s.db.Where("post_id = ?", postID).Order("id ASC").Find(&rows).Error; err != nil {
+ return nil, err
+ }
+ out := make([]PostAttachmentDTO, 0, len(rows))
+ unlockedIDs := map[uint]bool{}
+ if viewerID > 0 {
+ ids := make([]uint, 0, len(rows))
+ for _, r := range rows {
+ if r.PricePoints > 0 {
+ ids = append(ids, r.ID)
+ }
+ }
+ if len(ids) > 0 {
+ var unlocks []model.PostAttachmentUnlock
+ s.db.Where("attachment_id IN ? AND user_id = ?", ids, viewerID).Find(&unlocks)
+ for _, u := range unlocks {
+ unlockedIDs[u.AttachmentID] = true
+ }
+ }
+ }
+ for _, r := range rows {
+ ok := r.PricePoints <= 0 || viewerID == authorID || unlockedIDs[r.ID]
+ out = append(out, PostAttachmentDTO{
+ ID: r.ID, Name: r.Name, Size: r.Size, MIME: r.MIME,
+ PricePoints: r.PricePoints, DownloadCount: r.DownloadCount, Unlocked: ok,
+ })
+ }
+ return out, nil
+}
+
+// UnlockContent 积分解锁正文
+func (s *PostService) UnlockContent(userID, postID uint) (*PostDetail, error) {
+ var post model.Post
+ if err := s.db.Preload("Board").Preload("User").First(&post, postID).Error; err != nil {
+ return nil, ErrPostNotFound
+ }
+ if model.NormalizeContentAccess(post.ContentAccess) != model.ContentAccessPoints {
+ return nil, errors.New("本文无需积分解锁")
+ }
+ if post.UserID == userID {
+ return buildPostDetail(&post), nil
+ }
+ need := post.AccessPoints
+ if need <= 0 {
+ need = 1
+ }
+ err := s.db.Transaction(func(tx *gorm.DB) error {
+ var n int64
+ if err := tx.Model(&model.PostContentUnlock{}).
+ Where("post_id = ? AND user_id = ?", postID, userID).Count(&n).Error; err != nil {
+ return err
+ }
+ if n > 0 {
+ return nil
+ }
+ if _, err := DebitTx(tx, userID, need, model.PointReasonUnlockPost, "post", postID, "解锁帖子:"+post.Title); err != nil {
+ return err
+ }
+ if post.UserID > 0 {
+ if _, err := CreditTx(tx, post.UserID, need, model.PointReasonUnlockPost, "post_earn", postID, "正文解锁收益"); err != nil {
+ return err
+ }
+ }
+ return tx.Create(&model.PostContentUnlock{
+ PostID: postID, UserID: userID, Points: need,
+ }).Error
+ })
+ if err != nil {
+ return nil, err
+ }
+ detail := buildPostDetail(&post)
+ atts, _ := s.listAttachmentDTOs(post.ID, userID, post.UserID)
+ detail.Attachments = atts
+ s.fillInteractState(detail, &post, userID, nil)
+ return detail, nil
+}
+
+// Create 创建帖子。status 由 handler 按角色计算
+func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
+ title := strings.TrimSpace(in.Title)
+ content := strings.TrimSpace(in.Content)
+ if title == "" {
+ return nil, errors.New("标题不能为空")
+ }
+ if content == "" {
+ return nil, errors.New("内容不能为空")
+ }
+ if in.BoardID == 0 {
+ return nil, errors.New("请选择板块")
+ }
+ status := in.Status
+ if status != model.ContentStatusPending && status != model.ContentStatusPublished {
+ status = model.ContentStatusPending
+ }
+ postType := model.NormalizePostType(in.PostType)
+ if !model.ValidPostType(postType) {
+ return nil, errors.New("无效的帖子类型")
+ }
+ access := model.NormalizeContentAccess(in.ContentAccess)
+ accessPts := in.AccessPoints
+ if access == model.ContentAccessPoints {
+ if accessPts <= 0 {
+ return nil, errors.New("请设置解锁所需积分")
+ }
+ if accessPts > 100000 {
+ return nil, errors.New("解锁积分过高")
+ }
+ } else {
+ accessPts = 0
+ }
+ typeMeta, err := NormalizeAndValidateTypeMeta(postType, in.TypeMeta)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.checkNewUserCooldown(in.UserID); err != nil {
+ return nil, err
+ }
+
+ now := time.Now().UTC()
+ if postType == model.PostTypeQuestion {
+ qm, _ := parseQuestionMeta(typeMeta)
+ if qm == nil {
+ qm = &QuestionMeta{}
+ }
+ typeMeta, _ = encodeMeta(qm)
+ }
+
+ // 悬赏:创建时托管积分 + 防刷(未结算上限 / 每日上限)
+ var bountyPts int
+ if postType == model.PostTypeBounty {
+ bm, _ := parseBountyMeta(typeMeta)
+ bountyPts = bm.Points
+ var bountyPosts []model.Post
+ s.db.Select("type_meta").
+ Where("user_id = ? AND post_type = ? AND deleted_at IS NULL", in.UserID, model.PostTypeBounty).
+ Find(&bountyPosts)
+ open := 0
+ for _, bp := range bountyPosts {
+ m, err := parseBountyMeta(bp.TypeMeta)
+ if err != nil {
+ continue
+ }
+ if m.Escrowed && m.AcceptedCommentID == 0 && !m.Refunded && !m.Expired {
+ open++
+ }
+ }
+ if open >= 3 {
+ return nil, errors.New("未结算悬赏最多同时 3 个,请先采纳或退回")
+ }
+ var todayN int64
+ s.db.Model(&model.Post{}).
+ Where("user_id = ? AND post_type = ? AND created_at >= CURRENT_DATE AND deleted_at IS NULL",
+ in.UserID, model.PostTypeBounty).
+ Count(&todayN)
+ if todayN >= 5 {
+ return nil, errors.New("今日悬赏发帖已达上限(5)")
+ }
+ bm.Escrowed = true
+ bm.Refunded = false
+ bm.Expired = false
+ bm.AcceptedCommentID = 0
+ if status == model.ContentStatusPublished {
+ days := normalizeExpireDays(bm.ExpireDays, 7, []int{3, 7, 14, 30})
+ bm.ExpireDays = 0
+ bm.EndsAt = endsAtFromDays(days, now)
+ }
+ typeMeta, _ = encodeMeta(bm)
+ }
+
+ if postType == model.PostTypeLottery {
+ lm, _ := parseLotteryMeta(typeMeta)
+ if lm.WinnerIDs == nil {
+ lm.WinnerIDs = []uint{}
+ }
+ if status == model.ContentStatusPublished {
+ days := normalizeExpireDays(lm.ExpireDays, 7, []int{1, 3, 7, 14})
+ lm.ExpireDays = 0
+ lm.EndsAt = endsAtFromDays(days, now)
+ }
+ typeMeta, _ = encodeMeta(lm)
+ }
+
+ post := &model.Post{
+ BoardID: in.BoardID, UserID: in.UserID,
+ Title: title, Content: content, Tags: in.Tags,
+ PostType: postType, ContentAccess: access, AccessPoints: accessPts,
+ TypeMeta: typeMeta, Status: status,
+ }
+ err = s.db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Create(post).Error; err != nil {
+ return err
+ }
+ if bountyPts > 0 {
+ if _, err := DebitTx(tx, in.UserID, bountyPts, model.PointReasonBountyEscrow, "post", post.ID, "悬赏托管:"+title); err != nil {
+ return err
+ }
+ }
+ if len(in.AttachmentIDs) == 0 {
+ return nil
+ }
+ if len(in.AttachmentIDs) > MaxPostAttachments {
+ return ErrTooManyAttachments
+ }
+ var atts []model.PostAttachment
+ if err := tx.Where("id IN ? AND user_id = ? AND post_id = 0", in.AttachmentIDs, in.UserID).
+ Find(&atts).Error; err != nil {
+ return err
+ }
+ if len(atts) != len(in.AttachmentIDs) {
+ return errors.New("部分附件无效或无权使用")
+ }
+ return tx.Model(&model.PostAttachment{}).
+ Where("id IN ? AND user_id = ? AND post_id = 0", in.AttachmentIDs, in.UserID).
+ Update("post_id", post.ID).Error
+ })
+ if err != nil {
+ return nil, err
+ }
+ s.db.Preload("Board").Preload("User").First(post, post.ID)
+ detail := buildPostDetail(post)
+ atts, _ := s.listAttachmentDTOs(post.ID, in.UserID, post.UserID)
+ detail.Attachments = atts
+ s.fillInteractState(detail, post, in.UserID, nil)
+ return detail, nil
+}
+
+func validateTypeMeta(postType, meta string) error {
+ _, err := NormalizeAndValidateTypeMeta(postType, meta)
+ return err
+}
+
+// Update 更新帖子(作者本人,或对该板块有审核权的管理成员)
+func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInput) (*PostDetail, error) {
+ var post model.Post
+ if err := s.db.First(&post, postID).Error; err != nil {
+ return nil, ErrPostNotFound
+ }
+ if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
+ return nil, ErrPostForbidden
+ }
+
+ updates := map[string]interface{}{}
+ if in.Title != "" {
+ t := strings.TrimSpace(in.Title)
+ if t == "" {
+ return nil, errors.New("标题不能为空")
+ }
+ updates["title"] = t
+ }
+ if in.Content != "" {
+ c := strings.TrimSpace(in.Content)
+ if c == "" {
+ return nil, errors.New("内容不能为空")
+ }
+ updates["content"] = c
+ }
+ updates["tags"] = in.Tags
+
+ if in.ContentAccess != nil {
+ access := model.NormalizeContentAccess(*in.ContentAccess)
+ updates["content_access"] = access
+ if access == model.ContentAccessPoints {
+ pts := post.AccessPoints
+ if in.AccessPoints != nil {
+ pts = *in.AccessPoints
+ }
+ if pts <= 0 {
+ return nil, errors.New("请设置解锁所需积分")
+ }
+ updates["access_points"] = pts
+ } else {
+ updates["access_points"] = 0
+ }
+ } else if in.AccessPoints != nil && model.NormalizeContentAccess(post.ContentAccess) == model.ContentAccessPoints {
+ if *in.AccessPoints <= 0 {
+ return nil, errors.New("请设置解锁所需积分")
+ }
+ updates["access_points"] = *in.AccessPoints
+ }
+ if in.TypeMeta != nil {
+ pt := model.NormalizePostType(post.PostType)
+ meta, err := NormalizeAndValidateTypeMeta(pt, *in.TypeMeta)
+ if err != nil {
+ return nil, err
+ }
+ // 悬赏已托管:禁止改积分,保留结算/截止字段
+ if pt == model.PostTypeBounty {
+ old, _ := parseBountyMeta(post.TypeMeta)
+ neu, _ := parseBountyMeta(meta)
+ if old != nil && neu != nil {
+ neu.Escrowed = old.Escrowed
+ neu.Refunded = old.Refunded
+ neu.Expired = old.Expired
+ neu.AcceptedCommentID = old.AcceptedCommentID
+ neu.EndsAt = old.EndsAt
+ if old.Escrowed || old.AcceptedCommentID > 0 || old.Refunded || old.Expired {
+ neu.Points = old.Points
+ }
+ meta, _ = encodeMeta(neu)
+ }
+ }
+ // 投票已有票:明确报错,禁止改选项/单多选/匿名
+ if pt == model.PostTypePoll {
+ old, _ := parsePollMeta(post.TypeMeta)
+ neu, _ := parsePollMeta(meta)
+ if old != nil && neu != nil {
+ var n int64
+ s.db.Model(&model.PostPollVote{}).Where("post_id = ?", postID).Count(&n)
+ if n > 0 {
+ optsChanged := len(neu.Options) != len(old.Options)
+ if !optsChanged {
+ for i := range old.Options {
+ if neu.Options[i] != old.Options[i] {
+ optsChanged = true
+ break
+ }
+ }
+ }
+ if optsChanged || neu.Multi != old.Multi || neu.Anonymous != old.Anonymous {
+ return nil, ErrPollOptionsLocked
+ }
+ neu.Options = old.Options
+ neu.Multi = old.Multi
+ neu.Anonymous = old.Anonymous
+ neu.Closed = old.Closed
+ meta, _ = encodeMeta(neu)
+ } else {
+ neu.Closed = old.Closed
+ meta, _ = encodeMeta(neu)
+ }
+ }
+ }
+ if pt == model.PostTypeLottery {
+ old, _ := parseLotteryMeta(post.TypeMeta)
+ neu, _ := parseLotteryMeta(meta)
+ if old != nil && neu != nil {
+ neu.Drawn = old.Drawn
+ neu.WinnerIDs = old.WinnerIDs
+ neu.Closed = old.Closed
+ neu.EndsAt = old.EndsAt
+ if old.Drawn {
+ neu.Slots = old.Slots
+ }
+ meta, _ = encodeMeta(neu)
+ }
+ }
+ if pt == model.PostTypeQuestion {
+ old, _ := parseQuestionMeta(post.TypeMeta)
+ neu, _ := parseQuestionMeta(meta)
+ if old != nil && neu != nil {
+ // 解题态走专用 API,编辑帖子不覆盖
+ neu.Solved = old.Solved
+ neu.AcceptedCommentID = old.AcceptedCommentID
+ meta, _ = encodeMeta(neu)
+ }
+ }
+ updates["type_meta"] = meta
+ }
+
+ err := s.db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Model(&post).Updates(updates).Error; err != nil {
+ return err
+ }
+ if in.AttachmentIDs == nil {
+ return nil
+ }
+ ids := *in.AttachmentIDs
+ if len(ids) > MaxPostAttachments {
+ return ErrTooManyAttachments
+ }
+ if err := tx.Model(&model.PostAttachment{}).
+ Where("post_id = ? AND user_id = ?", postID, post.UserID).
+ Update("post_id", 0).Error; err != nil {
+ return err
+ }
+ if len(ids) == 0 {
+ return nil
+ }
+ var atts []model.PostAttachment
+ if err := tx.Where(
+ "id IN ? AND user_id = ? AND (post_id = 0 OR post_id = ?)",
+ ids, post.UserID, postID,
+ ).Find(&atts).Error; err != nil {
+ return err
+ }
+ if len(atts) != len(ids) {
+ return errors.New("部分附件无效或无权使用")
+ }
+ return tx.Model(&model.PostAttachment{}).
+ Where("id IN ? AND user_id = ?", ids, post.UserID).
+ Update("post_id", postID).Error
+ })
+ if err != nil {
+ return nil, err
+ }
+ s.db.Preload("Board").Preload("User").First(&post, post.ID)
+ detail := buildPostDetail(&post)
+ atts, _ := s.listAttachmentDTOs(post.ID, userID, post.UserID)
+ detail.Attachments = atts
+ s.fillInteractState(detail, &post, userID, nil)
+ return detail, nil
}
// EnsurePostVisible 校验帖子对当前访问者可见(评论列表等场景复用,不增加浏览量)
@@ -354,105 +925,27 @@ func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func()
return nil
}
-// Create 创建帖子。status 由 handler 按角色计算:
-// 管理团队成员直发 published,普通用户进入 pending 等待审核
-func (s *PostService) Create(userID uint, boardID uint, title, content, tags, postType, status string) (*model.Post, error) {
- title = strings.TrimSpace(title)
- content = strings.TrimSpace(content)
- if title == "" {
- return nil, errors.New("标题不能为空")
- }
- if content == "" {
- return nil, errors.New("内容不能为空")
- }
- if boardID == 0 {
- return nil, errors.New("请选择板块")
- }
- if status != model.ContentStatusPending && status != model.ContentStatusPublished {
- status = model.ContentStatusPending
- }
-
- // 新用户 24h 冷静期校验
- if err := s.checkNewUserCooldown(userID); err != nil {
- return nil, err
- }
-
- post := &model.Post{
- BoardID: boardID,
- UserID: userID,
- Title: title,
- Content: content,
- Tags: tags,
- PostType: postType,
- Status: status,
- }
- if err := s.db.Create(post).Error; err != nil {
- return nil, err
- }
- // 预加载关联
- s.db.Preload("Board").Preload("User").First(post, post.ID)
- return post, nil
-}
-
// checkNewUserCooldown 新用户发帖 24h 冷静期
func (s *PostService) checkNewUserCooldown(userID uint) error {
var user model.User
if err := s.db.First(&user, userID).Error; err != nil {
return err
}
- // 注册不足 24 小时的新用户不能发帖
if time.Since(user.CreatedAt) < 24*time.Hour {
return errors.New("新用户注册 24 小时后才能发帖")
}
return nil
}
-// Update 更新帖子(作者本人,或对该板块有审核权的管理成员)
-func (s *PostService) Update(actor *Actor, postID, userID uint, title, content, tags string) (*model.Post, error) {
- var post model.Post
- if err := s.db.First(&post, postID).Error; err != nil {
- return nil, ErrPostNotFound
- }
- // 权限校验:作者本人或板块审核权
- if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
- return nil, ErrPostForbidden
- }
-
- updates := map[string]interface{}{}
- if title != "" {
- t := strings.TrimSpace(title)
- if t == "" {
- return nil, errors.New("标题不能为空")
- }
- updates["title"] = t
- }
- if content != "" {
- c := strings.TrimSpace(content)
- if c == "" {
- return nil, errors.New("内容不能为空")
- }
- updates["content"] = c
- }
- updates["tags"] = tags
-
- if err := s.db.Model(&post).Updates(updates).Error; err != nil {
- return nil, err
- }
- s.db.Preload("Board").Preload("User").First(&post, post.ID)
- return &post, nil
-}
-
// Delete 删除帖子(作者本人,或对该板块有审核权的管理成员)
func (s *PostService) Delete(actor *Actor, postID, userID uint) error {
var post model.Post
if err := s.db.First(&post, postID).Error; err != nil {
return ErrPostNotFound
}
- // 权限校验:作者本人或板块审核权
if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
return ErrPostForbidden
}
- // 软删除(gorm DeletedAt)
if err := s.db.Delete(&post).Error; err != nil {
return err
}
diff --git a/backend/service/post_file.go b/backend/service/post_file.go
new file mode 100644
index 0000000..c65e399
--- /dev/null
+++ b/backend/service/post_file.go
@@ -0,0 +1,283 @@
+package service
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "mime"
+ "os"
+ "path/filepath"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/freefire/jiang13-bbs/model"
+ "gorm.io/gorm"
+)
+
+const (
+ FileMaxBytes = 20 << 20 // 20 MiB
+ MaxPostAttachments = 10
+)
+
+var (
+ ErrAttachmentNotFound = errors.New("附件不存在")
+ ErrAttachmentForbidden = errors.New("无权操作此附件")
+ ErrTooManyAttachments = errors.New("附件数量超过上限")
+)
+
+// PostFileService 帖子文件附件(私有目录 + API 下载)
+type PostFileService struct {
+ db *gorm.DB
+ dir string // data/private/files
+}
+
+func NewPostFileService(db *gorm.DB, privateDir string) *PostFileService {
+ return &PostFileService{db: db, dir: filepath.Join(privateDir, "files")}
+}
+
+func (s *PostFileService) EnsureDir() error {
+ return os.MkdirAll(s.dir, 0o755)
+}
+
+func absPath(dir, stored string) string {
+ return filepath.Join(dir, stored)
+}
+
+// SaveDraftFile 上传附件(先挂 post_id=0,发帖时绑定)
+func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data []byte, pricePoints int) (*model.PostAttachment, error) {
+ if len(data) == 0 {
+ return nil, errors.New("文件为空")
+ }
+ if len(data) > FileMaxBytes {
+ return nil, errors.New("附件不能超过 20MB")
+ }
+ if pricePoints < 0 {
+ pricePoints = 0
+ }
+ if pricePoints > 100000 {
+ return nil, errors.New("积分定价过高")
+ }
+ name := sanitizeFilename(originalName)
+ if name == "" {
+ name = "file"
+ }
+
+ var orphan int64
+ if err := s.db.Model(&model.PostAttachment{}).
+ Where("user_id = ? AND post_id = 0", userID).Count(&orphan).Error; err != nil {
+ return nil, err
+ }
+ if orphan >= MaxPostAttachments {
+ return nil, ErrTooManyAttachments
+ }
+
+ ext := filepath.Ext(name)
+ if utf8.RuneCountInString(ext) > 16 {
+ ext = ""
+ }
+ raw := make([]byte, 16)
+ if _, err := rand.Read(raw); err != nil {
+ return nil, err
+ }
+ stored := hex.EncodeToString(raw) + ext
+ full := absPath(s.dir, stored)
+ if err := os.WriteFile(full, data, 0o600); err != nil {
+ return nil, err
+ }
+
+ mimeType := mime.TypeByExtension(ext)
+ if mimeType == "" {
+ mimeType = "application/octet-stream"
+ }
+
+ att := &model.PostAttachment{
+ PostID: 0,
+ UserID: userID,
+ Name: name,
+ StoredName: stored,
+ MIME: mimeType,
+ Size: len(data),
+ PricePoints: pricePoints,
+ }
+ if err := s.db.Create(att).Error; err != nil {
+ _ = os.Remove(full)
+ return nil, err
+ }
+ return att, nil
+}
+
+func sanitizeFilename(name string) string {
+ name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
+ name = strings.TrimSpace(name)
+ name = strings.Map(func(r rune) rune {
+ switch r {
+ case '/', '\\', '\x00', ':', '*', '?', '"', '<', '>', '|':
+ return '_'
+ default:
+ return r
+ }
+ }, name)
+ if utf8.RuneCountInString(name) > 200 {
+ runes := []rune(name)
+ name = string(runes[:200])
+ }
+ return name
+}
+
+// BindToPost 将草稿附件绑定到帖子(仅本人、未绑定)
+func (s *PostFileService) BindToPost(userID, postID uint, ids []uint) error {
+ if len(ids) == 0 {
+ return nil
+ }
+ if len(ids) > MaxPostAttachments {
+ return ErrTooManyAttachments
+ }
+ return s.db.Transaction(func(tx *gorm.DB) error {
+ var atts []model.PostAttachment
+ if err := tx.Where("id IN ? AND user_id = ? AND post_id = 0", ids, userID).Find(&atts).Error; err != nil {
+ return err
+ }
+ if len(atts) != len(ids) {
+ return errors.New("部分附件无效或无权使用")
+ }
+ return tx.Model(&model.PostAttachment{}).
+ Where("id IN ? AND user_id = ? AND post_id = 0", ids, userID).
+ Update("post_id", postID).Error
+ })
+}
+
+// ReplacePostAttachments 编辑时重绑附件列表(ids 为最终列表;可含已绑定本帖的)
+func (s *PostFileService) ReplacePostAttachments(userID, postID uint, ids []uint) error {
+ if len(ids) > MaxPostAttachments {
+ return ErrTooManyAttachments
+ }
+ return s.db.Transaction(func(tx *gorm.DB) error {
+ var keep []model.PostAttachment
+ if len(ids) > 0 {
+ if err := tx.Where(
+ "id IN ? AND user_id = ? AND (post_id = 0 OR post_id = ?)",
+ ids, userID, postID,
+ ).Find(&keep).Error; err != nil {
+ return err
+ }
+ if len(keep) != len(ids) {
+ return errors.New("部分附件无效或无权使用")
+ }
+ }
+ // 解绑本帖旧附件(软删物理文件可选:P1 仅解绑)
+ if err := tx.Model(&model.PostAttachment{}).
+ Where("post_id = ? AND user_id = ?", postID, userID).
+ Update("post_id", 0).Error; err != nil {
+ return err
+ }
+ if len(ids) == 0 {
+ return nil
+ }
+ return tx.Model(&model.PostAttachment{}).
+ Where("id IN ? AND user_id = ?", ids, userID).
+ Update("post_id", postID).Error
+ })
+}
+
+// ListByPost 帖子附件列表
+func (s *PostFileService) ListByPost(postID uint) ([]model.PostAttachment, error) {
+ var list []model.PostAttachment
+ err := s.db.Where("post_id = ?", postID).Order("id ASC").Find(&list).Error
+ return list, err
+}
+
+// UpdatePrice 更新附件积分定价(作者)
+func (s *PostFileService) UpdatePrice(userID, attID uint, price int) error {
+ if price < 0 {
+ price = 0
+ }
+ res := s.db.Model(&model.PostAttachment{}).
+ Where("id = ? AND user_id = ?", attID, userID).
+ Update("price_points", price)
+ if res.Error != nil {
+ return res.Error
+ }
+ if res.RowsAffected == 0 {
+ return ErrAttachmentNotFound
+ }
+ return nil
+}
+
+// DeleteOwn 删除本人未绑定或本帖附件
+func (s *PostFileService) DeleteOwn(userID, attID uint) error {
+ var att model.PostAttachment
+ if err := s.db.First(&att, attID).Error; err != nil {
+ return ErrAttachmentNotFound
+ }
+ if att.UserID != userID {
+ return ErrAttachmentForbidden
+ }
+ path := absPath(s.dir, att.StoredName)
+ if err := s.db.Delete(&att).Error; err != nil {
+ return err
+ }
+ _ = os.Remove(path)
+ return nil
+}
+
+// OpenForDownload 鉴权后打开文件;需先确认帖子可见与积分
+func (s *PostFileService) Get(attID uint) (*model.PostAttachment, error) {
+ var att model.PostAttachment
+ if err := s.db.First(&att, attID).Error; err != nil {
+ return nil, ErrAttachmentNotFound
+ }
+ return &att, nil
+}
+
+func (s *PostFileService) FilePath(att *model.PostAttachment) string {
+ return absPath(s.dir, att.StoredName)
+}
+
+func (s *PostFileService) IncDownload(attID uint) {
+ s.db.Model(&model.PostAttachment{}).Where("id = ?", attID).
+ UpdateColumn("download_count", gorm.Expr("download_count + 1"))
+}
+
+// EnsureAttachmentUnlocked 免费或已购/作者;积分附件扣费一次
+func (s *PostFileService) EnsureAttachmentUnlocked(userID uint, att *model.PostAttachment) error {
+ if att.PricePoints <= 0 {
+ return nil
+ }
+ if att.UserID == userID {
+ return nil
+ }
+ var n int64
+ if err := s.db.Model(&model.PostAttachmentUnlock{}).
+ Where("attachment_id = ? AND user_id = ?", att.ID, userID).
+ Count(&n).Error; err != nil {
+ return err
+ }
+ if n > 0 {
+ return nil
+ }
+ return s.db.Transaction(func(tx *gorm.DB) error {
+ var again int64
+ if err := tx.Model(&model.PostAttachmentUnlock{}).
+ Where("attachment_id = ? AND user_id = ?", att.ID, userID).
+ Count(&again).Error; err != nil {
+ return err
+ }
+ if again > 0 {
+ return nil
+ }
+ if _, err := DebitTx(tx, userID, att.PricePoints, model.PointReasonDownloadFile, "attachment", att.ID, "下载附件:"+att.Name); err != nil {
+ return err
+ }
+ // 积分转给作者
+ if att.UserID > 0 && att.UserID != userID {
+ if _, err := CreditTx(tx, att.UserID, att.PricePoints, model.PointReasonDownloadFile, "attachment_earn", att.ID, "附件收益:"+att.Name); err != nil {
+ return err
+ }
+ }
+ return tx.Create(&model.PostAttachmentUnlock{
+ AttachmentID: att.ID,
+ UserID: userID,
+ Points: att.PricePoints,
+ }).Error
+ })
+}
diff --git a/backend/service/post_interact.go b/backend/service/post_interact.go
new file mode 100644
index 0000000..8acb5d9
--- /dev/null
+++ b/backend/service/post_interact.go
@@ -0,0 +1,1082 @@
+package service
+
+import (
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "math/big"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/freefire/jiang13-bbs/model"
+ "gorm.io/gorm"
+)
+
+// ---- TypeMeta 结构 ----
+
+type QuestionMeta struct {
+ Solved bool `json:"solved"`
+ AcceptedCommentID uint `json:"accepted_comment_id"`
+}
+
+type PollMeta struct {
+ Options []string `json:"options"`
+ Multi bool `json:"multi"`
+ Closed bool `json:"closed"`
+ Anonymous bool `json:"anonymous"` // 默认 true;缺省字段在 parse 时补 true
+}
+
+type BountyMeta struct {
+ Points int `json:"points"`
+ AcceptedCommentID uint `json:"accepted_comment_id"`
+ Escrowed bool `json:"escrowed"`
+ Refunded bool `json:"refunded"`
+ Expired bool `json:"expired"`
+ EndsAt string `json:"ends_at"` // RFC3339;空表示待发布时补写
+ ExpireDays int `json:"expire_days,omitempty"` // 发帖入参,规范化后可清
+}
+
+type LotteryMeta struct {
+ Slots int `json:"slots"`
+ Drawn bool `json:"drawn"`
+ WinnerIDs []uint `json:"winner_ids"`
+ Closed bool `json:"closed"`
+ EndsAt string `json:"ends_at"`
+ ExpireDays int `json:"expire_days,omitempty"`
+}
+
+var (
+ ErrPollClosed = errors.New("投票已结束")
+ ErrAlreadyVoted = errors.New("你已投过票")
+ ErrInvalidPollOpt = errors.New("无效的投票选项")
+ ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置")
+ ErrBountySettled = errors.New("悬赏已结算")
+ ErrBountyNotEscrow = errors.New("悬赏未托管")
+ ErrBountyExpired = errors.New("悬赏已过期")
+ ErrLotteryDrawn = errors.New("已开奖")
+ ErrLotteryClosed = errors.New("抽奖已截止")
+ ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖")
+ ErrQuestionNotType = errors.New("本文不是问答帖")
+)
+
+func parseQuestionMeta(raw string) (*QuestionMeta, error) {
+ if strings.TrimSpace(raw) == "" {
+ return &QuestionMeta{}, nil
+ }
+ var m QuestionMeta
+ if err := json.Unmarshal([]byte(raw), &m); err != nil {
+ return nil, errors.New("问答配置无效")
+ }
+ return &m, nil
+}
+
+func parsePollMeta(raw string) (*PollMeta, error) {
+ var wire struct {
+ Options []string `json:"options"`
+ Multi bool `json:"multi"`
+ Closed bool `json:"closed"`
+ Anonymous *bool `json:"anonymous"`
+ }
+ if err := json.Unmarshal([]byte(raw), &wire); err != nil {
+ return nil, errors.New("投票配置无效")
+ }
+ anon := true // 缺省 / null → 匿名
+ if wire.Anonymous != nil {
+ anon = *wire.Anonymous
+ }
+ return &PollMeta{
+ Options: wire.Options,
+ Multi: wire.Multi,
+ Closed: wire.Closed,
+ Anonymous: anon,
+ }, nil
+}
+
+func parseBountyMeta(raw string) (*BountyMeta, error) {
+ var m BountyMeta
+ if err := json.Unmarshal([]byte(raw), &m); err != nil {
+ return nil, errors.New("悬赏配置无效")
+ }
+ return &m, nil
+}
+
+func parseLotteryMeta(raw string) (*LotteryMeta, error) {
+ var m LotteryMeta
+ if err := json.Unmarshal([]byte(raw), &m); err != nil {
+ return nil, errors.New("抽奖配置无效")
+ }
+ return &m, nil
+}
+
+func encodeMeta(v any) (string, error) {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
+
+func normalizeExpireDays(d, def int, allowed []int) int {
+ if d <= 0 {
+ return def
+ }
+ for _, a := range allowed {
+ if d == a {
+ return d
+ }
+ }
+ return def
+}
+
+func endsAtFromDays(days int, from time.Time) string {
+ return from.UTC().Add(time.Duration(days) * 24 * time.Hour).Format(time.RFC3339)
+}
+
+func parseEndsAt(s string) (time.Time, bool) {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return time.Time{}, false
+ }
+ t, err := time.Parse(time.RFC3339, s)
+ if err != nil {
+ return time.Time{}, false
+ }
+ return t, true
+}
+
+// ComputeTypeStatus 列表徽章状态
+func ComputeTypeStatus(postType, typeMeta string) string {
+ pt := model.NormalizePostType(postType)
+ switch pt {
+ case model.PostTypeQuestion:
+ m, err := parseQuestionMeta(typeMeta)
+ if err != nil {
+ return "unsolved"
+ }
+ if m.Solved {
+ return "solved"
+ }
+ return "unsolved"
+ case model.PostTypePoll:
+ m, err := parsePollMeta(typeMeta)
+ if err != nil {
+ return "open"
+ }
+ if m.Closed {
+ return "closed"
+ }
+ return "open"
+ case model.PostTypeBounty:
+ m, err := parseBountyMeta(typeMeta)
+ if err != nil {
+ return "open"
+ }
+ if m.AcceptedCommentID > 0 {
+ return "solved"
+ }
+ if m.Expired || m.Refunded {
+ return "expired"
+ }
+ return "open"
+ case model.PostTypeLottery:
+ m, err := parseLotteryMeta(typeMeta)
+ if err != nil {
+ return "open"
+ }
+ if m.Drawn {
+ return "drawn"
+ }
+ if m.Closed {
+ return "closed"
+ }
+ return "open"
+ default:
+ return ""
+ }
+}
+
+// NormalizeAndValidateTypeMeta 校验并规范化 type_meta(发帖/编辑)
+func NormalizeAndValidateTypeMeta(postType, meta string) (string, error) {
+ postType = model.NormalizePostType(postType)
+ meta = strings.TrimSpace(meta)
+ switch postType {
+ case model.PostTypeQuestion:
+ m, err := parseQuestionMeta(meta)
+ if err != nil {
+ return "", err
+ }
+ // 新建默认未解决
+ return encodeMeta(m)
+ case model.PostTypePoll:
+ if meta == "" {
+ return "", errors.New("请填写至少 2 个投票选项")
+ }
+ m, err := parsePollMeta(meta)
+ if err != nil {
+ return "", err
+ }
+ opts := make([]string, 0, len(m.Options))
+ seen := map[string]bool{}
+ for _, o := range m.Options {
+ o = strings.TrimSpace(o)
+ if o == "" {
+ continue
+ }
+ if utf8.RuneCountInString(o) > 64 {
+ return "", errors.New("选项最多 64 字")
+ }
+ key := strings.ToLower(o)
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ opts = append(opts, o)
+ }
+ if len(opts) < 2 {
+ return "", errors.New("至少需要 2 个投票选项")
+ }
+ if len(opts) > 10 {
+ return "", errors.New("最多 10 个投票选项")
+ }
+ m.Options = opts
+ return encodeMeta(m)
+ case model.PostTypeBounty:
+ if meta == "" {
+ return "", errors.New("请设置悬赏积分")
+ }
+ m, err := parseBountyMeta(meta)
+ if err != nil {
+ return "", err
+ }
+ if m.Points < 1 {
+ return "", errors.New("悬赏积分至少为 1")
+ }
+ if m.Points > 100000 {
+ return "", errors.New("悬赏积分过高")
+ }
+ days := normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30})
+ m.ExpireDays = days
+ // ends_at 在 Create/Approve 时按发布时间写入;编辑保留旧值
+ return encodeMeta(m)
+ case model.PostTypeLottery:
+ if meta == "" {
+ return "", errors.New("请设置中奖名额")
+ }
+ m, err := parseLotteryMeta(meta)
+ if err != nil {
+ return "", err
+ }
+ if m.Slots < 1 {
+ return "", errors.New("中奖名额至少为 1")
+ }
+ if m.Slots > 100 {
+ return "", errors.New("中奖名额过多")
+ }
+ if m.WinnerIDs == nil {
+ m.WinnerIDs = []uint{}
+ }
+ days := normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14})
+ m.ExpireDays = days
+ return encodeMeta(m)
+ default:
+ return "", nil
+ }
+}
+
+// EnsureDeadlineOnPublish 首次公开发布时补写 ends_at(悬赏/抽奖)
+func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (string, bool) {
+ pt := model.NormalizePostType(postType)
+ switch pt {
+ case model.PostTypeBounty:
+ m, err := parseBountyMeta(typeMeta)
+ if err != nil {
+ return typeMeta, false
+ }
+ if m.EndsAt != "" {
+ return typeMeta, false
+ }
+ days := normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30})
+ m.ExpireDays = 0
+ m.EndsAt = endsAtFromDays(days, publishedAt)
+ raw, err := encodeMeta(m)
+ if err != nil {
+ return typeMeta, false
+ }
+ return raw, true
+ case model.PostTypeLottery:
+ m, err := parseLotteryMeta(typeMeta)
+ if err != nil {
+ return typeMeta, false
+ }
+ if m.EndsAt != "" {
+ return typeMeta, false
+ }
+ days := normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14})
+ m.ExpireDays = 0
+ m.EndsAt = endsAtFromDays(days, publishedAt)
+ raw, err := encodeMeta(m)
+ if err != nil {
+ return typeMeta, false
+ }
+ return raw, true
+ default:
+ return typeMeta, false
+ }
+}
+
+// ---- 详情附加状态 ----
+
+type QuestionState struct {
+ Solved bool `json:"solved"`
+ AcceptedCommentID uint `json:"accepted_comment_id"`
+ CanAccept bool `json:"can_accept"`
+ CanSolve bool `json:"can_solve"`
+ CanReopen bool `json:"can_reopen"`
+}
+
+type PollVoter struct {
+ ID uint `json:"id"`
+ Nickname string `json:"nickname"`
+ Username string `json:"username"`
+ Avatar string `json:"avatar"`
+}
+
+type PollOptionStat struct {
+ Index int `json:"index"`
+ Text string `json:"text"`
+ Votes int `json:"votes"`
+ Percent int `json:"percent"`
+ Voters []PollVoter `json:"voters,omitempty"` // 非匿名且可看结果时填充
+}
+
+type PollState struct {
+ Options []PollOptionStat `json:"options"`
+ Multi bool `json:"multi"`
+ Closed bool `json:"closed"`
+ Anonymous bool `json:"anonymous"`
+ TotalVotes int `json:"total_votes"`
+ Voters int `json:"voters"`
+ MyOptions []int `json:"my_options"`
+ CanVote bool `json:"can_vote"`
+ CanClose bool `json:"can_close"`
+ CanEditOptions bool `json:"can_edit_options"`
+}
+
+type BountyState struct {
+ Points int `json:"points"`
+ AcceptedCommentID uint `json:"accepted_comment_id"`
+ Escrowed bool `json:"escrowed"`
+ Refunded bool `json:"refunded"`
+ Expired bool `json:"expired"`
+ EndsAt string `json:"ends_at,omitempty"`
+ Settled bool `json:"settled"`
+ CanAccept bool `json:"can_accept"`
+ CanRefund bool `json:"can_refund"`
+}
+
+type LotteryEntrant struct {
+ ID uint `json:"id"`
+ Nickname string `json:"nickname"`
+ Username string `json:"username"`
+ Avatar string `json:"avatar"`
+}
+
+type LotteryState struct {
+ Slots int `json:"slots"`
+ Drawn bool `json:"drawn"`
+ Closed bool `json:"closed"`
+ EndsAt string `json:"ends_at,omitempty"`
+ EntryCount int `json:"entry_count"` // 回帖参与人数
+ Eligible bool `json:"eligible"` // 当前用户是否在奖池
+ CanDraw bool `json:"can_draw"`
+ CanClose bool `json:"can_close"`
+ Winners []LotteryEntrant `json:"winners"`
+}
+
+func (s *PostService) fillInteractState(detail *PostDetail, post *model.Post, viewerID uint, loadActor func() *Actor) {
+ pt := model.NormalizePostType(post.PostType)
+ isAuthor := viewerID > 0 && viewerID == post.UserID
+ isMod := false
+ if loadActor != nil {
+ if a := loadActor(); a != nil {
+ isMod = a.CanModerateBoard(post.BoardID)
+ }
+ }
+ canManage := isAuthor || isMod
+
+ switch pt {
+ case model.PostTypeQuestion:
+ detail.Question = s.buildQuestionState(post, canManage)
+ case model.PostTypePoll:
+ detail.Poll = s.buildPollState(post, viewerID, canManage)
+ case model.PostTypeBounty:
+ detail.Bounty = s.buildBountyState(post, canManage)
+ case model.PostTypeLottery:
+ detail.Lottery = s.buildLotteryState(post, viewerID, canManage)
+ }
+}
+
+func (s *PostService) buildQuestionState(post *model.Post, canManage bool) *QuestionState {
+ m, err := parseQuestionMeta(post.TypeMeta)
+ if err != nil || m == nil {
+ m = &QuestionMeta{}
+ }
+ return &QuestionState{
+ Solved: m.Solved,
+ AcceptedCommentID: m.AcceptedCommentID,
+ CanAccept: canManage && !m.Solved,
+ CanSolve: canManage && !m.Solved,
+ CanReopen: canManage && m.Solved,
+ }
+}
+
+func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose bool) *PollState {
+ m, err := parsePollMeta(post.TypeMeta)
+ if err != nil || len(m.Options) == 0 {
+ return nil
+ }
+ var votes []model.PostPollVote
+ s.db.Where("post_id = ?", post.ID).Find(&votes)
+ counts := make([]int, len(m.Options))
+ voterSet := map[uint]struct{}{}
+ my := []int{}
+ // optionIndex → userIDs(去重保序)
+ optVoters := make([][]uint, len(m.Options))
+ seenOptUser := make([]map[uint]struct{}, len(m.Options))
+ for i := range seenOptUser {
+ seenOptUser[i] = map[uint]struct{}{}
+ }
+ for _, v := range votes {
+ if v.OptionIndex >= 0 && v.OptionIndex < len(counts) {
+ counts[v.OptionIndex]++
+ if _, ok := seenOptUser[v.OptionIndex][v.UserID]; !ok {
+ seenOptUser[v.OptionIndex][v.UserID] = struct{}{}
+ optVoters[v.OptionIndex] = append(optVoters[v.OptionIndex], v.UserID)
+ }
+ }
+ voterSet[v.UserID] = struct{}{}
+ if viewerID > 0 && v.UserID == viewerID {
+ my = append(my, v.OptionIndex)
+ }
+ }
+ total := 0
+ for _, c := range counts {
+ total += c
+ }
+ // 非匿名:已投票或已结束(或作者/版主)才下发投票人,避免未投票先窥名单
+ revealVoters := !m.Anonymous && (m.Closed || len(my) > 0 || canClose)
+ userMap := map[uint]model.User{}
+ if revealVoters {
+ uidSet := map[uint]struct{}{}
+ for _, ids := range optVoters {
+ for _, id := range ids {
+ uidSet[id] = struct{}{}
+ }
+ }
+ if len(uidSet) > 0 {
+ uids := make([]uint, 0, len(uidSet))
+ for id := range uidSet {
+ uids = append(uids, id)
+ }
+ var users []model.User
+ s.db.Select("id, username, nickname, avatar").Where("id IN ?", uids).Find(&users)
+ for _, u := range users {
+ userMap[u.ID] = u
+ }
+ }
+ }
+ opts := make([]PollOptionStat, len(m.Options))
+ for i, text := range m.Options {
+ pct := 0
+ if total > 0 {
+ pct = counts[i] * 100 / total
+ }
+ stat := PollOptionStat{Index: i, Text: text, Votes: counts[i], Percent: pct}
+ if revealVoters {
+ vs := make([]PollVoter, 0, len(optVoters[i]))
+ for _, id := range optVoters[i] {
+ if u, ok := userMap[id]; ok {
+ vs = append(vs, PollVoter{
+ ID: u.ID, Nickname: u.Nickname, Username: u.Username, Avatar: u.Avatar,
+ })
+ }
+ }
+ stat.Voters = vs
+ }
+ opts[i] = stat
+ }
+ voters := len(voterSet)
+ return &PollState{
+ Options: opts,
+ Multi: m.Multi,
+ Closed: m.Closed,
+ Anonymous: m.Anonymous,
+ TotalVotes: total,
+ Voters: voters,
+ MyOptions: my,
+ CanVote: viewerID > 0 && !m.Closed && len(my) == 0,
+ CanClose: canClose && !m.Closed,
+ CanEditOptions: voters == 0 && !m.Closed,
+ }
+}
+
+func (s *PostService) buildBountyState(post *model.Post, canManage bool) *BountyState {
+ m, err := parseBountyMeta(post.TypeMeta)
+ if err != nil {
+ return nil
+ }
+ settled := m.AcceptedCommentID > 0 || m.Refunded || m.Expired
+ return &BountyState{
+ Points: m.Points,
+ AcceptedCommentID: m.AcceptedCommentID,
+ Escrowed: m.Escrowed,
+ Refunded: m.Refunded,
+ Expired: m.Expired,
+ EndsAt: m.EndsAt,
+ Settled: settled,
+ CanAccept: canManage && m.Escrowed && !settled && !m.Expired,
+ CanRefund: canManage && m.Escrowed && !settled && !m.Expired,
+ }
+}
+
+func (s *PostService) lotteryEligibleIDs(postID, authorID uint) ([]uint, error) {
+ var ids []uint
+ err := s.db.Model(&model.Comment{}).
+ Where("post_id = ? AND status = ? AND deleted_at IS NULL AND user_id <> ?",
+ postID, model.ContentStatusPublished, authorID).
+ Distinct("user_id").
+ Pluck("user_id", &ids).Error
+ return ids, err
+}
+
+func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canManage bool) *LotteryState {
+ m, err := parseLotteryMeta(post.TypeMeta)
+ if err != nil {
+ return nil
+ }
+ ids, _ := s.lotteryEligibleIDs(post.ID, post.UserID)
+ eligible := false
+ for _, id := range ids {
+ if viewerID > 0 && id == viewerID {
+ eligible = true
+ break
+ }
+ }
+ winners := []LotteryEntrant{}
+ if len(m.WinnerIDs) > 0 {
+ var users []model.User
+ s.db.Select("id, username, nickname, avatar").Where("id IN ?", m.WinnerIDs).Find(&users)
+ umap := map[uint]model.User{}
+ for _, u := range users {
+ umap[u.ID] = u
+ }
+ for _, id := range m.WinnerIDs {
+ if u, ok := umap[id]; ok {
+ winners = append(winners, LotteryEntrant{
+ ID: u.ID, Nickname: u.Nickname, Username: u.Username, Avatar: u.Avatar,
+ })
+ }
+ }
+ }
+ return &LotteryState{
+ Slots: m.Slots,
+ Drawn: m.Drawn,
+ Closed: m.Closed,
+ EndsAt: m.EndsAt,
+ EntryCount: len(ids),
+ Eligible: eligible,
+ CanDraw: canManage && !m.Drawn,
+ CanClose: canManage && !m.Drawn && !m.Closed,
+ Winners: winners,
+ }
+}
+
+// VotePoll 投票
+func (s *PostService) VotePoll(userID, postID uint, optionIndexes []int) (*PostDetail, error) {
+ var post model.Post
+ if err := s.db.Preload("Board").Preload("User").First(&post, postID).Error; err != nil {
+ return nil, ErrPostNotFound
+ }
+ if model.NormalizePostType(post.PostType) != model.PostTypePoll {
+ return nil, errors.New("本文不是投票帖")
+ }
+ if post.Status != model.ContentStatusPublished {
+ return nil, errors.New("帖子未公开,无法投票")
+ }
+ m, err := parsePollMeta(post.TypeMeta)
+ if err != nil {
+ return nil, err
+ }
+ if m.Closed {
+ return nil, ErrPollClosed
+ }
+ if len(optionIndexes) == 0 {
+ return nil, ErrInvalidPollOpt
+ }
+ if !m.Multi && len(optionIndexes) != 1 {
+ return nil, errors.New("单选只能选一项")
+ }
+ if m.Multi && len(optionIndexes) > len(m.Options) {
+ return nil, ErrInvalidPollOpt
+ }
+ seen := map[int]bool{}
+ clean := make([]int, 0, len(optionIndexes))
+ for _, idx := range optionIndexes {
+ if idx < 0 || idx >= len(m.Options) {
+ return nil, ErrInvalidPollOpt
+ }
+ if seen[idx] {
+ continue
+ }
+ seen[idx] = true
+ clean = append(clean, idx)
+ }
+
+ err = s.db.Transaction(func(tx *gorm.DB) error {
+ var n int64
+ if err := tx.Model(&model.PostPollVote{}).
+ Where("post_id = ? AND user_id = ?", postID, userID).Count(&n).Error; err != nil {
+ return err
+ }
+ if n > 0 {
+ return ErrAlreadyVoted
+ }
+ for _, idx := range clean {
+ if err := tx.Create(&model.PostPollVote{
+ PostID: postID, UserID: userID, OptionIndex: idx,
+ }).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return s.detailAfterInteract(&post, userID)
+}
+
+// ClosePoll 结束投票
+func (s *PostService) ClosePoll(actor *Actor, userID, postID uint) (*PostDetail, error) {
+ post, err := s.loadManageablePost(actor, userID, postID)
+ if err != nil {
+ return nil, err
+ }
+ if model.NormalizePostType(post.PostType) != model.PostTypePoll {
+ return nil, errors.New("本文不是投票帖")
+ }
+ m, err := parsePollMeta(post.TypeMeta)
+ if err != nil {
+ return nil, err
+ }
+ 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)
+}
+
+// AcceptQuestion 采纳问答答案
+func (s *PostService) AcceptQuestion(actor *Actor, userID, postID, commentID uint) (*PostDetail, error) {
+ post, err := s.loadManageablePost(actor, userID, postID)
+ if err != nil {
+ return nil, err
+ }
+ if model.NormalizePostType(post.PostType) != model.PostTypeQuestion {
+ return nil, ErrQuestionNotType
+ }
+ m, err := parseQuestionMeta(post.TypeMeta)
+ if err != nil {
+ return nil, err
+ }
+ if m.Solved {
+ return nil, errors.New("问题已解决")
+ }
+ var cm model.Comment
+ if err := s.db.First(&cm, commentID).Error; err != nil || cm.PostID != postID {
+ return nil, errors.New("评论不存在")
+ }
+ if cm.Status != model.ContentStatusPublished {
+ return nil, errors.New("只能采纳已公开的评论")
+ }
+ if cm.UserID == post.UserID {
+ return nil, errors.New("不能采纳自己的评论")
+ }
+ m.AcceptedCommentID = commentID
+ m.Solved = 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)
+}
+
+// SolveQuestion 手动标为已解决
+func (s *PostService) SolveQuestion(actor *Actor, userID, postID uint) (*PostDetail, error) {
+ post, err := s.loadManageablePost(actor, userID, postID)
+ if err != nil {
+ return nil, err
+ }
+ if model.NormalizePostType(post.PostType) != model.PostTypeQuestion {
+ return nil, ErrQuestionNotType
+ }
+ m, err := parseQuestionMeta(post.TypeMeta)
+ if err != nil {
+ return nil, err
+ }
+ m.Solved = 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)
+}
+
+// ReopenQuestion 重新打开
+func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDetail, error) {
+ post, err := s.loadManageablePost(actor, userID, postID)
+ if err != nil {
+ return nil, err
+ }
+ if model.NormalizePostType(post.PostType) != model.PostTypeQuestion {
+ return nil, ErrQuestionNotType
+ }
+ m := &QuestionMeta{Solved: false, AcceptedCommentID: 0}
+ 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)
+}
+
+// AcceptBounty 采纳评论并发放悬赏
+func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID 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
+ }
+ var cm model.Comment
+ if err := s.db.First(&cm, commentID).Error; err != nil || cm.PostID != postID {
+ return nil, errors.New("评论不存在")
+ }
+ if cm.Status != model.ContentStatusPublished {
+ return nil, errors.New("只能采纳已公开的评论")
+ }
+ if cm.UserID == post.UserID {
+ return nil, errors.New("不能采纳自己的评论")
+ }
+
+ err = s.db.Transaction(func(tx *gorm.DB) error {
+ if _, err := CreditTx(tx, cm.UserID, m.Points, model.PointReasonBountyAward, "post", postID, "悬赏采纳:"+post.Title); err != nil {
+ return err
+ }
+ m.AcceptedCommentID = commentID
+ m.Escrowed = false
+ raw, err := encodeMeta(m)
+ if err != nil {
+ return err
+ }
+ return tx.Model(post).Update("type_meta", raw).Error
+ })
+ if err != nil {
+ return nil, err
+ }
+ s.db.Preload("Board").Preload("User").First(post, post.ID)
+ return s.detailAfterInteract(post, userID)
+}
+
+// RefundBounty 退回悬赏积分(未采纳时)
+func (s *PostService) RefundBounty(actor *Actor, userID, postID uint) (*PostDetail, error) {
+ post, err := s.loadManageablePost(actor, userID, postID)
+ if err != nil {
+ return nil, err
+ }
+ _ = s.settleExpiredBountyIfNeeded(post)
+ s.db.First(post, post.ID)
+
+ if model.NormalizePostType(post.PostType) != model.PostTypeBounty {
+ return nil, errors.New("本文不是悬赏帖")
+ }
+ m, err := parseBountyMeta(post.TypeMeta)
+ if err != nil {
+ return nil, err
+ }
+ if m.Expired {
+ return nil, ErrBountyExpired
+ }
+ if !m.Escrowed {
+ return nil, ErrBountyNotEscrow
+ }
+ if m.AcceptedCommentID > 0 || m.Refunded {
+ return nil, ErrBountySettled
+ }
+
+ err = s.db.Transaction(func(tx *gorm.DB) error {
+ if _, err := CreditTx(tx, post.UserID, m.Points, model.PointReasonBountyRefund, "post", postID, "悬赏退回:"+post.Title); err != nil {
+ return err
+ }
+ m.Refunded = true
+ m.Escrowed = false
+ raw, err := encodeMeta(m)
+ if err != nil {
+ return err
+ }
+ return tx.Model(post).Update("type_meta", raw).Error
+ })
+ if err != nil {
+ return nil, err
+ }
+ s.db.Preload("Board").Preload("User").First(post, post.ID)
+ return s.detailAfterInteract(post, userID)
+}
+
+func (s *PostService) settleExpiredBountyIfNeeded(post *model.Post) error {
+ if model.NormalizePostType(post.PostType) != model.PostTypeBounty {
+ return nil
+ }
+ m, err := parseBountyMeta(post.TypeMeta)
+ if err != nil {
+ return nil
+ }
+ if !m.Escrowed || m.AcceptedCommentID > 0 || m.Refunded || m.Expired {
+ return nil
+ }
+ ends, ok := parseEndsAt(m.EndsAt)
+ if !ok || time.Now().UTC().Before(ends) {
+ return nil
+ }
+ return s.db.Transaction(func(tx *gorm.DB) error {
+ var fresh model.Post
+ if err := tx.First(&fresh, post.ID).Error; err != nil {
+ return err
+ }
+ fm, err := parseBountyMeta(fresh.TypeMeta)
+ if err != nil {
+ return err
+ }
+ if !fm.Escrowed || fm.AcceptedCommentID > 0 || fm.Refunded || fm.Expired {
+ return nil
+ }
+ if _, err := CreditTx(tx, fresh.UserID, fm.Points, model.PointReasonBountyRefund, "post", fresh.ID, "悬赏过期退回:"+fresh.Title); err != nil {
+ return err
+ }
+ fm.Expired = true
+ fm.Refunded = true
+ fm.Escrowed = false
+ raw, err := encodeMeta(fm)
+ if err != nil {
+ return err
+ }
+ if err := tx.Model(&fresh).Update("type_meta", raw).Error; err != nil {
+ return err
+ }
+ post.TypeMeta = raw
+ return nil
+ })
+}
+
+// CloseLotteryEntries 截止抽奖(仍可手动开奖)
+func (s *PostService) CloseLotteryEntries(actor *Actor, userID, postID uint) (*PostDetail, error) {
+ post, err := s.loadManageablePost(actor, userID, postID)
+ if err != nil {
+ return nil, err
+ }
+ m, err := parseLotteryMeta(post.TypeMeta)
+ if err != nil {
+ return nil, err
+ }
+ if m.Drawn {
+ return nil, ErrLotteryDrawn
+ }
+ m.Closed = true
+ raw, err := encodeMeta(m)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil {
+ return nil, err
+ }
+ post.TypeMeta = raw
+ return s.detailAfterInteract(post, userID)
+}
+
+func (s *PostService) drawLotteryCore(post *model.Post, m *LotteryMeta) error {
+ ids, err := s.lotteryEligibleIDs(post.ID, post.UserID)
+ if err != nil {
+ return err
+ }
+ nWin := m.Slots
+ if nWin > len(ids) {
+ nWin = len(ids)
+ }
+ if nWin > 0 {
+ for i := len(ids) - 1; i > 0; i-- {
+ jBig, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
+ if err != nil {
+ return err
+ }
+ j := int(jBig.Int64())
+ ids[i], ids[j] = ids[j], ids[i]
+ }
+ m.WinnerIDs = ids[:nWin]
+ } else {
+ m.WinnerIDs = []uint{}
+ }
+ m.Drawn = true
+ m.Closed = true
+ 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
+}
+
+// DrawLottery 开奖(回帖用户奖池)
+func (s *PostService) DrawLottery(actor *Actor, userID, postID uint) (*PostDetail, error) {
+ post, err := s.loadManageablePost(actor, userID, postID)
+ if err != nil {
+ return nil, err
+ }
+ if model.NormalizePostType(post.PostType) != model.PostTypeLottery {
+ return nil, errors.New("本文不是抽奖帖")
+ }
+ m, err := parseLotteryMeta(post.TypeMeta)
+ if err != nil {
+ return nil, err
+ }
+ if m.Drawn {
+ return nil, ErrLotteryDrawn
+ }
+ if err := s.drawLotteryCore(post, m); err != nil {
+ return nil, err
+ }
+ return s.detailAfterInteract(post, userID)
+}
+
+func (s *PostService) settleDueLotteryIfNeeded(post *model.Post) error {
+ if model.NormalizePostType(post.PostType) != model.PostTypeLottery {
+ return nil
+ }
+ m, err := parseLotteryMeta(post.TypeMeta)
+ if err != nil || m.Drawn {
+ return nil
+ }
+ ends, ok := parseEndsAt(m.EndsAt)
+ if !ok || time.Now().UTC().Before(ends) {
+ return nil
+ }
+ return s.drawLotteryCore(post, m)
+}
+
+// SettleDueTypedPosts 定时任务:悬赏过期退回 + 抽奖到期开奖
+func (s *PostService) SettleDueTypedPosts(limit int) {
+ if limit <= 0 {
+ limit = 50
+ }
+ var bounties []model.Post
+ s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty).
+ Order("id ASC").Limit(limit * 3).Find(&bounties)
+ n := 0
+ for i := range bounties {
+ if n >= limit {
+ break
+ }
+ before := bounties[i].TypeMeta
+ _ = s.settleExpiredBountyIfNeeded(&bounties[i])
+ if bounties[i].TypeMeta != before {
+ n++
+ }
+ }
+
+ var lotteries []model.Post
+ s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeLottery).
+ Order("id ASC").Limit(limit * 3).Find(&lotteries)
+ n = 0
+ for i := range lotteries {
+ if n >= limit {
+ break
+ }
+ before := lotteries[i].TypeMeta
+ _ = s.settleDueLotteryIfNeeded(&lotteries[i])
+ if lotteries[i].TypeMeta != before {
+ n++
+ }
+ }
+}
+
+// StartTypedPostSettler 启动定时结算
+func StartTypedPostSettler(s *PostService) {
+ go func() {
+ t := time.NewTicker(2 * time.Minute)
+ defer t.Stop()
+ for range t.C {
+ s.SettleDueTypedPosts(50)
+ }
+ }()
+}
+
+func (s *PostService) loadManageablePost(actor *Actor, userID, postID uint) (*model.Post, error) {
+ var post model.Post
+ if err := s.db.Preload("Board").Preload("User").First(&post, postID).Error; err != nil {
+ return nil, ErrPostNotFound
+ }
+ if post.UserID != userID && (actor == nil || !actor.CanModerateBoard(post.BoardID)) {
+ return nil, ErrPostForbidden
+ }
+ return &post, nil
+}
+
+func (s *PostService) detailAfterInteract(post *model.Post, viewerID uint) (*PostDetail, error) {
+ detail := buildPostDetail(post)
+ locked, hint := s.evalContentAccess(post, viewerID, nil)
+ detail.ContentLocked = locked
+ detail.AccessHint = hint
+ if locked {
+ detail.Content = ""
+ }
+ atts, _ := s.listAttachmentDTOs(post.ID, viewerID, post.UserID)
+ detail.Attachments = atts
+ s.fillInteractState(detail, post, viewerID, nil)
+ return detail, nil
+}
diff --git a/backend/service/ratelimit.go b/backend/service/ratelimit.go
index 7a4161e..3840594 100644
--- a/backend/service/ratelimit.go
+++ b/backend/service/ratelimit.go
@@ -67,6 +67,8 @@ const (
RatePost = "post"
RateComment = "comment"
RateChat = "chat" // 群聊发消息
+ RateUpload = "upload" // 帖子插图等上传
+ RateInteract = "interact" // 投票/抽奖/解锁等互动
)
// DefaultRateLimiter 创建默认速率限制器
@@ -77,5 +79,7 @@ func DefaultRateLimiter() *RateLimiter {
rl.SetLimit(RatePost, 10) // 发帖 10/分钟
rl.SetLimit(RateComment, 30) // 评论 30/分钟
rl.SetLimit(RateChat, 30) // 群聊消息 30/分钟
+ rl.SetLimit(RateUpload, 20) // 图片上传 20/分钟
+ rl.SetLimit(RateInteract, 40) // 互动 40/分钟
return rl
}
diff --git a/backend/service/upload.go b/backend/service/upload.go
index 1432648..2326074 100644
--- a/backend/service/upload.go
+++ b/backend/service/upload.go
@@ -5,6 +5,9 @@ import (
"crypto/rand"
"encoding/hex"
"errors"
+ "image"
+ _ "image/jpeg"
+ _ "image/png"
"log"
"os"
"path/filepath"
@@ -20,6 +23,10 @@ const (
AvatarMaxBytes = 2 << 20 // 2 MiB
AvatarMinDim = 64
AvatarMaxDim = 512
+
+ // 帖子插图:允许 JPEG/PNG/WebP,不强制转码
+ ImageMaxBytes = 5 << 20 // 5 MiB
+ ImageMaxDim = 4096
)
// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
@@ -34,7 +41,10 @@ func NewUploadService(db *gorm.DB, uploadDir string) *UploadService {
// EnsureDir 启动时确保上传目录存在
func (s *UploadService) EnsureDir() error {
- return os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755)
+ if err := os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755); err != nil {
+ return err
+ }
+ return os.MkdirAll(filepath.Join(s.dir, "images"), 0o755)
}
// SaveAvatar 保存裁剪后的 WebP 头像:校验魔数/大小/尺寸 → 落盘 → 写附件记录 → 更新用户头像
@@ -96,6 +106,89 @@ func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment,
return att, nil
}
+// imageFormat 由魔数识别的插图格式
+type imageFormat struct {
+ ext string
+ mime string
+}
+
+func detectImageFormat(data []byte) (imageFormat, error) {
+ if len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff {
+ return imageFormat{ext: ".jpg", mime: "image/jpeg"}, nil
+ }
+ if len(data) >= 8 && string(data[0:8]) == "\x89PNG\r\n\x1a\n" {
+ return imageFormat{ext: ".png", mime: "image/png"}, nil
+ }
+ if len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
+ return imageFormat{ext: ".webp", mime: "image/webp"}, nil
+ }
+ return imageFormat{}, errors.New("仅支持 JPEG / PNG / WebP")
+}
+
+func decodeImageSize(data []byte, mime string) (w, h int, err error) {
+ r := bytes.NewReader(data)
+ var cfg image.Config
+ switch mime {
+ case "image/webp":
+ cfg, err = webp.DecodeConfig(r)
+ default:
+ cfg, _, err = image.DecodeConfig(r)
+ }
+ if err != nil {
+ return 0, 0, errors.New("无法解析图片")
+ }
+ return cfg.Width, cfg.Height, nil
+}
+
+// SaveImage 保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image)
+func (s *UploadService) SaveImage(userID uint, data []byte) (*model.Attachment, error) {
+ if len(data) == 0 {
+ return nil, errors.New("文件为空")
+ }
+ if len(data) > ImageMaxBytes {
+ return nil, errors.New("图片不能超过 5MB")
+ }
+ format, err := detectImageFormat(data)
+ if err != nil {
+ return nil, err
+ }
+ w, h, err := decodeImageSize(data, format.mime)
+ if err != nil {
+ return nil, err
+ }
+ if w < 1 || h < 1 {
+ return nil, errors.New("无效的图片尺寸")
+ }
+ if w > ImageMaxDim || h > ImageMaxDim {
+ return nil, errors.New("图片边长不能超过 4096px")
+ }
+
+ nameBytes := make([]byte, 16)
+ if _, err := rand.Read(nameBytes); err != nil {
+ return nil, err
+ }
+ filename := hex.EncodeToString(nameBytes) + format.ext
+ fullPath := filepath.Join(s.dir, "images", filename)
+ if err := os.WriteFile(fullPath, data, 0o644); err != nil {
+ return nil, err
+ }
+
+ att := &model.Attachment{
+ UserID: userID,
+ Kind: model.AttachmentKindImage,
+ URL: "/uploads/images/" + filename,
+ MIME: format.mime,
+ Size: len(data),
+ Width: w,
+ Height: h,
+ }
+ if err := s.db.Create(att).Error; err != nil {
+ _ = os.Remove(fullPath)
+ return nil, err
+ }
+ return att, nil
+}
+
// UseAvatar 选用一张【本人历史上传】的头像
func (s *UploadService) UseAvatar(userID uint, url string) error {
url = strings.TrimSpace(url)
diff --git a/frontend/app/admin/AdminNav.tsx b/frontend/app/admin/AdminNav.tsx
index 425860c..c0e2e12 100644
--- a/frontend/app/admin/AdminNav.tsx
+++ b/frontend/app/admin/AdminNav.tsx
@@ -12,6 +12,7 @@ import {
ShieldCheck,
ArrowLeft,
ClipboardCheck,
+ Coins,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts } from "@/lib/api";
@@ -47,6 +48,12 @@ const NAV_ITEMS: NavItem[] = [
visible: (u) => canModerateAny(u),
pendingBadge: true,
},
+ {
+ href: "/admin/economy",
+ label: "积分看板",
+ icon: Coins,
+ visible: (u) => isAdminOrAbove(u.role),
+ },
{
href: "/admin/announcements",
label: "公告管理",
diff --git a/frontend/app/admin/announcements/AnnouncementAdmin.tsx b/frontend/app/admin/announcements/AnnouncementAdmin.tsx
index 7769443..9a1a7f5 100644
--- a/frontend/app/admin/announcements/AnnouncementAdmin.tsx
+++ b/frontend/app/admin/announcements/AnnouncementAdmin.tsx
@@ -191,7 +191,7 @@ export default function AnnouncementAdmin() {
@@ -334,7 +334,7 @@ export default function AnnouncementAdmin() {
target="_blank"
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
style={{ color: "var(--ink-3)" }}
- title="查看"
+ data-tip="查看"
aria-label="查看公告"
>
@@ -344,7 +344,7 @@ export default function AnnouncementAdmin() {
onClick={() => startEdit(a)}
className="w-8 h-8 inline-flex items-center justify-center rounded-full transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
style={{ color: "var(--ink-3)" }}
- title="编辑"
+ data-tip="编辑"
aria-label="编辑公告"
>
@@ -356,7 +356,7 @@ export default function AnnouncementAdmin() {
style={{ color: "var(--ink-3)" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "var(--danger)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--ink-3)")}
- title="删除"
+ data-tip="删除"
aria-label="删除公告"
>
{deletingId === a.id ? : }
diff --git a/frontend/app/admin/appearance/AppearanceAdmin.tsx b/frontend/app/admin/appearance/AppearanceAdmin.tsx
index bb09655..c63280b 100644
--- a/frontend/app/admin/appearance/AppearanceAdmin.tsx
+++ b/frontend/app/admin/appearance/AppearanceAdmin.tsx
@@ -121,7 +121,7 @@ export default function AppearanceAdmin({ initialAccent }: { initialAccent: stri
cursor: "pointer",
}}
aria-pressed={active}
- title={isDefault ? `${p.name}(默认)` : p.name}
+ data-tip={isDefault ? `${p.name}(默认)` : p.name}
>
{/* 上半圆=浅色模式,下半圆=暗色模式 */}
@@ -163,7 +163,7 @@ export default function AppearanceAdmin({ initialAccent }: { initialAccent: stri
该板块还没有帖子
来发第一篇,抢占沙发
-
+
写新帖
diff --git a/frontend/app/chat/ChatShell.tsx b/frontend/app/chat/ChatShell.tsx
index 8b733b4..245e6da 100644
--- a/frontend/app/chat/ChatShell.tsx
+++ b/frontend/app/chat/ChatShell.tsx
@@ -331,7 +331,7 @@ export default function ChatShell({
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-[var(--accent-soft)] transition-colors"
style={{ color: "var(--accent)" }}
aria-label="新建群聊"
- title="新建群聊"
+ data-tip="新建群聊"
>
@@ -480,7 +480,7 @@ export default function ChatShell({
background: "var(--accent-soft)",
color: "var(--accent)",
}}
- title={room.is_default ? "强制置顶" : "置顶"}
+ data-tip={room.is_default ? "强制置顶" : "置顶"}
>
置顶
diff --git a/frontend/app/chat/[id]/ChatRoomClient.tsx b/frontend/app/chat/[id]/ChatRoomClient.tsx
index 76127b9..b921155 100644
--- a/frontend/app/chat/[id]/ChatRoomClient.tsx
+++ b/frontend/app/chat/[id]/ChatRoomClient.tsx
@@ -132,7 +132,7 @@ function RecallNotice({
href={`/u/${uid}`}
className="font-semibold transition-opacity hover:opacity-80"
style={nameStyle}
- title={`查看 ${name} 的主页`}
+ data-tip={`查看 ${name} 的主页`}
>
{name}
@@ -217,7 +217,7 @@ function highlightMentions(content: string, members: ChatRoomMember[]) {
key={`m-${i}`}
href={`/u/${user.id}`}
className="j13-mention-token"
- title={`查看 ${user.nickname || user.username} 的主页`}
+ data-tip={`查看 ${user.nickname || user.username} 的主页`}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.stopPropagation()}
>
@@ -1001,7 +1001,7 @@ export default function ChatRoomClient({
color: rightPanel === "members" ? "var(--accent)" : "var(--ink-2)",
background: rightPanel === "members" ? "var(--accent-soft)" : undefined,
}}
- title="成员"
+ data-tip="成员"
>
@@ -1015,7 +1015,7 @@ export default function ChatRoomClient({
color: rightPanel === "invite" ? "var(--accent)" : "var(--ink-2)",
background: rightPanel === "invite" ? "var(--accent-soft)" : undefined,
}}
- title="邀请成员"
+ data-tip="邀请成员"
>
@@ -1029,7 +1029,7 @@ export default function ChatRoomClient({
color: rightPanel === "settings" ? "var(--accent)" : "var(--ink-2)",
background: rightPanel === "settings" ? "var(--accent-soft)" : undefined,
}}
- title="群设置"
+ data-tip="群设置"
>
@@ -1040,7 +1040,7 @@ export default function ChatRoomClient({
aria-label={isDirect ? "关闭私聊" : "退出群聊"}
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--danger-soft)]"
style={{ color: "var(--ink-3)" }}
- title={isDirect ? "关闭私聊" : "退出群聊"}
+ data-tip={isDirect ? "关闭私聊" : "退出群聊"}
>
diff --git a/frontend/app/compose/layout.tsx b/frontend/app/compose/layout.tsx
new file mode 100644
index 0000000..1195e6c
--- /dev/null
+++ b/frontend/app/compose/layout.tsx
@@ -0,0 +1,18 @@
+/**
+ * 发帖路由布局:标记 data-compose-viewport,配合 globals.css
+ * 让舞台占满 main 剩余高度(对齐私信体量,保留页脚)。
+ */
+export default function ComposeLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/app/compose/page.tsx b/frontend/app/compose/page.tsx
index 259aeff..59be47b 100644
--- a/frontend/app/compose/page.tsx
+++ b/frontend/app/compose/page.tsx
@@ -1,153 +1,64 @@
"use client";
-import { useState, useEffect } from "react";
-import { useRouter } from "next/navigation";
+import { useEffect, useState, Suspense } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
-import { ChevronRight, PenSquare } from "lucide-react";
-import { apiCreatePost, apiMe, fetchBoards, type Board } from "@/lib/api";
-import { toast } from "@/lib/toast";
+import { ChevronRight } from "lucide-react";
+import { apiMe } from "@/lib/api";
+import PostComposer from "@/components/PostComposer";
-export default function ComposePage() {
+function ComposeInner() {
const router = useRouter();
- const [boards, setBoards] = useState([]);
- const [boardId, setBoardId] = useState(0);
- const [title, setTitle] = useState("");
- const [content, setContent] = useState("");
- const [tags, setTags] = useState("");
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState("");
+ const searchParams = useSearchParams();
+ const initialBoardId = Number(searchParams.get("board")) || 0;
+ const [ready, setReady] = useState(false);
useEffect(() => {
apiMe()
.then((res) => {
if (!res.user) {
- router.push("/login?redirect=/compose");
+ const q = initialBoardId > 0 ? `?board=${initialBoardId}` : "";
+ router.push(`/login?redirect=${encodeURIComponent(`/compose${q}`)}`);
return;
}
- fetchBoards().then((data) => {
- setBoards(data.boards);
- if (data.boards.length > 0) setBoardId(data.boards[0].id);
- });
+ setReady(true);
})
- .catch(() => router.push("/login?redirect=/compose"));
- }, [router]);
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setLoading(true);
- setError("");
- try {
- const res = await apiCreatePost({
- board_id: boardId,
- title,
- content,
- tags,
- post_type: "normal",
+ .catch(() => {
+ router.push("/login?redirect=/compose");
});
- if (res.post) {
- // 普通用户发帖进入待审核队列:跳转详情页(作者可见)并提示
- if (res.post.status === "pending") {
- toast("已提交,等待审核通过后公开", "ok");
- }
- router.push(`/post/${res.post.id}`);
- } else {
- setError(res.error || "发帖失败");
- }
- } finally {
- setLoading(false);
- }
- };
+ }, [router, initialBoardId]);
+ if (!ready) {
+ return (
+
+ );
+ }
+
+ return ;
+}
+
+export default function ComposePage() {
return (
-
-
);
}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index d451199..72b1604 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -113,6 +113,497 @@ body {
text-rendering: optimizeLegibility;
}
+/* 全站悬停即显提示气泡(配合 TipHost + data-tip,替代原生 title 延迟) */
+.j13-tip-bubble {
+ position: fixed;
+ z-index: 9999;
+ max-width: min(280px, calc(100vw - 20px));
+ padding: 5px 10px;
+ border-radius: 8px;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1.35;
+ letter-spacing: 0.01em;
+ pointer-events: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--panel);
+ background: color-mix(in srgb, var(--ink) 92%, transparent);
+ border: 1px solid color-mix(in srgb, var(--ink) 88%, transparent);
+ box-shadow: var(--shadow);
+ animation: j13-tip-in 80ms ease-out;
+}
+.dark .j13-tip-bubble {
+ color: var(--ink);
+ background: color-mix(in srgb, var(--panel-2) 94%, var(--ink));
+ border-color: var(--line-2);
+}
+@keyframes j13-tip-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .j13-tip-bubble {
+ animation: none;
+ }
+}
+
+/* ============================================================
+ 发帖台:文档式舞台(对齐私信页体量;不改动全站 .field)
+ 控件:白底 + 单层描边;聚焦同 j13-chat-search
+ ============================================================ */
+.j13-compose-page {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+/* Suspense / 客户端壳层也要把高度往下传 */
+.j13-compose-page > :last-child {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+.j13-compose-stage {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: var(--r);
+ box-shadow: var(--shadow);
+ overflow: visible;
+}
+.j13-compose-body {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ padding: 1.35rem 1.35rem 0.85rem;
+ gap: 1.1rem;
+}
+@media (min-width: 640px) {
+ .j13-compose-body {
+ padding: 1.5rem 1.75rem 0.9rem;
+ gap: 1.15rem;
+ }
+}
+.j13-compose-actions {
+ flex-shrink: 0;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem 1rem;
+ padding: 1rem 1.5rem 1.25rem;
+ border-top: 1px solid var(--line);
+ background: var(--panel);
+}
+@media (min-width: 640px) {
+ .j13-compose-actions {
+ padding-left: 2rem;
+ padding-right: 2rem;
+ }
+}
+
+/* 元信息:板块固定宽 + 标签吃满剩余 + 可见性 */
+.j13-compose-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
+ gap: 0.85rem 1rem;
+}
+.j13-compose-meta-board {
+ width: 100%;
+ max-width: 220px;
+ flex: 0 1 220px;
+}
+.j13-compose-meta-tags {
+ flex: 1 1 240px;
+ min-width: 200px;
+ max-width: 100%;
+}
+.j13-compose-meta-access {
+ width: 100%;
+ max-width: 200px;
+ flex: 0 1 200px;
+}
+
+.j13-compose-typebar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.j13-compose-typechip {
+ height: 34px;
+ padding: 0 14px;
+ border-radius: 999px;
+ border: 1px solid var(--line);
+ background: var(--panel);
+ color: var(--ink-2);
+ font-size: 13px;
+ font-weight: 700;
+ transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
+}
+.j13-compose-typechip:hover:not(:disabled) {
+ border-color: color-mix(in srgb, var(--accent) 35%, var(--line));
+ color: var(--accent);
+}
+.j13-compose-typechip.is-on {
+ background: var(--accent-soft);
+ border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
+ color: var(--accent);
+}
+.j13-compose-typechip:disabled:not(.is-on) {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+.j13-compose-typepanel {
+ padding: 12px 14px;
+ border-radius: 12px;
+ border: 1px dashed color-mix(in srgb, var(--accent) 28%, var(--line));
+ background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel));
+}
+.j13-compose-attach-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ border-radius: 12px;
+ border: 1px solid var(--line);
+ background: var(--panel);
+}
+.j13-interact-card {
+ padding: 14px 16px;
+ border-radius: 14px;
+ border: 1px solid var(--line);
+ background: color-mix(in srgb, var(--tint) 65%, var(--panel));
+}
+
+.j13-compose-field {
+ width: 100%;
+ padding: 10px 14px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ color: var(--ink);
+ font-size: 15px;
+ font-family: var(--font-sans);
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+}
+.j13-compose-field::placeholder { color: var(--ink-3); }
+.j13-compose-field:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+}
+.j13-compose-field:disabled {
+ opacity: 0.65;
+ cursor: not-allowed;
+}
+
+.j13-compose-title {
+ width: 100%;
+ padding: 0.25rem 0 0.75rem;
+ margin: 0;
+ border: 0;
+ border-bottom: 1px solid var(--line);
+ border-radius: 0;
+ background: transparent;
+ color: var(--ink);
+ font-family: var(--font-sans);
+ font-size: clamp(1.4rem, 2.5vw, 1.75rem);
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ line-height: 1.3;
+ transition: border-color 0.15s ease;
+}
+.j13-compose-title::placeholder {
+ color: var(--ink-3);
+ font-weight: 700;
+}
+.j13-compose-title:focus {
+ outline: none;
+ border-bottom-color: var(--accent);
+ box-shadow: none;
+}
+
+/* 标签:对齐私信搜索 —— 同高、同聚焦,输入无内层描边;白底避免灰块 */
+.j13-tag-field {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ min-height: 2.5rem;
+ padding: 4px 12px;
+ cursor: text;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+}
+.j13-tag-field:focus-within {
+ border-color: var(--accent) !important;
+ box-shadow: 0 0 0 3px var(--accent-soft);
+}
+.j13-tag-field-input,
+.j13-tag-field-input:focus,
+.j13-tag-field-input:focus-visible {
+ outline: none !important;
+ box-shadow: none !important;
+ border: 0 !important;
+ background: transparent !important;
+}
+
+.j13-board-picker { position: relative; }
+.j13-board-trigger {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ height: 2.5rem;
+ min-height: 2.5rem;
+ padding: 0 12px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ color: var(--ink);
+ text-align: left;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+ cursor: pointer;
+}
+.j13-board-trigger:hover:not(:disabled) {
+ border-color: color-mix(in srgb, var(--line-2) 40%, var(--line));
+}
+.j13-board-trigger:focus { outline: none; }
+.j13-board-trigger:focus-visible,
+.j13-board-trigger[aria-expanded="true"] {
+ outline: none !important;
+ border-color: var(--accent) !important;
+ box-shadow: 0 0 0 3px var(--accent-soft);
+}
+.j13-board-trigger:disabled {
+ opacity: 0.65;
+ cursor: not-allowed;
+}
+.j13-board-trigger-ico {
+ width: 24px;
+ height: 24px;
+ border-radius: 7px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ font-weight: 800;
+ flex-shrink: 0;
+}
+.j13-board-menu {
+ position: absolute;
+ z-index: 40;
+ top: calc(100% + 6px);
+ left: 0;
+ right: 0;
+ min-width: 100%;
+ max-height: 280px;
+ overflow-y: auto;
+ padding: 6px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ box-shadow: var(--shadow-lg);
+}
+.j13-board-option {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ border-radius: 10px;
+ color: var(--ink);
+ font-size: 14px;
+ font-weight: 600;
+ text-align: left;
+ cursor: pointer;
+ transition: background 0.12s ease;
+}
+.j13-board-option:hover,
+.j13-board-option.is-active,
+.j13-board-option.is-selected {
+ background: var(--accent-soft);
+ color: var(--accent);
+}
+.j13-board-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ height: 2.5rem;
+ min-height: 2.5rem;
+ padding: 0 12px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ color: var(--ink-2);
+ font-size: 14px;
+ font-weight: 700;
+}
+
+/* 正文画布占满剩余高度 */
+.j13-compose-canvas {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+.j13-md-editor {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ margin-top: 0;
+ border: 0;
+ background: transparent;
+ overflow: visible;
+}
+.j13-md-editor.is-fullscreen {
+ position: fixed;
+ inset: 0;
+ z-index: 80;
+ margin: 0;
+ padding: 1rem 1.25rem 1.25rem;
+ background: var(--panel);
+ border: 0;
+ box-shadow: var(--shadow-lg);
+}
+.j13-md-toolbar {
+ flex-shrink: 0;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 2px;
+ padding: 4px 0 12px;
+ border-bottom: 1px solid var(--line);
+ background: transparent;
+}
+.j13-md-tool {
+ width: 34px;
+ height: 34px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 10px;
+ color: var(--ink-2);
+ transition: background 0.12s ease, color 0.12s ease;
+}
+.j13-md-tool:hover:not(:disabled) {
+ background: var(--accent-soft);
+ color: var(--accent);
+}
+.j13-md-tool:disabled { opacity: 0.4; }
+.j13-md-tool:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+.j13-md-mode {
+ display: inline-flex;
+ align-items: center;
+ padding: 3px;
+ border-radius: 999px;
+ background: var(--panel);
+ border: 1px solid var(--line);
+}
+.j13-md-mode button {
+ height: 30px;
+ padding: 0 12px;
+ border-radius: 999px;
+ font-size: 12.5px;
+ font-weight: 700;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ color: var(--ink-3);
+}
+.j13-md-mode button.is-on {
+ background: var(--accent-soft);
+ color: var(--accent);
+}
+.j13-md-textarea {
+ flex: 1 1 auto;
+ width: 100%;
+ min-height: 320px;
+ height: 100%;
+ resize: none;
+ border: 0 !important;
+ border-radius: 0 !important;
+ box-shadow: none !important;
+ background: transparent !important;
+ padding: 1rem 0 0.75rem !important;
+ color: var(--ink);
+ font-family: var(--font-sans);
+ font-size: 15.5px !important;
+ line-height: 1.8 !important;
+}
+.j13-md-editor.is-fullscreen .j13-md-textarea,
+.j13-md-editor.is-fullscreen .j13-md-preview {
+ min-height: 0;
+}
+.j13-md-textarea::placeholder { color: var(--ink-3); }
+.j13-md-textarea:focus,
+.j13-md-textarea:focus-visible {
+ outline: none !important;
+ box-shadow: none !important;
+ border: 0 !important;
+}
+.j13-md-preview {
+ flex: 1 1 auto;
+ min-height: 320px;
+ overflow-y: auto;
+ padding: 1rem 0 0.75rem;
+ background: transparent;
+}
+.j13-md-foot {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 10px 0 0;
+ font-size: 12.5px;
+ color: var(--ink-3);
+ border-top: 1px solid var(--line);
+ background: transparent;
+}
+
+.j13-compose-shell {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: var(--r);
+ box-shadow: var(--shadow);
+ overflow: visible;
+}
+
+
+/* 发帖/编辑:占满 main 剩余高度,减少卡片下方空余(保留页脚) */
+body:has([data-compose-viewport]) #main {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ /* 略收紧上下边距,把高度留给舞台 */
+ padding-top: 1.15rem;
+ padding-bottom: 1.15rem;
+}
+body:has([data-compose-viewport]) #main > [data-compose-viewport] {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
/* 聊天页:整页锁在视口内,避免「页面滚动 + 对话区滚动」双滚动条 */
body:has([data-chat-viewport]) {
height: 100dvh;
@@ -511,6 +1002,28 @@ a.j13-mention-token:hover {
border-radius: 999px;
white-space: nowrap;
}
+ .badge-type {
+ display: inline-flex;
+ align-items: center;
+ font-size: 12px;
+ font-weight: 700;
+ padding: 4px 11px;
+ background: color-mix(in srgb, var(--accent) 12%, var(--tint));
+ color: var(--accent);
+ border-radius: 999px;
+ white-space: nowrap;
+ }
+ .badge-access {
+ display: inline-flex;
+ align-items: center;
+ font-size: 12px;
+ font-weight: 700;
+ padding: 4px 11px;
+ background: var(--tint);
+ color: var(--ink-2);
+ border-radius: 999px;
+ white-space: nowrap;
+ }
/* 帖子列表中的板块小标签:全站统一浅灰配色,避免每行多色眩目 */
.j13-board-tag {
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index d8efcd2..9b27273 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -5,6 +5,7 @@ import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import Toaster from "@/components/Toaster";
+import TipHost from "@/components/TipHost";
import AccountGuard from "@/components/AccountGuard";
import ThemeSync from "@/components/ThemeSync";
import RealtimeProvider from "@/components/RealtimeProvider";
@@ -112,6 +113,7 @@ export default async function RootLayout({
+
diff --git a/frontend/app/points/PointsClient.tsx b/frontend/app/points/PointsClient.tsx
new file mode 100644
index 0000000..de7cc6e
--- /dev/null
+++ b/frontend/app/points/PointsClient.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import Link from "next/link";
+import { ChevronRight } from "lucide-react";
+import {
+ apiGetPoints,
+ apiGetPointsLedger,
+ type PointLedgerItem,
+} from "@/lib/api";
+import { pointReasonLabel } from "@/lib/points";
+import { formatRelative } from "@/lib/format";
+import Pagination from "@/components/Pagination";
+
+export default function PointsClient() {
+ const [balance, setBalance] = useState(null);
+ const [items, setItems] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [loading, setLoading] = useState(true);
+ const size = 20;
+
+ const load = useCallback(async (p: number) => {
+ setLoading(true);
+ try {
+ const [bal, led] = await Promise.all([
+ apiGetPoints(),
+ apiGetPointsLedger(p, size),
+ ]);
+ setBalance(typeof bal.points === "number" ? bal.points : 0);
+ setItems(led.items || []);
+ setTotal(led.total || 0);
+ setPage(led.page || p);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load(1);
+ }, [load]);
+
+ return (
+
+
+
+ 首页
+
+
+ 我的积分
+
+
+
+ 可用余额
+
+ {balance === null ? "—" : balance.toLocaleString("zh-CN")}
+
+
+ 签到、悬赏采纳与附件收益会增加余额;解锁正文、悬赏托管与积分附件会扣减。
+
+
+
+
+
+
+ 积分流水
+
+ {total} 条
+
+
+ {loading && items.length === 0 ? (
+ 加载中…
+ ) : items.length === 0 ? (
+ 暂无流水,去首页签到赚第一笔积分吧
+ ) : (
+
+ {items.map((it) => (
+ -
+
+
+ {pointReasonLabel(it.reason)}
+
+ {it.note && (
+
{it.note}
+ )}
+
+ {formatRelative(it.created_at)}
+
+
+
+
= 0 ? "var(--accent)" : "var(--danger)" }}
+ >
+ {it.delta >= 0 ? `+${it.delta}` : it.delta}
+
+
+ 余 {it.balance}
+
+
+
+ ))}
+
+ )}
+
+ {total > size && (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/points/page.tsx b/frontend/app/points/page.tsx
new file mode 100644
index 0000000..92dd4a8
--- /dev/null
+++ b/frontend/app/points/page.tsx
@@ -0,0 +1,20 @@
+import type { Metadata } from "next";
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+import { authCookieHeader } from "@/lib/cookies";
+import { getMeCached } from "@/lib/serverData";
+import PointsClient from "./PointsClient";
+
+export const metadata: Metadata = {
+ title: "我的积分",
+ robots: { index: false, follow: false },
+};
+
+export default async function PointsPage() {
+ const cookie = authCookieHeader(await cookies());
+ const me = await getMeCached(cookie || undefined);
+ if (!me.user) {
+ redirect(`/login?redirect=${encodeURIComponent("/points")}`);
+ }
+ return ;
+}
diff --git a/frontend/app/post/[id]/edit/EditPostClient.tsx b/frontend/app/post/[id]/edit/EditPostClient.tsx
new file mode 100644
index 0000000..80b5a72
--- /dev/null
+++ b/frontend/app/post/[id]/edit/EditPostClient.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import PostComposer from "@/components/PostComposer";
+import type { PostAttachment } from "@/lib/api";
+
+type Props = {
+ postId: number;
+ initialTitle: string;
+ initialContent: string;
+ initialTags: string;
+ initialPostType?: string;
+ initialContentAccess?: string;
+ initialAccessPoints?: number;
+ initialTypeMeta?: string;
+ initialAttachments?: PostAttachment[];
+ canEditPollOptions?: boolean;
+ boardName: string;
+ boardId: number;
+ boardColorIndex?: number;
+};
+
+export default function EditPostClient({
+ postId,
+ initialTitle,
+ initialContent,
+ initialTags,
+ initialPostType,
+ initialContentAccess,
+ initialAccessPoints,
+ initialTypeMeta,
+ initialAttachments,
+ canEditPollOptions,
+ boardName,
+ boardId,
+ boardColorIndex = 0,
+}: Props) {
+ return (
+
+ );
+}
diff --git a/frontend/app/post/[id]/edit/page.tsx b/frontend/app/post/[id]/edit/page.tsx
index 85286ac..a324599 100644
--- a/frontend/app/post/[id]/edit/page.tsx
+++ b/frontend/app/post/[id]/edit/page.tsx
@@ -1,152 +1,74 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import { useRouter } from "next/navigation";
+import { cookies } from "next/headers";
+import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
-import { fetchPostDetail, apiMe, apiUpdatePost, type Post, type User } from "@/lib/api";
+import { authCookieHeader } from "@/lib/cookies";
+import { getMeCached } from "@/lib/serverData";
+import { fetchPostDetail } from "@/lib/api";
import { canModerateBoard } from "@/lib/roles";
+import EditPostClient from "./EditPostClient";
-interface EditPageProps {
+interface PageProps {
params: Promise<{ id: string }>;
}
-export default function EditPostPage({ params }: EditPageProps) {
- const router = useRouter();
- const [post, setPost] = useState(null);
- const [user, setUser] = useState(null);
- const [title, setTitle] = useState("");
- const [content, setContent] = useState("");
- const [tags, setTags] = useState("");
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState("");
- const [checking, setChecking] = useState(true);
-
- const [id, setId] = useState("");
- useEffect(() => {
- params.then((p) => setId(p.id));
- }, [params]);
-
- useEffect(() => {
- if (!id) return;
- Promise.all([fetchPostDetail(id), apiMe()])
- .then(([postData, meData]) => {
- setPost(postData.post);
- setTitle(postData.post.title);
- setContent(postData.post.content);
- setTags(postData.post.tags || "");
- const u = meData.user;
- setUser(u || null);
- if (
- !u ||
- (u.id !== postData.post.user.id && !canModerateBoard(u, postData.post.board_id))
- ) {
- router.push(`/post/${id}`);
- }
- })
- .catch(() => router.push(`/post/${id}`))
- .finally(() => setChecking(false));
- }, [id, router]);
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!post) return;
- setLoading(true);
- setError("");
- try {
- const res = await apiUpdatePost(String(post.id), { title, content, tags });
- if (res.post) {
- router.push(`/post/${post.id}`);
- } else {
- setError(res.error || "更新失败");
- }
- } finally {
- setLoading(false);
- }
- };
-
- if (checking) {
- return (
-
- );
+export default async function EditPostPage({ params }: PageProps) {
+ const { id } = await params;
+ const cookie = authCookieHeader(await cookies());
+ const me = await getMeCached(cookie || undefined);
+ if (!me.user) {
+ redirect(`/login?redirect=${encodeURIComponent(`/post/${id}/edit`)}`);
}
- if (!post) return null;
+ let post: Awaited>["post"];
+ try {
+ ({ post } = await fetchPostDetail(id, cookie));
+ } catch {
+ notFound();
+ }
+
+ const allowed =
+ me.user.id === post.user.id ||
+ canModerateBoard(me.user, post.board_id);
+ if (!allowed) {
+ redirect(`/post/${id}`);
+ }
return (
-
-
- 首页
+
+
+
+ 首页
+
-
+
帖子详情
- 编辑
+ 编辑
-
-
- 编辑帖子
-
-
-
-
+
);
}
diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx
index 5586d0f..dfeecf6 100644
--- a/frontend/app/post/[id]/page.tsx
+++ b/frontend/app/post/[id]/page.tsx
@@ -2,7 +2,6 @@ import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { cookies } from "next/headers";
-import ReactMarkdown from "react-markdown";
import {
Clock,
Heart,
@@ -33,8 +32,13 @@ import PostToc, { type TocItem } from "@/components/PostToc";
import MobilePostBar from "@/components/MobilePostBar";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
+import MarkdownBody from "@/components/MarkdownBody";
+import ContentLockGate from "@/components/ContentLockGate";
+import PostAttachments from "@/components/PostAttachments";
+import PostInteractPanels from "@/components/PostInteractPanels";
import { canModerateBoard } from "@/lib/roles";
import { IconComment, IconEye } from "@/components/Icons";
+import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel } from "@/lib/postMeta";
interface PageProps {
params: Promise<{ id: string }>;
@@ -47,12 +51,15 @@ export async function generateMetadata({ params }: PageProps): Promise
try {
// 带登录态:作者/管理团队看待审帖时标签标题也应是真实标题而非"帖子不存在"
const { post } = await fetchPostDetail(id, authCookieHeader(await cookies()));
+ const desc = post.content_locked
+ ? post.access_hint || "部分内容需满足条件后可见"
+ : post.content.slice(0, 160);
return {
title: post.title,
- description: post.content.slice(0, 160),
+ description: desc,
openGraph: {
title: post.title,
- description: post.content.slice(0, 160),
+ description: desc,
type: "article",
},
};
@@ -194,7 +201,7 @@ function AuthorPosts({ posts }: { posts: PostListItem[] }) {
{posts.map((p, i) => (
-
+
{i + 1}
p.id !== post.id).slice(0, 5);
const authorPosts = (profile?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
- const toc = extractToc(post.content);
- const readingMinutes = Math.max(1, Math.ceil(post.content.length / 400));
+ const toc = post.content_locked ? [] : extractToc(post.content);
+ const readingMinutes = Math.max(
+ 1,
+ Math.ceil((post.content_locked ? post.title.length : post.content.length) / 400)
+ );
const edited =
new Date(post.updated_at).getTime() - new Date(post.created_at).getTime() > 60_000;
// 非公开帖(待审/未通过)仅作者本人与管理团队可看,顶部给出醒目状态
const isPublished = post.status === "published";
- const canSeeStatus = !!me.user && (me.user.id === post.user.id || canModerateBoard(me.user, post.board_id));
+ const postType = normalizePostType(post.post_type);
+ const typeStatus = typeStatusLabel(postType, post.type_status);
+ const canManageTyped =
+ !!me.user && (me.user.id === post.user.id || canModerateBoard(me.user, post.board_id));
+ const canSeeStatus = canManageTyped;
+ const questionSolved = !!post.question?.solved;
+ const acceptMode =
+ postType === "question" && !questionSolved && canManageTyped
+ ? ("question" as const)
+ : post.bounty?.can_accept
+ ? ("bounty" as const)
+ : null;
const jsonLd = {
"@context": "https://schema.org",
"@type": "DiscussionForumPosting",
headline: post.title,
- articleBody: post.content,
+ // 锁定正文不进 JSON-LD,避免 SEO 泄露付费/回复可见内容
+ articleBody: post.content_locked ? undefined : post.content,
datePublished: post.created_at,
dateModified: post.updated_at,
author: { "@type": "Person", name: post.user.nickname },
@@ -409,11 +431,35 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
- {post.pinned > 0 && 置顶}
- {post.recommended && 推荐}
+
+ {post.board.name}
+
+
+
+ {/* 仅置顶/推荐在标题左侧 */}
+ {post.pinned > 0 && 置顶}
+ {post.recommended && 推荐}
+
+ {post.title}
+
+ {/* 其余徽章紧跟标题右侧;问答不显示类型徽章 */}
+ {postType !== "discussion" && postType !== "question" && (
+ {postTypeLabel(postType)}
+ )}
+ {typeStatus && (
+ {typeStatus}
+ )}
+ {post.content_access && post.content_access !== "public" && (
+
+ {contentAccessLabel(post.content_access)}
+
+ )}
{!isPublished && canSeeStatus && post.status === "pending" && (
等待审核
@@ -421,19 +467,13 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
)}
{!isPublished && canSeeStatus && post.status === "rejected" && (
未通过审核
)}
-
- {post.board.name}
-
-
- {post.title}
-
{/* 作者行:头像 + 昵称居左,点赞/更多操作居右 */}
)}
-
-
- {post.content}
-
-
+
+
+ {post.content_locked ? (
+
+ ) : (
+
+ )}
+
+ {!post.content_locked && (
+
+ )}
{/* 文末点赞:分隔线后居中,唯一的点赞入口(作者行不再放置) */}
) : (
diff --git a/frontend/app/u/[id]/page.tsx b/frontend/app/u/[id]/page.tsx
index dc8d006..b2b1ee4 100644
--- a/frontend/app/u/[id]/page.tsx
+++ b/frontend/app/u/[id]/page.tsx
@@ -88,7 +88,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
const u = data.user;
const joinDate = new Date(u.created_at).toLocaleDateString("zh-CN");
- const showPoints = data.stats.points > 0;
+ const showPoints = data.stats.points > 0 || isOwner;
// 导航项(桌面左栏 / 移动 chips 共用定义)
const navItems: { key: UserTab; label: string; count?: number; icon: React.ReactNode; ownerOnly?: boolean }[] = [
@@ -359,7 +359,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
- 签到
+ 积分
{data.stats.streak > 0 && (
{data.stats.points}
- 累计积分
+ 可用余额
+ {isOwner && (
+
+ 查看流水 →
+
+ )}
)}
diff --git a/frontend/components/AvatarCropModal.tsx b/frontend/components/AvatarCropModal.tsx
index 464ce64..6df3848 100644
--- a/frontend/components/AvatarCropModal.tsx
+++ b/frontend/components/AvatarCropModal.tsx
@@ -315,7 +315,7 @@ export default function AvatarCropModal({
type="button"
onClick={() => handlePick(item)}
disabled={busy || active}
- title={
+ data-tip={
active
? "当前头像"
: item.kind === "image"
@@ -366,7 +366,7 @@ export default function AvatarCropModal({
}}
disabled={busy}
aria-label={confirming ? "再次点击确认删除" : "删除图片"}
- title={confirming ? "再次点击确认彻底删除" : "彻底删除"}
+ data-tip={confirming ? "再次点击确认彻底删除" : "彻底删除"}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
style={{
background: confirming ? "var(--red, #dc2626)" : "var(--panel)",
diff --git a/frontend/components/AvatarPicker.tsx b/frontend/components/AvatarPicker.tsx
index c0f0568..b79705e 100644
--- a/frontend/components/AvatarPicker.tsx
+++ b/frontend/components/AvatarPicker.tsx
@@ -69,8 +69,8 @@ export default function AvatarPicker({
onClick={() => setOpen(true)}
className="group relative inline-flex items-center justify-center shrink-0 p-0 rounded-full cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--panel)]"
style={{ width: size, height: size, lineHeight: 0 }}
+ data-tip="更换头像"
aria-label="更换头像"
- title="更换头像"
>
{/* hover/focus 遮罩:与头像严格重合(按钮宽高固定 = size) */}
diff --git a/frontend/components/BoardPicker.tsx b/frontend/components/BoardPicker.tsx
new file mode 100644
index 0000000..e0e7566
--- /dev/null
+++ b/frontend/components/BoardPicker.tsx
@@ -0,0 +1,192 @@
+"use client";
+
+import { useEffect, useId, useRef, useState } from "react";
+import { Check, ChevronDown } from "lucide-react";
+import type { Board } from "@/lib/api";
+
+type BoardPickerProps = {
+ boards: Board[];
+ value: number;
+ onChange: (id: number) => void;
+ disabled?: boolean;
+ loading?: boolean;
+ id?: string;
+};
+
+function boardColorIndex(b: Board, fallbackIndex: number) {
+ return Math.max(0, b.color_index ?? fallbackIndex) % 8;
+}
+
+export default function BoardPicker({
+ boards,
+ value,
+ onChange,
+ disabled,
+ loading,
+ id,
+}: BoardPickerProps) {
+ const listId = useId();
+ const rootRef = useRef(null);
+ const [open, setOpen] = useState(false);
+ const [activeIndex, setActiveIndex] = useState(0);
+
+ const selectedIdx = boards.findIndex((b) => b.id === value);
+ const selected = selectedIdx >= 0 ? boards[selectedIdx] : null;
+
+ useEffect(() => {
+ if (!open) return;
+ setActiveIndex(selectedIdx >= 0 ? selectedIdx : 0);
+ }, [open, selectedIdx]);
+
+ useEffect(() => {
+ if (!open) return;
+ const onDoc = (e: MouseEvent) => {
+ if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
+ };
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ setOpen(false);
+ }
+ };
+ document.addEventListener("mousedown", onDoc);
+ document.addEventListener("keydown", onKey);
+ return () => {
+ document.removeEventListener("mousedown", onDoc);
+ document.removeEventListener("keydown", onKey);
+ };
+ }, [open]);
+
+ const pick = (id: number) => {
+ onChange(id);
+ setOpen(false);
+ };
+
+ const onTriggerKeyDown = (e: React.KeyboardEvent) => {
+ if (disabled || loading) return;
+ if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ setOpen(true);
+ }
+ };
+
+ const onMenuKeyDown = (e: React.KeyboardEvent) => {
+ if (!boards.length) return;
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setActiveIndex((i) => (i + 1) % boards.length);
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setActiveIndex((i) => (i - 1 + boards.length) % boards.length);
+ } else if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ const b = boards[activeIndex];
+ if (b) pick(b.id);
+ } else if (e.key === "Home") {
+ e.preventDefault();
+ setActiveIndex(0);
+ } else if (e.key === "End") {
+ e.preventDefault();
+ setActiveIndex(boards.length - 1);
+ }
+ };
+
+ const label = loading
+ ? "加载板块…"
+ : selected
+ ? selected.name
+ : boards.length === 0
+ ? "暂无可用板块"
+ : "选择板块";
+
+ const colorIdx = selected
+ ? boardColorIndex(selected, selectedIdx)
+ : 0;
+
+ return (
+
+
+
+ {open && (
+
+ {boards.map((b, i) => {
+ const selectedHere = b.id === value;
+ const c = boardColorIndex(b, i);
+ return (
+ -
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+/** 编辑态只读板块徽章(与触发器同高) */
+export function BoardBadge({
+ name,
+ colorIndex = 0,
+}: {
+ name: string;
+ colorIndex?: number;
+}) {
+ const c = Math.max(0, colorIndex) % 8;
+ return (
+
+
+ {name.slice(0, 1)}
+
+ {name}
+
+ );
+}
diff --git a/frontend/components/CommentSection.tsx b/frontend/components/CommentSection.tsx
index d8d5551..8273087 100644
--- a/frontend/components/CommentSection.tsx
+++ b/frontend/components/CommentSection.tsx
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link";
import { Trash2, MessagesSquare, ChevronRight, ChevronDown } from "lucide-react";
import ReactMarkdown from "react-markdown";
-import { apiCreateComment, apiDeleteComment, type CommentNode, type User } from "@/lib/api";
+import { apiCreateComment, apiDeleteComment, apiAcceptBounty, apiAcceptQuestion, type CommentNode, type User } from "@/lib/api";
import { canModerateBoard } from "@/lib/roles";
import { toast } from "@/lib/toast";
import Avatar from "./Avatar";
@@ -15,6 +15,8 @@ interface CommentSectionProps {
postId: string;
/** 帖子所属板块:板块管理员仅可管理授权板块的评论 */
boardId: number;
+ /** 楼主 ID:不可采纳楼主自己的评论 */
+ authorId: number;
comments: CommentNode[];
total: number; // 楼层数(主评论数)→ 分页与楼层号计算
totalComments: number; // 全部评论数(含回复)→ 头部徽标
@@ -22,6 +24,9 @@ interface CommentSectionProps {
size: number;
// SSR 直出的登录用户(layout 已取,避免客户端再请求一次 /api/me)
user: User | null;
+ /** 悬赏或问答:可采纳时在评论旁显示采纳按钮(二者按帖子类型二选一) */
+ acceptMode?: "bounty" | "question" | null;
+ acceptedCommentId?: number;
}
// 删除按钮的两段式确认:点一下进入确认态,3 秒未确认自动复原
@@ -35,12 +40,15 @@ function countReplies(node: CommentNode): number {
export default function CommentSection({
postId,
boardId,
+ authorId,
comments,
total,
totalComments,
page,
size,
user,
+ acceptMode = null,
+ acceptedCommentId = 0,
}: CommentSectionProps) {
const router = useRouter();
const [content, setContent] = useState("");
@@ -50,6 +58,7 @@ export default function CommentSection({
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState(null);
const [confirmingId, setConfirmingId] = useState(null);
+ const [acceptingId, setAcceptingId] = useState(null);
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
const [expandedFloors, setExpandedFloors] = useState>(new Set());
const textareaRef = useRef(null);
@@ -133,11 +142,38 @@ export default function CommentSection({
}
};
+ const handleAccept = async (commentId: number, commentAuthorId: number) => {
+ if (!acceptMode || !user || commentAuthorId === authorId) return;
+ setAcceptingId(commentId);
+ try {
+ const res =
+ acceptMode === "question"
+ ? await apiAcceptQuestion(postId, commentId)
+ : await apiAcceptBounty(postId, commentId);
+ if (res.post) {
+ toast(acceptMode === "question" ? "已采纳为最佳答案" : "已采纳并发放悬赏", "ok");
+ router.refresh();
+ } else {
+ toast(res.error || "采纳失败");
+ }
+ } catch {
+ toast("网络错误,采纳失败");
+ } finally {
+ setAcceptingId(null);
+ }
+ };
+
// 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能)
const canDelete = (comment: CommentNode) =>
!!user && (user.id === comment.user.id || canModerateBoard(user, boardId));
+ const canAccept = (comment: CommentNode) =>
+ !!acceptMode &&
+ !!user &&
+ comment.status === "published" &&
+ comment.user.id !== authorId;
+
const openReply = (c: CommentNode) => {
setReplyTarget({ id: c.id, nickname: c.user.nickname || c.user.username });
setReplyContent("");
@@ -247,11 +283,25 @@ export default function CommentSection({
replyTarget?.id === c.id ? "!text-[var(--accent)] font-semibold" : ""
}`}
aria-label={`回复 ${c.user.nickname || c.user.username}`}
- title="回复"
+ data-tip="回复"
>
回复
)}
+ {acceptedCommentId === c.id && (
+ 已采纳
+ )}
+ {canAccept(c) && (
+
+ )}
{canDelete(c) && (
)}
+ {acceptedCommentId === c.id && (
+ 已采纳
+ )}
+ {canAccept(c) && (
+
+ )}
{canDelete(c) && (
@@ -289,6 +290,7 @@ export default function Header({
href="/chat"
prefetch={false}
aria-label="消息"
+ data-tip="消息"
className="relative inline-flex items-center justify-center w-10 h-10 rounded-full transition-colors"
style={{
color: pathname?.startsWith("/chat")
diff --git a/frontend/components/LikeButton.tsx b/frontend/components/LikeButton.tsx
index e9d3d7d..9ac3db5 100644
--- a/frontend/components/LikeButton.tsx
+++ b/frontend/components/LikeButton.tsx
@@ -72,7 +72,8 @@ export default function LikeButton({
diff --git a/frontend/components/MarkdownBody.tsx b/frontend/components/MarkdownBody.tsx
new file mode 100644
index 0000000..d00d752
--- /dev/null
+++ b/frontend/components/MarkdownBody.tsx
@@ -0,0 +1,20 @@
+import ReactMarkdown, { type Components } from "react-markdown";
+
+type MarkdownBodyProps = {
+ content: string;
+ className?: string;
+ components?: Components;
+};
+
+/** 站点统一 Markdown 渲染:详情页 / 发帖预览共用 prose-content */
+export default function MarkdownBody({
+ content,
+ className = "prose-content",
+ components,
+}: MarkdownBodyProps) {
+ return (
+
+ {content || ""}
+
+ );
+}
diff --git a/frontend/components/MarkdownEditor.tsx b/frontend/components/MarkdownEditor.tsx
new file mode 100644
index 0000000..2c6ef27
--- /dev/null
+++ b/frontend/components/MarkdownEditor.tsx
@@ -0,0 +1,320 @@
+"use client";
+
+import { useRef, useState, useCallback, useEffect, type ReactNode } from "react";
+import {
+ Bold,
+ Italic,
+ Code,
+ Quote,
+ List,
+ Heading2,
+ Link as LinkIcon,
+ Image as ImageIcon,
+ Eye,
+ Pencil,
+ Maximize2,
+ Minimize2,
+} from "lucide-react";
+import { apiUploadPostImage } from "@/lib/api";
+import { toast } from "@/lib/toast";
+import MarkdownBody from "@/components/MarkdownBody";
+
+type MarkdownEditorProps = {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ rows?: number;
+ disabled?: boolean;
+ onSubmitShortcut?: () => void;
+ id?: string;
+};
+
+type WrapOpts = {
+ before: string;
+ after?: string;
+ placeholder?: string;
+ linePrefix?: string;
+};
+
+function applyWrap(
+ el: HTMLTextAreaElement,
+ value: string,
+ opts: WrapOpts
+): { next: string; selStart: number; selEnd: number } {
+ const start = el.selectionStart;
+ const end = el.selectionEnd;
+ const selected = value.slice(start, end);
+ const after = opts.after ?? "";
+
+ if (opts.linePrefix) {
+ const block = selected || opts.placeholder || "列表项";
+ const lines = block.split("\n").map((l) =>
+ l.startsWith(opts.linePrefix!) ? l : `${opts.linePrefix}${l}`
+ );
+ const inserted = lines.join("\n");
+ const next = value.slice(0, start) + inserted + value.slice(end);
+ return {
+ next,
+ selStart: start,
+ selEnd: start + inserted.length,
+ };
+ }
+
+ const inner = selected || opts.placeholder || "";
+ const inserted = opts.before + inner + after;
+ const next = value.slice(0, start) + inserted + value.slice(end);
+ const selStart = start + opts.before.length;
+ const selEnd = selStart + inner.length;
+ return { next, selStart, selEnd };
+}
+
+export default function MarkdownEditor({
+ value,
+ onChange,
+ placeholder,
+ rows = 16,
+ disabled,
+ onSubmitShortcut,
+ id,
+}: MarkdownEditorProps) {
+ const taRef = useRef(null);
+ const fileRef = useRef(null);
+ const [mode, setMode] = useState<"write" | "preview">("write");
+ const [uploading, setUploading] = useState(false);
+ const [fullscreen, setFullscreen] = useState(false);
+
+ useEffect(() => {
+ if (!fullscreen) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ setFullscreen(false);
+ }
+ };
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ window.addEventListener("keydown", onKey);
+ return () => {
+ document.body.style.overflow = prev;
+ window.removeEventListener("keydown", onKey);
+ };
+ }, [fullscreen]);
+
+ const runWrap = useCallback(
+ (opts: WrapOpts) => {
+ const el = taRef.current;
+ if (!el || disabled) return;
+ const { next, selStart, selEnd } = applyWrap(el, value, opts);
+ onChange(next);
+ requestAnimationFrame(() => {
+ el.focus();
+ el.setSelectionRange(selStart, selEnd);
+ });
+ },
+ [value, onChange, disabled]
+ );
+
+ const insertAtCursor = useCallback(
+ (text: string) => {
+ const el = taRef.current;
+ if (!el) {
+ onChange(value + (value.endsWith("\n") || !value ? "" : "\n") + text);
+ return;
+ }
+ const start = el.selectionStart;
+ const end = el.selectionEnd;
+ const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
+ const padAfter = end < value.length && value[end] !== "\n" ? "\n" : "";
+ const inserted = padBefore + text + padAfter;
+ const next = value.slice(0, start) + inserted + value.slice(end);
+ onChange(next);
+ const caret = start + inserted.length;
+ requestAnimationFrame(() => {
+ el.focus();
+ el.setSelectionRange(caret, caret);
+ });
+ },
+ [value, onChange]
+ );
+
+ const handleImagePick = async (file: File | null) => {
+ if (!file || disabled) return;
+ if (!/^image\/(jpeg|png|webp)$/i.test(file.type)) {
+ toast("仅支持 JPEG / PNG / WebP", "error");
+ return;
+ }
+ if (file.size > 5 * 1024 * 1024) {
+ toast("图片不能超过 5MB", "error");
+ return;
+ }
+ setUploading(true);
+ try {
+ const res = await apiUploadPostImage(file);
+ if (!res.url) {
+ toast(res.error || "上传失败", "error");
+ return;
+ }
+ const alt = file.name.replace(/\.[^.]+$/, "") || "图片";
+ insertAtCursor(``);
+ setMode("write");
+ toast("图片已插入", "ok");
+ } catch {
+ toast("上传失败", "error");
+ } finally {
+ setUploading(false);
+ if (fileRef.current) fileRef.current.value = "";
+ }
+ };
+
+ const tools: {
+ key: string;
+ label: string;
+ icon: ReactNode;
+ onClick: () => void;
+ }[] = [
+ {
+ key: "bold",
+ label: "粗体",
+ icon: ,
+ onClick: () => runWrap({ before: "**", after: "**", placeholder: "粗体" }),
+ },
+ {
+ key: "italic",
+ label: "斜体",
+ icon: ,
+ onClick: () => runWrap({ before: "*", after: "*", placeholder: "斜体" }),
+ },
+ {
+ key: "code",
+ label: "行内代码",
+ icon: ,
+ onClick: () => runWrap({ before: "`", after: "`", placeholder: "code" }),
+ },
+ {
+ key: "quote",
+ label: "引用",
+ icon:
,
+ onClick: () =>
+ runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }),
+ },
+ {
+ key: "list",
+ label: "列表",
+ icon:
,
+ onClick: () =>
+ runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" }),
+ },
+ {
+ key: "h2",
+ label: "二级标题",
+ icon: ,
+ onClick: () => runWrap({ before: "## ", after: "", placeholder: "标题" }),
+ },
+ {
+ key: "link",
+ label: "链接",
+ icon: ,
+ onClick: () =>
+ runWrap({ before: "[", after: "](https://)", placeholder: "链接文字" }),
+ },
+ {
+ key: "image",
+ label: uploading ? "上传中…" : "插入图片",
+ icon: ,
+ onClick: () => fileRef.current?.click(),
+ },
+ ];
+
+ return (
+
+
+
+ {tools.map((t) => (
+
+ {t.icon}
+
+ ))}
+
+
+
+
setMode("write")}
+ >
+ 编写
+
+
setMode("preview")}
+ >
+ 预览
+
+
+
setFullscreen((v) => !v)}
+ >
+ {fullscreen ? : }
+
+
+
+
+
handleImagePick(e.target.files?.[0] ?? null)}
+ />
+
+ {mode === "write" ? (
+
+ );
+}
diff --git a/frontend/components/NotificationBell.tsx b/frontend/components/NotificationBell.tsx
index 21ea9a7..2b90d78 100644
--- a/frontend/components/NotificationBell.tsx
+++ b/frontend/components/NotificationBell.tsx
@@ -157,6 +157,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
{
+ if (a.price_points > 0 && !a.unlocked) {
+ // 仍走下载 URL:后端会扣积分或 402
+ toast(`将消耗 ${a.price_points} 积分下载`, "ok");
+ }
+ window.location.href = postAttachmentDownloadURL(postId, a.id);
+ };
+
+ return (
+
+
+ 附件
+
+
+ {items.map((a) => (
+ -
+ onClick(a)}
+ className="w-full flex items-center gap-3 px-3.5 py-3 rounded-[12px] text-left transition-colors hover:bg-[var(--accent-soft)]"
+ style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
+ >
+
+
+
+ {a.name}
+
+
+ {formatSize(a.size)}
+ {a.price_points > 0
+ ? a.unlocked
+ ? " · 已解锁"
+ : ` · ${a.price_points} 积分`
+ : " · 免费"}
+ {a.download_count > 0 ? ` · ${a.download_count} 次下载` : ""}
+
+
+ 下载
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/components/PostComposer.tsx b/frontend/components/PostComposer.tsx
new file mode 100644
index 0000000..28c6c2a
--- /dev/null
+++ b/frontend/components/PostComposer.tsx
@@ -0,0 +1,957 @@
+"use client";
+
+import { useState, useEffect, useRef, useCallback } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { X, Paperclip, Download } from "lucide-react";
+import {
+ apiCreatePost,
+ apiUpdatePost,
+ apiUploadPostFile,
+ apiDeletePostFile,
+ apiUpdatePostFilePrice,
+ fetchBoards,
+ type Board,
+ type PostAttachment,
+} from "@/lib/api";
+import {
+ POST_TYPES,
+ CONTENT_ACCESS,
+ BOUNTY_EXPIRE_DAYS,
+ LOTTERY_EXPIRE_DAYS,
+ normalizePostType,
+ type PostTypeValue,
+ type ContentAccessValue,
+} from "@/lib/postMeta";
+import { toast } from "@/lib/toast";
+import MarkdownEditor from "@/components/MarkdownEditor";
+import BoardPicker, { BoardBadge } from "@/components/BoardPicker";
+
+const TITLE_MAX = 256;
+const DRAFT_KEY = "j13-compose-draft";
+const DRAFT_DEBOUNCE_MS = 600;
+
+type ComposeDraft = {
+ title: string;
+ content: string;
+ tags: string;
+ boardId: number;
+ postType?: string;
+ contentAccess?: string;
+ accessPoints?: number;
+ typeMeta?: string;
+ updatedAt: number;
+};
+
+function parseTags(raw: string): string[] {
+ return [
+ ...new Set(
+ raw
+ .split(/[,,]/)
+ .map((t) => t.trim())
+ .filter(Boolean)
+ ),
+ ].slice(0, 12);
+}
+
+function serializeTags(tags: string[]): string {
+ return tags.join(", ");
+}
+
+function mapPostError(msg: string): string {
+ if (!msg) return "操作失败";
+ if (msg.includes("24 小时") || msg.includes("24小时") || msg.includes("冷静")) {
+ return "新用户注册满 24 小时后才能发帖,请稍后再试";
+ }
+ if (msg.includes("积分")) return msg;
+ if (msg.includes("rate") || msg.includes("频繁") || msg.includes("太快")) {
+ return "操作过于频繁,请稍后再试";
+ }
+ return msg;
+}
+
+function readDraft(): ComposeDraft | null {
+ try {
+ const raw = localStorage.getItem(DRAFT_KEY);
+ if (!raw) return null;
+ const d = JSON.parse(raw) as ComposeDraft;
+ if (!d || typeof d !== "object") return null;
+ return d;
+ } catch {
+ return null;
+ }
+}
+
+function clearDraft() {
+ try {
+ localStorage.removeItem(DRAFT_KEY);
+ } catch {
+ /* ignore */
+ }
+}
+
+function writeDraft(d: ComposeDraft) {
+ try {
+ localStorage.setItem(DRAFT_KEY, JSON.stringify(d));
+ } catch {
+ /* ignore */
+ }
+}
+
+function formatSize(n: number) {
+ if (n < 1024) return `${n} B`;
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
+}
+
+type CreateProps = {
+ mode: "create";
+ initialBoardId?: number;
+ cancelHref?: string;
+};
+
+type EditProps = {
+ mode: "edit";
+ postId: number;
+ initialTitle: string;
+ initialContent: string;
+ initialTags?: string;
+ initialPostType?: string;
+ initialContentAccess?: string;
+ initialAccessPoints?: number;
+ initialTypeMeta?: string;
+ initialAttachments?: PostAttachment[];
+ /** 投票帖:有票后禁止改选项 */
+ canEditPollOptions?: boolean;
+ boardName?: string;
+ boardId?: number;
+ boardColorIndex?: number;
+ cancelHref: string;
+};
+
+export type PostComposerProps = CreateProps | EditProps;
+
+export default function PostComposer(props: PostComposerProps) {
+ const router = useRouter();
+ const isCreate = props.mode === "create";
+
+ const [boards, setBoards] = useState([]);
+ const [boardsReady, setBoardsReady] = useState(!isCreate);
+ const [boardId, setBoardId] = useState(
+ isCreate ? props.initialBoardId || 0 : props.boardId || 0
+ );
+ const [title, setTitle] = useState(isCreate ? "" : props.initialTitle);
+ const [content, setContent] = useState(isCreate ? "" : props.initialContent);
+ const [tagList, setTagList] = useState(
+ parseTags(isCreate ? "" : props.initialTags || "")
+ );
+ const [tagInput, setTagInput] = useState("");
+ const [postType, setPostType] = useState(
+ normalizePostType(isCreate ? "discussion" : props.initialPostType)
+ );
+ const [contentAccess, setContentAccess] = useState(
+ (isCreate
+ ? "public"
+ : (props.initialContentAccess as ContentAccessValue) || "public") as ContentAccessValue
+ );
+ const [accessPoints, setAccessPoints] = useState(
+ isCreate ? 10 : props.initialAccessPoints || 10
+ );
+ const [typeMeta, setTypeMeta] = useState(
+ isCreate ? "" : props.initialTypeMeta || ""
+ );
+ // 类型专属表单状态
+ const [pollOptions, setPollOptions] = useState(() => {
+ try {
+ const m = JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}");
+ return Array.isArray(m.options) && m.options.length >= 2
+ ? m.options
+ : ["", ""];
+ } catch {
+ return ["", ""];
+ }
+ });
+ const [pollMulti, setPollMulti] = useState(() => {
+ try {
+ return !!JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").multi;
+ } catch {
+ return false;
+ }
+ });
+ const [pollAnonymous, setPollAnonymous] = useState(() => {
+ if (isCreate) return true;
+ try {
+ const m = JSON.parse(props.initialTypeMeta || "{}");
+ // 缺省视为匿名(与后端一致)
+ return m.anonymous !== false;
+ } catch {
+ return true;
+ }
+ });
+ const [bountyPoints, setBountyPoints] = useState(() => {
+ try {
+ const p = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").points);
+ return p > 0 ? p : 20;
+ } catch {
+ return 20;
+ }
+ });
+ const [lotterySlots, setLotterySlots] = useState(() => {
+ try {
+ const s = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").slots);
+ return s > 0 ? s : 1;
+ } catch {
+ return 1;
+ }
+ });
+ const [bountyExpireDays, setBountyExpireDays] = useState(() => {
+ try {
+ const d = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").expire_days);
+ return (BOUNTY_EXPIRE_DAYS as readonly number[]).includes(d) ? d : 7;
+ } catch {
+ return 7;
+ }
+ });
+ const [lotteryExpireDays, setLotteryExpireDays] = useState(() => {
+ try {
+ const d = Number(JSON.parse(isCreate ? "{}" : props.initialTypeMeta || "{}").expire_days);
+ return (LOTTERY_EXPIRE_DAYS as readonly number[]).includes(d) ? d : 7;
+ } catch {
+ return 7;
+ }
+ });
+ const canEditPoll =
+ isCreate || (props.mode === "edit" && props.canEditPollOptions !== false);
+ const [attachments, setAttachments] = useState(
+ isCreate ? [] : props.initialAttachments || []
+ );
+ const [fileBusy, setFileBusy] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [draftBanner, setDraftBanner] = useState(null);
+ const draftTimer = useRef | null>(null);
+ const skipDraftSave = useRef(true);
+ const formRef = useRef(null);
+ const fileRef = useRef(null);
+
+ const preferredBoardId = isCreate ? props.initialBoardId || 0 : 0;
+
+ useEffect(() => {
+ if (!isCreate) return;
+ let cancelled = false;
+ fetchBoards()
+ .then((data) => {
+ if (cancelled) return;
+ const list = data.boards || [];
+ setBoards(list);
+ setBoardsReady(true);
+ const preferred = preferredBoardId;
+ const hit = preferred && list.some((b) => b.id === preferred);
+ if (hit) {
+ setBoardId(preferred);
+ } else if (list.length > 0) {
+ setBoardId((cur) =>
+ cur && list.some((b) => b.id === cur) ? cur : list[0].id
+ );
+ }
+ const draft = readDraft();
+ if (
+ draft &&
+ (draft.title.trim() || draft.content.trim() || draft.tags.trim())
+ ) {
+ setDraftBanner(draft);
+ skipDraftSave.current = true;
+ } else {
+ skipDraftSave.current = false;
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setBoardsReady(true);
+ skipDraftSave.current = false;
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [isCreate, preferredBoardId]);
+
+ useEffect(() => {
+ if (!isCreate || skipDraftSave.current) return;
+ if (draftTimer.current) clearTimeout(draftTimer.current);
+ draftTimer.current = setTimeout(() => {
+ if (!title.trim() && !content.trim() && tagList.length === 0) {
+ clearDraft();
+ return;
+ }
+ writeDraft({
+ title,
+ content,
+ tags: serializeTags(tagList),
+ boardId,
+ postType,
+ contentAccess,
+ accessPoints,
+ typeMeta,
+ updatedAt: Date.now(),
+ });
+ }, DRAFT_DEBOUNCE_MS);
+ return () => {
+ if (draftTimer.current) clearTimeout(draftTimer.current);
+ };
+ }, [
+ isCreate,
+ title,
+ content,
+ tagList,
+ boardId,
+ postType,
+ contentAccess,
+ accessPoints,
+ typeMeta,
+ ]);
+
+ const restoreDraft = () => {
+ if (!draftBanner) return;
+ setTitle(draftBanner.title || "");
+ setContent(draftBanner.content || "");
+ setTagList(parseTags(draftBanner.tags || ""));
+ if (draftBanner.boardId) setBoardId(draftBanner.boardId);
+ if (draftBanner.postType) setPostType(normalizePostType(draftBanner.postType));
+ if (draftBanner.contentAccess) {
+ setContentAccess(draftBanner.contentAccess as ContentAccessValue);
+ }
+ if (draftBanner.accessPoints) setAccessPoints(draftBanner.accessPoints);
+ if (draftBanner.typeMeta) setTypeMeta(draftBanner.typeMeta);
+ setDraftBanner(null);
+ skipDraftSave.current = false;
+ toast("已恢复草稿", "ok");
+ };
+
+ const discardDraft = () => {
+ clearDraft();
+ setDraftBanner(null);
+ skipDraftSave.current = false;
+ };
+
+ const commitTagInput = useCallback(() => {
+ const parts = parseTags(tagInput);
+ if (parts.length === 0) {
+ setTagInput("");
+ return;
+ }
+ setTagList((prev) => {
+ const next = [...prev];
+ for (const p of parts) {
+ if (next.length >= 12) break;
+ if (!next.includes(p)) next.push(p);
+ }
+ return next;
+ });
+ setTagInput("");
+ }, [tagInput]);
+
+ const removeTag = (t: string) => {
+ setTagList((prev) => prev.filter((x) => x !== t));
+ };
+
+ const buildTypeMeta = (): string => {
+ if (postType === "discussion") return "";
+ if (postType === "question") {
+ return JSON.stringify({ solved: false, accepted_comment_id: 0 });
+ }
+ if (postType === "poll") {
+ return JSON.stringify({
+ options: pollOptions.map((o) => o.trim()).filter(Boolean),
+ multi: pollMulti,
+ anonymous: pollAnonymous,
+ closed: false,
+ });
+ }
+ if (postType === "bounty") {
+ return JSON.stringify({
+ points: Math.max(1, bountyPoints || 1),
+ accepted_comment_id: 0,
+ escrowed: false,
+ refunded: false,
+ expired: false,
+ expire_days: bountyExpireDays,
+ });
+ }
+ if (postType === "lottery") {
+ return JSON.stringify({
+ slots: Math.max(1, lotterySlots || 1),
+ drawn: false,
+ winner_ids: [],
+ closed: false,
+ expire_days: lotteryExpireDays,
+ });
+ }
+ return typeMeta.trim();
+ };
+
+ const onPickFile = async (file: File | null) => {
+ if (!file || fileBusy) return;
+ if (attachments.length >= 10) {
+ toast("最多 10 个附件", "error");
+ return;
+ }
+ setFileBusy(true);
+ try {
+ const res = await apiUploadPostFile(file, 0);
+ if (res.attachment) {
+ setAttachments((prev) => [...prev, res.attachment!]);
+ toast("附件已上传", "ok");
+ } else {
+ toast(res.error || "上传失败", "error");
+ }
+ } catch {
+ toast("上传失败", "error");
+ } finally {
+ setFileBusy(false);
+ if (fileRef.current) fileRef.current.value = "";
+ }
+ };
+
+ const removeAttachment = async (id: number) => {
+ setAttachments((prev) => prev.filter((a) => a.id !== id));
+ try {
+ await apiDeletePostFile(id);
+ } catch {
+ /* 本地已移除 */
+ }
+ };
+
+ const setAttPrice = async (id: number, price: number) => {
+ const p = Math.max(0, Math.min(100000, Math.floor(price) || 0));
+ setAttachments((prev) =>
+ prev.map((a) => (a.id === id ? { ...a, price_points: p } : a))
+ );
+ await apiUpdatePostFilePrice(id, p);
+ };
+
+ const canSubmit =
+ !loading &&
+ title.trim().length > 0 &&
+ content.trim().length > 0 &&
+ (isCreate ? boardsReady && boardId > 0 : true) &&
+ (contentAccess !== "points" || accessPoints > 0) &&
+ (postType !== "poll" ||
+ pollOptions.map((o) => o.trim()).filter(Boolean).length >= 2) &&
+ (postType !== "bounty" || bountyPoints > 0) &&
+ (postType !== "lottery" || lotterySlots > 0);
+
+ const handleSubmit = async (e?: React.FormEvent) => {
+ e?.preventDefault();
+ if (!canSubmit) return;
+ setLoading(true);
+ setError("");
+ const tags = serializeTags(tagList);
+ const meta = buildTypeMeta();
+ const attIds = attachments.map((a) => a.id);
+ try {
+ if (isCreate) {
+ const res = await apiCreatePost({
+ board_id: boardId,
+ title: title.trim(),
+ content: content.trim(),
+ tags,
+ post_type: postType,
+ content_access: contentAccess,
+ access_points: contentAccess === "points" ? accessPoints : 0,
+ type_meta: meta,
+ attachment_ids: attIds,
+ });
+ if (res.post) {
+ clearDraft();
+ if (res.post.status === "pending") {
+ toast("已提交,等待审核通过后公开", "ok");
+ } else {
+ toast("发布成功", "ok");
+ }
+ router.push(`/post/${res.post.id}`);
+ } else {
+ setError(mapPostError(res.error || "发帖失败"));
+ }
+ } else {
+ const res = await apiUpdatePost(String(props.postId), {
+ title: title.trim(),
+ content: content.trim(),
+ tags,
+ content_access: contentAccess,
+ access_points: contentAccess === "points" ? accessPoints : 0,
+ type_meta: meta,
+ attachment_ids: attIds,
+ });
+ if (res.post) {
+ toast("已保存", "ok");
+ router.push(props.cancelHref);
+ } else {
+ setError(mapPostError(res.error || "保存失败"));
+ }
+ }
+ } catch {
+ setError("网络错误,请稍后重试");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const cancelHref = isCreate ? props.cancelHref || "/" : props.cancelHref;
+
+ return (
+
+ );
+}
diff --git a/frontend/components/PostInteractPanels.tsx b/frontend/components/PostInteractPanels.tsx
new file mode 100644
index 0000000..e8752a0
--- /dev/null
+++ b/frontend/components/PostInteractPanels.tsx
@@ -0,0 +1,421 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import {
+ apiVotePoll,
+ apiClosePoll,
+ apiRefundBounty,
+ apiCloseLottery,
+ apiDrawLottery,
+ apiSolveQuestion,
+ apiReopenQuestion,
+ type PollState,
+ type BountyState,
+ type LotteryState,
+ type QuestionState,
+} from "@/lib/api";
+import { toast } from "@/lib/toast";
+import Avatar from "@/components/Avatar";
+
+export default function PostInteractPanels({
+ postId,
+ question,
+ poll,
+ bounty,
+ lottery,
+ loggedIn,
+}: {
+ postId: number;
+ question?: QuestionState | null;
+ poll?: PollState | null;
+ bounty?: BountyState | null;
+ lottery?: LotteryState | null;
+ loggedIn: boolean;
+}) {
+ if (!question && !poll && !bounty && !lottery) return null;
+ return (
+
+ {question &&
}
+ {poll &&
}
+ {bounty &&
}
+ {lottery &&
}
+
+ );
+}
+
+function formatEndsAt(iso?: string) {
+ if (!iso) return "";
+ try {
+ return new Date(iso).toLocaleString("zh-CN");
+ } catch {
+ return iso;
+ }
+}
+
+function QuestionPanel({ postId, state }: { postId: number; state: QuestionState }) {
+ const router = useRouter();
+ const [busy, setBusy] = useState(false);
+
+ const act = async (fn: () => Promise<{ post?: unknown; error?: string }>, ok: string) => {
+ setBusy(true);
+ try {
+ const res = await fn();
+ if (res.post) {
+ toast(ok, "ok");
+ router.refresh();
+ } else toast(res.error || "操作失败", "error");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+
+ 问答 · {state.solved ? "已解决" : "未解决"}
+
+
+ {state.solved
+ ? state.accepted_comment_id
+ ? `已采纳评论 #${state.accepted_comment_id}`
+ : "作者已标记为已解决"
+ : "可在评论旁采纳答案,或手动标记已解决"}
+
+
+
+ {state.can_solve && (
+ void act(() => apiSolveQuestion(String(postId)), "已标记为已解决")}
+ >
+ 标记已解决
+
+ )}
+ {state.can_reopen && (
+ void act(() => apiReopenQuestion(String(postId)), "已重新打开")}
+ >
+ 重新打开
+
+ )}
+
+
+
+ );
+}
+
+function PollPanel({
+ postId,
+ state,
+ loggedIn,
+}: {
+ postId: number;
+ state: PollState;
+ loggedIn: boolean;
+}) {
+ const router = useRouter();
+ const [sel, setSel] = useState(state.my_options || []);
+ const [busy, setBusy] = useState(false);
+ const showResults = state.closed || (state.my_options && state.my_options.length > 0);
+
+ const toggle = (idx: number) => {
+ if (!state.can_vote) return;
+ if (state.multi) {
+ setSel((prev) =>
+ prev.includes(idx) ? prev.filter((x) => x !== idx) : [...prev, idx]
+ );
+ } else {
+ setSel([idx]);
+ }
+ };
+
+ const vote = async () => {
+ if (sel.length === 0) {
+ toast("请先选择选项", "error");
+ return;
+ }
+ setBusy(true);
+ try {
+ const res = await apiVotePoll(String(postId), sel);
+ if (res.post) {
+ toast("投票成功", "ok");
+ router.refresh();
+ } else toast(res.error || "投票失败", "error");
+ } catch {
+ toast("投票失败", "error");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const close = async () => {
+ setBusy(true);
+ try {
+ const res = await apiClosePoll(String(postId));
+ if (res.post) {
+ toast("已结束投票", "ok");
+ router.refresh();
+ } else toast(res.error || "操作失败", "error");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+ 投票{state.multi ? "(多选)" : "(单选)"}
+ {state.anonymous ? " · 匿名" : " · 实名"}
+
+
+ {state.voters} 人参与 · {state.total_votes} 票
+ {state.closed ? " · 已结束" : ""}
+
+
+
+ {state.options.map((o) => {
+ const checked = sel.includes(o.index);
+ const namedVoters = !state.anonymous && showResults ? o.voters || [] : [];
+ return (
+ -
+ toggle(o.index)}
+ className="w-full text-left rounded-[12px] px-3 py-2.5 relative overflow-hidden transition-colors"
+ style={{
+ border: `1px solid ${checked ? "var(--accent)" : "var(--line)"}`,
+ background: checked ? "var(--accent-soft)" : "var(--panel)",
+ }}
+ >
+ {showResults && (
+
+ )}
+
+
+ {state.can_vote ? (checked ? "✓ " : "") : ""}
+ {o.text}
+
+ {showResults && (
+
+ {o.votes} · {o.percent}%
+
+ )}
+
+
+ {namedVoters.length > 0 && (
+
+ {namedVoters.map((v) => (
+ -
+
+
+ {v.nickname || v.username}
+
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+
+ {state.can_vote && (
+ void vote()}>
+ 提交投票
+
+ )}
+ {!loggedIn && !state.closed && (
+
+ 登录后投票
+
+ )}
+ {state.can_close && (
+ void close()}
+ >
+ 结束投票
+
+ )}
+
+
+ );
+}
+
+function BountyPanel({ postId, state }: { postId: number; state: BountyState }) {
+ const router = useRouter();
+ const [busy, setBusy] = useState(false);
+ const [confirming, setConfirming] = useState(false);
+
+ const refund = async () => {
+ if (!confirming) {
+ setConfirming(true);
+ window.setTimeout(() => setConfirming(false), 3000);
+ return;
+ }
+ setBusy(true);
+ try {
+ const res = await apiRefundBounty(String(postId));
+ if (res.post) {
+ toast("悬赏已退回", "ok");
+ router.refresh();
+ } else toast(res.error || "退回失败", "error");
+ } finally {
+ setBusy(false);
+ setConfirming(false);
+ }
+ };
+
+ const statusText = state.expired
+ ? "已过期,积分已自动退回"
+ : state.refunded
+ ? "已退回给发帖人"
+ : state.accepted_comment_id
+ ? `已采纳评论 #${state.accepted_comment_id}`
+ : state.escrowed
+ ? "积分已托管,采纳优质回答后发放"
+ : "悬赏未托管";
+
+ return (
+
+
+
+
+ 悬赏 {state.points} 积分
+
+
+ {statusText}
+ {state.ends_at && !state.settled ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""}
+
+
+ {state.can_refund && (
+
void refund()}
+ >
+ {confirming ? "再点确认退回" : "退回悬赏"}
+
+ )}
+ {state.can_accept && (
+
在评论旁点击「采纳」发放积分
+ )}
+
+
+ );
+}
+
+function LotteryPanel({
+ postId,
+ state,
+}: {
+ postId: number;
+ state: LotteryState;
+}) {
+ const router = useRouter();
+ const [busy, setBusy] = useState(false);
+
+ const act = async (fn: () => Promise<{ post?: unknown; error?: string }>, ok: string) => {
+ setBusy(true);
+ try {
+ const res = await fn();
+ if (res.post) {
+ toast(ok, "ok");
+ router.refresh();
+ } else toast(res.error || "操作失败", "error");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+
+ 抽奖 · {state.slots} 个名额
+
+
+ 已有 {state.entry_count} 名回帖用户参与
+ {state.drawn ? " · 已开奖" : state.closed ? " · 已截止" : ""}
+ {state.eligible && !state.drawn ? " · 你已在奖池中" : ""}
+ {state.ends_at && !state.drawn ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""}
+
+ {!state.drawn && (
+
对本帖发表评论即可参与(楼主除外)
+ )}
+
+
+ {state.can_close && (
+ void act(() => apiCloseLottery(String(postId)), "已截止")}
+ >
+ 提前截止
+
+ )}
+ {state.can_draw && (
+ void act(() => apiDrawLottery(String(postId)), "开奖完成")}
+ >
+ 开奖
+
+ )}
+
+
+ {state.drawn && (
+
+
+ {state.winners.length > 0 ? "中奖名单" : "无人回帖,开奖结果为空"}
+
+ {state.winners.length > 0 && (
+
+ {state.winners.map((w) => (
+ -
+
+
+ {w.nickname || w.username}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/components/PostRow.tsx b/frontend/components/PostRow.tsx
index 05bc65c..905fda3 100644
--- a/frontend/components/PostRow.tsx
+++ b/frontend/components/PostRow.tsx
@@ -2,6 +2,7 @@ import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import type { PostListItem } from "@/lib/api";
import { formatRelative } from "@/lib/format";
+import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel } from "@/lib/postMeta";
import LikeButton from "./LikeButton";
import Avatar from "./Avatar";
import { IconComment, IconEye } from "./Icons";
@@ -28,6 +29,8 @@ export default function PostRow({
}) {
const lastReply = post.last_reply;
const replied = lastReply && new Date(lastReply.created_at).getTime() > new Date(post.created_at).getTime() + 60_000;
+ const pt = normalizePostType(post.post_type);
+ const statusBadge = pt !== "discussion" ? typeStatusLabel(pt, post.type_status) : "";
return (
+ {/* 仅置顶/推荐在标题左侧 */}
{post.pinned > 0 && 置顶}
{post.recommended && 推荐}
- {post.status === "pending" && 待审}
-
-
+
- {post.title}
-
-
+
+ {post.title}
+
+
+ {/* 其余徽章紧跟标题右侧 */}
+ {post.status === "pending" && 待审}
+ {pt !== "discussion" && pt !== "question" && (
+ {postTypeLabel(post.post_type)}
+ )}
+ {statusBadge && (
+ {statusBadge}
+ )}
+ {post.content_access && post.content_access !== "public" && (
+ {contentAccessLabel(post.content_access)}
+ )}
+
{truncate ?
{label} : label}
diff --git a/frontend/components/TipHost.tsx b/frontend/components/TipHost.tsx
new file mode 100644
index 0000000..c30f41b
--- /dev/null
+++ b/frontend/components/TipHost.tsx
@@ -0,0 +1,135 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+
+type TipState = {
+ text: string;
+ x: number;
+ y: number;
+ place: "top" | "bottom";
+} | null;
+
+/**
+ * 全站悬停即显提示:监听 [data-tip],替代浏览器原生 title 的延迟气泡。
+ * 挂在 root layout 即可,业务侧写 data-tip="文案" 并保留 aria-label。
+ */
+export default function TipHost() {
+ const [tip, setTip] = useState
(null);
+ const tipRef = useRef(null);
+ const activeEl = useRef(null);
+
+ useEffect(() => {
+ const hide = () => {
+ activeEl.current = null;
+ setTip(null);
+ };
+
+ const placeTip = (el: Element, text: string) => {
+ const rect = el.getBoundingClientRect();
+ const preferBottom = rect.top < 48;
+ const x = rect.left + rect.width / 2;
+ const y = preferBottom ? rect.bottom + 8 : rect.top - 8;
+ setTip({
+ text,
+ x,
+ y,
+ place: preferBottom ? "bottom" : "top",
+ });
+ };
+
+ const onOver = (e: Event) => {
+ const t = e.target;
+ if (!(t instanceof Element)) return;
+ const host = t.closest("[data-tip]");
+ if (!host) {
+ if (activeEl.current) hide();
+ return;
+ }
+ const text = host.getAttribute("data-tip")?.trim();
+ if (!text) {
+ hide();
+ return;
+ }
+ activeEl.current = host;
+ placeTip(host, text);
+ };
+
+ const onOut = (e: Event) => {
+ if (!(e instanceof MouseEvent)) return;
+ const related = e.relatedTarget;
+ if (
+ related instanceof Node &&
+ activeEl.current &&
+ activeEl.current.contains(related)
+ ) {
+ return;
+ }
+ hide();
+ };
+
+ const onScrollOrResize = () => {
+ if (!activeEl.current) return;
+ const text = activeEl.current.getAttribute("data-tip")?.trim();
+ if (!text) {
+ hide();
+ return;
+ }
+ placeTip(activeEl.current, text);
+ };
+
+ // mouseover 冒泡,便于 closest;立即显示,无延时
+ document.addEventListener("mouseover", onOver);
+ document.addEventListener("mouseout", onOut);
+ document.addEventListener("focusin", onOver);
+ document.addEventListener("focusout", onOut);
+ window.addEventListener("scroll", onScrollOrResize, true);
+ window.addEventListener("resize", onScrollOrResize);
+
+ return () => {
+ document.removeEventListener("mouseover", onOver);
+ document.removeEventListener("mouseout", onOut);
+ document.removeEventListener("focusin", onOver);
+ document.removeEventListener("focusout", onOut);
+ window.removeEventListener("scroll", onScrollOrResize, true);
+ window.removeEventListener("resize", onScrollOrResize);
+ };
+ }, []);
+
+ // 测量后微调,避免贴边裁切
+ useEffect(() => {
+ if (!tip || !tipRef.current) return;
+ const el = tipRef.current;
+ const w = el.offsetWidth;
+ const pad = 10;
+ let left = tip.x;
+ const half = w / 2;
+ if (left - half < pad) left = pad + half;
+ if (left + half > window.innerWidth - pad) {
+ left = window.innerWidth - pad - half;
+ }
+ el.style.left = `${left}px`;
+ }, [tip]);
+
+ if (!tip) return null;
+
+ return createPortal(
+
+ {tip.text}
+
,
+ document.body
+ );
+}
diff --git a/frontend/components/home/CheckinCard.tsx b/frontend/components/home/CheckinCard.tsx
index a743486..631cf1b 100644
--- a/frontend/components/home/CheckinCard.tsx
+++ b/frontend/components/home/CheckinCard.tsx
@@ -50,8 +50,16 @@ export function PulseCheckin({ initial }: { initial?: CheckinStatus | null }) {
{guest
? "登录后每日签到领积分"
: checked
- ? `连签 ${status?.streak ?? 0} 天 · 累计 ${status?.total_points ?? 0} 积分`
+ ? `连签 ${status?.streak ?? 0} 天 · 余额 ${status?.total_points ?? 0}`
: `连签 ${status?.streak ?? 0} 天 · 今日 +${status?.today_points ?? 5}`}
+ {!guest && (
+ <>
+ {" · "}
+
+ 流水
+
+ >
+ )}
{guest ? (
diff --git a/frontend/components/home/CommunityPanels.tsx b/frontend/components/home/CommunityPanels.tsx
index 8f8f93b..e29dbd5 100644
--- a/frontend/components/home/CommunityPanels.tsx
+++ b/frontend/components/home/CommunityPanels.tsx
@@ -67,7 +67,7 @@ function PulseCard({ stats, checkin }: { stats: OverviewStats; checkin?: Checkin
实时
@@ -124,7 +124,7 @@ function Announcements({ items }: { items: AnnouncementSummary[] }) {
{a.title}
@@ -151,7 +151,7 @@ function HotRanking({ posts }: { posts: PostListItem[] }) {
{posts.map((p, i) => (
-
-
+
{i + 1}
{name}
@@ -232,7 +232,7 @@ function ActiveUsers({ users }: { users: ActiveUser[] }) {
const name = u.nickname || u.username;
return (
-
-
+
) : (
-
+
{l.name}
)
diff --git a/frontend/components/home/WorkspaceNav.tsx b/frontend/components/home/WorkspaceNav.tsx
index 5c0b0ab..c95a4d8 100644
--- a/frontend/components/home/WorkspaceNav.tsx
+++ b/frontend/components/home/WorkspaceNav.tsx
@@ -14,7 +14,11 @@ function boardHref(id: number) {
export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user: User | null }) {
const params = useSearchParams();
const activeBoard = Number(params.get("board")) || 0;
- const composeHref = user ? "/compose" : "/login?redirect=/compose";
+ const composePath =
+ activeBoard > 0 ? `/compose?board=${activeBoard}` : "/compose";
+ const composeHref = user
+ ? composePath
+ : `/login?redirect=${encodeURIComponent(composePath)}`;
return (
@@ -96,7 +100,11 @@ export function WorkspaceNavMobile({
}) {
const params = useSearchParams();
const activeBoard = Number(params.get("board")) || 0;
- const composeHref = user ? "/compose" : "/login?redirect=/compose";
+ const composePath =
+ activeBoard > 0 ? `/compose?board=${activeBoard}` : "/compose";
+ const composeHref = user
+ ? composePath
+ : `/login?redirect=${encodeURIComponent(composePath)}`;
return (
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index a7880b8..3679049 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -141,6 +141,9 @@ export interface PostListItem {
title: string;
tags: string;
post_type: string;
+ type_status?: string;
+ content_access?: string;
+ access_points?: number;
pinned: number;
recommended: boolean;
like_count: number;
@@ -157,6 +160,16 @@ export interface PostListItem {
user: User;
}
+export interface PostAttachment {
+ id: number;
+ name: string;
+ size: number;
+ mime: string;
+ price_points: number;
+ download_count: number;
+ unlocked: boolean;
+}
+
export interface Post {
id: number;
board_id: number;
@@ -165,6 +178,10 @@ export interface Post {
content: string;
tags: string;
post_type: string;
+ content_access?: string;
+ access_points?: number;
+ type_meta?: string;
+ type_status?: string;
pinned: number;
recommended: boolean;
status: string;
@@ -176,6 +193,80 @@ export interface Post {
updated_at: string;
board: Board;
user: User;
+ content_locked?: boolean;
+ access_hint?: string;
+ attachments?: PostAttachment[];
+ question?: QuestionState | null;
+ poll?: PollState | null;
+ bounty?: BountyState | null;
+ lottery?: LotteryState | null;
+}
+
+export interface QuestionState {
+ solved: boolean;
+ accepted_comment_id: number;
+ can_accept: boolean;
+ can_solve: boolean;
+ can_reopen: boolean;
+}
+
+export interface PollVoter {
+ id: number;
+ nickname: string;
+ username: string;
+ avatar: string;
+}
+
+export interface PollOptionStat {
+ index: number;
+ text: string;
+ votes: number;
+ percent: number;
+ voters?: PollVoter[];
+}
+
+export interface PollState {
+ options: PollOptionStat[];
+ multi: boolean;
+ closed: boolean;
+ anonymous: boolean;
+ total_votes: number;
+ voters: number;
+ my_options: number[];
+ can_vote: boolean;
+ can_close: boolean;
+ can_edit_options: boolean;
+}
+
+export interface BountyState {
+ points: number;
+ accepted_comment_id: number;
+ escrowed: boolean;
+ refunded: boolean;
+ expired?: boolean;
+ ends_at?: string;
+ settled: boolean;
+ can_accept: boolean;
+ can_refund: boolean;
+}
+
+export interface LotteryEntrant {
+ id: number;
+ nickname: string;
+ username: string;
+ avatar: string;
+}
+
+export interface LotteryState {
+ slots: number;
+ drawn: boolean;
+ closed: boolean;
+ ends_at?: string;
+ entry_count: number;
+ eligible?: boolean;
+ can_draw: boolean;
+ can_close: boolean;
+ winners: LotteryEntrant[];
}
export interface Comment {
@@ -528,6 +619,10 @@ export async function apiCreatePost(data: {
content: string;
tags?: string;
post_type?: string;
+ content_access?: string;
+ access_points?: number;
+ type_meta?: string;
+ attachment_ids?: number[];
}) {
const res = await fetchWithRefresh("/api/posts", {
method: "POST",
@@ -548,7 +643,15 @@ export async function apiCreateComment(postId: string, content: string, parentId
export async function apiUpdatePost(
postId: string,
- data: { title?: string; content?: string; tags?: string }
+ data: {
+ title?: string;
+ content?: string;
+ tags?: string;
+ content_access?: string;
+ access_points?: number;
+ type_meta?: string;
+ attachment_ids?: number[];
+ }
) {
const res = await fetchWithRefresh(`/api/posts/${postId}`, {
method: "PUT",
@@ -558,6 +661,181 @@ export async function apiUpdatePost(
return res.json();
}
+export async function apiUnlockPost(postId: string) {
+ const res = await fetchWithRefresh(`/api/posts/${postId}/unlock`, {
+ method: "POST",
+ headers: clientHeaders(),
+ });
+ return res.json();
+}
+
+export async function apiGetPoints(): Promise<{ points: number }> {
+ const res = await fetchWithRefresh("/api/points", {
+ method: "GET",
+ headers: clientHeaders(),
+ });
+ return res.json();
+}
+
+export interface PointLedgerItem {
+ id: number;
+ delta: number;
+ balance: number;
+ reason: string;
+ ref_type: string;
+ ref_id: number;
+ note: string;
+ created_at: string;
+}
+
+export async function apiGetPointsLedger(
+ page = 1,
+ size = 20
+): Promise<{ items: PointLedgerItem[]; total: number; page: number; size: number; error?: string }> {
+ const res = await fetchWithRefresh(`/api/points/ledger?page=${page}&size=${size}`, {
+ method: "GET",
+ headers: clientHeaders(),
+ });
+ return res.json();
+}
+
+export interface EconomyStats {
+ total_balance: number;
+ ledger_count: number;
+ checkin_today: number;
+ open_bounties: number;
+ escrowed_points: number;
+ posts_by_type: Record;
+ recent_ledger: PointLedgerItem[];
+}
+
+export async function apiAdminEconomyStats(): Promise {
+ const res = await fetchWithRefresh("/api/admin/economy", {
+ method: "GET",
+ headers: clientHeaders(),
+ });
+ return res.json();
+}
+
+export async function fetchAdminEconomyStats(
+ cookieHeader?: string
+): Promise {
+ const res = await fetch(`${API_BASE}/api/admin/economy`, ssrInit(cookieHeaders(cookieHeader)));
+ if (!res.ok) throw new Error("failed");
+ return res.json();
+}
+
+export async function apiUploadPostFile(
+ file: File,
+ pricePoints = 0
+): Promise<{ attachment?: PostAttachment; error?: string }> {
+ const fd = new FormData();
+ fd.append("file", file);
+ fd.append("price_points", String(pricePoints));
+ const res = await fetchWithRefresh("/api/upload/file", {
+ method: "POST",
+ headers: clientHeaders(),
+ body: fd,
+ });
+ return res.json();
+}
+
+export async function apiDeletePostFile(id: number) {
+ const res = await fetchWithRefresh(`/api/upload/file/${id}`, {
+ method: "DELETE",
+ headers: clientHeaders(),
+ });
+ return res.json();
+}
+
+export async function apiUpdatePostFilePrice(id: number, pricePoints: number) {
+ const res = await fetchWithRefresh(`/api/upload/file/${id}/price`, {
+ method: "PUT",
+ headers: clientHeaders({ "Content-Type": "application/json" }),
+ body: JSON.stringify({ price_points: pricePoints }),
+ });
+ return res.json();
+}
+
+export function postAttachmentDownloadURL(postId: number | string, attId: number) {
+ return `/api/posts/${postId}/attachments/${attId}/download`;
+}
+
+export async function apiVotePoll(postId: string, options: number[]) {
+ const res = await fetchWithRefresh(`/api/posts/${postId}/poll/vote`, {
+ method: "POST",
+ headers: clientHeaders({ "Content-Type": "application/json" }),
+ body: JSON.stringify({ options }),
+ });
+ return res.json();
+}
+
+export async function apiClosePoll(postId: string) {
+ const res = await fetchWithRefresh(`/api/posts/${postId}/poll/close`, {
+ method: "POST",
+ headers: clientHeaders(),
+ });
+ return res.json();
+}
+
+export async function apiAcceptBounty(postId: string, commentId: number) {
+ const res = await fetchWithRefresh(`/api/posts/${postId}/bounty/accept`, {
+ method: "POST",
+ headers: clientHeaders({ "Content-Type": "application/json" }),
+ body: JSON.stringify({ comment_id: commentId }),
+ });
+ return res.json();
+}
+
+export async function apiRefundBounty(postId: string) {
+ const res = await fetchWithRefresh(`/api/posts/${postId}/bounty/refund`, {
+ method: "POST",
+ headers: clientHeaders(),
+ });
+ return res.json();
+}
+
+export async function apiAcceptQuestion(postId: string, commentId: number) {
+ const res = await fetchWithRefresh(`/api/posts/${postId}/question/accept`, {
+ method: "POST",
+ headers: clientHeaders({ "Content-Type": "application/json" }),
+ body: JSON.stringify({ comment_id: commentId }),
+ });
+ return res.json();
+}
+
+export async function apiSolveQuestion(postId: string) {
+ const res = await fetchWithRefresh(`/api/posts/${postId}/question/solve`, {
+ method: "POST",
+ headers: clientHeaders(),
+ });
+ return res.json();
+}
+
+export async function apiReopenQuestion(postId: string) {
+ const res = await fetchWithRefresh(`/api/posts/${postId}/question/reopen`, {
+ method: "POST",
+ headers: clientHeaders(),
+ });
+ return res.json();
+}
+
+export async function apiCloseLottery(postId: string) {
+ const res = await fetchWithRefresh(`/api/posts/${postId}/lottery/close`, {
+ method: "POST",
+ headers: clientHeaders(),
+ });
+ return res.json();
+}
+
+export async function apiDrawLottery(postId: string) {
+ const res = await fetchWithRefresh(`/api/posts/${postId}/lottery/draw`, {
+ method: "POST",
+ headers: clientHeaders(),
+ });
+ return res.json();
+}
+
export async function apiDeletePost(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}`, {
method: "DELETE",
@@ -630,6 +908,25 @@ export async function apiUploadAvatar(
return res.json();
}
+// 上传帖子插图(JPEG / PNG / WebP,≤5MB)
+export async function apiUploadPostImage(
+ file: File | Blob,
+ filename = "image.jpg"
+): Promise<{ url?: string; attachment?: MediaAttachment; error?: string }> {
+ const form = new FormData();
+ const name =
+ file instanceof File && file.name
+ ? file.name
+ : filename;
+ form.append("file", file, name);
+ const res = await fetchWithRefresh("/api/upload/image", {
+ method: "POST",
+ headers: clientHeaders(),
+ body: form,
+ });
+ return res.json();
+}
+
// 选用一张本人历史上传的头像
export async function apiUseAvatar(
url: string
diff --git a/frontend/lib/points.ts b/frontend/lib/points.ts
new file mode 100644
index 0000000..3a77467
--- /dev/null
+++ b/frontend/lib/points.ts
@@ -0,0 +1,14 @@
+/** 积分流水原因文案 */
+const REASON_LABELS: Record = {
+ checkin: "每日签到",
+ unlock_post: "解锁正文",
+ download_file: "附件下载",
+ migrate_checkin: "历史签到回填",
+ bounty_escrow: "悬赏托管",
+ bounty_refund: "悬赏退回",
+ bounty_award: "悬赏采纳",
+};
+
+export function pointReasonLabel(reason: string): string {
+ return REASON_LABELS[reason] || reason || "其他";
+}
diff --git a/frontend/lib/postMeta.ts b/frontend/lib/postMeta.ts
new file mode 100644
index 0000000..a16b17f
--- /dev/null
+++ b/frontend/lib/postMeta.ts
@@ -0,0 +1,61 @@
+/** 帖子类型 / 可见性 / 展示文案(前后端共用约定) */
+
+export const POST_TYPES = [
+ { value: "discussion", label: "讨论", hint: "默认讨论帖" },
+ { value: "question", label: "问答", hint: "提问求解" },
+ { value: "poll", label: "投票", hint: "发起投票,读者可参与" },
+ { value: "bounty", label: "悬赏", hint: "发布时托管积分,采纳后发放" },
+ { value: "lottery", label: "抽奖", hint: "回帖即参与,截止自动开奖" },
+] as const;
+
+export type PostTypeValue = (typeof POST_TYPES)[number]["value"];
+
+export const CONTENT_ACCESS = [
+ { value: "public", label: "公开" },
+ { value: "login", label: "登录可见" },
+ { value: "reply", label: "回复可见" },
+ { value: "points", label: "积分可见" },
+] as const;
+
+export type ContentAccessValue = (typeof CONTENT_ACCESS)[number]["value"];
+
+export const BOUNTY_EXPIRE_DAYS = [3, 7, 14, 30] as const;
+export const LOTTERY_EXPIRE_DAYS = [1, 3, 7, 14] as const;
+
+export function normalizePostType(t?: string): PostTypeValue {
+ if (t === "normal" || !t) return "discussion";
+ if (POST_TYPES.some((x) => x.value === t)) return t as PostTypeValue;
+ return "discussion";
+}
+
+export function postTypeLabel(t?: string): string {
+ const n = normalizePostType(t);
+ return POST_TYPES.find((x) => x.value === n)?.label || "讨论";
+}
+
+export function contentAccessLabel(a?: string): string {
+ const hit = CONTENT_ACCESS.find((x) => x.value === a);
+ return hit?.label || "公开";
+}
+
+/** 列表 type_status → 徽章文案 */
+export function typeStatusLabel(postType?: string, typeStatus?: string): string {
+ const pt = normalizePostType(postType);
+ const s = (typeStatus || "").toLowerCase();
+ switch (pt) {
+ case "question":
+ return s === "solved" ? "已解决" : "未解决";
+ case "poll":
+ return s === "closed" ? "已结束" : "进行中";
+ case "bounty":
+ if (s === "solved") return "已采纳";
+ if (s === "expired") return "已过期";
+ return "进行中";
+ case "lottery":
+ if (s === "drawn") return "已开奖";
+ if (s === "closed") return "已截止";
+ return "进行中";
+ default:
+ return "";
+ }
+}