feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
197
backend/handler/post_interact.go
Normal file
197
backend/handler/post_interact.go
Normal file
@@ -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})
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
231
backend/service/points.go
Normal file
231
backend/service/points.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
283
backend/service/post_file.go
Normal file
283
backend/service/post_file.go
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
1082
backend/service/post_interact.go
Normal file
1082
backend/service/post_interact.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: "公告管理",
|
||||
|
||||
@@ -191,7 +191,7 @@ export default function AnnouncementAdmin() {
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
title={c.label}
|
||||
data-tip={c.label}
|
||||
aria-label={c.label}
|
||||
aria-pressed={form.tag_color === c.value}
|
||||
className={`j13-anno-dot is-${c.value}`}
|
||||
@@ -322,7 +322,7 @@ export default function AnnouncementAdmin() {
|
||||
<p
|
||||
className="mt-1 truncate text-[14px] font-semibold"
|
||||
style={{ color: "var(--ink)" }}
|
||||
title={a.title}
|
||||
data-tip={a.title}
|
||||
>
|
||||
{a.title}
|
||||
</p>
|
||||
@@ -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="查看公告"
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
@@ -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="编辑公告"
|
||||
>
|
||||
<Pencil size={15} />
|
||||
@@ -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 ? <Loader2 size={15} className="animate-spin" /> : <Trash2 size={15} />}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<span className="relative flex items-center justify-center">
|
||||
{/* 上半圆=浅色模式,下半圆=暗色模式 */}
|
||||
@@ -163,7 +163,7 @@ export default function AppearanceAdmin({ initialAccent }: { initialAccent: stri
|
||||
<label
|
||||
className="relative w-12 h-12 rounded-[var(--r-sm)] overflow-hidden border cursor-pointer shrink-0"
|
||||
style={{ borderColor: "var(--line-2)" }}
|
||||
title="打开取色器"
|
||||
data-tip="打开取色器"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
|
||||
176
frontend/app/admin/economy/page.tsx
Normal file
176
frontend/app/admin/economy/page.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { cookies } from "next/headers";
|
||||
import { Coins, Flame, Gift, Layers } from "lucide-react";
|
||||
import { fetchAdminEconomyStats } from "@/lib/api";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import { postTypeLabel } from "@/lib/postMeta";
|
||||
import { pointReasonLabel } from "@/lib/points";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import Forbidden from "../Forbidden";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "积分看板",
|
||||
};
|
||||
|
||||
const fmt = (n: number) => n.toLocaleString("zh-CN");
|
||||
|
||||
export default async function AdminEconomyPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||
return <Forbidden text="积分看板仅管理员及以上可查看。" />;
|
||||
}
|
||||
|
||||
let stats: Awaited<ReturnType<typeof fetchAdminEconomyStats>> | null = null;
|
||||
try {
|
||||
stats = await fetchAdminEconomyStats(cookie);
|
||||
} catch {
|
||||
stats = null;
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="panel p-8 text-center">
|
||||
<p className="meta">暂时无法加载积分数据</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{
|
||||
label: "用户余额合计",
|
||||
value: fmt(stats.total_balance),
|
||||
icon: <Coins size={18} />,
|
||||
hint: "全站可用积分之和",
|
||||
},
|
||||
{
|
||||
label: "今日签到",
|
||||
value: fmt(stats.checkin_today),
|
||||
icon: <Flame size={18} />,
|
||||
hint: "今日已签到人数",
|
||||
},
|
||||
{
|
||||
label: "托管中悬赏",
|
||||
value: `${fmt(stats.open_bounties)} / ${fmt(stats.escrowed_points)}`,
|
||||
icon: <Gift size={18} />,
|
||||
hint: "未结算帖数 / 托管积分",
|
||||
},
|
||||
{
|
||||
label: "流水条数",
|
||||
value: fmt(stats.ledger_count),
|
||||
icon: <Layers size={18} />,
|
||||
hint: "历史入账出账记录",
|
||||
},
|
||||
];
|
||||
|
||||
const typeEntries = Object.entries(stats.posts_by_type || {}).sort(
|
||||
(a, b) => b[1] - a[1]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-[22px] font-extrabold" style={{ color: "var(--ink)" }}>
|
||||
积分与类型帖看板
|
||||
</h1>
|
||||
<p className="meta text-[13px] mt-1">
|
||||
观察全站积分流通、悬赏托管与帖子类型分布
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
|
||||
{cards.map((c) => (
|
||||
<div key={c.label} className="panel p-5">
|
||||
<div className="flex items-center gap-2 meta text-[12.5px]">
|
||||
<span style={{ color: "var(--accent)" }}>{c.icon}</span>
|
||||
{c.label}
|
||||
</div>
|
||||
<p
|
||||
className="mt-3 text-[22px] font-extrabold tabular-nums leading-none"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{c.value}
|
||||
</p>
|
||||
<p className="meta text-[12px] mt-2">{c.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<section className="panel overflow-hidden">
|
||||
<div
|
||||
className="px-5 py-3.5 font-bold text-[15px]"
|
||||
style={{ borderBottom: "1px solid var(--line)", color: "var(--ink)" }}
|
||||
>
|
||||
帖子类型分布
|
||||
</div>
|
||||
{typeEntries.length === 0 ? (
|
||||
<p className="meta text-center py-10">暂无数据</p>
|
||||
) : (
|
||||
<ul>
|
||||
{typeEntries.map(([t, n]) => (
|
||||
<li
|
||||
key={t}
|
||||
className="px-5 py-3 flex items-center justify-between"
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
>
|
||||
<span className="font-semibold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
{postTypeLabel(t)}
|
||||
</span>
|
||||
<span className="tabular-nums meta">{fmt(n)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel overflow-hidden">
|
||||
<div
|
||||
className="px-5 py-3.5 font-bold text-[15px]"
|
||||
style={{ borderBottom: "1px solid var(--line)", color: "var(--ink)" }}
|
||||
>
|
||||
最近流水
|
||||
</div>
|
||||
{(stats.recent_ledger || []).length === 0 ? (
|
||||
<p className="meta text-center py-10">暂无流水</p>
|
||||
) : (
|
||||
<ul>
|
||||
{stats.recent_ledger.map((it) => (
|
||||
<li
|
||||
key={it.id}
|
||||
className="px-5 py-3 flex items-start justify-between gap-3"
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
|
||||
{pointReasonLabel(it.reason)}
|
||||
{it.note ? ` · ${it.note}` : ""}
|
||||
</p>
|
||||
<p className="meta text-[12px] mt-0.5">{formatRelative(it.created_at)}</p>
|
||||
</div>
|
||||
<span
|
||||
className="tabular-nums font-bold shrink-0"
|
||||
style={{ color: it.delta >= 0 ? "var(--accent)" : "var(--danger)" }}
|
||||
>
|
||||
{it.delta >= 0 ? `+${it.delta}` : it.delta}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p className="meta text-[12.5px] mt-5">
|
||||
用户可在{" "}
|
||||
<Link href="/points" className="hover:text-[var(--accent)]">
|
||||
/points
|
||||
</Link>{" "}
|
||||
查看个人流水。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
|
||||
import { canModerateAny, isSuperOrOwner, isAdminOrAbove } from "@/lib/roles";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
|
||||
@@ -127,6 +127,7 @@ export default async function AdminDashboardPage() {
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
const canMod = canModerateAny(me.user);
|
||||
const canUsers = isSuperOrOwner(me.user?.role);
|
||||
const canEconomy = isAdminOrAbove(me.user?.role);
|
||||
|
||||
const [overview, pendingCounts] = await Promise.all([
|
||||
fetchOverview(cookie).catch(() => null),
|
||||
@@ -163,6 +164,21 @@ export default async function AdminDashboardPage() {
|
||||
|
||||
{canMod && pendingCounts && <PendingWorkPanel counts={pendingCounts} />}
|
||||
|
||||
{canEconomy && (
|
||||
<Link
|
||||
href="/admin/economy"
|
||||
className="panel mb-4 px-4 sm:px-5 py-4 flex items-center justify-between gap-3 transition-colors hover:bg-[var(--panel-2)]"
|
||||
>
|
||||
<div>
|
||||
<p className="font-bold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
积分与类型帖看板
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-0.5">余额合计、悬赏托管、类型分布与最近流水</p>
|
||||
</div>
|
||||
<ChevronRight size={18} style={{ color: "var(--ink-3)" }} />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="grid lg:grid-cols-2 gap-4 items-start">
|
||||
<div className="panel overflow-hidden">
|
||||
<PanelHead
|
||||
|
||||
@@ -281,7 +281,7 @@ export default function UsersAdmin({
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-[12.5px] font-bold"
|
||||
style={{ color: "var(--danger)" }}
|
||||
title="账号已封禁"
|
||||
data-tip="账号已封禁"
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: "var(--danger)" }} aria-hidden />
|
||||
封禁
|
||||
@@ -293,7 +293,7 @@ export default function UsersAdmin({
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-[12.5px] font-bold"
|
||||
style={{ color: "var(--ok)" }}
|
||||
title={u.last_seen_at ? `最近活跃 ${formatRelative(u.last_seen_at)}` : "在线"}
|
||||
data-tip={u.last_seen_at ? `最近活跃 ${formatRelative(u.last_seen_at)}` : "在线"}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: "var(--ok)" }} aria-hidden />
|
||||
在线
|
||||
@@ -304,7 +304,7 @@ export default function UsersAdmin({
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
title={u.last_seen_at ? `最近活跃 ${formatRelative(u.last_seen_at)}` : "从未活跃"}
|
||||
data-tip={u.last_seen_at ? `最近活跃 ${formatRelative(u.last_seen_at)}` : "从未活跃"}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
@@ -321,7 +321,7 @@ export default function UsersAdmin({
|
||||
type="button"
|
||||
onClick={() => setLogsTarget(u)}
|
||||
className="min-w-0 text-left group rounded-lg -mx-1.5 px-1.5 py-1 transition-colors hover:bg-[var(--panel-2)]"
|
||||
title="查看登录历史"
|
||||
data-tip="查看登录历史"
|
||||
aria-label={`查看 ${u.username} 的登录历史`}
|
||||
>
|
||||
<span
|
||||
@@ -341,7 +341,7 @@ export default function UsersAdmin({
|
||||
const auditBtn = canAudit ? (
|
||||
<Link
|
||||
href={`/admin/users/${u.id}`}
|
||||
title="内容档案"
|
||||
data-tip="内容档案"
|
||||
aria-label={`查看 ${u.username} 的内容档案`}
|
||||
className={ghostBtn}
|
||||
style={{ color: "var(--accent)" }}
|
||||
@@ -357,7 +357,7 @@ export default function UsersAdmin({
|
||||
{auditBtn}
|
||||
<Link
|
||||
href={`/u/${u.id}`}
|
||||
title="查看个人主页"
|
||||
data-tip="查看个人主页"
|
||||
className={ghostBtn}
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
@@ -385,7 +385,7 @@ export default function UsersAdmin({
|
||||
{auditBtn}
|
||||
<button
|
||||
type="button"
|
||||
title="登录历史"
|
||||
data-tip="登录历史"
|
||||
aria-label={`查看 ${u.username} 的登录历史`}
|
||||
onClick={() => setLogsTarget(u)}
|
||||
className={ghostBtn}
|
||||
@@ -401,7 +401,7 @@ export default function UsersAdmin({
|
||||
</button>
|
||||
<Link
|
||||
href={`/u/${u.id}`}
|
||||
title="查看个人主页"
|
||||
data-tip="查看个人主页"
|
||||
aria-label={`查看 ${u.nickname} 的个人主页`}
|
||||
className={ghostBtn}
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
@@ -410,7 +410,7 @@ export default function UsersAdmin({
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
title="角色与账号"
|
||||
data-tip="角色与账号"
|
||||
aria-label={`设置 ${u.username} 的角色与账号状态`}
|
||||
disabled={busy}
|
||||
onClick={() => setRoleTarget(u)}
|
||||
@@ -653,7 +653,7 @@ export default function UsersAdmin({
|
||||
<p
|
||||
className="text-[12.5px] font-semibold tabular-nums"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
title="仅统计已发布内容;驳回/删除见内容档案"
|
||||
data-tip="仅统计已发布内容;驳回/删除见内容档案"
|
||||
>
|
||||
帖 {u.post_count} · 评 {u.comment_count}
|
||||
</p>
|
||||
@@ -695,7 +695,7 @@ export default function UsersAdmin({
|
||||
<p
|
||||
className="mt-2.5 text-[11.5px] tabular-nums"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
title="仅统计已发布内容;驳回/删除见内容档案"
|
||||
data-tip="仅统计已发布内容;驳回/删除见内容档案"
|
||||
>
|
||||
帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)}
|
||||
</p>
|
||||
@@ -1195,7 +1195,7 @@ function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClos
|
||||
<span
|
||||
className="mt-1 w-2 h-2 rounded-full shrink-0"
|
||||
style={{ background: log.success ? "var(--ok)" : "var(--danger)" }}
|
||||
title={log.success ? "成功" : "失败"}
|
||||
data-tip={log.success ? "成功" : "失败"}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -1212,7 +1212,7 @@ function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClos
|
||||
{log.success ? "成功" : "失败"}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-[11px] mt-0.5 truncate" style={{ color: "var(--ink-3)" }} title={log.user_agent}>
|
||||
<p className="text-[11px] mt-0.5 truncate" style={{ color: "var(--ink-3)" }} data-tip={log.user_agent}>
|
||||
{log.user_agent || "未知设备"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,7 @@ export default async function BoardPage({ params, searchParams }: PageProps) {
|
||||
<p className="meta mt-1.5">{postsData.total} 篇讨论</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/compose" className="btn btn-primary">
|
||||
<Link href={`/compose?board=${id}`} className="btn btn-primary">
|
||||
<PenSquare size={15} /> 在此板块发帖
|
||||
</Link>
|
||||
</div>
|
||||
@@ -117,7 +117,7 @@ export default async function BoardPage({ params, searchParams }: PageProps) {
|
||||
</span>
|
||||
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>该板块还没有帖子</p>
|
||||
<p className="meta mt-1.5">来发第一篇,抢占沙发</p>
|
||||
<Link href="/compose" className="btn btn-primary mt-6">
|
||||
<Link href={`/compose?board=${id}`} className="btn btn-primary mt-6">
|
||||
<PenSquare size={15} /> 写新帖
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -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="新建群聊"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
@@ -480,7 +480,7 @@ export default function ChatShell({
|
||||
background: "var(--accent-soft)",
|
||||
color: "var(--accent)",
|
||||
}}
|
||||
title={room.is_default ? "强制置顶" : "置顶"}
|
||||
data-tip={room.is_default ? "强制置顶" : "置顶"}
|
||||
>
|
||||
<Pin size={10} /> 置顶
|
||||
</span>
|
||||
|
||||
@@ -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}
|
||||
</Link>
|
||||
@@ -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="成员"
|
||||
>
|
||||
<Users size={16} />
|
||||
</button>
|
||||
@@ -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="邀请成员"
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
</button>
|
||||
@@ -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="群设置"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
@@ -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 ? "关闭私聊" : "退出群聊"}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
|
||||
18
frontend/app/compose/layout.tsx
Normal file
18
frontend/app/compose/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 发帖路由布局:标记 data-compose-viewport,配合 globals.css
|
||||
* 让舞台占满 main 剩余高度(对齐私信体量,保留页脚)。
|
||||
*/
|
||||
export default function ComposeLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-compose-viewport
|
||||
className="flex flex-col flex-1 min-h-0 w-full max-w-6xl mx-auto"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Board[]>([]);
|
||||
const [boardId, setBoardId] = useState<number>(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 (
|
||||
<div className="j13-compose-stage px-8 py-16 text-center">
|
||||
<p className="meta">正在准备发帖…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PostComposer mode="create" initialBoardId={initialBoardId} />;
|
||||
}
|
||||
|
||||
export default function ComposePage() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
|
||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
|
||||
<div className="j13-compose-page">
|
||||
<nav className="meta mb-4 flex items-center gap-1.5 text-[13px] shrink-0">
|
||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">
|
||||
首页
|
||||
</Link>
|
||||
<ChevronRight size={13} />
|
||||
<span style={{ color: "var(--accent)" }}>发布新帖</span>
|
||||
<span style={{ color: "var(--ink-2)" }}>发布新帖</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-7 flex items-center gap-3.5">
|
||||
<span
|
||||
className="w-12 h-12 rounded-2xl flex items-center justify-center shrink-0"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<PenSquare size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-[26px] sm:text-[30px] font-extrabold tracking-tight leading-tight" style={{ color: "var(--ink)" }}>
|
||||
发布新帖
|
||||
</h1>
|
||||
<p className="meta mt-0.5">清晰的标题和内容,更容易获得回应</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="panel p-5 sm:p-7 space-y-5">
|
||||
{error && <div className="alert-error">{error}</div>}
|
||||
|
||||
<div>
|
||||
<label className="field-label">选择板块</label>
|
||||
<select
|
||||
value={boardId}
|
||||
onChange={(e) => setBoardId(Number(e.target.value))}
|
||||
className="field"
|
||||
>
|
||||
{boards.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label">标题</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
maxLength={256}
|
||||
placeholder="一句话说清楚你要讨论什么"
|
||||
className="field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label">标签(逗号分隔,可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="Go, PostgreSQL, 架构"
|
||||
className="field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label">正文(支持 Markdown)</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
required
|
||||
rows={14}
|
||||
placeholder="# 开头写结论 支持 Markdown 语法..."
|
||||
className="field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !title.trim() || !content.trim()}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? "发布中..." : "发布帖子"}
|
||||
</button>
|
||||
<Link href="/" className="btn btn-line">
|
||||
取消
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="j13-compose-stage px-8 py-16 text-center">
|
||||
<p className="meta">正在准备发帖…</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ComposeInner />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
</main>
|
||||
<Footer />
|
||||
<Toaster />
|
||||
<TipHost />
|
||||
<AccountGuard />
|
||||
<ThemeSync initialAccent={accent || ""} />
|
||||
<RealtimeProvider userId={user ? user.id : null} />
|
||||
|
||||
130
frontend/app/points/PointsClient.tsx
Normal file
130
frontend/app/points/PointsClient.tsx
Normal file
@@ -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<number | null>(null);
|
||||
const [items, setItems] = useState<PointLedgerItem[]>([]);
|
||||
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 (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<nav className="meta mb-5 flex items-center gap-1.5 text-[13px]">
|
||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">
|
||||
首页
|
||||
</Link>
|
||||
<ChevronRight size={13} />
|
||||
<span style={{ color: "var(--ink-2)" }}>我的积分</span>
|
||||
</nav>
|
||||
|
||||
<section className="panel p-6 sm:p-8 mb-5">
|
||||
<p className="meta text-[13px]">可用余额</p>
|
||||
<p
|
||||
className="mt-2 text-[36px] font-extrabold tabular-nums leading-none"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
{balance === null ? "—" : balance.toLocaleString("zh-CN")}
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-3">
|
||||
签到、悬赏采纳与附件收益会增加余额;解锁正文、悬赏托管与积分附件会扣减。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="panel overflow-hidden">
|
||||
<div
|
||||
className="px-5 py-3.5 flex items-center justify-between"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<h1 className="font-extrabold text-[16px]" style={{ color: "var(--ink)" }}>
|
||||
积分流水
|
||||
</h1>
|
||||
<span className="meta text-[12px] tabular-nums">{total} 条</span>
|
||||
</div>
|
||||
|
||||
{loading && items.length === 0 ? (
|
||||
<p className="meta text-center py-12">加载中…</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="meta text-center py-12">暂无流水,去首页签到赚第一笔积分吧</p>
|
||||
) : (
|
||||
<ul>
|
||||
{items.map((it) => (
|
||||
<li
|
||||
key={it.id}
|
||||
className="px-5 py-3.5 flex items-start justify-between gap-4"
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
{pointReasonLabel(it.reason)}
|
||||
</p>
|
||||
{it.note && (
|
||||
<p className="meta text-[12.5px] mt-0.5 truncate">{it.note}</p>
|
||||
)}
|
||||
<p className="meta text-[12px] mt-1 tabular-nums">
|
||||
{formatRelative(it.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<p
|
||||
className="font-extrabold tabular-nums text-[15px]"
|
||||
style={{ color: it.delta >= 0 ? "var(--accent)" : "var(--danger)" }}
|
||||
>
|
||||
{it.delta >= 0 ? `+${it.delta}` : it.delta}
|
||||
</p>
|
||||
<p className="meta text-[12px] tabular-nums mt-0.5">
|
||||
余 {it.balance}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{total > size && (
|
||||
<div className="px-5 py-4" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<Pagination
|
||||
total={total}
|
||||
page={page}
|
||||
size={size}
|
||||
basePath="/points"
|
||||
onChange={(p) => void load(p)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
frontend/app/points/page.tsx
Normal file
20
frontend/app/points/page.tsx
Normal file
@@ -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 <PointsClient />;
|
||||
}
|
||||
56
frontend/app/post/[id]/edit/EditPostClient.tsx
Normal file
56
frontend/app/post/[id]/edit/EditPostClient.tsx
Normal file
@@ -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 (
|
||||
<PostComposer
|
||||
mode="edit"
|
||||
postId={postId}
|
||||
initialTitle={initialTitle}
|
||||
initialContent={initialContent}
|
||||
initialTags={initialTags}
|
||||
initialPostType={initialPostType}
|
||||
initialContentAccess={initialContentAccess}
|
||||
initialAccessPoints={initialAccessPoints}
|
||||
initialTypeMeta={initialTypeMeta}
|
||||
initialAttachments={initialAttachments}
|
||||
canEditPollOptions={canEditPollOptions}
|
||||
boardName={boardName}
|
||||
boardId={boardId}
|
||||
boardColorIndex={boardColorIndex}
|
||||
cancelHref={`/post/${postId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<Post | null>(null);
|
||||
const [user, setUser] = useState<User | null>(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 (
|
||||
<div className="max-w-3xl mx-auto text-center py-20">
|
||||
<p className="meta">正在加载帖子…</p>
|
||||
</div>
|
||||
);
|
||||
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<ReturnType<typeof fetchPostDetail>>["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 (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<nav className="meta mb-5 flex items-center gap-1.5 flex-wrap text-[13px]">
|
||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
|
||||
<div
|
||||
data-compose-viewport
|
||||
className="j13-compose-page w-full max-w-6xl mx-auto"
|
||||
>
|
||||
<nav className="meta mb-4 flex items-center gap-1.5 flex-wrap text-[13px] shrink-0">
|
||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">
|
||||
首页
|
||||
</Link>
|
||||
<ChevronRight size={13} />
|
||||
<Link href={`/post/${post.id}`} className="hover:text-[var(--accent)] transition-colors truncate max-w-[12rem]">
|
||||
<Link
|
||||
href={`/post/${post.id}`}
|
||||
className="hover:text-[var(--accent)] transition-colors truncate max-w-[12rem]"
|
||||
>
|
||||
帖子详情
|
||||
</Link>
|
||||
<ChevronRight size={13} />
|
||||
<span style={{ color: "var(--accent)" }}>编辑</span>
|
||||
<span style={{ color: "var(--ink-2)" }}>编辑</span>
|
||||
</nav>
|
||||
|
||||
<div className="mb-7">
|
||||
<h1 className="text-[26px] sm:text-[30px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
|
||||
编辑帖子
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="panel p-5 sm:p-7 space-y-5">
|
||||
{error && <div className="alert-error">{error}</div>}
|
||||
|
||||
<div>
|
||||
<label className="field-label">标题</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
maxLength={256}
|
||||
className="field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label">标签(逗号分隔,可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="Go, PostgreSQL, 架构"
|
||||
className="field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label">正文(支持 Markdown)</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
required
|
||||
rows={14}
|
||||
className="field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !title.trim() || !content.trim()}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? "保存中..." : "保存修改"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/post/${post.id}`)}
|
||||
className="btn btn-line"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<EditPostClient
|
||||
postId={post.id}
|
||||
initialTitle={post.title}
|
||||
initialContent={post.content}
|
||||
initialTags={post.tags || ""}
|
||||
initialPostType={post.post_type}
|
||||
initialContentAccess={post.content_access}
|
||||
initialAccessPoints={post.access_points}
|
||||
initialTypeMeta={post.type_meta}
|
||||
initialAttachments={post.attachments || []}
|
||||
canEditPollOptions={post.poll?.can_edit_options !== false}
|
||||
boardName={post.board?.name || "未知板块"}
|
||||
boardId={post.board_id}
|
||||
boardColorIndex={post.board?.color_index ?? 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Metadata>
|
||||
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[] }) {
|
||||
<li key={p.id}>
|
||||
<Link
|
||||
href={`/post/${p.id}`}
|
||||
title={p.title}
|
||||
data-tip={p.title}
|
||||
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
|
||||
>
|
||||
<span
|
||||
@@ -236,7 +243,7 @@ function RelatedPosts({
|
||||
<ol>
|
||||
{posts.map((p, i) => (
|
||||
<li key={p.id}>
|
||||
<Link href={`/post/${p.id}`} className="j13-rank" title={p.title}>
|
||||
<Link href={`/post/${p.id}`} className="j13-rank" data-tip={p.title}>
|
||||
<span className="j13-rank-no">{i + 1}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
@@ -333,20 +340,35 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
const related = (relatedData?.posts ?? []).filter((p) => 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
|
||||
<div className="panel p-6 sm:p-8">
|
||||
<header className="mb-7">
|
||||
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||
{post.pinned > 0 && <span className="badge-pin">置顶</span>}
|
||||
{post.recommended && <span className="badge-rec">推荐</span>}
|
||||
<Link href={`/board/${post.board.id}`} className="chip">
|
||||
{post.board.name}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 flex-wrap mb-6">
|
||||
{/* 仅置顶/推荐在标题左侧 */}
|
||||
{post.pinned > 0 && <span className="badge-pin shrink-0 mt-2 sm:mt-3">置顶</span>}
|
||||
{post.recommended && <span className="badge-rec shrink-0 mt-2 sm:mt-3">推荐</span>}
|
||||
<h1
|
||||
className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight min-w-0"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{post.title}
|
||||
</h1>
|
||||
{/* 其余徽章紧跟标题右侧;问答不显示类型徽章 */}
|
||||
{postType !== "discussion" && postType !== "question" && (
|
||||
<span className="badge-type shrink-0 mt-2 sm:mt-3">{postTypeLabel(postType)}</span>
|
||||
)}
|
||||
{typeStatus && (
|
||||
<span className="badge-access shrink-0 mt-2 sm:mt-3">{typeStatus}</span>
|
||||
)}
|
||||
{post.content_access && post.content_access !== "public" && (
|
||||
<span className="badge-access shrink-0 mt-2 sm:mt-3">
|
||||
{contentAccessLabel(post.content_access)}
|
||||
</span>
|
||||
)}
|
||||
{!isPublished && canSeeStatus && post.status === "pending" && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] font-bold"
|
||||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] font-bold shrink-0 mt-2 sm:mt-3"
|
||||
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
|
||||
>
|
||||
等待审核
|
||||
@@ -421,19 +467,13 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
)}
|
||||
{!isPublished && canSeeStatus && post.status === "rejected" && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] font-bold"
|
||||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] font-bold shrink-0 mt-2 sm:mt-3"
|
||||
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
|
||||
>
|
||||
未通过审核
|
||||
</span>
|
||||
)}
|
||||
<Link href={`/board/${post.board.id}`} className="chip">
|
||||
{post.board.name}
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight mb-6" style={{ color: "var(--ink)" }}>
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
{/* 作者行:头像 + 昵称居左,点赞/更多操作居右 */}
|
||||
<div
|
||||
@@ -487,11 +527,43 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prose-content">
|
||||
<ReactMarkdown components={{ h2: headingRenderer("h2"), h3: headingRenderer("h3") }}>
|
||||
{post.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<PostInteractPanels
|
||||
postId={post.id}
|
||||
question={
|
||||
postType === "question"
|
||||
? post.question ?? {
|
||||
solved: false,
|
||||
accepted_comment_id: 0,
|
||||
can_accept: canManageTyped,
|
||||
can_solve: canManageTyped,
|
||||
can_reopen: false,
|
||||
}
|
||||
: post.question
|
||||
}
|
||||
poll={post.poll}
|
||||
bounty={post.bounty}
|
||||
lottery={post.lottery}
|
||||
loggedIn={!!me.user}
|
||||
/>
|
||||
|
||||
{post.content_locked ? (
|
||||
<ContentLockGate
|
||||
postId={post.id}
|
||||
hint={post.access_hint}
|
||||
access={post.content_access}
|
||||
points={post.access_points}
|
||||
loggedIn={!!me.user}
|
||||
/>
|
||||
) : (
|
||||
<MarkdownBody
|
||||
content={post.content}
|
||||
components={{ h2: headingRenderer("h2"), h3: headingRenderer("h3") }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!post.content_locked && (
|
||||
<PostAttachments postId={post.id} items={post.attachments || []} />
|
||||
)}
|
||||
|
||||
{/* 文末点赞:分隔线后居中,唯一的点赞入口(作者行不再放置) */}
|
||||
<div
|
||||
@@ -519,12 +591,19 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
<CommentSection
|
||||
postId={id}
|
||||
boardId={post.board_id}
|
||||
authorId={post.user.id}
|
||||
comments={comments}
|
||||
total={commentsData.total}
|
||||
totalComments={commentsData.total_comments}
|
||||
page={commentsData.page}
|
||||
size={commentsData.size}
|
||||
user={me.user}
|
||||
acceptMode={acceptMode}
|
||||
acceptedCommentId={
|
||||
post.question?.accepted_comment_id ||
|
||||
post.bounty?.accepted_comment_id ||
|
||||
0
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<section className="panel p-6 sm:p-8 mt-6 text-center" aria-label="评论区">
|
||||
|
||||
@@ -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)
|
||||
<section className="j13-module">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="j13-module-title">
|
||||
<Flame size={15} style={{ color: "var(--clay)" }} /> 签到
|
||||
<Flame size={15} style={{ color: "var(--clay)" }} /> 积分
|
||||
</p>
|
||||
{data.stats.streak > 0 && (
|
||||
<span
|
||||
@@ -376,7 +376,16 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
>
|
||||
{data.stats.points}
|
||||
</p>
|
||||
<p className="meta text-[11px] mt-1.5">累计积分</p>
|
||||
<p className="meta text-[11px] mt-1.5">可用余额</p>
|
||||
{isOwner && (
|
||||
<Link
|
||||
href="/points"
|
||||
className="inline-flex mt-3 text-[12.5px] font-semibold hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
>
|
||||
查看流水 →
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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="更换头像"
|
||||
>
|
||||
<Avatar name={name} src={current || undefined} size={size} />
|
||||
{/* hover/focus 遮罩:与头像严格重合(按钮宽高固定 = size) */}
|
||||
|
||||
192
frontend/components/BoardPicker.tsx
Normal file
192
frontend/components/BoardPicker.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="j13-board-picker" ref={rootRef}>
|
||||
<button
|
||||
id={id}
|
||||
type="button"
|
||||
className="j13-board-trigger"
|
||||
disabled={disabled || loading || boards.length === 0}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={onTriggerKeyDown}
|
||||
>
|
||||
<span className={`j13-board-trigger-ico av-${colorIdx}`} aria-hidden>
|
||||
{(selected?.name || "板").slice(0, 1)}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate font-semibold text-[14px]">
|
||||
{label}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="shrink-0 opacity-60"
|
||||
style={{
|
||||
color: "var(--ink-3)",
|
||||
transform: open ? "rotate(180deg)" : undefined,
|
||||
transition: "transform 0.15s ease",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-label="选择板块"
|
||||
className="j13-board-menu"
|
||||
tabIndex={-1}
|
||||
onKeyDown={onMenuKeyDown}
|
||||
>
|
||||
{boards.map((b, i) => {
|
||||
const selectedHere = b.id === value;
|
||||
const c = boardColorIndex(b, i);
|
||||
return (
|
||||
<li key={b.id} role="presentation">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selectedHere}
|
||||
className={`j13-board-option${
|
||||
selectedHere ? " is-selected" : ""
|
||||
}${i === activeIndex ? " is-active" : ""}`}
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
onClick={() => pick(b.id)}
|
||||
>
|
||||
<span className={`j13-board-trigger-ico av-${c}`} aria-hidden>
|
||||
{b.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate">{b.name}</span>
|
||||
{selectedHere && <Check size={15} className="shrink-0" />}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 编辑态只读板块徽章(与触发器同高) */
|
||||
export function BoardBadge({
|
||||
name,
|
||||
colorIndex = 0,
|
||||
}: {
|
||||
name: string;
|
||||
colorIndex?: number;
|
||||
}) {
|
||||
const c = Math.max(0, colorIndex) % 8;
|
||||
return (
|
||||
<div className="j13-board-badge">
|
||||
<span className={`j13-board-trigger-ico av-${c}`} aria-hidden>
|
||||
{name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<number | null>(null);
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||
const [acceptingId, setAcceptingId] = useState<number | null>(null);
|
||||
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
|
||||
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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="回复"
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
)}
|
||||
{acceptedCommentId === c.id && (
|
||||
<span className="badge-type !text-[10px] !py-0.5 !px-1.5">已采纳</span>
|
||||
)}
|
||||
{canAccept(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-reply-btn !text-[var(--accent)] font-semibold"
|
||||
disabled={acceptingId === c.id}
|
||||
onClick={() => void handleAccept(c.id, c.user.id)}
|
||||
data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"}
|
||||
>
|
||||
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canDelete(c) && (
|
||||
<button
|
||||
@@ -261,7 +311,7 @@ export default function CommentSection({
|
||||
confirmingId === c.id ? "!text-[var(--danger)] font-semibold" : "hover:text-[var(--danger)]"
|
||||
}`}
|
||||
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
||||
title={confirmingId === c.id ? "再点一次确认删除" : "删除"}
|
||||
data-tip={confirmingId === c.id ? "再点一次确认删除" : "删除"}
|
||||
>
|
||||
{confirmingId === c.id ? (
|
||||
<span className="text-[11.5px] whitespace-nowrap">
|
||||
@@ -325,11 +375,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="回复"
|
||||
>
|
||||
回复
|
||||
</button>
|
||||
)}
|
||||
{acceptedCommentId === c.id && (
|
||||
<span className="badge-type !text-[10px] !py-0.5 !px-1.5">已采纳</span>
|
||||
)}
|
||||
{canAccept(c) && (
|
||||
<button
|
||||
type="button"
|
||||
className="cmt-reply-btn !text-[var(--accent)] font-semibold"
|
||||
disabled={acceptingId === c.id}
|
||||
onClick={() => void handleAccept(c.id, c.user.id)}
|
||||
data-tip={acceptMode === "question" ? "采纳为最佳答案" : "采纳并发放悬赏积分"}
|
||||
>
|
||||
{acceptingId === c.id ? "采纳中…" : acceptMode === "question" ? "采纳答案" : "采纳"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canDelete(c) && (
|
||||
<button
|
||||
@@ -339,7 +403,7 @@ export default function CommentSection({
|
||||
confirmingId === c.id ? "!text-[var(--danger)] font-semibold" : "hover:text-[var(--danger)]"
|
||||
}`}
|
||||
aria-label={confirmingId === c.id ? "再次点击确认删除" : "删除评论"}
|
||||
title={confirmingId === c.id ? "再点一次确认删除" : "删除"}
|
||||
data-tip={confirmingId === c.id ? "再点一次确认删除" : "删除"}
|
||||
>
|
||||
{confirmingId === c.id ? (
|
||||
<span className="text-[11.5px] whitespace-nowrap">
|
||||
|
||||
92
frontend/components/ContentLockGate.tsx
Normal file
92
frontend/components/ContentLockGate.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Lock } from "lucide-react";
|
||||
import { apiUnlockPost } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export default function ContentLockGate({
|
||||
postId,
|
||||
hint,
|
||||
access,
|
||||
points,
|
||||
loggedIn,
|
||||
}: {
|
||||
postId: number;
|
||||
hint?: string;
|
||||
access?: string;
|
||||
points?: number;
|
||||
loggedIn: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const unlock = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiUnlockPost(String(postId));
|
||||
if (res.post) {
|
||||
toast("已解锁全文", "ok");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast(res.error || "解锁失败", "error");
|
||||
}
|
||||
} catch {
|
||||
toast("解锁失败", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-[14px] px-6 py-10 text-center my-4"
|
||||
style={{
|
||||
background: "var(--accent-soft)",
|
||||
border: "1px solid color-mix(in srgb, var(--accent) 20%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Lock size={28} className="mx-auto mb-3" style={{ color: "var(--accent)" }} />
|
||||
<p className="font-bold text-[16px] mb-1" style={{ color: "var(--ink)" }}>
|
||||
{hint || "正文暂不可见"}
|
||||
</p>
|
||||
<p className="meta text-[13px] mb-5">
|
||||
{access === "points"
|
||||
? `支付 ${points || 0} 积分后可阅读全文`
|
||||
: access === "reply"
|
||||
? "发表一条回复后即可阅读全文"
|
||||
: access === "login"
|
||||
? "登录后即可阅读全文"
|
||||
: null}
|
||||
</p>
|
||||
{access === "login" && !loggedIn && (
|
||||
<Link
|
||||
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
去登录
|
||||
</Link>
|
||||
)}
|
||||
{access === "points" && loggedIn && (
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void unlock()}>
|
||||
{busy ? "解锁中…" : `支付 ${points || 0} 积分解锁`}
|
||||
</button>
|
||||
)}
|
||||
{access === "points" && !loggedIn && (
|
||||
<Link
|
||||
href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
登录后解锁
|
||||
</Link>
|
||||
)}
|
||||
{access === "reply" && (
|
||||
<a href="#comments" className="btn btn-primary">
|
||||
去回复
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -281,6 +281,7 @@ export default function Header({
|
||||
className={`${iconBtn} hidden lg:inline-flex`}
|
||||
style={iconBtnStyle}
|
||||
aria-label="切换主题"
|
||||
data-tip={theme === "dark" ? "切换浅色" : "切换深色"}
|
||||
>
|
||||
{theme === "dark" ? <Sun size={17} /> : <Moon size={17} />}
|
||||
</button>
|
||||
@@ -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")
|
||||
|
||||
@@ -72,7 +72,8 @@ export default function LikeButton({
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
title={liked ? "取消点赞" : "点赞"}
|
||||
data-tip={liked ? "取消点赞" : "点赞"}
|
||||
aria-label={liked ? "取消点赞" : "点赞"}
|
||||
aria-pressed={liked}
|
||||
className={`j13-like inline-flex items-center justify-center font-semibold tabular-nums rounded-full border disabled:opacity-50 ${liked ? "is-liked" : ""} ${sizeCls}`}
|
||||
>
|
||||
|
||||
20
frontend/components/MarkdownBody.tsx
Normal file
20
frontend/components/MarkdownBody.tsx
Normal file
@@ -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 (
|
||||
<div className={className}>
|
||||
<ReactMarkdown components={components}>{content || ""}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
frontend/components/MarkdownEditor.tsx
Normal file
320
frontend/components/MarkdownEditor.tsx
Normal file
@@ -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<HTMLTextAreaElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(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: <Bold size={15} />,
|
||||
onClick: () => runWrap({ before: "**", after: "**", placeholder: "粗体" }),
|
||||
},
|
||||
{
|
||||
key: "italic",
|
||||
label: "斜体",
|
||||
icon: <Italic size={15} />,
|
||||
onClick: () => runWrap({ before: "*", after: "*", placeholder: "斜体" }),
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
label: "行内代码",
|
||||
icon: <Code size={15} />,
|
||||
onClick: () => runWrap({ before: "`", after: "`", placeholder: "code" }),
|
||||
},
|
||||
{
|
||||
key: "quote",
|
||||
label: "引用",
|
||||
icon: <Quote size={15} />,
|
||||
onClick: () =>
|
||||
runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }),
|
||||
},
|
||||
{
|
||||
key: "list",
|
||||
label: "列表",
|
||||
icon: <List size={15} />,
|
||||
onClick: () =>
|
||||
runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" }),
|
||||
},
|
||||
{
|
||||
key: "h2",
|
||||
label: "二级标题",
|
||||
icon: <Heading2 size={15} />,
|
||||
onClick: () => runWrap({ before: "## ", after: "", placeholder: "标题" }),
|
||||
},
|
||||
{
|
||||
key: "link",
|
||||
label: "链接",
|
||||
icon: <LinkIcon size={15} />,
|
||||
onClick: () =>
|
||||
runWrap({ before: "[", after: "](https://)", placeholder: "链接文字" }),
|
||||
},
|
||||
{
|
||||
key: "image",
|
||||
label: uploading ? "上传中…" : "插入图片",
|
||||
icon: <ImageIcon size={15} />,
|
||||
onClick: () => fileRef.current?.click(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={`j13-md-editor${fullscreen ? " is-fullscreen" : ""}`}>
|
||||
<div className="j13-md-toolbar">
|
||||
<div className="flex items-center gap-0.5 flex-1 flex-wrap min-w-0">
|
||||
{tools.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
className="j13-md-tool"
|
||||
data-tip={t.label}
|
||||
aria-label={t.label}
|
||||
disabled={disabled || (t.key === "image" && uploading)}
|
||||
onClick={t.onClick}
|
||||
>
|
||||
{t.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="j13-md-mode" role="tablist" aria-label="编辑模式">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === "write"}
|
||||
className={mode === "write" ? "is-on" : ""}
|
||||
onClick={() => setMode("write")}
|
||||
>
|
||||
<Pencil size={12} /> 编写
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === "preview"}
|
||||
className={mode === "preview" ? "is-on" : ""}
|
||||
onClick={() => setMode("preview")}
|
||||
>
|
||||
<Eye size={12} /> 预览
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-tool"
|
||||
data-tip={fullscreen ? "退出全屏 (Esc)" : "全屏写作"}
|
||||
aria-label={fullscreen ? "退出全屏" : "全屏写作"}
|
||||
onClick={() => setFullscreen((v) => !v)}
|
||||
>
|
||||
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImagePick(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
|
||||
{mode === "write" ? (
|
||||
<textarea
|
||||
ref={taRef}
|
||||
id={id}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
className="j13-md-textarea"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onSubmitShortcut?.();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="j13-md-preview">
|
||||
{value.trim() ? (
|
||||
<MarkdownBody content={value} />
|
||||
) : (
|
||||
<p className="meta text-center py-16">暂无内容可预览</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="j13-md-foot">
|
||||
<span>Markdown · Ctrl/⌘ + Enter 提交</span>
|
||||
<span className="tabular-nums">{value.length} 字</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -157,6 +157,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
aria-label="通知"
|
||||
data-tip="通知"
|
||||
className="relative w-10 h-10 flex items-center justify-center rounded-full transition-colors"
|
||||
style={{
|
||||
color: open ? "var(--accent)" : "var(--ink-2)",
|
||||
|
||||
66
frontend/components/PostAttachments.tsx
Normal file
66
frontend/components/PostAttachments.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Download } from "lucide-react";
|
||||
import { postAttachmentDownloadURL, type PostAttachment } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
export default function PostAttachments({
|
||||
postId,
|
||||
items,
|
||||
}: {
|
||||
postId: number;
|
||||
items: PostAttachment[];
|
||||
}) {
|
||||
if (!items.length) return null;
|
||||
|
||||
const onClick = (a: PostAttachment) => {
|
||||
if (a.price_points > 0 && !a.unlocked) {
|
||||
// 仍走下载 URL:后端会扣积分或 402
|
||||
toast(`将消耗 ${a.price_points} 积分下载`, "ok");
|
||||
}
|
||||
window.location.href = postAttachmentDownloadURL(postId, a.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mt-8 pt-6" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<h2 className="font-extrabold text-[15px] mb-3" style={{ color: "var(--ink)" }}>
|
||||
附件
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{items.map((a) => (
|
||||
<li key={a.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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)" }}
|
||||
>
|
||||
<Download size={16} style={{ color: "var(--accent)" }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
{a.name}
|
||||
</div>
|
||||
<div className="meta text-[12px]">
|
||||
{formatSize(a.size)}
|
||||
{a.price_points > 0
|
||||
? a.unlocked
|
||||
? " · 已解锁"
|
||||
: ` · ${a.price_points} 积分`
|
||||
: " · 免费"}
|
||||
{a.download_count > 0 ? ` · ${a.download_count} 次下载` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<span className="btn btn-line btn-sm shrink-0">下载</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
957
frontend/components/PostComposer.tsx
Normal file
957
frontend/components/PostComposer.tsx
Normal file
@@ -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<Board[]>([]);
|
||||
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<string[]>(
|
||||
parseTags(isCreate ? "" : props.initialTags || "")
|
||||
);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [postType, setPostType] = useState<PostTypeValue>(
|
||||
normalizePostType(isCreate ? "discussion" : props.initialPostType)
|
||||
);
|
||||
const [contentAccess, setContentAccess] = useState<ContentAccessValue>(
|
||||
(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<string[]>(() => {
|
||||
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<PostAttachment[]>(
|
||||
isCreate ? [] : props.initialAttachments || []
|
||||
);
|
||||
const [fileBusy, setFileBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [draftBanner, setDraftBanner] = useState<ComposeDraft | null>(null);
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const skipDraftSave = useRef(true);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(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 (
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="j13-compose-stage">
|
||||
<div className="j13-compose-body">
|
||||
{draftBanner && (
|
||||
<div
|
||||
className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 rounded-[12px] text-[13px] shrink-0"
|
||||
style={{
|
||||
background: "var(--accent-soft)",
|
||||
color: "var(--ink)",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--accent) 18%, transparent)",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
检测到未发布的本地草稿
|
||||
{draftBanner.updatedAt
|
||||
? `(${new Date(draftBanner.updatedAt).toLocaleString("zh-CN")})`
|
||||
: ""}
|
||||
</span>
|
||||
<span className="flex gap-2">
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={restoreDraft}>
|
||||
恢复
|
||||
</button>
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={discardDraft}>
|
||||
丢弃
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="alert-error shrink-0">{error}</div>}
|
||||
|
||||
{/* 帖子类型 */}
|
||||
<div className="shrink-0">
|
||||
<div className="field-label mb-2">帖子类型</div>
|
||||
<div className="j13-compose-typebar" role="radiogroup" aria-label="帖子类型">
|
||||
{POST_TYPES.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={postType === t.value}
|
||||
className={`j13-compose-typechip${postType === t.value ? " is-on" : ""}`}
|
||||
disabled={loading || (!isCreate && t.value !== postType)}
|
||||
data-tip={!isCreate && t.value !== postType ? "编辑时不可更改类型" : t.hint}
|
||||
onClick={() => setPostType(t.value)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 元信息:板块 + 标签(标签 flex 占满) + 可见性 */}
|
||||
<div className="j13-compose-meta shrink-0">
|
||||
<div className="j13-compose-meta-board">
|
||||
<label className="field-label" htmlFor="compose-board">
|
||||
板块
|
||||
</label>
|
||||
{isCreate ? (
|
||||
<BoardPicker
|
||||
id="compose-board"
|
||||
boards={boards}
|
||||
value={boardId}
|
||||
onChange={setBoardId}
|
||||
disabled={loading}
|
||||
loading={!boardsReady}
|
||||
/>
|
||||
) : (
|
||||
<BoardBadge
|
||||
name={props.boardName || "未知板块"}
|
||||
colorIndex={props.boardColorIndex ?? 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="j13-compose-meta-tags">
|
||||
<label className="field-label" htmlFor="compose-tag-input">
|
||||
标签
|
||||
<span className="font-normal" style={{ color: "var(--ink-3)" }}>
|
||||
{" "}
|
||||
· 可选,最多 12 个
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
className="j13-tag-field"
|
||||
onClick={() => document.getElementById("compose-tag-input")?.focus()}
|
||||
>
|
||||
{tagList.map((t) => (
|
||||
<span key={t} className="chip inline-flex items-center gap-1 !py-0.5">
|
||||
# {t}
|
||||
<button
|
||||
type="button"
|
||||
data-tip={`移除 ${t}`}
|
||||
aria-label={`移除标签 ${t}`}
|
||||
className="opacity-60 hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(t);
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
id="compose-tag-input"
|
||||
value={tagInput}
|
||||
disabled={loading || tagList.length >= 12}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === "," || e.key === ",") {
|
||||
e.preventDefault();
|
||||
commitTagInput();
|
||||
} else if (e.key === "Backspace" && !tagInput && tagList.length > 0) {
|
||||
removeTag(tagList[tagList.length - 1]);
|
||||
}
|
||||
}}
|
||||
onBlur={commitTagInput}
|
||||
placeholder={tagList.length === 0 ? "输入后回车添加" : ""}
|
||||
className="j13-tag-field-input flex-1 min-w-[8rem] self-stretch h-8 text-[13px]"
|
||||
style={{ color: "var(--ink)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="j13-compose-meta-access">
|
||||
<label className="field-label" htmlFor="compose-access">
|
||||
正文可见性
|
||||
</label>
|
||||
<select
|
||||
id="compose-access"
|
||||
className="j13-compose-field"
|
||||
value={contentAccess}
|
||||
disabled={loading}
|
||||
onChange={(e) => setContentAccess(e.target.value as ContentAccessValue)}
|
||||
>
|
||||
{CONTENT_ACCESS.map((a) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{contentAccess === "points" && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<label className="meta text-[12.5px] shrink-0" htmlFor="compose-access-pts">
|
||||
所需积分
|
||||
</label>
|
||||
<input
|
||||
id="compose-access-pts"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100000}
|
||||
value={accessPoints}
|
||||
disabled={loading}
|
||||
onChange={(e) => setAccessPoints(Number(e.target.value) || 0)}
|
||||
className="j13-compose-field !py-1.5 w-28"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 类型专属面板 */}
|
||||
{postType === "poll" && (
|
||||
<div className="j13-compose-typepanel shrink-0 space-y-3">
|
||||
<p className="font-bold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
投票选项
|
||||
</p>
|
||||
{!canEditPoll && (
|
||||
<p className="meta text-[12.5px]">已有人投票,无法修改选项、单/多选或匿名设置</p>
|
||||
)}
|
||||
{pollOptions.map((opt, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input
|
||||
className="j13-compose-field flex-1"
|
||||
value={opt}
|
||||
maxLength={64}
|
||||
placeholder={`选项 ${i + 1}`}
|
||||
disabled={loading || !canEditPoll}
|
||||
onChange={(e) => {
|
||||
const next = [...pollOptions];
|
||||
next[i] = e.target.value;
|
||||
setPollOptions(next);
|
||||
}}
|
||||
/>
|
||||
{pollOptions.length > 2 && canEditPoll && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm !px-2"
|
||||
onClick={() => setPollOptions((p) => p.filter((_, j) => j !== i))}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{pollOptions.length < 10 && canEditPoll && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => setPollOptions((p) => [...p, ""])}
|
||||
>
|
||||
添加选项
|
||||
</button>
|
||||
)}
|
||||
<label className="meta text-[13px] inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pollMulti}
|
||||
disabled={loading || !canEditPoll}
|
||||
onChange={(e) => setPollMulti(e.target.checked)}
|
||||
/>
|
||||
允许多选
|
||||
</label>
|
||||
<label className="meta text-[13px] inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pollAnonymous}
|
||||
disabled={loading || !canEditPoll}
|
||||
onChange={(e) => setPollAnonymous(e.target.checked)}
|
||||
/>
|
||||
匿名投票
|
||||
</label>
|
||||
</div>
|
||||
{!pollAnonymous && (
|
||||
<p className="meta text-[12px]">非匿名:投票结果公开后可看到每位投票人</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{postType === "bounty" && (
|
||||
<div className="j13-compose-typepanel shrink-0 space-y-3">
|
||||
<p className="font-bold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
悬赏积分
|
||||
</p>
|
||||
<p className="meta text-[12.5px]">
|
||||
发布时从你的积分余额托管;到期未采纳将自动退回。采纳回答后发给对方。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100000}
|
||||
value={bountyPoints}
|
||||
disabled={loading || !isCreate}
|
||||
onChange={(e) => setBountyPoints(Number(e.target.value) || 0)}
|
||||
className="j13-compose-field w-36"
|
||||
/>
|
||||
<span className="meta text-[13px]">积分</span>
|
||||
{!isCreate && (
|
||||
<span className="meta text-[12.5px]">编辑不可更改已托管金额</span>
|
||||
)}
|
||||
</div>
|
||||
{isCreate && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="meta text-[13px]">过期天数</span>
|
||||
{BOUNTY_EXPIRE_DAYS.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className={`btn btn-sm ${bountyExpireDays === d ? "btn-primary" : "btn-line"}`}
|
||||
disabled={loading}
|
||||
onClick={() => setBountyExpireDays(d)}
|
||||
>
|
||||
{d} 天
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{postType === "lottery" && (
|
||||
<div className="j13-compose-typepanel shrink-0 space-y-3">
|
||||
<p className="font-bold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
抽奖设置
|
||||
</p>
|
||||
<p className="meta text-[12.5px]">
|
||||
对本帖发表评论的用户自动进入奖池(楼主除外);截止时可自动开奖。
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="meta text-[13px]" htmlFor="lottery-slots">
|
||||
中奖名额
|
||||
</label>
|
||||
<input
|
||||
id="lottery-slots"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={lotterySlots}
|
||||
disabled={loading || !isCreate}
|
||||
onChange={(e) => setLotterySlots(Number(e.target.value) || 1)}
|
||||
className="j13-compose-field w-28"
|
||||
/>
|
||||
</div>
|
||||
{isCreate && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="meta text-[13px]">截止天数</span>
|
||||
{LOTTERY_EXPIRE_DAYS.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className={`btn btn-sm ${lotteryExpireDays === d ? "btn-primary" : "btn-line"}`}
|
||||
disabled={loading}
|
||||
onClick={() => setLotteryExpireDays(d)}
|
||||
>
|
||||
{d} 天
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 大标题 */}
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-end justify-between gap-3 mb-1">
|
||||
<label className="sr-only" htmlFor="compose-title">
|
||||
标题
|
||||
</label>
|
||||
<span className="meta text-[12px] tabular-nums ml-auto">
|
||||
{title.length}/{TITLE_MAX}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="compose-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value.slice(0, TITLE_MAX))}
|
||||
required
|
||||
maxLength={TITLE_MAX}
|
||||
placeholder="一句话说清楚你要讨论什么"
|
||||
className="j13-compose-title"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 正文 */}
|
||||
<div className="j13-compose-canvas">
|
||||
<label className="sr-only" htmlFor="compose-content">
|
||||
正文
|
||||
</label>
|
||||
<MarkdownEditor
|
||||
id="compose-content"
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
disabled={loading}
|
||||
rows={18}
|
||||
placeholder={
|
||||
"从这里开始写正文…\n\n支持 Markdown;可用工具栏插入格式与图片。可点全屏沉浸写作。"
|
||||
}
|
||||
onSubmitShortcut={() => {
|
||||
if (canSubmit) void handleSubmit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 附件 */}
|
||||
<div className="j13-compose-attach shrink-0">
|
||||
<div className="flex items-center justify-between gap-3 mb-2">
|
||||
<div className="field-label !mb-0">附件</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm inline-flex items-center gap-1.5"
|
||||
disabled={loading || fileBusy || attachments.length >= 10}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Paperclip size={14} />
|
||||
{fileBusy ? "上传中…" : "添加文件"}
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => void onPickFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</div>
|
||||
<p className="meta text-[12.5px] mb-2">
|
||||
单文件 ≤20MB,最多 10 个;可设免费或积分下载
|
||||
</p>
|
||||
{attachments.length === 0 ? (
|
||||
<p className="meta text-[13px] py-2">暂无附件</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{attachments.map((a) => (
|
||||
<li key={a.id} className="j13-compose-attach-row">
|
||||
<Download size={15} className="shrink-0 opacity-50" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-[13.5px]">{a.name}</div>
|
||||
<div className="meta text-[12px]">{formatSize(a.size)}</div>
|
||||
</div>
|
||||
<label className="meta text-[12px] flex items-center gap-1.5 shrink-0">
|
||||
积分
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100000}
|
||||
value={a.price_points}
|
||||
disabled={loading}
|
||||
onChange={(e) => void setAttPrice(a.id, Number(e.target.value))}
|
||||
className="j13-compose-field !py-1 !px-2 w-20 text-[13px]"
|
||||
data-tip="0 = 免费下载"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm !px-2"
|
||||
data-tip="移除附件"
|
||||
aria-label="移除附件"
|
||||
onClick={() => void removeAttachment(a.id)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="j13-compose-actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="btn btn-primary min-w-[8.5rem] !px-7 !py-3 !text-[15px]"
|
||||
>
|
||||
{loading
|
||||
? isCreate
|
||||
? "发布中…"
|
||||
: "保存中…"
|
||||
: isCreate
|
||||
? "发布帖子"
|
||||
: "保存修改"}
|
||||
</button>
|
||||
{isCreate ? (
|
||||
<Link href={cancelHref} className="btn btn-line !py-3">
|
||||
取消
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(cancelHref)}
|
||||
className="btn btn-line !py-3"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
<span className="meta text-[12.5px] sm:ml-auto max-w-md">
|
||||
{isCreate
|
||||
? "普通用户发帖需审核 · Ctrl/⌘+Enter 提交 · Esc 退出全屏"
|
||||
: "保存后立即生效 · 类型不可更改"}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
421
frontend/components/PostInteractPanels.tsx
Normal file
421
frontend/components/PostInteractPanels.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-4 mb-8">
|
||||
{question && <QuestionPanel postId={postId} state={question} />}
|
||||
{poll && <PollPanel postId={postId} state={poll} loggedIn={loggedIn} />}
|
||||
{bounty && <BountyPanel postId={postId} state={bounty} />}
|
||||
{lottery && <LotteryPanel postId={postId} state={lottery} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-extrabold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
问答 · {state.solved ? "已解决" : "未解决"}
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mt-1">
|
||||
{state.solved
|
||||
? state.accepted_comment_id
|
||||
? `已采纳评论 #${state.accepted_comment_id}`
|
||||
: "作者已标记为已解决"
|
||||
: "可在评论旁采纳答案,或手动标记已解决"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state.can_solve && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void act(() => apiSolveQuestion(String(postId)), "已标记为已解决")}
|
||||
>
|
||||
标记已解决
|
||||
</button>
|
||||
)}
|
||||
{state.can_reopen && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void act(() => apiReopenQuestion(String(postId)), "已重新打开")}
|
||||
>
|
||||
重新打开
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PollPanel({
|
||||
postId,
|
||||
state,
|
||||
loggedIn,
|
||||
}: {
|
||||
postId: number;
|
||||
state: PollState;
|
||||
loggedIn: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [sel, setSel] = useState<number[]>(state.my_options || []);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const showResults = state.closed || (state.my_options && state.my_options.length > 0);
|
||||
|
||||
const 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 (
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<h2 className="font-extrabold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
投票{state.multi ? "(多选)" : "(单选)"}
|
||||
{state.anonymous ? " · 匿名" : " · 实名"}
|
||||
</h2>
|
||||
<span className="meta text-[12.5px]">
|
||||
{state.voters} 人参与 · {state.total_votes} 票
|
||||
{state.closed ? " · 已结束" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{state.options.map((o) => {
|
||||
const checked = sel.includes(o.index);
|
||||
const namedVoters = !state.anonymous && showResults ? o.voters || [] : [];
|
||||
return (
|
||||
<li key={o.index}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!state.can_vote || busy}
|
||||
onClick={() => toggle(o.index)}
|
||||
className="w-full text-left rounded-[12px] px-3 py-2.5 relative overflow-hidden transition-colors"
|
||||
style={{
|
||||
border: `1px solid ${checked ? "var(--accent)" : "var(--line)"}`,
|
||||
background: checked ? "var(--accent-soft)" : "var(--panel)",
|
||||
}}
|
||||
>
|
||||
{showResults && (
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 opacity-30"
|
||||
style={{
|
||||
width: `${o.percent}%`,
|
||||
background: "var(--accent)",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span className="relative flex items-center justify-between gap-3">
|
||||
<span className="font-semibold text-[14px]" style={{ color: "var(--ink)" }}>
|
||||
{state.can_vote ? (checked ? "✓ " : "") : ""}
|
||||
{o.text}
|
||||
</span>
|
||||
{showResults && (
|
||||
<span className="meta tabular-nums text-[12.5px] shrink-0">
|
||||
{o.votes} · {o.percent}%
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{namedVoters.length > 0 && (
|
||||
<ul className="mt-1.5 flex flex-wrap gap-1.5 px-1">
|
||||
{namedVoters.map((v) => (
|
||||
<li key={v.id}>
|
||||
<Link
|
||||
href={`/u/${v.id}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[12px] font-semibold"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<Avatar name={v.nickname || v.username} src={v.avatar || undefined} size={16} />
|
||||
{v.nickname || v.username}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{state.can_vote && (
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => void vote()}>
|
||||
提交投票
|
||||
</button>
|
||||
)}
|
||||
{!loggedIn && !state.closed && (
|
||||
<Link href={`/login?redirect=${encodeURIComponent(`/post/${postId}`)}`} className="btn btn-line btn-sm">
|
||||
登录后投票
|
||||
</Link>
|
||||
)}
|
||||
{state.can_close && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void close()}
|
||||
>
|
||||
结束投票
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-extrabold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
悬赏 {state.points} 积分
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mt-1">
|
||||
{statusText}
|
||||
{state.ends_at && !state.settled ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{state.can_refund && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${confirming ? "btn-primary" : "btn-line"}`}
|
||||
disabled={busy}
|
||||
onClick={() => void refund()}
|
||||
>
|
||||
{confirming ? "再点确认退回" : "退回悬赏"}
|
||||
</button>
|
||||
)}
|
||||
{state.can_accept && (
|
||||
<p className="meta text-[12.5px]">在评论旁点击「采纳」发放积分</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="j13-interact-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h2 className="font-extrabold text-[15px]" style={{ color: "var(--ink)" }}>
|
||||
抽奖 · {state.slots} 个名额
|
||||
</h2>
|
||||
<p className="meta text-[12.5px] mt-1">
|
||||
已有 {state.entry_count} 名回帖用户参与
|
||||
{state.drawn ? " · 已开奖" : state.closed ? " · 已截止" : ""}
|
||||
{state.eligible && !state.drawn ? " · 你已在奖池中" : ""}
|
||||
{state.ends_at && !state.drawn ? ` · 截止 ${formatEndsAt(state.ends_at)}` : ""}
|
||||
</p>
|
||||
{!state.drawn && (
|
||||
<p className="meta text-[12px] mt-1">对本帖发表评论即可参与(楼主除外)</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state.can_close && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void act(() => apiCloseLottery(String(postId)), "已截止")}
|
||||
>
|
||||
提前截止
|
||||
</button>
|
||||
)}
|
||||
{state.can_draw && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => void act(() => apiDrawLottery(String(postId)), "开奖完成")}
|
||||
>
|
||||
开奖
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{state.drawn && (
|
||||
<div>
|
||||
<p className="font-bold text-[13px] mb-2" style={{ color: "var(--ink)" }}>
|
||||
{state.winners.length > 0 ? "中奖名单" : "无人回帖,开奖结果为空"}
|
||||
</p>
|
||||
{state.winners.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-3">
|
||||
{state.winners.map((w) => (
|
||||
<li key={w.id}>
|
||||
<Link
|
||||
href={`/u/${w.id}`}
|
||||
className="inline-flex items-center gap-2 px-2.5 py-1.5 rounded-full"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<Avatar name={w.nickname || w.username} src={w.avatar || undefined} size={22} />
|
||||
<span className="text-[13px] font-bold">{w.nickname || w.username}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -52,23 +55,36 @@ export default function PostRow({
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* 仅置顶/推荐在标题左侧 */}
|
||||
{post.pinned > 0 && <span className="badge-pin shrink-0">置顶</span>}
|
||||
{post.recommended && <span className="badge-rec shrink-0">推荐</span>}
|
||||
{post.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
|
||||
<Link
|
||||
href={`/post/${post.id}`}
|
||||
className="stretch-link min-w-0 flex-1"
|
||||
aria-label={post.title}
|
||||
>
|
||||
<h3
|
||||
className={`font-bold leading-snug truncate transition-colors group-hover:text-[var(--accent)] ${
|
||||
compact ? "text-[15px]" : "text-[16px] sm:text-[17px]"
|
||||
}`}
|
||||
style={{ color: "var(--ink)" }}
|
||||
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-hidden">
|
||||
<Link
|
||||
href={`/post/${post.id}`}
|
||||
className="stretch-link min-w-0 shrink"
|
||||
aria-label={post.title}
|
||||
>
|
||||
{post.title}
|
||||
</h3>
|
||||
</Link>
|
||||
<h3
|
||||
className={`font-bold leading-snug truncate transition-colors group-hover:text-[var(--accent)] ${
|
||||
compact ? "text-[15px]" : "text-[16px] sm:text-[17px]"
|
||||
}`}
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{post.title}
|
||||
</h3>
|
||||
</Link>
|
||||
{/* 其余徽章紧跟标题右侧 */}
|
||||
{post.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
|
||||
{pt !== "discussion" && pt !== "question" && (
|
||||
<span className="badge-type shrink-0">{postTypeLabel(post.post_type)}</span>
|
||||
)}
|
||||
{statusBadge && (
|
||||
<span className="badge-access shrink-0">{statusBadge}</span>
|
||||
)}
|
||||
{post.content_access && post.content_access !== "public" && (
|
||||
<span className="badge-access shrink-0">{contentAccessLabel(post.content_access)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`meta relative z-[2] flex items-center gap-x-2 gap-y-1 flex-wrap ${
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function RoleBadge({
|
||||
truncate ? "max-w-full whitespace-nowrap" : "whitespace-nowrap"
|
||||
} ${className}`}
|
||||
style={{ background: `var(${meta.softToken})`, color: `var(${meta.colorToken})` }}
|
||||
title={title ?? label}
|
||||
data-tip={title ?? label}
|
||||
>
|
||||
<Icon size={size} className="shrink-0" />
|
||||
{truncate ? <span className="overflow-hidden text-ellipsis">{label}</span> : label}
|
||||
|
||||
135
frontend/components/TipHost.tsx
Normal file
135
frontend/components/TipHost.tsx
Normal file
@@ -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<TipState>(null);
|
||||
const tipRef = useRef<HTMLDivElement>(null);
|
||||
const activeEl = useRef<Element | null>(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(
|
||||
<div
|
||||
ref={tipRef}
|
||||
role="tooltip"
|
||||
className="j13-tip-bubble"
|
||||
data-place={tip.place}
|
||||
style={{
|
||||
left: tip.x,
|
||||
top: tip.y,
|
||||
transform:
|
||||
tip.place === "top"
|
||||
? "translate(-50%, -100%)"
|
||||
: "translate(-50%, 0)",
|
||||
}}
|
||||
>
|
||||
{tip.text}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<>
|
||||
{" · "}
|
||||
<Link href="/points" className="hover:text-[var(--accent)] transition-colors">
|
||||
流水
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{guest ? (
|
||||
|
||||
@@ -67,7 +67,7 @@ function PulseCard({ stats, checkin }: { stats: OverviewStats; checkin?: Checkin
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-[11px] font-normal normal-case tracking-normal"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
title="近 5 分钟在线"
|
||||
data-tip="近 5 分钟在线"
|
||||
>
|
||||
<span className="j13-pulse-dot" aria-hidden="true" />
|
||||
实时
|
||||
@@ -124,7 +124,7 @@ function Announcements({ items }: { items: AnnouncementSummary[] }) {
|
||||
<span
|
||||
className="mt-1.5 block truncate text-[13px] leading-snug transition-colors group-hover:text-[var(--accent)]"
|
||||
style={{ color: "var(--ink)" }}
|
||||
title={a.title}
|
||||
data-tip={a.title}
|
||||
>
|
||||
{a.title}
|
||||
</span>
|
||||
@@ -151,7 +151,7 @@ function HotRanking({ posts }: { posts: PostListItem[] }) {
|
||||
<ol className="j13-ranklist">
|
||||
{posts.map((p, i) => (
|
||||
<li key={p.id}>
|
||||
<Link href={`/post/${p.id}`} className="j13-rank group" title={p.title}>
|
||||
<Link href={`/post/${p.id}`} className="j13-rank group" data-tip={p.title}>
|
||||
<span className="j13-rank-no">{i + 1}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
@@ -200,7 +200,7 @@ function NewUsers({ users }: { users: NewUser[] }) {
|
||||
<span
|
||||
className="w-full truncate text-center text-[11px]"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
title={name}
|
||||
data-tip={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
@@ -232,7 +232,7 @@ function ActiveUsers({ users }: { users: ActiveUser[] }) {
|
||||
const name = u.nickname || u.username;
|
||||
return (
|
||||
<li key={u.id}>
|
||||
<Link href={`/u/${u.id}`} className="j13-raillink" title={name}>
|
||||
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}>
|
||||
<Avatar name={name} src={u.avatar} size={30} />
|
||||
<span className="flex-1 min-w-0">
|
||||
<span
|
||||
@@ -294,7 +294,7 @@ function FriendLinks() {
|
||||
{l.name}
|
||||
</a>
|
||||
) : (
|
||||
<span key={l.name} className="j13-chip" title="友链待配置">
|
||||
<span key={l.name} className="j13-chip" data-tip="友链待配置">
|
||||
{l.name}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<nav className="hidden lg:block w-56 xl:w-60 shrink-0" aria-label="工作台导航">
|
||||
@@ -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 (
|
||||
<div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6">
|
||||
|
||||
@@ -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<string, number>;
|
||||
recent_ledger: PointLedgerItem[];
|
||||
}
|
||||
|
||||
export async function apiAdminEconomyStats(): Promise<EconomyStats & { error?: string }> {
|
||||
const res = await fetchWithRefresh("/api/admin/economy", {
|
||||
method: "GET",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchAdminEconomyStats(
|
||||
cookieHeader?: string
|
||||
): Promise<EconomyStats> {
|
||||
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
|
||||
|
||||
14
frontend/lib/points.ts
Normal file
14
frontend/lib/points.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/** 积分流水原因文案 */
|
||||
const REASON_LABELS: Record<string, string> = {
|
||||
checkin: "每日签到",
|
||||
unlock_post: "解锁正文",
|
||||
download_file: "附件下载",
|
||||
migrate_checkin: "历史签到回填",
|
||||
bounty_escrow: "悬赏托管",
|
||||
bounty_refund: "悬赏退回",
|
||||
bounty_award: "悬赏采纳",
|
||||
};
|
||||
|
||||
export function pointReasonLabel(reason: string): string {
|
||||
return REASON_LABELS[reason] || reason || "其他";
|
||||
}
|
||||
61
frontend/lib/postMeta.ts
Normal file
61
frontend/lib/postMeta.ts
Normal file
@@ -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 "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user