feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件 - 新增等级模型:model/level 及前端 levels 元数据 - 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置 - 积分、签到、评论等服务适配等级与徽章逻辑
This commit is contained in:
67
backend/handler/admin_points.go
Normal file
67
backend/handler/admin_points.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 积分管理(usersAPI 组:RequireStaff + CSRF + PermUsers,超管/站长) =====
|
||||||
|
|
||||||
|
// adminAdjustMaxDelta 单次调整量的绝对值上界(与积分规则值上界同级)
|
||||||
|
const adminAdjustMaxDelta = 100000
|
||||||
|
|
||||||
|
// AdminAdjustUserPoints 管理员手动调整用户积分:
|
||||||
|
// 加分走 CreditTx(同步累加累计获得→可升级),扣分走 DebitTx(仅扣余额、不降级);
|
||||||
|
// 理由必填,写入流水 note(reason=admin_adjust),响应返回调整后余额/累计/等级。
|
||||||
|
func (h *Handlers) AdminAdjustUserPoints(c *gin.Context) {
|
||||||
|
id, ok := parseAdminUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Delta int `json:"delta"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Delta == 0 || body.Delta > adminAdjustMaxDelta || body.Delta < -adminAdjustMaxDelta {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "积分数量无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
note := strings.TrimSpace(body.Note)
|
||||||
|
if note == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "调整理由必填"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(note) > 200 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "调整理由过长(≤200 字)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bal, total, level, err := h.Points.AdminAdjustPoints(id, body.Delta, note)
|
||||||
|
if err != nil {
|
||||||
|
respondAdminUserError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"balance": bal, "total_points": total, "level": level})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUserPointLedger 某用户的积分流水(分页,id DESC)
|
||||||
|
func (h *Handlers) AdminUserPointLedger(c *gin.Context) {
|
||||||
|
id, ok := parseAdminUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
items, total, err := h.Points.AdminUserLedger(id, page, 20)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取积分流水失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total, "page": page, "size": 20})
|
||||||
|
}
|
||||||
@@ -209,7 +209,9 @@ func respondAdminUserError(c *gin.Context, err error) {
|
|||||||
errors.Is(err, service.ErrInvalidRole),
|
errors.Is(err, service.ErrInvalidRole),
|
||||||
errors.Is(err, service.ErrCannotAssignRole),
|
errors.Is(err, service.ErrCannotAssignRole),
|
||||||
errors.Is(err, service.ErrBoardRequired),
|
errors.Is(err, service.ErrBoardRequired),
|
||||||
errors.Is(err, service.ErrBoardNotFound):
|
errors.Is(err, service.ErrBoardNotFound),
|
||||||
|
errors.Is(err, service.ErrInsufficientPoints),
|
||||||
|
errors.Is(err, service.ErrInvalidPoints):
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"})
|
||||||
|
|||||||
@@ -342,12 +342,13 @@ func (h *Handlers) RevokeLoginDevice(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// meUserBody 统一的用户信息响应体。boardIDs 为板块管理员被授权的板块,
|
// meUserBody 统一的用户信息响应体。boardIDs 为板块管理员被授权的板块,
|
||||||
// 供前端渲染管理菜单与前台审核按钮;其他角色为空数组
|
// 供前端渲染管理菜单与前台审核按钮;其他角色为空数组。
|
||||||
func meUserBody(user *model.User, boardIDs []uint) gin.H {
|
// badges 为管理员颁发徽章(Header 用户菜单展示;查询失败时省略)。
|
||||||
|
func meUserBody(user *model.User, boardIDs []uint, badges []model.UserBadge) gin.H {
|
||||||
if boardIDs == nil {
|
if boardIDs == nil {
|
||||||
boardIDs = []uint{}
|
boardIDs = []uint{}
|
||||||
}
|
}
|
||||||
return gin.H{
|
body := gin.H{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
"nickname": user.Nickname,
|
"nickname": user.Nickname,
|
||||||
@@ -357,7 +358,13 @@ func meUserBody(user *model.User, boardIDs []uint) gin.H {
|
|||||||
"role": user.Role,
|
"role": user.Role,
|
||||||
"board_ids": boardIDs,
|
"board_ids": boardIDs,
|
||||||
"can_manage_messages": user.CanManageMessages,
|
"can_manage_messages": user.CanManageMessages,
|
||||||
|
"level": user.Level,
|
||||||
|
"total_points": user.TotalPoints,
|
||||||
}
|
}
|
||||||
|
if badges != nil {
|
||||||
|
body["badges"] = badges
|
||||||
|
}
|
||||||
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
// Me 获取当前用户信息 + 未读通知数(合并为一次请求,供 SSR layout 直出)
|
// Me 获取当前用户信息 + 未读通知数(合并为一次请求,供 SSR layout 直出)
|
||||||
@@ -394,7 +401,12 @@ func (h *Handlers) Me(c *gin.Context) {
|
|||||||
if user.Role == model.RoleBoardAdmin {
|
if user.Role == model.RoleBoardAdmin {
|
||||||
boardIDs, _ = h.Auth.GetUserBoardIDs(claims.ID)
|
boardIDs, _ = h.Auth.GetUserBoardIDs(claims.ID)
|
||||||
}
|
}
|
||||||
body := meUserBody(user, boardIDs)
|
// 颁发徽章(Header 用户菜单展示)
|
||||||
|
var badges []model.UserBadge
|
||||||
|
if h.Badge != nil {
|
||||||
|
badges, _ = h.Badge.ForUser(claims.ID)
|
||||||
|
}
|
||||||
|
body := meUserBody(user, boardIDs, badges)
|
||||||
// 群管理员数量:非站长/超管用于后台消息入口判定
|
// 群管理员数量:非站长/超管用于后台消息入口判定
|
||||||
if h.Chat != nil && user.Role != model.RoleOwner && user.Role != model.RoleSuperAdmin {
|
if h.Chat != nil && user.Role != model.RoleOwner && user.Role != model.RoleSuperAdmin {
|
||||||
if n, err := h.Chat.CountAdminRooms(user.ID); err == nil {
|
if n, err := h.Chat.CountAdminRooms(user.ID); err == nil {
|
||||||
|
|||||||
151
backend/handler/badge.go
Normal file
151
backend/handler/badge.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
|
"github.com/freefire/jiang13-bbs/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type badgeRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Icon string `json:"icon"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type badgeGrantRequest struct {
|
||||||
|
UserID uint `json:"user_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// badgeError 按业务错误映射响应
|
||||||
|
func badgeError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrBadgeNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, service.ErrBadgeNameExists):
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, service.ErrBadgeInvalid):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "徽章名称(1-16 字)或颜色无效"})
|
||||||
|
case errors.Is(err, service.ErrBadgeUserGone):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "徽章操作失败"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListBadges 全部徽章(含持有数)
|
||||||
|
func (h *Handlers) AdminListBadges(c *gin.Context) {
|
||||||
|
badges, err := h.Badge.List()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取徽章列表失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"badges": badges})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateBadge 新建徽章
|
||||||
|
func (h *Handlers) AdminCreateBadge(c *gin.Context) {
|
||||||
|
var req badgeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
badge, err := h.Badge.Create(req.Name, req.Icon, req.Color)
|
||||||
|
if err != nil {
|
||||||
|
badgeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"badge": badge})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateBadge 修改徽章
|
||||||
|
func (h *Handlers) AdminUpdateBadge(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req badgeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
badge, err := h.Badge.Update(uint(id), req.Name, req.Icon, req.Color)
|
||||||
|
if err != nil {
|
||||||
|
badgeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"badge": badge})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteBadge 删除徽章(级联删除授予记录)
|
||||||
|
func (h *Handlers) AdminDeleteBadge(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Badge.Delete(uint(id)); err != nil {
|
||||||
|
badgeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGrantBadge 授予徽章(幂等)
|
||||||
|
func (h *Handlers) AdminGrantBadge(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": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req badgeGrantRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Badge.Grant(uint(id), req.UserID, claims.ID); err != nil {
|
||||||
|
badgeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRevokeBadge 撤销授予
|
||||||
|
func (h *Handlers) AdminRevokeBadge(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, err := strconv.ParseUint(c.Param("userId"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Badge.Revoke(uint(id), uint(userID)); err != nil {
|
||||||
|
badgeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminBadgeHolders 某徽章全部持有者(后台授予面板用)
|
||||||
|
func (h *Handlers) AdminBadgeHolders(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
holders, err := h.Badge.Holders(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取持有者失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"holders": holders})
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ type Handlers struct {
|
|||||||
HidePwd *service.HidePasswordCookie
|
HidePwd *service.HidePasswordCookie
|
||||||
Ads *service.AdService
|
Ads *service.AdService
|
||||||
Sidebar *service.SidebarService
|
Sidebar *service.SidebarService
|
||||||
|
Badge *service.BadgeService
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePublishStatus 决定新帖/新评的初始状态:
|
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||||
|
|||||||
@@ -530,7 +530,8 @@ func (h *Handlers) ToggleRecommend(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rec, err := h.Post.ToggleRecommend(uint(id))
|
claims := middleware.CurrentUser(c)
|
||||||
|
rec, err := h.Post.ToggleRecommend(uint(id), claims.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
"github.com/freefire/jiang13-bbs/realtime"
|
"github.com/freefire/jiang13-bbs/realtime"
|
||||||
"github.com/freefire/jiang13-bbs/service"
|
"github.com/freefire/jiang13-bbs/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -57,6 +58,18 @@ type updateSettingsRequest struct {
|
|||||||
BrandLogoSize *string `json:"brand_logo_size"`
|
BrandLogoSize *string `json:"brand_logo_size"`
|
||||||
BrandLogoFit *string `json:"brand_logo_fit"`
|
BrandLogoFit *string `json:"brand_logo_fit"`
|
||||||
FooterLinks *[]service.FooterLink `json:"footer_links"`
|
FooterLinks *[]service.FooterLink `json:"footer_links"`
|
||||||
|
|
||||||
|
PointsCheckinBase *int `json:"points_checkin_base"`
|
||||||
|
PointsStreakEveryDays *int `json:"points_streak_every_days"`
|
||||||
|
PointsStreakBonus *int `json:"points_streak_bonus"`
|
||||||
|
PointsPostReward *int `json:"points_post_reward"`
|
||||||
|
PointsPostDailyCap *int `json:"points_post_daily_cap"`
|
||||||
|
PointsReplyReward *int `json:"points_reply_reward"`
|
||||||
|
PointsReplyDailyCap *int `json:"points_reply_daily_cap"`
|
||||||
|
PointsRecommendReward *int `json:"points_recommend_reward"`
|
||||||
|
|
||||||
|
Levels *[]model.LevelDef `json:"levels"`
|
||||||
|
LevelsFx *bool `json:"levels_fx"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||||
@@ -97,6 +110,18 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
|||||||
"brand_logo_size": saved.BrandLogoSize,
|
"brand_logo_size": saved.BrandLogoSize,
|
||||||
"brand_logo_fit": saved.BrandLogoFit,
|
"brand_logo_fit": saved.BrandLogoFit,
|
||||||
"footer_links": saved.FooterLinks,
|
"footer_links": saved.FooterLinks,
|
||||||
|
|
||||||
|
"points_checkin_base": saved.PointsCheckinBase,
|
||||||
|
"points_streak_every_days": saved.PointsStreakEveryDays,
|
||||||
|
"points_streak_bonus": saved.PointsStreakBonus,
|
||||||
|
"points_post_reward": saved.PointsPostReward,
|
||||||
|
"points_post_daily_cap": saved.PointsPostDailyCap,
|
||||||
|
"points_reply_reward": saved.PointsReplyReward,
|
||||||
|
"points_reply_daily_cap": saved.PointsReplyDailyCap,
|
||||||
|
"points_recommend_reward": saved.PointsRecommendReward,
|
||||||
|
|
||||||
|
"levels": saved.Levels,
|
||||||
|
"levels_fx": saved.LevelsFx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +138,11 @@ func (req *updateSettingsRequest) hasAny() bool {
|
|||||||
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
|
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
|
||||||
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
|
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
|
||||||
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
|
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
|
||||||
req.FooterLinks != nil
|
req.FooterLinks != nil ||
|
||||||
|
req.PointsCheckinBase != nil || req.PointsStreakEveryDays != nil || req.PointsStreakBonus != nil ||
|
||||||
|
req.PointsPostReward != nil || req.PointsPostDailyCap != nil ||
|
||||||
|
req.PointsReplyReward != nil || req.PointsReplyDailyCap != nil ||
|
||||||
|
req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
|
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
|
||||||
@@ -218,6 +247,49 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pointsRules := []struct {
|
||||||
|
req *int
|
||||||
|
set func(int) error
|
||||||
|
hint string
|
||||||
|
}{
|
||||||
|
{req.PointsCheckinBase, h.Setting.SetPointsCheckinBase, "签到基础分须为 0–100"},
|
||||||
|
{req.PointsStreakEveryDays, h.Setting.SetPointsStreakEveryDays, "连续签到触发天数须为 0–100"},
|
||||||
|
{req.PointsStreakBonus, h.Setting.SetPointsStreakBonus, "连续签到加成分须为 0–100"},
|
||||||
|
{req.PointsPostReward, h.Setting.SetPointsPostReward, "发帖奖励须为 0–100"},
|
||||||
|
{req.PointsPostDailyCap, h.Setting.SetPointsPostDailyCap, "发帖奖励每日上限须为 0–100"},
|
||||||
|
{req.PointsReplyReward, h.Setting.SetPointsReplyReward, "回复奖励须为 0–100"},
|
||||||
|
{req.PointsReplyDailyCap, h.Setting.SetPointsReplyDailyCap, "回复奖励每日上限须为 0–100"},
|
||||||
|
{req.PointsRecommendReward, h.Setting.SetPointsRecommendReward, "推荐奖励须为 0–100"},
|
||||||
|
}
|
||||||
|
for _, r := range pointsRules {
|
||||||
|
if r.req == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := r.set(*r.req); err != nil {
|
||||||
|
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": r.hint})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.Levels != nil {
|
||||||
|
if err := h.Setting.SetLevels(*req.Levels); err != nil {
|
||||||
|
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "等级配置无效(2–12 级,阈值须非负且严格递增、首级为 0,颜色须在色板内)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.LevelsFx != nil {
|
||||||
|
if err := h.Setting.SetLevelsFx(*req.LevelsFx); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if req.CodeBlockAutoFold != nil {
|
if req.CodeBlockAutoFold != nil {
|
||||||
if err := h.Setting.SetCodeBlockAutoFold(*req.CodeBlockAutoFold); err != nil {
|
if err := h.Setting.SetCodeBlockAutoFold(*req.CodeBlockAutoFold); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
|
|||||||
@@ -117,11 +117,19 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
|||||||
"role": user.Role,
|
"role": user.Role,
|
||||||
"board_ids": boardIDs,
|
"board_ids": boardIDs,
|
||||||
"created_at": user.CreatedAt,
|
"created_at": user.CreatedAt,
|
||||||
|
"level": user.Level,
|
||||||
|
"total_points": user.TotalPoints,
|
||||||
}
|
}
|
||||||
// 仅对具备用户管理权限的访客下发封禁态(避免信息公开)
|
// 仅对具备用户管理权限的访客下发封禁态(避免信息公开)
|
||||||
if actor != nil && actor.HasPerm(service.PermUsers) {
|
if actor != nil && actor.HasPerm(service.PermUsers) {
|
||||||
userPayload["banned"] = user.Banned
|
userPayload["banned"] = user.Banned
|
||||||
}
|
}
|
||||||
|
// 徽章墙(失败不阻塞资料返回)
|
||||||
|
if h.Badge != nil {
|
||||||
|
if badges, err := h.Badge.ForUser(user.ID); err == nil && badges != nil {
|
||||||
|
userPayload["badges"] = badges
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"user": userPayload,
|
"user": userPayload,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func InitDB(dsn string) error {
|
|||||||
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||||
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
||||||
|
&Badge{}, &UserBadge{},
|
||||||
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{},
|
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{},
|
||||||
&ImportRecord{},
|
&ImportRecord{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -73,6 +74,10 @@ func InitDB(dsn string) error {
|
|||||||
return fmt.Errorf("站长角色迁移失败: %w", err)
|
return fmt.Errorf("站长角色迁移失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 用户等级:按积分流水回填累计获得积分(经验值,LevelOf 定级依据)。
|
||||||
|
// 每次启动执行,幂等自愈,保证与 CreditTx 增量口径一致(均排除 bounty_refund)
|
||||||
|
backfillUserTotalPoints(db)
|
||||||
|
|
||||||
// login_logs.success 早期 default=true 与 GORM 零值省略叠加,
|
// login_logs.success 早期 default=true 与 GORM 零值省略叠加,
|
||||||
// 会把失败登录错存为成功;AutoMigrate 不会改列默认值,这里幂等修正
|
// 会把失败登录错存为成功;AutoMigrate 不会改列默认值,这里幂等修正
|
||||||
if err := db.Exec(`ALTER TABLE login_logs ALTER COLUMN success SET DEFAULT false`).Error; err != nil {
|
if err := db.Exec(`ALTER TABLE login_logs ALTER COLUMN success SET DEFAULT false`).Error; err != nil {
|
||||||
@@ -334,6 +339,35 @@ func ensureOwnerRole(db *gorm.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backfillUserTotalPoints 按积分流水回填累计获得积分(经验值)。
|
||||||
|
// 口径与 CreditTx 增量一致:仅计正入账,排除悬赏托管退回(bounty_refund)。
|
||||||
|
// 每次启动执行,幂等自愈;失败仅记日志不阻断启动(等级纯展示,可下次启动修复)。
|
||||||
|
func backfillUserTotalPoints(db *gorm.DB) {
|
||||||
|
var tableCount int64
|
||||||
|
if err := db.Raw(`SELECT count(1) FROM information_schema.tables WHERE table_name = 'point_ledgers'`).
|
||||||
|
Scan(&tableCount).Error; err != nil {
|
||||||
|
log.Printf("[model] 回填累计获得积分前置检查失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tableCount == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := db.Exec(`
|
||||||
|
UPDATE users SET total_points = COALESCE((
|
||||||
|
SELECT SUM(delta) FROM point_ledgers
|
||||||
|
WHERE point_ledgers.user_id = users.id AND delta > 0 AND reason <> 'bounty_refund'
|
||||||
|
), 0)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`)
|
||||||
|
if res.Error != nil {
|
||||||
|
log.Printf("[model] 回填累计获得积分失败: %v", res.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.RowsAffected > 0 {
|
||||||
|
log.Printf("[model] 已按积分流水回填累计获得积分 %d 行", res.RowsAffected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// backfillBoardDefaults 为升级前已存在的板块补齐新字段默认值。
|
// backfillBoardDefaults 为升级前已存在的板块补齐新字段默认值。
|
||||||
// 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填
|
// 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填
|
||||||
// post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。
|
// post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。
|
||||||
|
|||||||
90
backend/model/level.go
Normal file
90
backend/model/level.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 用户等级:按「累计获得积分」(User.TotalPoints)定级,消费不减经验。
|
||||||
|
// 等级体系(数量/阈值/颜色)由管理员在后台配置,持久化于 site_settings 的 levels_config 键;
|
||||||
|
// 进程内持有一份原子快照供 LevelOf 纯算术计算,配置保存时刷新,无需重启。
|
||||||
|
|
||||||
|
// LevelDef 单级配置:Threshold 为该级累计积分下限(Lv1 恒为 0),Color 为展示色板 key
|
||||||
|
type LevelDef struct {
|
||||||
|
Threshold int `json:"threshold"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MinLevelCount / MaxLevelCount 等级数量边界(前端 CSS 变量按 MaxLevelCount 档固定下发)
|
||||||
|
MinLevelCount = 2
|
||||||
|
MaxLevelCount = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultLevelDefs 默认 7 级(与历史硬编码阶梯一致),缺行/配置非法时回落
|
||||||
|
func DefaultLevelDefs() []LevelDef {
|
||||||
|
return []LevelDef{
|
||||||
|
{Threshold: 0, Color: "neutral"},
|
||||||
|
{Threshold: 50, Color: "info"},
|
||||||
|
{Threshold: 200, Color: "teal"},
|
||||||
|
{Threshold: 500, Color: "ok"},
|
||||||
|
{Threshold: 1200, Color: "accent"},
|
||||||
|
{Threshold: 3000, Color: "purple"},
|
||||||
|
{Threshold: 8000, Color: "gold"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// levelSnapshot 当前生效的等级配置([]LevelDef)
|
||||||
|
var levelSnapshot atomic.Value
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
levelSnapshot.Store(DefaultLevelDefs())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidLevelDefs 结构校验:数量 2–12、阈值非负且严格递增、首级为 0(色板合法性由 service 层校验)
|
||||||
|
func ValidLevelDefs(defs []LevelDef) bool {
|
||||||
|
if len(defs) < MinLevelCount || len(defs) > MaxLevelCount {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if defs[0].Threshold != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, d := range defs {
|
||||||
|
if d.Threshold < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if i > 0 && d.Threshold <= defs[i-1].Threshold {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyLevelDefs 刷新进程内等级快照;非法/空配置回落默认表
|
||||||
|
func ApplyLevelDefs(defs []LevelDef) {
|
||||||
|
if !ValidLevelDefs(defs) {
|
||||||
|
defs = DefaultLevelDefs()
|
||||||
|
}
|
||||||
|
levelSnapshot.Store(append([]LevelDef(nil), defs...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// LevelDefs 当前生效的等级配置
|
||||||
|
func LevelDefs() []LevelDef {
|
||||||
|
if v, ok := levelSnapshot.Load().([]LevelDef); ok && len(v) > 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return DefaultLevelDefs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LevelOf 由累计获得积分计算等级,恒返回 1..len(当前配置)
|
||||||
|
func LevelOf(totalPoints int) int {
|
||||||
|
defs := LevelDefs()
|
||||||
|
level := 1
|
||||||
|
for i := 1; i < len(defs); i++ {
|
||||||
|
if totalPoints >= defs[i].Threshold {
|
||||||
|
level = i + 1
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return level
|
||||||
|
}
|
||||||
106
backend/model/level_test.go
Normal file
106
backend/model/level_test.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// withLevelDefs 临时切换等级快照并在测试结束后恢复现场(快照为进程级全局态)
|
||||||
|
func withLevelDefs(t *testing.T, defs []LevelDef) {
|
||||||
|
t.Helper()
|
||||||
|
prev := LevelDefs()
|
||||||
|
t.Cleanup(func() { ApplyLevelDefs(prev) })
|
||||||
|
ApplyLevelDefs(defs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLevelOfDefault(t *testing.T) {
|
||||||
|
withLevelDefs(t, DefaultLevelDefs())
|
||||||
|
cases := []struct {
|
||||||
|
total int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{0, 1},
|
||||||
|
{49, 1},
|
||||||
|
{50, 2},
|
||||||
|
{51, 2},
|
||||||
|
{199, 2},
|
||||||
|
{200, 3},
|
||||||
|
{500, 4},
|
||||||
|
{1200, 5},
|
||||||
|
{3000, 6},
|
||||||
|
{7999, 6},
|
||||||
|
{8000, 7},
|
||||||
|
{1 << 30, 7},
|
||||||
|
{-5, 1},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := LevelOf(c.total); got != c.want {
|
||||||
|
t.Errorf("LevelOf(%d) = %d, want %d", c.total, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLevelOfCustomConfig(t *testing.T) {
|
||||||
|
withLevelDefs(t, []LevelDef{
|
||||||
|
{Threshold: 0, Color: "neutral"},
|
||||||
|
{Threshold: 10, Color: "gold"},
|
||||||
|
{Threshold: 100, Color: "info"},
|
||||||
|
})
|
||||||
|
cases := []struct {
|
||||||
|
total int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{0, 1},
|
||||||
|
{9, 1},
|
||||||
|
{10, 2},
|
||||||
|
{99, 2},
|
||||||
|
{100, 3},
|
||||||
|
{1 << 20, 3},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := LevelOf(c.total); got != c.want {
|
||||||
|
t.Errorf("LevelOf(%d) = %d, want %d", c.total, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyLevelDefsFallback(t *testing.T) {
|
||||||
|
withLevelDefs(t, DefaultLevelDefs())
|
||||||
|
// 非法配置(首级阈值非 0、数量不足、非递增)一律回落默认表
|
||||||
|
invalid := [][]LevelDef{
|
||||||
|
{{Threshold: 10, Color: "gold"}, {Threshold: 50, Color: "info"}},
|
||||||
|
{{Threshold: 0, Color: "neutral"}},
|
||||||
|
{{Threshold: 0, Color: "neutral"}, {Threshold: 50, Color: "info"}, {Threshold: 50, Color: "ok"}},
|
||||||
|
{{Threshold: 0, Color: "neutral"}, {Threshold: -1, Color: "info"}},
|
||||||
|
}
|
||||||
|
for _, defs := range invalid {
|
||||||
|
ApplyLevelDefs(defs)
|
||||||
|
got := LevelDefs()
|
||||||
|
if len(got) != len(DefaultLevelDefs()) {
|
||||||
|
t.Fatalf("ApplyLevelDefs(invalid) 未回落默认表: got %d levels", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidLevelDefs(t *testing.T) {
|
||||||
|
valid := []LevelDef{{Threshold: 0, Color: "neutral"}, {Threshold: 1, Color: "gold"}}
|
||||||
|
if !ValidLevelDefs(valid) {
|
||||||
|
t.Errorf("ValidLevelDefs(合法两极) = false")
|
||||||
|
}
|
||||||
|
tooMany := make([]LevelDef, MaxLevelCount+1)
|
||||||
|
for i := range tooMany {
|
||||||
|
tooMany[i] = LevelDef{Threshold: i, Color: "gold"}
|
||||||
|
}
|
||||||
|
if ValidLevelDefs(tooMany) {
|
||||||
|
t.Errorf("ValidLevelDefs(超上限) = true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLevelDefsBoundaryCount(t *testing.T) {
|
||||||
|
withLevelDefs(t, DefaultLevelDefs())
|
||||||
|
defs := make([]LevelDef, MaxLevelCount)
|
||||||
|
for i := range defs {
|
||||||
|
defs[i] = LevelDef{Threshold: i * 10, Color: "gold"}
|
||||||
|
}
|
||||||
|
ApplyLevelDefs(defs)
|
||||||
|
if got := LevelOf(1 << 30); got != MaxLevelCount {
|
||||||
|
t.Errorf("LevelOf(极大值) = %d, want %d", got, MaxLevelCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,11 +123,22 @@ type User struct {
|
|||||||
Banned bool `gorm:"default:false" json:"banned"`
|
Banned bool `gorm:"default:false" json:"banned"`
|
||||||
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
|
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
|
||||||
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
|
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
|
||||||
|
TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减)
|
||||||
|
Level int `gorm:"-" json:"level"` // 用户等级(AfterFind 由 TotalPoints 计算,纯展示不入库)
|
||||||
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
|
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
|
||||||
LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新)
|
LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新)
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
|
// 管理员颁发的徽章;仅显式 Preload("User.Badges.Badge") 的查询点加载,未加载时省略
|
||||||
|
Badges []UserBadge `gorm:"foreignKey:UserID" json:"badges,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AfterFind 由累计获得积分计算等级(纯算术,随行加载,所有序列化 User 的接口自动携带 level)
|
||||||
|
func (u *User) AfterFind(tx *gorm.DB) error {
|
||||||
|
u.Level = LevelOf(u.TotalPoints)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshToken 刷新令牌表(支持服务端撤销、一次性轮转与盗用检测)
|
// RefreshToken 刷新令牌表(支持服务端撤销、一次性轮转与盗用检测)
|
||||||
@@ -276,6 +287,32 @@ type PointLedger struct {
|
|||||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Badge 管理员自定义徽章定义(名称/图标/颜色)
|
||||||
|
type Badge struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"size:32;not null;uniqueIndex" json:"name"` // ≤16 字(runes 校验见 service 层)
|
||||||
|
Icon string `gorm:"size:32;not null;default:''" json:"icon"` // lucide 图标名(前端白名单渲染,未知回落 Medal)
|
||||||
|
Color string `gorm:"size:16;not null;default:''" json:"color"` // CSS token 色板键(gold/accent/ok/danger/info/purple/teal)
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserBadge 徽章授予记录(同一徽章每用户至多一枚)
|
||||||
|
type UserBadge struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
BadgeID uint `gorm:"not null;uniqueIndex:idx_badge_user" json:"badge_id"`
|
||||||
|
UserID uint `gorm:"not null;uniqueIndex:idx_badge_user;index" json:"user_id"`
|
||||||
|
Badge Badge `gorm:"foreignKey:BadgeID" json:"badge"` // 嵌套 Preload("User.Badges.Badge") 时填充
|
||||||
|
AwardedBy uint `json:"awarded_by"`
|
||||||
|
AwardedAt time.Time `json:"awarded_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 徽章色板键(对应前端 CSS token --<color> / --<color>-soft)
|
||||||
|
var BadgeColors = map[string]bool{
|
||||||
|
"gold": true, "accent": true, "ok": true, "danger": true,
|
||||||
|
"info": true, "purple": true, "teal": true,
|
||||||
|
}
|
||||||
|
|
||||||
// 积分流水原因
|
// 积分流水原因
|
||||||
const (
|
const (
|
||||||
PointReasonCheckin = "checkin"
|
PointReasonCheckin = "checkin"
|
||||||
@@ -285,6 +322,11 @@ const (
|
|||||||
PointReasonBountyEscrow = "bounty_escrow"
|
PointReasonBountyEscrow = "bounty_escrow"
|
||||||
PointReasonBountyRefund = "bounty_refund"
|
PointReasonBountyRefund = "bounty_refund"
|
||||||
PointReasonBountyAward = "bounty_award"
|
PointReasonBountyAward = "bounty_award"
|
||||||
|
PointReasonPostReward = "post_reward" // 发帖奖励(可配置,0=关闭)
|
||||||
|
PointReasonReplyReward = "reply_reward" // 回复奖励(可配置,0=关闭)
|
||||||
|
PointReasonStreakBonus = "streak_bonus" // 连续签到里程碑加成
|
||||||
|
PointReasonRecommend = "recommend_reward" // 帖子被推荐奖励(可配置,0=关闭,每帖仅一次)
|
||||||
|
PointReasonAdminAdjust = "admin_adjust" // 管理员手动调整(加分计累计可升级;扣分仅扣余额)
|
||||||
)
|
)
|
||||||
|
|
||||||
// PostPollVote 投票记录(多选时同一用户多行)
|
// PostPollVote 投票记录(多选时同一用户多行)
|
||||||
@@ -396,6 +438,7 @@ const (
|
|||||||
NotificationTypeRejected = "rejected" // 内容审核未通过
|
NotificationTypeRejected = "rejected" // 内容审核未通过
|
||||||
NotificationTypeMention = "mention" // 群聊中 @ 了你(PostID=0,用 RoomID/MessageID)
|
NotificationTypeMention = "mention" // 群聊中 @ 了你(PostID=0,用 RoomID/MessageID)
|
||||||
NotificationTypePendingReview = "pending_review" // 有新内容待审核(发给可审者)
|
NotificationTypePendingReview = "pending_review" // 有新内容待审核(发给可审者)
|
||||||
|
NotificationTypeBadge = "badge" // 获得管理员颁发的徽章
|
||||||
NotificationTypeDeleted = "deleted" // 帖子被管理员删除
|
NotificationTypeDeleted = "deleted" // 帖子被管理员删除
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -414,7 +457,8 @@ type Notification struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"`
|
Actor User `gorm:"foreignKey:ActorID" json:"actor,omitempty"`
|
||||||
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
|
// PostID 允许为 0(mention/badge 等无关联帖子),禁建 FK 约束,否则 post_id=0 插入违反外键
|
||||||
|
Post Post `gorm:"foreignKey:PostID;constraint:-" json:"post,omitempty"`
|
||||||
Room *ChatRoom `gorm:"foreignKey:RoomID;constraint:-" json:"room,omitempty"`
|
Room *ChatRoom `gorm:"foreignKey:RoomID;constraint:-" json:"room,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,14 +58,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
|
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
|
||||||
boardSvc := service.NewBoardService(model.DB)
|
boardSvc := service.NewBoardService(model.DB)
|
||||||
settingSvc := service.NewSettingService(model.DB)
|
settingSvc := service.NewSettingService(model.DB)
|
||||||
|
// 等级体系快照随进程启动加载(后台保存时经 SetLevels 刷新,无需重启)
|
||||||
|
model.ApplyLevelDefs(settingSvc.Levels())
|
||||||
ops := service.NewOperations(model.DB, cfg)
|
ops := service.NewOperations(model.DB, cfg)
|
||||||
go ops.Run(context.Background())
|
go ops.Run(context.Background())
|
||||||
postSvc := service.NewPostService(model.DB).WithSetting(settingSvc).WithBoard(boardSvc).WithDevMode(cfg.DevMode)
|
postSvc := service.NewPostService(model.DB).WithSetting(settingSvc).WithBoard(boardSvc).WithDevMode(cfg.DevMode)
|
||||||
commentSvc := service.NewCommentService(model.DB)
|
commentSvc := service.NewCommentService(model.DB).WithSetting(settingSvc)
|
||||||
likeSvc := service.NewLikeService(model.DB)
|
likeSvc := service.NewLikeService(model.DB)
|
||||||
notifSvc := service.NewNotificationService(model.DB)
|
notifSvc := service.NewNotificationService(model.DB)
|
||||||
overviewSvc := service.NewOverviewService(model.DB)
|
overviewSvc := service.NewOverviewService(model.DB)
|
||||||
checkinSvc := service.NewCheckinService(model.DB)
|
checkinSvc := service.NewCheckinService(model.DB).WithSetting(settingSvc)
|
||||||
announcementSvc := service.NewAnnouncementService(model.DB)
|
announcementSvc := service.NewAnnouncementService(model.DB)
|
||||||
sitePageSvc := service.NewSitePageService(model.DB)
|
sitePageSvc := service.NewSitePageService(model.DB)
|
||||||
uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads")).WithSetting(settingSvc)
|
uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads")).WithSetting(settingSvc)
|
||||||
@@ -77,6 +79,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
|
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
|
||||||
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
||||||
chatSvc := service.NewChatService(model.DB, notifSvc)
|
chatSvc := service.NewChatService(model.DB, notifSvc)
|
||||||
|
badgeSvc := service.NewBadgeService(model.DB).WithNotification(notifSvc)
|
||||||
visitSvc := service.NewVisitStatsService(model.DB, settingSvc)
|
visitSvc := service.NewVisitStatsService(model.DB, settingSvc)
|
||||||
analyticsSvc := service.NewAnalyticsService(model.DB)
|
analyticsSvc := service.NewAnalyticsService(model.DB)
|
||||||
adSvc := service.NewAdService(model.DB, service.NewCaptchaStore())
|
adSvc := service.NewAdService(model.DB, service.NewCaptchaStore())
|
||||||
@@ -122,6 +125,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
|
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
|
||||||
Ads: adSvc,
|
Ads: adSvc,
|
||||||
Sidebar: service.NewSidebarService(model.DB),
|
Sidebar: service.NewSidebarService(model.DB),
|
||||||
|
Badge: badgeSvc,
|
||||||
}
|
}
|
||||||
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
||||||
notifSvc.OnNotifyNew = func(userID uint) {
|
notifSvc.OnNotifyNew = func(userID uint) {
|
||||||
@@ -340,6 +344,18 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
staffAPI.GET("/sidebar", authMW.RequirePerm(service.PermSettings), h.AdminGetSidebar)
|
staffAPI.GET("/sidebar", authMW.RequirePerm(service.PermSettings), h.AdminGetSidebar)
|
||||||
staffAPI.PUT("/sidebar/config", authMW.RequirePerm(service.PermSettings), h.AdminSaveSidebarConfig)
|
staffAPI.PUT("/sidebar/config", authMW.RequirePerm(service.PermSettings), h.AdminSaveSidebarConfig)
|
||||||
staffAPI.POST("/sidebar/widgets", authMW.RequirePerm(service.PermSettings), h.AdminCreateSidebarWidget)
|
staffAPI.POST("/sidebar/widgets", authMW.RequirePerm(service.PermSettings), h.AdminCreateSidebarWidget)
|
||||||
|
|
||||||
|
// 徽章管理(超级管理员/站长):定义 CRUD + 授予/撤销
|
||||||
|
badgeAPI := staffAPI.Group("/badges", authMW.RequirePerm(service.PermSettings))
|
||||||
|
{
|
||||||
|
badgeAPI.GET("", h.AdminListBadges)
|
||||||
|
badgeAPI.POST("", h.AdminCreateBadge)
|
||||||
|
badgeAPI.PUT("/:id", h.AdminUpdateBadge)
|
||||||
|
badgeAPI.DELETE("/:id", h.AdminDeleteBadge)
|
||||||
|
badgeAPI.GET("/:id/holders", h.AdminBadgeHolders)
|
||||||
|
badgeAPI.POST("/:id/grant", h.AdminGrantBadge)
|
||||||
|
badgeAPI.DELETE("/:id/grant/:userId", h.AdminRevokeBadge)
|
||||||
|
}
|
||||||
staffAPI.PUT("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminUpdateSidebarWidget)
|
staffAPI.PUT("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminUpdateSidebarWidget)
|
||||||
staffAPI.DELETE("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminDeleteSidebarWidget)
|
staffAPI.DELETE("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminDeleteSidebarWidget)
|
||||||
|
|
||||||
@@ -368,6 +384,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages)
|
usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages)
|
||||||
usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan)
|
usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan)
|
||||||
usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs)
|
usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs)
|
||||||
|
// 积分管理:手动调整(加分计累计/扣分仅扣余额)与积分流水分页
|
||||||
|
usersAPI.PUT("/users/:id/points", h.AdminAdjustUserPoints)
|
||||||
|
usersAPI.GET("/users/:id/points/ledger", h.AdminUserPointLedger)
|
||||||
// 站长内容档案(handler 内再校验 owner)
|
// 站长内容档案(handler 内再校验 owner)
|
||||||
usersAPI.GET("/users/:id", h.AdminGetUserAudit)
|
usersAPI.GET("/users/:id", h.AdminGetUserAudit)
|
||||||
usersAPI.GET("/users/:id/posts", h.AdminUserAuditPosts)
|
usersAPI.GET("/users/:id/posts", h.AdminUserAuditPosts)
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ type AdminUserItem struct {
|
|||||||
PostCount int64 `json:"post_count"`
|
PostCount int64 `json:"post_count"`
|
||||||
CommentCount int64 `json:"comment_count"`
|
CommentCount int64 `json:"comment_count"`
|
||||||
Points int `json:"points"`
|
Points int `json:"points"`
|
||||||
|
TotalPoints int `json:"total_points"`
|
||||||
|
Level int `json:"level"` // AfterFind 按 TotalPoints 计算
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
LastSeenAt *time.Time `json:"last_seen_at"`
|
LastSeenAt *time.Time `json:"last_seen_at"`
|
||||||
Online bool `json:"online"`
|
Online bool `json:"online"`
|
||||||
@@ -90,7 +92,7 @@ type AdminUserListQuery struct {
|
|||||||
Keyword string // 用户名 / 昵称 / 邮箱模糊匹配
|
Keyword string // 用户名 / 昵称 / 邮箱模糊匹配
|
||||||
Role string // "" 全部 | "staff" 管理团队 | 具体角色枚举
|
Role string // "" 全部 | "staff" 管理团队 | 具体角色枚举
|
||||||
Status string // "" 全部 | "banned" 已封禁 | "normal" 正常
|
Status string // "" 全部 | "banned" 已封禁 | "normal" 正常
|
||||||
Sort string // 白名单列:id / role / status / points / last_login / counts / created_at / online
|
Sort string // 白名单列:id / role / status / points / total_points / last_login / counts / created_at / online
|
||||||
Dir string // asc | desc;非法值按 desc
|
Dir string // asc | desc;非法值按 desc
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +112,8 @@ func adminUserOrderSQL(sort, dir string) string {
|
|||||||
return fmt.Sprintf("users.banned %s, users.id DESC", ord)
|
return fmt.Sprintf("users.banned %s, users.id DESC", ord)
|
||||||
case "points":
|
case "points":
|
||||||
return fmt.Sprintf("users.points %s, users.id DESC", ord)
|
return fmt.Sprintf("users.points %s, users.id DESC", ord)
|
||||||
|
case "total_points":
|
||||||
|
return fmt.Sprintf("users.total_points %s, users.id DESC", ord)
|
||||||
case "created_at":
|
case "created_at":
|
||||||
return fmt.Sprintf("users.created_at %s, users.id DESC", ord)
|
return fmt.Sprintf("users.created_at %s, users.id DESC", ord)
|
||||||
case "online":
|
case "online":
|
||||||
@@ -234,6 +238,8 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem {
|
|||||||
Banned: u.Banned,
|
Banned: u.Banned,
|
||||||
CanManageMessages: u.CanManageMessages,
|
CanManageMessages: u.CanManageMessages,
|
||||||
Points: u.Points,
|
Points: u.Points,
|
||||||
|
TotalPoints: u.TotalPoints,
|
||||||
|
Level: u.Level,
|
||||||
CreatedAt: u.CreatedAt,
|
CreatedAt: u.CreatedAt,
|
||||||
LastSeenAt: u.LastSeenAt,
|
LastSeenAt: u.LastSeenAt,
|
||||||
}
|
}
|
||||||
|
|||||||
240
backend/service/badge.go
Normal file
240
backend/service/badge.go
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 徽章业务错误
|
||||||
|
var (
|
||||||
|
ErrBadgeNotFound = errors.New("徽章不存在")
|
||||||
|
ErrBadgeNameExists = errors.New("徽章名称已存在")
|
||||||
|
ErrBadgeInvalid = errors.New("徽章参数无效")
|
||||||
|
ErrBadgeGranted = errors.New("用户已持有该徽章")
|
||||||
|
ErrBadgeUserGone = errors.New("用户不存在")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxBadgeNameRunes = 16
|
||||||
|
DefaultBadgeColor = "accent"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BadgeWithHolders 徽章 + 持有人数(后台管理列表用)
|
||||||
|
type BadgeWithHolders struct {
|
||||||
|
model.Badge
|
||||||
|
Holders int64 `json:"holders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BadgeService 管理员自定义徽章
|
||||||
|
type BadgeService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
notif *NotificationService
|
||||||
|
setting *SettingService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBadgeService(db *gorm.DB) *BadgeService {
|
||||||
|
return &BadgeService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithNotification 注入通知服务(授予时通知用户)
|
||||||
|
func (s *BadgeService) WithNotification(n *NotificationService) *BadgeService {
|
||||||
|
s.notif = n
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateBadge 校验并规整名称/图标/颜色
|
||||||
|
func validateBadge(name, icon, color string) (string, string, string, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if utf8.RuneCountInString(name) < 1 || utf8.RuneCountInString(name) > MaxBadgeNameRunes {
|
||||||
|
return "", "", "", ErrBadgeInvalid
|
||||||
|
}
|
||||||
|
icon = strings.TrimSpace(icon)
|
||||||
|
if utf8.RuneCountInString(icon) > 32 {
|
||||||
|
return "", "", "", ErrBadgeInvalid
|
||||||
|
}
|
||||||
|
color = strings.TrimSpace(color)
|
||||||
|
if color == "" {
|
||||||
|
color = DefaultBadgeColor
|
||||||
|
}
|
||||||
|
if !model.BadgeColors[color] {
|
||||||
|
return "", "", "", ErrBadgeInvalid
|
||||||
|
}
|
||||||
|
return name, icon, color, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 全部徽章 + 持有数
|
||||||
|
func (s *BadgeService) List() ([]BadgeWithHolders, error) {
|
||||||
|
var badges []model.Badge
|
||||||
|
if err := s.db.Order("id ASC").Find(&badges).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
type cnt struct {
|
||||||
|
BadgeID uint
|
||||||
|
N int64
|
||||||
|
}
|
||||||
|
var counts []cnt
|
||||||
|
if err := s.db.Model(&model.UserBadge{}).
|
||||||
|
Select("badge_id, count(1) AS n").
|
||||||
|
Group("badge_id").Scan(&counts).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := make(map[uint]int64, len(counts))
|
||||||
|
for _, c := range counts {
|
||||||
|
m[c.BadgeID] = c.N
|
||||||
|
}
|
||||||
|
out := make([]BadgeWithHolders, 0, len(badges))
|
||||||
|
for _, b := range badges {
|
||||||
|
out = append(out, BadgeWithHolders{Badge: b, Holders: m[b.ID]})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 新建徽章
|
||||||
|
func (s *BadgeService) Create(name, icon, color string) (*model.Badge, error) {
|
||||||
|
name, icon, color, err := validateBadge(name, icon, color)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b := model.Badge{Name: name, Icon: icon, Color: color}
|
||||||
|
if err := s.db.Create(&b).Error; err != nil {
|
||||||
|
if isUniqueConflict(err) {
|
||||||
|
return nil, ErrBadgeNameExists
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 修改徽章(不影响已授予记录)
|
||||||
|
func (s *BadgeService) Update(id uint, name, icon, color string) (*model.Badge, error) {
|
||||||
|
name, icon, color, err := validateBadge(name, icon, color)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var b model.Badge
|
||||||
|
if err := s.db.First(&b, id).Error; err != nil {
|
||||||
|
return nil, ErrBadgeNotFound
|
||||||
|
}
|
||||||
|
b.Name, b.Icon, b.Color = name, icon, color
|
||||||
|
if err := s.db.Save(&b).Error; err != nil {
|
||||||
|
if isUniqueConflict(err) {
|
||||||
|
return nil, ErrBadgeNameExists
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除徽章并级联删除全部授予记录。
|
||||||
|
// postgres 下 AutoMigrate 生成的 FK 为 NO ACTION,必须先删引用行再删徽章
|
||||||
|
func (s *BadgeService) Delete(id uint) error {
|
||||||
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("badge_id = ?", id).Delete(&model.UserBadge{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res := tx.Delete(&model.Badge{}, id)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return ErrBadgeNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant 授予徽章(幂等:已持有直接返回成功);同时发站内通知
|
||||||
|
func (s *BadgeService) Grant(badgeID, userID, operatorID uint) error {
|
||||||
|
var badge model.Badge
|
||||||
|
if err := s.db.First(&badge, badgeID).Error; err != nil {
|
||||||
|
return ErrBadgeNotFound
|
||||||
|
}
|
||||||
|
var user model.User
|
||||||
|
if err := s.db.Select("id", "nickname").First(&user, userID).Error; err != nil {
|
||||||
|
return ErrBadgeUserGone
|
||||||
|
}
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&model.UserBadge{}).
|
||||||
|
Where("badge_id = ? AND user_id = ?", badgeID, userID).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return nil // 幂等
|
||||||
|
}
|
||||||
|
ub := model.UserBadge{BadgeID: badgeID, UserID: userID, AwardedBy: operatorID, AwardedAt: time.Now()}
|
||||||
|
return tx.Create(&ub).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.notif != nil {
|
||||||
|
s.notif.Create(userID, operatorID, model.NotificationTypeBadge, 0, 0, "获得徽章「"+badge.Name+"」")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke 撤销授予
|
||||||
|
func (s *BadgeService) Revoke(badgeID, userID uint) error {
|
||||||
|
res := s.db.Where("badge_id = ? AND user_id = ?", badgeID, userID).Delete(&model.UserBadge{})
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return ErrBadgeNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BadgeHolder 持有者视图(后台授予面板用)
|
||||||
|
type BadgeHolder struct {
|
||||||
|
UserID uint `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
AwardedAt time.Time `json:"awarded_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holders 某徽章全部持有者(含用户名)
|
||||||
|
func (s *BadgeService) Holders(badgeID uint) ([]BadgeHolder, error) {
|
||||||
|
var rows []BadgeHolder
|
||||||
|
if err := s.db.Table("user_badges").
|
||||||
|
Select("user_badges.user_id, users.username, users.nickname, user_badges.awarded_at").
|
||||||
|
Joins("JOIN users ON users.id = user_badges.user_id").
|
||||||
|
Where("user_badges.badge_id = ? AND users.deleted_at IS NULL", badgeID).
|
||||||
|
Order("user_badges.awarded_at DESC").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForUser 用户持有徽章(含定义与授予时间)
|
||||||
|
func (s *BadgeService) ForUser(userID uint) ([]model.UserBadge, error) {
|
||||||
|
var rows []model.UserBadge
|
||||||
|
if err := s.db.Preload("Badge").
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
Order("awarded_at ASC").
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isUniqueConflict 唯一索引冲突(名称重复等):postgres 报 duplicate key,
|
||||||
|
// sqlite(测试内存库)报 UNIQUE constraint failed
|
||||||
|
func isUniqueConflict(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return errors.Is(err, gorm.ErrDuplicatedKey) ||
|
||||||
|
strings.Contains(msg, "duplicate key") ||
|
||||||
|
strings.Contains(msg, "unique constraint")
|
||||||
|
}
|
||||||
159
backend/service/badge_test.go
Normal file
159
backend/service/badge_test.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newBadgeTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.User{}, &model.Badge{}, &model.UserBadge{}); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadgeGrantIdempotent(t *testing.T) {
|
||||||
|
db := newBadgeTestDB(t)
|
||||||
|
s := NewBadgeService(db)
|
||||||
|
|
||||||
|
user := model.User{Username: "alice", Password: "x"}
|
||||||
|
if err := db.Create(&user).Error; err != nil {
|
||||||
|
t.Fatalf("seed user: %v", err)
|
||||||
|
}
|
||||||
|
badge, err := s.Create(" 骨灰玩家 ", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
if badge.Name != "骨灰玩家" || badge.Color != DefaultBadgeColor {
|
||||||
|
t.Fatalf("名称未规整/默认色不符: %+v", badge)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Grant(badge.ID, user.ID, 9); err != nil {
|
||||||
|
t.Fatalf("Grant: %v", err)
|
||||||
|
}
|
||||||
|
// 重复授予应幂等成功且不产生新行
|
||||||
|
if err := s.Grant(badge.ID, user.ID, 9); err != nil {
|
||||||
|
t.Fatalf("Grant twice: %v", err)
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
db.Model(&model.UserBadge{}).Where("badge_id = ?", badge.ID).Count(&count)
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("授予记录=%d want 1", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不存在的用户
|
||||||
|
if err := s.Grant(badge.ID, 999, 9); !errors.Is(err, ErrBadgeUserGone) {
|
||||||
|
t.Fatalf("不存在用户应 ErrBadgeUserGone, got %v", err)
|
||||||
|
}
|
||||||
|
// 不存在的徽章
|
||||||
|
if err := s.Grant(999, user.ID, 9); !errors.Is(err, ErrBadgeNotFound) {
|
||||||
|
t.Fatalf("不存在徽章应 ErrBadgeNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForUser 带 Badge 定义
|
||||||
|
rows, err := s.ForUser(user.ID)
|
||||||
|
if err != nil || len(rows) != 1 {
|
||||||
|
t.Fatalf("ForUser: %v rows=%d", err, len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].Badge.Name != "骨灰玩家" {
|
||||||
|
t.Fatalf("Preload Badge 失败: %+v", rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke 后再撤销 → NotFound
|
||||||
|
if err := s.Revoke(badge.ID, user.ID); err != nil {
|
||||||
|
t.Fatalf("Revoke: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Revoke(badge.ID, user.ID); !errors.Is(err, ErrBadgeNotFound) {
|
||||||
|
t.Fatal("重复撤销应 ErrBadgeNotFound")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadgeDeleteCascadeAndListCount(t *testing.T) {
|
||||||
|
db := newBadgeTestDB(t)
|
||||||
|
s := NewBadgeService(db)
|
||||||
|
|
||||||
|
u1 := model.User{Username: "u1", Password: "x"}
|
||||||
|
u2 := model.User{Username: "u2", Password: "x"}
|
||||||
|
if err := db.Create(&u1).Error; err != nil {
|
||||||
|
t.Fatalf("seed u1: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&u2).Error; err != nil {
|
||||||
|
t.Fatalf("seed u2: %v", err)
|
||||||
|
}
|
||||||
|
b1, _ := s.Create("元老", "medal", "gold")
|
||||||
|
b2, _ := s.Create("萌新", "", "")
|
||||||
|
|
||||||
|
if err := s.Grant(b1.ID, u1.ID, 9); err != nil {
|
||||||
|
t.Fatalf("Grant b1: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Grant(b1.ID, u2.ID, 9); err != nil {
|
||||||
|
t.Fatalf("Grant b1 u2: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Grant(b2.ID, u1.ID, 9); err != nil {
|
||||||
|
t.Fatalf("Grant b2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 计数
|
||||||
|
list, err := s.List()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 2 {
|
||||||
|
t.Fatalf("徽章数=%d want 2", len(list))
|
||||||
|
}
|
||||||
|
for _, b := range list {
|
||||||
|
want := int64(2)
|
||||||
|
if b.ID == b2.ID {
|
||||||
|
want = 1
|
||||||
|
}
|
||||||
|
if b.Holders != want {
|
||||||
|
t.Fatalf("徽章 %d 持有数=%d want %d", b.ID, b.Holders, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除级联:徽章与其授予记录一起消失
|
||||||
|
if err := s.Delete(b1.ID); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
db.Model(&model.UserBadge{}).Where("badge_id = ?", b1.ID).Count(&count)
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("删除后授予记录=%d want 0", count)
|
||||||
|
}
|
||||||
|
if err := s.Delete(b1.ID); !errors.Is(err, ErrBadgeNotFound) {
|
||||||
|
t.Fatal("重复删除应 ErrBadgeNotFound")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 名称唯一冲突
|
||||||
|
if _, err := s.Create("萌新", "", ""); !errors.Is(err, ErrBadgeNameExists) {
|
||||||
|
t.Fatalf("重名应 ErrBadgeNameExists, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadgeValidation(t *testing.T) {
|
||||||
|
s := NewBadgeService(newBadgeTestDB(t))
|
||||||
|
|
||||||
|
// 空名称 / 超长名称 / 非法颜色
|
||||||
|
if _, err := s.Create(" ", "", ""); !errors.Is(err, ErrBadgeInvalid) {
|
||||||
|
t.Fatal("空名称应被拒绝")
|
||||||
|
}
|
||||||
|
long := make([]rune, MaxBadgeNameRunes+1)
|
||||||
|
for i := range long {
|
||||||
|
long[i] = '字'
|
||||||
|
}
|
||||||
|
if _, err := s.Create(string(long), "", ""); !errors.Is(err, ErrBadgeInvalid) {
|
||||||
|
t.Fatal("超长名称应被拒绝")
|
||||||
|
}
|
||||||
|
if _, err := s.Create("x", "", "pink"); !errors.Is(err, ErrBadgeInvalid) {
|
||||||
|
t.Fatal("色板外颜色应被拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -455,7 +455,7 @@ func (s *BoardService) Sidebar(boardID uint) (*BoardSidebarData, error) {
|
|||||||
var hotPosts []model.Post
|
var hotPosts []model.Post
|
||||||
if err := s.db.Where("board_id = ? AND status = ? AND pinned = 0", boardID, model.ContentStatusPublished).
|
if err := s.db.Where("board_id = ? AND status = ? AND pinned = 0", boardID, model.ContentStatusPublished).
|
||||||
Order("(like_count + comment_count * 2) DESC, created_at DESC").
|
Order("(like_count + comment_count * 2) DESC, created_at DESC").
|
||||||
Limit(5).Preload("Board").Preload("User").Find(&hotPosts).Error; err != nil {
|
Limit(5).Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&hotPosts).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
data.Hot = toPostListItems(hotPosts)
|
data.Hot = toPostListItems(hotPosts)
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ func (s *ChatService) attachPeers(views []RoomView, viewerID uint) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var members []model.ChatRoomMember
|
var members []model.ChatRoomMember
|
||||||
s.db.Where("room_id IN ? AND user_id <> ?", directIDs, viewerID).Preload("User").Find(&members)
|
s.db.Where("room_id IN ? AND user_id <> ?", directIDs, viewerID).Preload("User").Preload("User.Badges.Badge").Find(&members)
|
||||||
peerByRoom := make(map[uint]*model.User, len(members))
|
peerByRoom := make(map[uint]*model.User, len(members))
|
||||||
for i := range members {
|
for i := range members {
|
||||||
u := members[i].User
|
u := members[i].User
|
||||||
@@ -717,7 +717,7 @@ func (s *ChatService) ListMembers(userID, roomID uint, oversee bool) ([]model.Ch
|
|||||||
var list []model.ChatRoomMember
|
var list []model.ChatRoomMember
|
||||||
err := s.db.Where("room_id = ?", roomID).
|
err := s.db.Where("room_id = ?", roomID).
|
||||||
Order("CASE WHEN role = 'owner' THEN 0 WHEN role = 'admin' THEN 1 ELSE 2 END, created_at ASC").
|
Order("CASE WHEN role = 'owner' THEN 0 WHEN role = 'admin' THEN 1 ELSE 2 END, created_at ASC").
|
||||||
Preload("User").
|
Preload("User").Preload("User.Badges.Badge").
|
||||||
Find(&list).Error
|
Find(&list).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1218,7 +1218,7 @@ func (s *ChatService) AdminListMessages(actor *Actor, page int, keyword, roomTyp
|
|||||||
participantsByRoom := map[uint][]AdminChatParticipant{}
|
participantsByRoom := map[uint][]AdminChatParticipant{}
|
||||||
if roomType == model.ChatRoomTypeDirect && len(roomIDs) > 0 {
|
if roomType == model.ChatRoomTypeDirect && len(roomIDs) > 0 {
|
||||||
var members []model.ChatRoomMember
|
var members []model.ChatRoomMember
|
||||||
s.db.Where("room_id IN ?", roomIDs).Preload("User").Find(&members)
|
s.db.Where("room_id IN ?", roomIDs).Preload("User").Preload("User.Badges.Badge").Find(&members)
|
||||||
for i := range members {
|
for i := range members {
|
||||||
name := members[i].User.Nickname
|
name := members[i].User.Nickname
|
||||||
if name == "" {
|
if name == "" {
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DailyCheckinPoints 每日签到固定积分
|
// DailyCheckinPoints 每日签到默认基础分(可被设置 points_checkin_base 覆盖)
|
||||||
const DailyCheckinPoints = 5
|
const DailyCheckinPoints = 5
|
||||||
|
|
||||||
// maxStreakScan 连续天数最多向前回溯的天数
|
// maxStreakScan 连续天数最多向前回溯的天数
|
||||||
@@ -22,18 +24,38 @@ type CheckinStatus struct {
|
|||||||
CheckedToday bool `json:"checked_today"`
|
CheckedToday bool `json:"checked_today"`
|
||||||
Streak int `json:"streak"` // 连续签到天数
|
Streak int `json:"streak"` // 连续签到天数
|
||||||
TotalPoints int `json:"total_points"` // 累计积分(可用余额)
|
TotalPoints int `json:"total_points"` // 累计积分(可用余额)
|
||||||
TodayPoints int `json:"today_points"` // 今日签到可得积分
|
TodayPoints int `json:"today_points"` // 今日签到可得基础积分(连续加成另行入账,流水可见)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckinService 每日签到
|
// CheckinService 每日签到
|
||||||
type CheckinService struct {
|
type CheckinService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
setting *SettingService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCheckinService(db *gorm.DB) *CheckinService {
|
func NewCheckinService(db *gorm.DB) *CheckinService {
|
||||||
return &CheckinService{db: db}
|
return &CheckinService{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithSetting 注入站点设置(积分增长规则;未注入时用默认值)
|
||||||
|
func (s *CheckinService) WithSetting(setting *SettingService) *CheckinService {
|
||||||
|
s.setting = setting
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// rules 读取积分规则;未注入或读取失败回落默认值(签到可用性优先于规则精度)
|
||||||
|
func (s *CheckinService) rules() PointsRules {
|
||||||
|
if s.setting == nil {
|
||||||
|
return PointsRules{CheckinBase: DefaultPointsCheckinBase, StreakEveryDays: DefaultPointsStreakEveryDays}
|
||||||
|
}
|
||||||
|
rules, err := s.setting.PointsRules()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[checkin] 读取积分规则失败,使用默认值: %v", err)
|
||||||
|
return PointsRules{CheckinBase: DefaultPointsCheckinBase, StreakEveryDays: DefaultPointsStreakEveryDays}
|
||||||
|
}
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
// localDay 取本地时区零点日期
|
// localDay 取本地时区零点日期
|
||||||
func localDay(t time.Time) time.Time {
|
func localDay(t time.Time) time.Time {
|
||||||
y, m, d := t.Date()
|
y, m, d := t.Date()
|
||||||
@@ -53,9 +75,25 @@ func streakFrom(dates map[string]bool, anchor time.Time) int {
|
|||||||
return streak
|
return streak
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// streakDatesInTx 从事务连接查询 anchor 起回溯窗口内的签到日期集合
|
||||||
|
func streakDatesInTx(tx *gorm.DB, userID uint, anchor time.Time) (map[string]bool, error) {
|
||||||
|
var dates []time.Time
|
||||||
|
if err := tx.Model(&model.Checkin{}).
|
||||||
|
Where("user_id = ? AND checkin_date >= ?", userID, anchor.AddDate(0, 0, -maxStreakScan)).
|
||||||
|
Pluck("checkin_date", &dates).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dateSet := make(map[string]bool, len(dates))
|
||||||
|
for _, d := range dates {
|
||||||
|
dateSet[localDay(d).Format("2006-01-02")] = true
|
||||||
|
}
|
||||||
|
return dateSet, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Status 查询用户签到状态
|
// Status 查询用户签到状态
|
||||||
func (s *CheckinService) Status(userID uint) (*CheckinStatus, error) {
|
func (s *CheckinService) Status(userID uint) (*CheckinStatus, error) {
|
||||||
today := localDay(time.Now())
|
today := localDay(time.Now())
|
||||||
|
base := s.rules().CheckinBase
|
||||||
|
|
||||||
var todayRow model.Checkin
|
var todayRow model.Checkin
|
||||||
err := s.db.Where("user_id = ? AND checkin_date::date = ?::date", userID, today).
|
err := s.db.Where("user_id = ? AND checkin_date::date = ?::date", userID, today).
|
||||||
@@ -73,16 +111,10 @@ func (s *CheckinService) Status(userID uint) (*CheckinStatus, error) {
|
|||||||
if !checkedToday {
|
if !checkedToday {
|
||||||
anchor = today.AddDate(0, 0, -1)
|
anchor = today.AddDate(0, 0, -1)
|
||||||
}
|
}
|
||||||
var dates []time.Time
|
dateSet, err := streakDatesInTx(s.db, userID, anchor)
|
||||||
if err := s.db.Model(&model.Checkin{}).
|
if err != nil {
|
||||||
Where("user_id = ? AND checkin_date >= ?", userID, anchor.AddDate(0, 0, -maxStreakScan)).
|
|
||||||
Pluck("checkin_date", &dates).Error; err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
dateSet := make(map[string]bool, len(dates))
|
|
||||||
for _, d := range dates {
|
|
||||||
dateSet[localDay(d).Format("2006-01-02")] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := s.db.Model(&model.User{}).
|
if err := s.db.Model(&model.User{}).
|
||||||
@@ -95,13 +127,15 @@ func (s *CheckinService) Status(userID uint) (*CheckinStatus, error) {
|
|||||||
CheckedToday: checkedToday,
|
CheckedToday: checkedToday,
|
||||||
Streak: streakFrom(dateSet, anchor),
|
Streak: streakFrom(dateSet, anchor),
|
||||||
TotalPoints: int(total),
|
TotalPoints: int(total),
|
||||||
TodayPoints: DailyCheckinPoints,
|
TodayPoints: base,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckIn 执行今日签到(每日一条,唯一索引兜底并发)
|
// CheckIn 执行今日签到(每日一条,唯一索引兜底并发)。
|
||||||
|
// 基础分与连续签到里程碑加成在同一事务内入账(加成规则可配置:每满 N 天额外 +X)。
|
||||||
func (s *CheckinService) CheckIn(userID uint) (*CheckinStatus, error) {
|
func (s *CheckinService) CheckIn(userID uint) (*CheckinStatus, error) {
|
||||||
today := localDay(time.Now())
|
today := localDay(time.Now())
|
||||||
|
rules := s.rules()
|
||||||
|
|
||||||
var status *CheckinStatus
|
var status *CheckinStatus
|
||||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
@@ -117,15 +151,28 @@ func (s *CheckinService) CheckIn(userID uint) (*CheckinStatus, error) {
|
|||||||
row := model.Checkin{
|
row := model.Checkin{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
CheckinDate: today,
|
CheckinDate: today,
|
||||||
Points: DailyCheckinPoints,
|
Points: rules.CheckinBase,
|
||||||
}
|
}
|
||||||
if err := tx.Create(&row).Error; err != nil {
|
if err := tx.Create(&row).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// 同步入账到用户积分余额
|
// 同步入账到用户积分余额
|
||||||
if _, err := CreditTx(tx, userID, DailyCheckinPoints, model.PointReasonCheckin, "checkin", row.ID, "每日签到"); err != nil {
|
if _, err := CreditTx(tx, userID, rules.CheckinBase, model.PointReasonCheckin, "checkin", row.ID, "每日签到"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 连续签到里程碑加成(每满 N 天额外 +X;N=0 或加成=0 时关闭)
|
||||||
|
if rules.StreakEveryDays > 0 && rules.StreakBonus > 0 {
|
||||||
|
dateSet, err := streakDatesInTx(tx, userID, today)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if streak := streakFrom(dateSet, today); streak > 0 && streak%rules.StreakEveryDays == 0 {
|
||||||
|
if _, err := CreditTx(tx, userID, rules.StreakBonus, model.PointReasonStreakBonus, "checkin", row.ID,
|
||||||
|
"连续签到 "+strconv.Itoa(streak)+" 天奖励"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -23,12 +23,19 @@ var (
|
|||||||
// CommentService 评论服务
|
// CommentService 评论服务
|
||||||
type CommentService struct {
|
type CommentService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
setting *SettingService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCommentService(db *gorm.DB) *CommentService {
|
func NewCommentService(db *gorm.DB) *CommentService {
|
||||||
return &CommentService{db: db}
|
return &CommentService{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithSetting 注入站点设置(回复奖励规则;未注入时奖励关闭)
|
||||||
|
func (s *CommentService) WithSetting(setting *SettingService) *CommentService {
|
||||||
|
s.setting = setting
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// CommentNode 评论树节点:嵌入 model.Comment 使 JSON 字段拍平,replies 为空时省略
|
// CommentNode 评论树节点:嵌入 model.Comment 使 JSON 字段拍平,replies 为空时省略
|
||||||
type CommentNode struct {
|
type CommentNode struct {
|
||||||
model.Comment
|
model.Comment
|
||||||
@@ -84,7 +91,7 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
|
|||||||
if err := base.Session(&gorm.Session{}).
|
if err := base.Session(&gorm.Session{}).
|
||||||
Where("parent_id IS NULL").
|
Where("parent_id IS NULL").
|
||||||
Order("created_at ASC").
|
Order("created_at ASC").
|
||||||
Preload("User").
|
Preload("User").Preload("User.Badges.Badge").
|
||||||
Offset((page - 1) * size).
|
Offset((page - 1) * size).
|
||||||
Limit(size).
|
Limit(size).
|
||||||
Find(&roots).Error; err != nil {
|
Find(&roots).Error; err != nil {
|
||||||
@@ -100,7 +107,7 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
|
|||||||
}
|
}
|
||||||
replyQ := applyCommentListVisibility(s.db.Unscoped().Where("root_id IN (?)", rootIDs), boardID, viewerID, actor)
|
replyQ := applyCommentListVisibility(s.db.Unscoped().Where("root_id IN (?)", rootIDs), boardID, viewerID, actor)
|
||||||
var replies []model.Comment
|
var replies []model.Comment
|
||||||
if err := replyQ.Order("created_at ASC").Preload("User").Find(&replies).Error; err != nil {
|
if err := replyQ.Order("created_at ASC").Preload("User").Preload("User.Badges.Badge").Find(&replies).Error; err != nil {
|
||||||
return nil, 0, 0, err
|
return nil, 0, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,9 +362,19 @@ func (s *CommentService) Create(userID, postID uint, content string, parentID *u
|
|||||||
// 仅已发布评论立即计入评论数;待审评论通过审核时才 +1
|
// 仅已发布评论立即计入评论数;待审评论通过审核时才 +1
|
||||||
if comment.Status == model.ContentStatusPublished {
|
if comment.Status == model.ContentStatusPublished {
|
||||||
s.db.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
|
s.db.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
|
||||||
|
// 回复奖励(可配置;待审不发且过审不补发,防拒审刷分)
|
||||||
|
if s.setting != nil {
|
||||||
|
if rules, err := s.setting.PointsRules(); err == nil {
|
||||||
|
s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
RewardIfConfigured(tx, userID, rules.ReplyReward, rules.ReplyDailyCap,
|
||||||
|
model.PointReasonReplyReward, "comment", comment.ID, "回复奖励:"+post.Title)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 预加载用户
|
// 预加载用户
|
||||||
s.db.Preload("User").First(comment, comment.ID)
|
s.db.Preload("User").Preload("User.Badges.Badge").First(comment, comment.ID)
|
||||||
return comment, parent, nil
|
return comment, parent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,7 +665,7 @@ func (s *CommentService) Update(actor *Actor, commentID, userID uint, content st
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.db.Preload("User").First(&comment, comment.ID).Error; err != nil {
|
if err := s.db.Preload("User").Preload("User.Badges.Badge").First(&comment, comment.ID).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
node := toCommentNode(comment, nil, false, userID, nil)
|
node := toCommentNode(comment, nil, false, userID, nil)
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ func (s *OverviewService) Get() (*OverviewData, error) {
|
|||||||
var hotPosts []model.Post
|
var hotPosts []model.Post
|
||||||
if err := s.db.Where("status = ? AND pinned = 0", model.ContentStatusPublished).
|
if err := s.db.Where("status = ? AND pinned = 0", model.ContentStatusPublished).
|
||||||
Order("(like_count + comment_count * 2) DESC, created_at DESC").
|
Order("(like_count + comment_count * 2) DESC, created_at DESC").
|
||||||
Limit(5).Preload("Board").Preload("User").Find(&hotPosts).Error; err != nil {
|
Limit(5).Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&hotPosts).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
data.Hot = toPostListItems(hotPosts)
|
data.Hot = toPostListItems(hotPosts)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
@@ -32,7 +33,8 @@ func (s *PointsService) Balance(userID uint) (int, error) {
|
|||||||
return u.Points, nil
|
return u.Points, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreditTx 在事务内入账
|
// CreditTx 在事务内入账;入账同时累加累计获得积分(经验值,等级依据)。
|
||||||
|
// bounty_refund 是悬赏托管退回(自己托给自己的钱),不计入经验,与启动回填口径一致。
|
||||||
func CreditTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
|
func CreditTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
|
||||||
if delta <= 0 {
|
if delta <= 0 {
|
||||||
return 0, ErrInvalidPoints
|
return 0, ErrInvalidPoints
|
||||||
@@ -43,7 +45,13 @@ func CreditTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
bal := u.Points + delta
|
bal := u.Points + delta
|
||||||
if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("points", bal).Error; err != nil {
|
updates := map[string]interface{}{
|
||||||
|
"points": bal,
|
||||||
|
}
|
||||||
|
if reason != model.PointReasonBountyRefund {
|
||||||
|
updates["total_points"] = gorm.Expr("total_points + ?", delta)
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if err := tx.Create(&model.PointLedger{
|
if err := tx.Create(&model.PointLedger{
|
||||||
@@ -60,6 +68,31 @@ func CreditTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID
|
|||||||
return bal, nil
|
return bal, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RewardIfConfigured 可配置奖励入账:规则分>0 且当日该 reason 正入账笔数未超 cap 时发放;
|
||||||
|
// cap<=0 不限。计数按本地日统计 point_ledgers 同 reason 正入账笔数。
|
||||||
|
// 并发下允许低概率超发 1-2 笔,论坛场景可接受(不做唯一索引级严格)。
|
||||||
|
// 发放失败仅记日志不阻断主流程(奖励非关键路径)。
|
||||||
|
func RewardIfConfigured(tx *gorm.DB, userID uint, points, cap int, reason, refType string, refID uint, note string) {
|
||||||
|
if points <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cap > 0 {
|
||||||
|
var used int64
|
||||||
|
if err := tx.Model(&model.PointLedger{}).
|
||||||
|
Where("user_id = ? AND reason = ? AND delta > 0 AND created_at >= ?", userID, reason, localDay(time.Now())).
|
||||||
|
Count(&used).Error; err != nil {
|
||||||
|
log.Printf("[points] 奖励上限检查失败(忽略): user=%d reason=%s err=%v", userID, reason, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if used >= int64(cap) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := CreditTx(tx, userID, points, reason, refType, refID, note); err != nil {
|
||||||
|
log.Printf("[points] 奖励入账失败(忽略): user=%d reason=%s err=%v", userID, reason, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DebitTx 在事务内扣款
|
// DebitTx 在事务内扣款
|
||||||
func DebitTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
|
func DebitTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
|
||||||
if delta <= 0 {
|
if delta <= 0 {
|
||||||
@@ -291,6 +324,11 @@ func transferEnds(a, b model.PointLedger) (fromID, toID uint) {
|
|||||||
|
|
||||||
// Ledger 分页查询本人积分流水
|
// Ledger 分页查询本人积分流水
|
||||||
func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64, error) {
|
func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64, error) {
|
||||||
|
return ledgerPage(s.db, userID, page, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ledgerPage 积分流水分页查询(本人/管理端共用口径:id DESC,size 1..50 缺省 20)
|
||||||
|
func ledgerPage(db *gorm.DB, userID uint, page, size int) ([]LedgerItem, int64, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
@@ -298,7 +336,7 @@ func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64
|
|||||||
size = 20
|
size = 20
|
||||||
}
|
}
|
||||||
var total int64
|
var total int64
|
||||||
q := s.db.Model(&model.PointLedger{}).Where("user_id = ?", userID)
|
q := db.Model(&model.PointLedger{}).Where("user_id = ?", userID)
|
||||||
if err := q.Count(&total).Error; err != nil {
|
if err := q.Count(&total).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@@ -316,6 +354,33 @@ func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64
|
|||||||
return out, total, nil
|
return out, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminUserLedger 管理端查询任意用户积分流水
|
||||||
|
func (s *PointsService) AdminUserLedger(userID uint, page, size int) ([]LedgerItem, int64, error) {
|
||||||
|
return ledgerPage(s.db, userID, page, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAdjustPoints 管理员手动调整积分:加分复用 CreditTx(同步累加累计获得→可升级),
|
||||||
|
// 扣分复用 DebitTx(仅扣余额、不降级);reason 统一 admin_adjust,note 为调整理由。
|
||||||
|
// 返回调整后余额、累计获得与等级。
|
||||||
|
func (s *PointsService) AdminAdjustPoints(userID uint, delta int, note string) (bal, total, level int, err error) {
|
||||||
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if delta > 0 {
|
||||||
|
bal, err = CreditTx(tx, userID, delta, model.PointReasonAdminAdjust, "admin", 0, note)
|
||||||
|
} else {
|
||||||
|
bal, err = DebitTx(tx, userID, -delta, model.PointReasonAdminAdjust, "admin", 0, note)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
var u model.User
|
||||||
|
if err := s.db.Select("id", "total_points").First(&u, userID).Error; err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
return bal, u.TotalPoints, model.LevelOf(u.TotalPoints), nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeltaDay 单日积分发放与消耗
|
// DeltaDay 单日积分发放与消耗
|
||||||
type DeltaDay struct {
|
type DeltaDay struct {
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
|
|||||||
order = "pinned DESC, created_at DESC"
|
order = "pinned DESC, created_at DESC"
|
||||||
}
|
}
|
||||||
if err := query.Order(order).Offset(offset).Limit(q.Size).
|
if err := query.Order(order).Offset(offset).Limit(q.Size).
|
||||||
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
|
Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&posts).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size
|
|||||||
var posts []model.Post
|
var posts []model.Post
|
||||||
offset := (page - 1) * size
|
offset := (page - 1) * size
|
||||||
if err := query.Order("pinned DESC, created_at DESC").Offset(offset).Limit(size).
|
if err := query.Order("pinned DESC, created_at DESC").Offset(offset).Limit(size).
|
||||||
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
|
Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&posts).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
items := toPostListItems(posts)
|
items := toPostListItems(posts)
|
||||||
@@ -330,8 +330,8 @@ func (s *PostService) TogglePin(id uint) (int, error) {
|
|||||||
return newPinned, nil
|
return newPinned, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToggleRecommend 切换推荐状态(仅管理员可操作,由 handler 校验权限)
|
// ToggleRecommend 切换帖子加精;加精时向作者发放可配置的推荐奖励(每帖仅一次,自荐不发)
|
||||||
func (s *PostService) ToggleRecommend(id uint) (bool, error) {
|
func (s *PostService) ToggleRecommend(id, operatorID uint) (bool, error) {
|
||||||
var post model.Post
|
var post model.Post
|
||||||
if err := s.db.First(&post, id).Error; err != nil {
|
if err := s.db.First(&post, id).Error; err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -341,9 +341,38 @@ func (s *PostService) ToggleRecommend(id uint) (bool, error) {
|
|||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return false, result.Error
|
return false, result.Error
|
||||||
}
|
}
|
||||||
|
if newVal && operatorID != post.UserID {
|
||||||
|
s.grantRecommendReward(post)
|
||||||
|
}
|
||||||
return newVal, nil
|
return newVal, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// grantRecommendReward 发放推荐奖励(非关键路径,失败仅记日志不阻断)
|
||||||
|
func (s *PostService) grantRecommendReward(post model.Post) {
|
||||||
|
if s.setting == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rules, err := s.setting.PointsRules()
|
||||||
|
if err != nil || rules.RecommendReward <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 每帖仅一次:已存在同 reason/ref 的正入账则跳过(防止反复 toggle 重复加分)
|
||||||
|
var used int64
|
||||||
|
if err := tx.Model(&model.PointLedger{}).
|
||||||
|
Where("user_id = ? AND reason = ? AND ref_type = ? AND ref_id = ? AND delta > 0",
|
||||||
|
post.UserID, model.PointReasonRecommend, "post", post.ID).
|
||||||
|
Count(&used).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if used > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
RewardIfConfigured(tx, post.UserID, rules.RecommendReward, 0, model.PointReasonRecommend, "post", post.ID, "帖子被推荐")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// visibleTo 非已发布帖子仅作者本人或对该板块有审核权的管理成员可见。
|
// visibleTo 非已发布帖子仅作者本人或对该板块有审核权的管理成员可见。
|
||||||
// loadActor 为懒加载回调:仅访问非已发布帖且访问者非作者时才触发,避免常规浏览多查 DB
|
// loadActor 为懒加载回调:仅访问非已发布帖且访问者非作者时才触发,避免常规浏览多查 DB
|
||||||
func visibleToPost(post *model.Post, viewerID uint, loadActor func() *Actor) bool {
|
func visibleToPost(post *model.Post, viewerID uint, loadActor func() *Actor) bool {
|
||||||
@@ -437,13 +466,13 @@ type UpdatePostInput struct {
|
|||||||
// 已软删帖返回 tombstone(无正文/附件);已硬删或不存在返回 ErrPostNotFound。
|
// 已软删帖返回 tombstone(无正文/附件);已硬删或不存在返回 ErrPostNotFound。
|
||||||
func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor, pwdUnlocked map[int]bool) (*PostDetail, error) {
|
func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor, pwdUnlocked map[int]bool) (*PostDetail, error) {
|
||||||
var post model.Post
|
var post model.Post
|
||||||
err := s.db.Preload("Board").Preload("User").First(&post, id).Error
|
err := s.db.Preload("Board").Preload("User").Preload("User.Badges.Badge").First(&post, id).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, ErrPostNotFound
|
return nil, ErrPostNotFound
|
||||||
}
|
}
|
||||||
// 默认 scope 找不到:尝试软删行 → tombstone
|
// 默认 scope 找不到:尝试软删行 → tombstone
|
||||||
if err := s.db.Unscoped().Preload("Board").Preload("User").First(&post, id).Error; err != nil {
|
if err := s.db.Unscoped().Preload("Board").Preload("User").Preload("User.Badges.Badge").First(&post, id).Error; err != nil {
|
||||||
return nil, ErrPostNotFound
|
return nil, ErrPostNotFound
|
||||||
}
|
}
|
||||||
if !post.DeletedAt.Valid {
|
if !post.DeletedAt.Valid {
|
||||||
@@ -655,7 +684,7 @@ func (s *PostService) UnlockByPassword(postID uint, password string) (matched []
|
|||||||
// UnlockContent 积分解锁正文隐藏块(一次支付解锁本帖全部积分块)
|
// UnlockContent 积分解锁正文隐藏块(一次支付解锁本帖全部积分块)
|
||||||
func (s *PostService) UnlockContent(userID, postID uint) (*PostDetail, error) {
|
func (s *PostService) UnlockContent(userID, postID uint) (*PostDetail, error) {
|
||||||
var post model.Post
|
var post model.Post
|
||||||
if err := s.db.Preload("Board").Preload("User").First(&post, postID).Error; err != nil {
|
if err := s.db.Preload("Board").Preload("User").Preload("User.Badges.Badge").First(&post, postID).Error; err != nil {
|
||||||
return nil, ErrPostNotFound
|
return nil, ErrPostNotFound
|
||||||
}
|
}
|
||||||
derived, derr := markdown.DeriveAccessFromContent(post.Content)
|
derived, derr := markdown.DeriveAccessFromContent(post.Content)
|
||||||
@@ -834,6 +863,14 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 发帖奖励(可配置;仅公开发布发奖,待审不发且过审不补发,防拒审刷分)
|
||||||
|
if status == model.ContentStatusPublished && s.setting != nil {
|
||||||
|
rules, err := s.setting.PointsRules()
|
||||||
|
if err == nil {
|
||||||
|
RewardIfConfigured(tx, in.UserID, rules.PostReward, rules.PostDailyCap,
|
||||||
|
model.PointReasonPostReward, "post", post.ID, "发帖奖励:"+title)
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(in.AttachmentIDs) == 0 {
|
if len(in.AttachmentIDs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -855,7 +892,7 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.db.Preload("Board").Preload("User").First(post, post.ID)
|
s.db.Preload("Board").Preload("User").Preload("User.Badges.Badge").First(post, post.ID)
|
||||||
detail := buildPostDetail(post)
|
detail := buildPostDetail(post)
|
||||||
atts, _ := s.listAttachmentDTOs(post.ID, in.UserID, post.UserID)
|
atts, _ := s.listAttachmentDTOs(post.ID, in.UserID, post.UserID)
|
||||||
detail.Attachments = atts
|
detail.Attachments = atts
|
||||||
@@ -1040,7 +1077,7 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.db.Preload("Board").Preload("User").First(&post, post.ID)
|
s.db.Preload("Board").Preload("User").Preload("User.Badges.Badge").First(&post, post.ID)
|
||||||
detail := buildPostDetail(&post)
|
detail := buildPostDetail(&post)
|
||||||
atts, _ := s.listAttachmentDTOs(post.ID, userID, post.UserID)
|
atts, _ := s.listAttachmentDTOs(post.ID, userID, post.UserID)
|
||||||
detail.Attachments = atts
|
detail.Attachments = atts
|
||||||
|
|||||||
@@ -723,7 +723,7 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int, view
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var c model.Comment
|
var c model.Comment
|
||||||
err := s.db.Unscoped().Preload("User").
|
err := s.db.Unscoped().Preload("User").Preload("User.Badges.Badge").
|
||||||
Where("id = ? AND post_id = ?", commentID, postID).
|
Where("id = ? AND post_id = ?", commentID, postID).
|
||||||
First(&c).Error
|
First(&c).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1039,7 +1039,7 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
|
|||||||
// VotePoll 投票
|
// VotePoll 投票
|
||||||
func (s *PostService) VotePoll(userID, postID uint, optionIndexes []int) (*PostDetail, error) {
|
func (s *PostService) VotePoll(userID, postID uint, optionIndexes []int) (*PostDetail, error) {
|
||||||
var post model.Post
|
var post model.Post
|
||||||
if err := s.db.Preload("Board").Preload("User").First(&post, postID).Error; err != nil {
|
if err := s.db.Preload("Board").Preload("User").Preload("User.Badges.Badge").First(&post, postID).Error; err != nil {
|
||||||
return nil, ErrPostNotFound
|
return nil, ErrPostNotFound
|
||||||
}
|
}
|
||||||
if model.NormalizePostType(post.PostType) != model.PostTypePoll {
|
if model.NormalizePostType(post.PostType) != model.PostTypePoll {
|
||||||
@@ -1286,7 +1286,7 @@ func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.db.Preload("Board").Preload("User").First(post, post.ID)
|
s.db.Preload("Board").Preload("User").Preload("User.Badges.Badge").First(post, post.ID)
|
||||||
return s.detailAfterInteract(post, userID)
|
return s.detailAfterInteract(post, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1498,7 +1498,7 @@ func StartTypedPostSettler(s *PostService) {
|
|||||||
|
|
||||||
func (s *PostService) loadManageablePost(actor *Actor, userID, postID uint) (*model.Post, error) {
|
func (s *PostService) loadManageablePost(actor *Actor, userID, postID uint) (*model.Post, error) {
|
||||||
var post model.Post
|
var post model.Post
|
||||||
if err := s.db.Preload("Board").Preload("User").First(&post, postID).Error; err != nil {
|
if err := s.db.Preload("Board").Preload("User").Preload("User.Badges.Badge").First(&post, postID).Error; err != nil {
|
||||||
return nil, ErrPostNotFound
|
return nil, ErrPostNotFound
|
||||||
}
|
}
|
||||||
if post.UserID != userID && (actor == nil || !actor.CanModerateBoard(post.BoardID)) {
|
if post.UserID != userID && (actor == nil || !actor.CanModerateBoard(post.BoardID)) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -57,6 +58,22 @@ const (
|
|||||||
SettingKeyAnalyticsEnabled = "analytics_enabled"
|
SettingKeyAnalyticsEnabled = "analytics_enabled"
|
||||||
// SettingKeyAnalyticsRetentionDays 访问明细保留天数;缺行=90
|
// SettingKeyAnalyticsRetentionDays 访问明细保留天数;缺行=90
|
||||||
SettingKeyAnalyticsRetentionDays = "analytics_retention_days"
|
SettingKeyAnalyticsRetentionDays = "analytics_retention_days"
|
||||||
|
|
||||||
|
// 积分增长规则(正整数奖励;0=关闭/不限,缺行=默认值)
|
||||||
|
SettingKeyPointsCheckinBase = "points_checkin_base" // 签到基础分;缺行=5
|
||||||
|
SettingKeyPointsStreakEveryDays = "points_streak_every_days" // 每满 N 天连续签到触发加成;0=关闭;缺行=7
|
||||||
|
SettingKeyPointsStreakBonus = "points_streak_bonus" // 触发加成时额外加分;缺行=0
|
||||||
|
SettingKeyPointsPostReward = "points_post_reward" // 发帖奖励;0=关闭;缺行=0
|
||||||
|
SettingKeyPointsPostDailyCap = "points_post_daily_cap" // 发帖奖励每日上限笔数;0=不限;缺行=0
|
||||||
|
SettingKeyPointsReplyReward = "points_reply_reward" // 回复奖励;0=关闭;缺行=0
|
||||||
|
SettingKeyPointsReplyDailyCap = "points_reply_daily_cap" // 回复奖励每日上限笔数;0=不限;缺行=0
|
||||||
|
SettingKeyPointsRecommendReward = "points_recommend_reward" // 帖子被推荐奖励(每帖仅一次);0=关闭;缺行=0
|
||||||
|
|
||||||
|
// SettingKeyLevelsConfig 用户等级体系配置(JSON []model.LevelDef:阈值+色板 key);缺行=默认 7 级
|
||||||
|
SettingKeyLevelsConfig = "levels_config"
|
||||||
|
|
||||||
|
// SettingKeyLevelsFx 等级特效动画总开关(流光/渐变动画);缺行视为开启(默认开)
|
||||||
|
SettingKeyLevelsFx = "levels_fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -82,6 +99,10 @@ const (
|
|||||||
DefaultAnalyticsRetentionDays = 90
|
DefaultAnalyticsRetentionDays = 90
|
||||||
MinAnalyticsRetentionDays = 7
|
MinAnalyticsRetentionDays = 7
|
||||||
MaxAnalyticsRetentionDays = 365
|
MaxAnalyticsRetentionDays = 365
|
||||||
|
|
||||||
|
DefaultPointsCheckinBase = 5
|
||||||
|
DefaultPointsStreakEveryDays = 7
|
||||||
|
MaxPointsRule = 100 // 各积分规则值/上限的统一上界
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
|
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
|
||||||
@@ -143,6 +164,21 @@ type PublicSiteSettings struct {
|
|||||||
BrandLogoSize string `json:"brand_logo_size"`
|
BrandLogoSize string `json:"brand_logo_size"`
|
||||||
BrandLogoFit string `json:"brand_logo_fit"`
|
BrandLogoFit string `json:"brand_logo_fit"`
|
||||||
FooterLinks []FooterLink `json:"footer_links"`
|
FooterLinks []FooterLink `json:"footer_links"`
|
||||||
|
|
||||||
|
// 积分增长规则(经济规则公开透明,签到卡等可直出)
|
||||||
|
PointsCheckinBase int `json:"points_checkin_base"`
|
||||||
|
PointsStreakEveryDays int `json:"points_streak_every_days"`
|
||||||
|
PointsStreakBonus int `json:"points_streak_bonus"`
|
||||||
|
PointsPostReward int `json:"points_post_reward"`
|
||||||
|
PointsPostDailyCap int `json:"points_post_daily_cap"`
|
||||||
|
PointsReplyReward int `json:"points_reply_reward"`
|
||||||
|
PointsReplyDailyCap int `json:"points_reply_daily_cap"`
|
||||||
|
PointsRecommendReward int `json:"points_recommend_reward"`
|
||||||
|
|
||||||
|
// 用户等级体系(数量/每级阈值/每级色板 key);等级数值由后端按累计获得积分计算
|
||||||
|
Levels []model.LevelDef `json:"levels"`
|
||||||
|
// LevelsFx 等级特效动画总开关(流光/渐变动画;关闭后徽章退化为静态配色)
|
||||||
|
LevelsFx bool `json:"levels_fx"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingService 站点级键值设置
|
// SettingService 站点级键值设置
|
||||||
@@ -308,6 +344,22 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
|||||||
}
|
}
|
||||||
out.AllowMessages = messages
|
out.AllowMessages = messages
|
||||||
|
|
||||||
|
rules, err := s.PointsRules()
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.PointsCheckinBase = rules.CheckinBase
|
||||||
|
out.PointsStreakEveryDays = rules.StreakEveryDays
|
||||||
|
out.PointsStreakBonus = rules.StreakBonus
|
||||||
|
out.PointsPostReward = rules.PostReward
|
||||||
|
out.PointsPostDailyCap = rules.PostDailyCap
|
||||||
|
out.PointsReplyReward = rules.ReplyReward
|
||||||
|
out.PointsReplyDailyCap = rules.ReplyDailyCap
|
||||||
|
out.PointsRecommendReward = rules.RecommendReward
|
||||||
|
|
||||||
|
out.Levels = s.Levels()
|
||||||
|
out.LevelsFx = s.LevelsFx()
|
||||||
|
|
||||||
hours, err := s.PostCooldownHours()
|
hours, err := s.PostCooldownHours()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
@@ -932,3 +984,173 @@ func (s *SettingService) SetAnalyticsRetentionDays(days int) error {
|
|||||||
}
|
}
|
||||||
return s.putValue(SettingKeyAnalyticsRetentionDays, strconv.Itoa(days))
|
return s.putValue(SettingKeyAnalyticsRetentionDays, strconv.Itoa(days))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PointsRules 积分增长规则聚合(全部 0-100 裁剪,非法/缺行取默认)
|
||||||
|
type PointsRules struct {
|
||||||
|
CheckinBase int
|
||||||
|
StreakEveryDays int
|
||||||
|
StreakBonus int
|
||||||
|
PostReward int
|
||||||
|
PostDailyCap int
|
||||||
|
ReplyReward int
|
||||||
|
ReplyDailyCap int
|
||||||
|
RecommendReward int
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPointsValue 读取单个积分规则键(0-100 裁剪,非法/缺行取 def)
|
||||||
|
func (s *SettingService) getPointsValue(key string, def int) (int, error) {
|
||||||
|
v, found, err := s.getValue(key)
|
||||||
|
if err != nil {
|
||||||
|
return def, err
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
n, convErr := strconv.Atoi(strings.TrimSpace(v))
|
||||||
|
if convErr != nil || n < 0 {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
if n > MaxPointsRule {
|
||||||
|
return MaxPointsRule, nil
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setPointsValue 持久化积分规则;等于默认值时删键保持「缺行=默认」
|
||||||
|
func (s *SettingService) setPointsValue(key string, n, def int) error {
|
||||||
|
if n < 0 || n > MaxPointsRule {
|
||||||
|
return ErrInvalidSiteSetting
|
||||||
|
}
|
||||||
|
if n == def {
|
||||||
|
return s.deleteKey(key)
|
||||||
|
}
|
||||||
|
return s.putValue(key, strconv.Itoa(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PointsRules 一次读取全部积分增长规则
|
||||||
|
func (s *SettingService) PointsRules() (PointsRules, error) {
|
||||||
|
rules := PointsRules{
|
||||||
|
CheckinBase: DefaultPointsCheckinBase,
|
||||||
|
StreakEveryDays: DefaultPointsStreakEveryDays,
|
||||||
|
}
|
||||||
|
keys := []struct {
|
||||||
|
key string
|
||||||
|
dst *int
|
||||||
|
}{
|
||||||
|
{SettingKeyPointsCheckinBase, &rules.CheckinBase},
|
||||||
|
{SettingKeyPointsStreakEveryDays, &rules.StreakEveryDays},
|
||||||
|
{SettingKeyPointsStreakBonus, &rules.StreakBonus},
|
||||||
|
{SettingKeyPointsPostReward, &rules.PostReward},
|
||||||
|
{SettingKeyPointsPostDailyCap, &rules.PostDailyCap},
|
||||||
|
{SettingKeyPointsReplyReward, &rules.ReplyReward},
|
||||||
|
{SettingKeyPointsReplyDailyCap, &rules.ReplyDailyCap},
|
||||||
|
{SettingKeyPointsRecommendReward, &rules.RecommendReward},
|
||||||
|
}
|
||||||
|
for _, it := range keys {
|
||||||
|
n, err := s.getPointsValue(it.key, *it.dst)
|
||||||
|
if err != nil {
|
||||||
|
return rules, err
|
||||||
|
}
|
||||||
|
*it.dst = n
|
||||||
|
}
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetPointsCheckinBase(n int) error {
|
||||||
|
return s.setPointsValue(SettingKeyPointsCheckinBase, n, DefaultPointsCheckinBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetPointsStreakEveryDays(n int) error {
|
||||||
|
return s.setPointsValue(SettingKeyPointsStreakEveryDays, n, DefaultPointsStreakEveryDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetPointsStreakBonus(n int) error {
|
||||||
|
return s.setPointsValue(SettingKeyPointsStreakBonus, n, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetPointsPostReward(n int) error {
|
||||||
|
return s.setPointsValue(SettingKeyPointsPostReward, n, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetPointsPostDailyCap(n int) error {
|
||||||
|
return s.setPointsValue(SettingKeyPointsPostDailyCap, n, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetPointsReplyReward(n int) error {
|
||||||
|
return s.setPointsValue(SettingKeyPointsReplyReward, n, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetPointsReplyDailyCap(n int) error {
|
||||||
|
return s.setPointsValue(SettingKeyPointsReplyDailyCap, n, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetPointsRecommendReward(n int) error {
|
||||||
|
return s.setPointsValue(SettingKeyPointsRecommendReward, n, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// levelColorPalette 等级可用色板 key(与徽章色板一致 + neutral 中性色)
|
||||||
|
var levelColorPalette = map[string]bool{
|
||||||
|
"neutral": true,
|
||||||
|
"gold": true,
|
||||||
|
"accent": true,
|
||||||
|
"ok": true,
|
||||||
|
"danger": true,
|
||||||
|
"info": true,
|
||||||
|
"purple": true,
|
||||||
|
"teal": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Levels 等级体系配置;缺行/解析失败/校验不过回落默认表
|
||||||
|
func (s *SettingService) Levels() []model.LevelDef {
|
||||||
|
v, found, err := s.getValue(SettingKeyLevelsConfig)
|
||||||
|
if err != nil || !found || strings.TrimSpace(v) == "" {
|
||||||
|
return model.DefaultLevelDefs()
|
||||||
|
}
|
||||||
|
var defs []model.LevelDef
|
||||||
|
if err := json.Unmarshal([]byte(v), &defs); err != nil {
|
||||||
|
return model.DefaultLevelDefs()
|
||||||
|
}
|
||||||
|
if !model.ValidLevelDefs(defs) || !levelColorsValid(defs) {
|
||||||
|
return model.DefaultLevelDefs()
|
||||||
|
}
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLevels 校验并保存等级配置,同时刷新进程内快照(LevelOf 立即生效)
|
||||||
|
func (s *SettingService) SetLevels(defs []model.LevelDef) error {
|
||||||
|
if !model.ValidLevelDefs(defs) || !levelColorsValid(defs) {
|
||||||
|
return ErrInvalidSiteSetting
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(defs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.putValue(SettingKeyLevelsConfig, string(b)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
model.ApplyLevelDefs(defs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LevelsFx 等级特效动画总开关;缺行视为开启
|
||||||
|
func (s *SettingService) LevelsFx() bool {
|
||||||
|
v, found, err := s.getValue(SettingKeyLevelsFx)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return parseBoolDefaultTrue(v, found)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLevelsFx 保存等级特效动画总开关(开=删行回落默认,关=显式存 false)
|
||||||
|
func (s *SettingService) SetLevelsFx(on bool) error {
|
||||||
|
return s.setBoolDefaultTrue(SettingKeyLevelsFx, on)
|
||||||
|
}
|
||||||
|
|
||||||
|
func levelColorsValid(defs []model.LevelDef) bool {
|
||||||
|
for _, d := range defs {
|
||||||
|
if !levelColorPalette[d.Color] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
441
frontend/app/admin/badges/BadgesClient.tsx
Normal file
441
frontend/app/admin/badges/BadgesClient.tsx
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||||
|
import Avatar from "@/components/Avatar";
|
||||||
|
import BadgeChip from "@/components/BadgeChip";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import { BADGE_ICONS } from "@/lib/badgeIcons";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import {
|
||||||
|
apiAdminBadgeHolders,
|
||||||
|
apiAdminCreateBadge,
|
||||||
|
apiAdminDeleteBadge,
|
||||||
|
apiAdminGrantBadge,
|
||||||
|
apiAdminListBadges,
|
||||||
|
apiAdminListUsers,
|
||||||
|
apiAdminRevokeBadge,
|
||||||
|
apiAdminUpdateBadge,
|
||||||
|
type BadgeHolder,
|
||||||
|
type BadgeInput,
|
||||||
|
type BadgeWithHolders,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
/** 与后端 model.BadgeColors 同步的色板(CSS token 名) */
|
||||||
|
const BADGE_COLORS = ["gold", "accent", "ok", "danger", "info", "purple", "teal"] as const;
|
||||||
|
const MAX_NAME_RUNES = 16;
|
||||||
|
|
||||||
|
const runeCount = (s: string) => [...s].length;
|
||||||
|
|
||||||
|
export default function BadgesClient() {
|
||||||
|
const [badges, setBadges] = useState<BadgeWithHolders[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// 编辑/新建表单
|
||||||
|
const [editing, setEditing] = useState<BadgeWithHolders | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [form, setForm] = useState<BadgeInput>({ name: "", icon: "medal", color: "accent" });
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 删除确认
|
||||||
|
const [deleting, setDeleting] = useState<BadgeWithHolders | null>(null);
|
||||||
|
const [deletingBusy, setDeletingBusy] = useState(false);
|
||||||
|
|
||||||
|
// 持有者面板
|
||||||
|
const [holdersFor, setHoldersFor] = useState<BadgeWithHolders | null>(null);
|
||||||
|
const [holders, setHolders] = useState<BadgeHolder[]>([]);
|
||||||
|
const [holdersLoading, setHoldersLoading] = useState(false);
|
||||||
|
const [userQuery, setUserQuery] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState<{ id: number; nickname: string; username: string; avatar: string }[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [grantingId, setGrantingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiAdminListBadges();
|
||||||
|
setBadges(res.badges ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "获取徽章列表失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setForm({ name: "", icon: "medal", color: "accent" });
|
||||||
|
setEditing(null);
|
||||||
|
setCreating(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (b: BadgeWithHolders) => {
|
||||||
|
setForm({ name: b.name, icon: b.icon || "medal", color: b.color || "accent" });
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(b);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeForm = () => {
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveForm = async () => {
|
||||||
|
if (saving) return;
|
||||||
|
if (runeCount(form.name.trim()) < 1 || runeCount(form.name.trim()) > MAX_NAME_RUNES) {
|
||||||
|
toast(`名称需 1–${MAX_NAME_RUNES} 字`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await apiAdminUpdateBadge(editing.id, form);
|
||||||
|
toast("徽章已更新", "ok");
|
||||||
|
} else {
|
||||||
|
await apiAdminCreateBadge(form);
|
||||||
|
toast("徽章已创建", "ok");
|
||||||
|
}
|
||||||
|
closeForm();
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doDelete = async () => {
|
||||||
|
if (!deleting || deletingBusy) return;
|
||||||
|
setDeletingBusy(true);
|
||||||
|
try {
|
||||||
|
await apiAdminDeleteBadge(deleting.id);
|
||||||
|
toast("徽章已删除", "ok");
|
||||||
|
setDeleting(null);
|
||||||
|
if (holdersFor?.id === deleting.id) setHoldersFor(null);
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "删除失败");
|
||||||
|
} finally {
|
||||||
|
setDeletingBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openHolders = async (b: BadgeWithHolders) => {
|
||||||
|
setHoldersFor(b);
|
||||||
|
setUserQuery("");
|
||||||
|
setSearchResults([]);
|
||||||
|
setHoldersLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiAdminBadgeHolders(b.id);
|
||||||
|
setHolders(res.holders ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "获取持有者失败");
|
||||||
|
setHolders([]);
|
||||||
|
} finally {
|
||||||
|
setHoldersLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchUsers = async () => {
|
||||||
|
const q = userQuery.trim();
|
||||||
|
if (!q || searching) return;
|
||||||
|
setSearching(true);
|
||||||
|
try {
|
||||||
|
const res = await apiAdminListUsers({ q, size: 8 });
|
||||||
|
setSearchResults(
|
||||||
|
(res.users ?? []).map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
nickname: u.nickname,
|
||||||
|
username: u.username,
|
||||||
|
avatar: u.avatar,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "搜索用户失败");
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const grant = async (userId: number) => {
|
||||||
|
if (!holdersFor || grantingId != null) return;
|
||||||
|
setGrantingId(userId);
|
||||||
|
try {
|
||||||
|
await apiAdminGrantBadge(holdersFor.id, userId);
|
||||||
|
toast("已授予", "ok");
|
||||||
|
await openHolders(holdersFor);
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "授予失败");
|
||||||
|
} finally {
|
||||||
|
setGrantingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revoke = async (userId: number) => {
|
||||||
|
if (!holdersFor) return;
|
||||||
|
try {
|
||||||
|
await apiAdminRevokeBadge(holdersFor.id, userId);
|
||||||
|
toast("已撤销", "ok");
|
||||||
|
await openHolders(holdersFor);
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "撤销失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const holdersHeldIds = useMemo(() => new Set(holders.map((h) => h.user_id)), [holders]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-settings-stack">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold" style={{ color: "var(--ink)" }}>
|
||||||
|
徽章管理
|
||||||
|
</h2>
|
||||||
|
<p className="meta text-sm mt-1">
|
||||||
|
管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-solid btn-sm" onClick={openCreate}>
|
||||||
|
<Plus size={14} /> 新建徽章
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="meta px-1 py-6">加载徽章列表…</p>
|
||||||
|
) : badges.length === 0 ? (
|
||||||
|
<div className="panel text-center py-12">
|
||||||
|
<p className="meta">还没有徽章,点击右上角“新建徽章”开始。</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="panel p-0 overflow-hidden">
|
||||||
|
{badges.map((b, i) => (
|
||||||
|
<div
|
||||||
|
key={b.id}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 flex-wrap"
|
||||||
|
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
<BadgeChip badge={b} />
|
||||||
|
<span className="meta text-xs">
|
||||||
|
{b.icon || "medal"} · {b.color || "accent"} · {b.holders} 人持有
|
||||||
|
</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => void openHolders(b)}
|
||||||
|
>
|
||||||
|
<Users size={13} /> 持有者
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-line btn-sm" onClick={() => openEdit(b)}>
|
||||||
|
<Pencil size={13} /> 编辑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
style={{ color: "var(--danger)" }}
|
||||||
|
onClick={() => setDeleting(b)}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} /> 删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 持有者面板 */}
|
||||||
|
<Modal
|
||||||
|
open={holdersFor != null}
|
||||||
|
title={`持有者 · ${holdersFor?.name ?? ""}`}
|
||||||
|
onClose={() => setHoldersFor(null)}
|
||||||
|
maxWidthClass="max-w-lg"
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
value={userQuery}
|
||||||
|
onChange={(e) => setUserQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") void searchUsers();
|
||||||
|
}}
|
||||||
|
placeholder="输入用户名或昵称搜索"
|
||||||
|
className="flex-1 min-w-0"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm shrink-0"
|
||||||
|
disabled={searching || !userQuery.trim()}
|
||||||
|
onClick={() => void searchUsers()}
|
||||||
|
>
|
||||||
|
{searching ? "搜索中…" : "搜索"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="meta text-xs mb-1.5">搜索结果</p>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{searchResults.map((u) => (
|
||||||
|
<div key={u.id} className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg" style={{ background: "var(--panel-2)" }}>
|
||||||
|
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={26} />
|
||||||
|
<span className="text-sm truncate" style={{ color: "var(--ink)" }}>
|
||||||
|
{u.nickname || u.username}
|
||||||
|
<span className="meta ml-1">@{u.username}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
{holdersHeldIds.has(u.id) ? (
|
||||||
|
<span className="meta text-xs">已持有</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-solid btn-sm"
|
||||||
|
disabled={grantingId === u.id}
|
||||||
|
onClick={() => void grant(u.id)}
|
||||||
|
>
|
||||||
|
{grantingId === u.id ? "授予中…" : "授予"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="meta text-xs mb-1.5">当前持有者({holders.length})</p>
|
||||||
|
{holdersLoading ? (
|
||||||
|
<p className="meta text-sm py-3">加载持有者…</p>
|
||||||
|
) : holders.length === 0 ? (
|
||||||
|
<p className="meta text-sm py-3">暂无人持有。</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto">
|
||||||
|
{holders.map((h) => (
|
||||||
|
<div key={h.user_id} className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg" style={{ background: "var(--panel-2)" }}>
|
||||||
|
<span className="text-sm truncate" style={{ color: "var(--ink)" }}>
|
||||||
|
{h.nickname || h.username}
|
||||||
|
<span className="meta ml-1">@{h.username}</span>
|
||||||
|
</span>
|
||||||
|
<span className="meta text-xs">{new Date(h.awarded_at).toLocaleDateString("zh-CN")} 授予</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
style={{ color: "var(--danger)" }}
|
||||||
|
onClick={() => void revoke(h.user_id)}
|
||||||
|
>
|
||||||
|
撤销
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 新建/编辑表单 */}
|
||||||
|
<Modal
|
||||||
|
open={creating || editing != null}
|
||||||
|
title={editing ? "编辑徽章" : "新建徽章"}
|
||||||
|
onClose={closeForm}
|
||||||
|
maxWidthClass="max-w-md"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
|
||||||
|
名称(1–{MAX_NAME_RUNES} 字)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
maxLength={MAX_NAME_RUNES * 2}
|
||||||
|
placeholder="如:元老、技术之星"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
|
||||||
|
图标
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-8 gap-1.5">
|
||||||
|
{Object.keys(BADGE_ICONS).map((key) => {
|
||||||
|
const Icon = BADGE_ICONS[key];
|
||||||
|
const active = form.icon === key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
aria-label={key}
|
||||||
|
title={key}
|
||||||
|
onClick={() => setForm((f) => ({ ...f, icon: key }))}
|
||||||
|
className="h-9 rounded-lg inline-flex items-center justify-center transition-colors"
|
||||||
|
style={{
|
||||||
|
background: active ? "var(--accent-soft)" : "var(--panel-2)",
|
||||||
|
color: active ? "var(--accent)" : "var(--ink-2)",
|
||||||
|
outline: active ? "1.5px solid var(--accent)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
|
||||||
|
颜色
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{BADGE_COLORS.map((c) => {
|
||||||
|
const active = form.color === c;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
aria-label={c}
|
||||||
|
title={c}
|
||||||
|
onClick={() => setForm((f) => ({ ...f, color: c }))}
|
||||||
|
className="px-3 h-8 rounded-full text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
background: `var(--${c}-soft)`,
|
||||||
|
color: `var(--${c})`,
|
||||||
|
outline: active ? "1.5px solid var(--ink)" : "1.5px solid transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="meta text-xs mb-1.5">预览</p>
|
||||||
|
<BadgeChip badge={{ name: form.name.trim() || "徽章名称", icon: form.icon, color: form.color }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 mt-5">
|
||||||
|
<button type="button" className="btn btn-line btn-sm" onClick={closeForm}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-solid btn-sm"
|
||||||
|
disabled={saving || runeCount(form.name.trim()) === 0}
|
||||||
|
onClick={() => void saveForm()}
|
||||||
|
>
|
||||||
|
{saving ? "保存中…" : "保存"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 删除确认 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleting != null}
|
||||||
|
title="删除徽章"
|
||||||
|
message={`确定删除徽章「${deleting?.name ?? ""}」?该徽章的全部授予记录(${deleting?.holders ?? 0} 人)会被一并删除,用户主页将不再显示。`}
|
||||||
|
confirmLabel="删除"
|
||||||
|
danger
|
||||||
|
busy={deletingBusy}
|
||||||
|
onCancel={() => setDeleting(null)}
|
||||||
|
onConfirm={() => void doDelete()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
frontend/app/admin/badges/page.tsx
Normal file
22
frontend/app/admin/badges/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import BadgesClient from "./BadgesClient";
|
||||||
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
|
import { getMeCached } from "@/lib/serverData";
|
||||||
|
import { isSuperOrOwner } from "@/lib/roles";
|
||||||
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "徽章管理",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 徽章管理仅超管/站长可用(与站点设置同权限;后端路由另有守卫)
|
||||||
|
export default async function AdminBadgesPage() {
|
||||||
|
const cookie = authCookieHeader(await cookies());
|
||||||
|
const me = await getMeCached(cookie || undefined);
|
||||||
|
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
||||||
|
return <Forbidden text="徽章管理仅超管/站长可操作。" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BadgesClient />;
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { fetchAdminEconomyStats } from "@/lib/api";
|
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
|
||||||
import { getMeCached } from "@/lib/serverData";
|
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
|
||||||
import Forbidden from "../Forbidden";
|
|
||||||
import EconomyClient from "./EconomyClient";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "积分看板",
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <EconomyClient stats={stats} />;
|
|
||||||
}
|
|
||||||
309
frontend/app/admin/levels/LevelsAdmin.tsx
Normal file
309
frontend/app/admin/levels/LevelsAdmin.tsx
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Plus, RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
import { AdminStepper, AdminSwitch } from "@/components/admin";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||||
|
import { apiGetSettings, apiUpdateSiteSettings } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
DEFAULT_LEVEL_DEFS,
|
||||||
|
LEVEL_BADGE_CLASS,
|
||||||
|
LEVEL_COLOR_KEYS,
|
||||||
|
LEVEL_COLOR_TOKENS,
|
||||||
|
MAX_LEVEL_COUNT,
|
||||||
|
splitBackground,
|
||||||
|
type LevelDef,
|
||||||
|
} from "@/lib/levels";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
const cloneDefs = (defs: LevelDef[]): LevelDef[] =>
|
||||||
|
defs.map((d) => ({ threshold: d.threshold, color: d.color }));
|
||||||
|
|
||||||
|
const sameDefs = (a: LevelDef[] | null, b: LevelDef[] | null) => {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (!a || !b || a.length !== b.length) return false;
|
||||||
|
return a.every((d, i) => d.threshold === b[i].threshold && d.color === b[i].color);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 等级管理:配置等级数量(2–12)、每级所需累计积分与前景/背景配色、特效开关 */
|
||||||
|
export default function LevelsAdmin() {
|
||||||
|
const [form, setForm] = useState<LevelDef[] | null>(null); // null = 加载中
|
||||||
|
const [saved, setSaved] = useState<LevelDef[] | null>(null);
|
||||||
|
const [fx, setFx] = useState(true);
|
||||||
|
const [savedFx, setSavedFx] = useState(true);
|
||||||
|
const [resetOpen, setResetOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const dirty = useMemo(
|
||||||
|
() => !sameDefs(form, saved) || fx !== savedFx,
|
||||||
|
[form, saved, fx, savedFx],
|
||||||
|
);
|
||||||
|
const leave = useUnsavedGuard(dirty);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
apiGetSettings()
|
||||||
|
.then((s) => {
|
||||||
|
if (!alive) return;
|
||||||
|
const defs = cloneDefs(s.levels);
|
||||||
|
setForm(defs);
|
||||||
|
setSaved(defs);
|
||||||
|
setFx(s.levels_fx);
|
||||||
|
setSavedFx(s.levels_fx);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
toast(e instanceof Error ? e.message : "获取等级配置失败");
|
||||||
|
if (alive) setForm([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setThreshold = (i: number, v: number) =>
|
||||||
|
setForm((f) => (f ? f.map((d, j) => (j === i ? { ...d, threshold: v } : d)) : f));
|
||||||
|
const setColor = (i: number, color: string) =>
|
||||||
|
setForm((f) => (f ? f.map((d, j) => (j === i ? { ...d, color } : d)) : f));
|
||||||
|
const addLevel = () =>
|
||||||
|
setForm((f) => {
|
||||||
|
if (!f || f.length >= MAX_LEVEL_COUNT) return f;
|
||||||
|
const last = f[f.length - 1];
|
||||||
|
return [...f, { threshold: (last?.threshold ?? 0) + 100, color: "neutral" }];
|
||||||
|
});
|
||||||
|
const removeLast = () =>
|
||||||
|
setForm((f) => (f && f.length > 2 ? f.slice(0, -1) : f));
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (saving || !dirty || !form) return;
|
||||||
|
if (form.length < 2) {
|
||||||
|
toast("至少保留 2 个等级");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (form[0].threshold !== 0) {
|
||||||
|
toast("Lv.1 所需积分须为 0");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let i = 1; i < form.length; i++) {
|
||||||
|
if (!(form[i].threshold > form[i - 1].threshold)) {
|
||||||
|
toast(`Lv.${i + 1} 所需积分须大于上一级`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await apiUpdateSiteSettings({ levels: form, levels_fx: fx });
|
||||||
|
const defs = cloneDefs(res.levels);
|
||||||
|
setForm(defs);
|
||||||
|
setSaved(defs);
|
||||||
|
setSavedFx(fx);
|
||||||
|
toast("等级配置已保存", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置:仅回填默认等级表(2–12 约束内),需再点保存才生效
|
||||||
|
const resetToDefault = () => {
|
||||||
|
if (!form) return;
|
||||||
|
setForm(cloneDefs(DEFAULT_LEVEL_DEFS));
|
||||||
|
setResetOpen(false);
|
||||||
|
toast("已恢复默认等级表,点击保存生效", "ok");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-settings-stack">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold" style={{ color: "var(--ink)" }}>
|
||||||
|
等级管理
|
||||||
|
</h2>
|
||||||
|
<p className="meta text-sm mt-1">
|
||||||
|
用户等级由累计获得积分决定,达到某级阈值即晋升。可配置等级数量(2–{MAX_LEVEL_COUNT}{" "}
|
||||||
|
级)、每级所需积分与配色;等级徽章仅显示 Lv.n,不弹悬浮提示。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!form ? (
|
||||||
|
<p className="meta px-1 py-6">加载等级配置…</p>
|
||||||
|
) : form.length === 0 ? (
|
||||||
|
<div className="panel text-center py-12">
|
||||||
|
<p className="meta">等级配置加载失败,请刷新页面重试。</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="panel p-0 overflow-hidden">
|
||||||
|
{form.map((d, i) => {
|
||||||
|
const n = i + 1;
|
||||||
|
const tokens = LEVEL_COLOR_TOKENS[d.color] ?? LEVEL_COLOR_TOKENS.neutral;
|
||||||
|
const textBg = splitBackground(tokens.text ?? "none");
|
||||||
|
const isLast = i === form.length - 1;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={n}
|
||||||
|
className="flex items-center gap-4 px-4 py-3 flex-wrap"
|
||||||
|
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
{/* 预览:与 LevelBadge 同构(外层底色 + .lv-text 文字层),实时反映未保存的修改 */}
|
||||||
|
<span className="w-14 shrink-0 flex">
|
||||||
|
<span
|
||||||
|
className={LEVEL_BADGE_CLASS}
|
||||||
|
style={{
|
||||||
|
background: tokens.bg,
|
||||||
|
boxShadow: `inset 0 0 0 1px ${tokens.ring ?? "transparent"}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="lv-text"
|
||||||
|
style={{
|
||||||
|
backgroundImage: textBg.image,
|
||||||
|
backgroundPosition: textBg.position,
|
||||||
|
backgroundSize: textBg.size,
|
||||||
|
backgroundClip: "text",
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
color: tokens.fg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Lv.{n}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<p className="text-xs mb-1" style={{ color: "var(--ink-2)" }}>
|
||||||
|
所需累计积分
|
||||||
|
</p>
|
||||||
|
<AdminStepper
|
||||||
|
value={d.threshold}
|
||||||
|
onChange={(v) => setThreshold(i, v)}
|
||||||
|
min={0}
|
||||||
|
max={1000000}
|
||||||
|
unit="分"
|
||||||
|
ariaLabel={`Lv.${n} 所需积分`}
|
||||||
|
disabled={saving || n === 1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs mb-1.5" style={{ color: "var(--ink-2)" }}>
|
||||||
|
配色(前景/背景)
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{LEVEL_COLOR_KEYS.map((c) => {
|
||||||
|
const t = LEVEL_COLOR_TOKENS[c];
|
||||||
|
const active = d.color === c;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
aria-label={c}
|
||||||
|
title={c}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => setColor(i, c)}
|
||||||
|
className="w-6 h-6 rounded-full transition-transform"
|
||||||
|
style={{
|
||||||
|
background: t.bg,
|
||||||
|
outline: active
|
||||||
|
? "2px solid var(--ink)"
|
||||||
|
: "1px solid var(--line)",
|
||||||
|
outlineOffset: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="flex-1" />
|
||||||
|
{isLast && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm shrink-0"
|
||||||
|
style={{ color: "var(--danger)" }}
|
||||||
|
disabled={saving || form.length <= 2}
|
||||||
|
onClick={removeLast}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} /> 删除此级
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* 添加新等级:整宽虚线条目,与上方项同宽 */}
|
||||||
|
{form.length < MAX_LEVEL_COUNT ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 py-3.5 text-sm transition-colors hover:opacity-80"
|
||||||
|
style={{
|
||||||
|
borderTop: "1.5px dashed var(--line)",
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={addLevel}
|
||||||
|
>
|
||||||
|
<Plus size={15} /> 添加新等级
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-full flex items-center justify-center py-3.5 text-xs"
|
||||||
|
style={{
|
||||||
|
borderTop: "1.5px dashed var(--line)",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
已达 {MAX_LEVEL_COUNT} 级上限
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={saving || !dirty}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
{saving ? "保存中…" : "保存等级配置"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={saving || !form}
|
||||||
|
onClick={() => setResetOpen(true)}
|
||||||
|
>
|
||||||
|
<RotateCcw size={13} /> 重置
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AdminSwitch
|
||||||
|
checked={fx}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={() => setFx((v) => !v)}
|
||||||
|
label="特效动画"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="admin-settings-instant">
|
||||||
|
{dirty
|
||||||
|
? "有未保存的修改"
|
||||||
|
: "已保存的配色在页面刷新后生效,等级数值即时按新阈值重算"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={resetOpen}
|
||||||
|
title="重置等级配置"
|
||||||
|
message={`将恢复为默认 ${DEFAULT_LEVEL_DEFS.length} 级配置(当前修改不会立即生效,需再点击保存)。确定重置?`}
|
||||||
|
confirmLabel="重置"
|
||||||
|
danger
|
||||||
|
onCancel={() => setResetOpen(false)}
|
||||||
|
onConfirm={resetToDefault}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={leave.leaveOpen}
|
||||||
|
title="有未保存的修改"
|
||||||
|
message="离开后,当前尚未保存的等级配置修改会丢失。确定离开?"
|
||||||
|
confirmLabel="离开"
|
||||||
|
danger
|
||||||
|
onCancel={leave.cancelLeave}
|
||||||
|
onConfirm={leave.confirmLeave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
frontend/app/admin/levels/page.tsx
Normal file
22
frontend/app/admin/levels/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import LevelsAdmin from "./LevelsAdmin";
|
||||||
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
|
import { getMeCached } from "@/lib/serverData";
|
||||||
|
import { isSuperOrOwner } from "@/lib/roles";
|
||||||
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "等级管理",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 等级管理仅超管/站长可用(与徽章管理同权限;后端路由另有守卫)
|
||||||
|
export default async function AdminLevelsPage() {
|
||||||
|
const cookie = authCookieHeader(await cookies());
|
||||||
|
const me = await getMeCached(cookie || undefined);
|
||||||
|
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
||||||
|
return <Forbidden text="等级管理仅超管/站长可操作。" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LevelsAdmin />;
|
||||||
|
}
|
||||||
@@ -154,9 +154,9 @@ export default async function AdminDashboardPage() {
|
|||||||
description={`${dateLabel} · 站点管理`}
|
description={`${dateLabel} · 站点管理`}
|
||||||
actions={
|
actions={
|
||||||
canEconomy ? (
|
canEconomy ? (
|
||||||
<Link href="/admin/economy" className="btn btn-ghost text-[13px] gap-1.5">
|
<Link href="/admin/points" className="btn btn-ghost text-[13px] gap-1.5">
|
||||||
<Coins size={15} aria-hidden />
|
<Coins size={15} aria-hidden />
|
||||||
积分看板
|
积分管理
|
||||||
</Link>
|
</Link>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
93
frontend/app/admin/points/PointsTabs.tsx
Normal file
93
frontend/app/admin/points/PointsTabs.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { AdminSegmented } from "@/components/admin";
|
||||||
|
import type { EconomyStats, PublicSettings } from "@/lib/api";
|
||||||
|
import EconomyOverview from "./EconomyOverview";
|
||||||
|
import EconomySettings from "../settings/EconomySettings";
|
||||||
|
import UserPointsTab from "./UserPointsTab";
|
||||||
|
|
||||||
|
type TabKey = "overview" | "rules" | "users";
|
||||||
|
|
||||||
|
const TAB_OPTIONS = [
|
||||||
|
{ key: "overview", label: "经济总览" },
|
||||||
|
{ key: "rules", label: "积分规则" },
|
||||||
|
{ key: "users", label: "用户积分" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 积分管理页标签壳:经济总览(管理员及以上)/ 积分规则 + 用户积分(超管/站长)。
|
||||||
|
* tab 记在 URL query(?tab=)便于刷新/分享保持;三个面板保持挂载、用 display 切换,
|
||||||
|
* 避免切标签丢失规则的未保存修改与用户列表状态。
|
||||||
|
*/
|
||||||
|
export default function PointsTabs({
|
||||||
|
stats,
|
||||||
|
settings,
|
||||||
|
canManage,
|
||||||
|
}: {
|
||||||
|
stats: EconomyStats | null;
|
||||||
|
settings: PublicSettings | null;
|
||||||
|
canManage: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const queryTab = searchParams.get("tab");
|
||||||
|
const [tab, setTab] = useState<TabKey>(() => {
|
||||||
|
const t = queryTab;
|
||||||
|
if (canManage && (t === "rules" || t === "users")) return t;
|
||||||
|
return "overview";
|
||||||
|
});
|
||||||
|
|
||||||
|
const switchTab = useCallback(
|
||||||
|
(next: string) => {
|
||||||
|
setTab(next as TabKey);
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (next === "overview") params.delete("tab");
|
||||||
|
else params.set("tab", next);
|
||||||
|
const qs = params.toString();
|
||||||
|
router.replace(qs ? `?${qs}` : "?", { scroll: false });
|
||||||
|
},
|
||||||
|
[router, searchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = canManage
|
||||||
|
? TAB_OPTIONS
|
||||||
|
: TAB_OPTIONS.filter((o) => o.key === "overview");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{canManage ? (
|
||||||
|
<div className="mb-4">
|
||||||
|
<AdminSegmented
|
||||||
|
options={options}
|
||||||
|
value={tab}
|
||||||
|
onChange={(v) => switchTab(String(v))}
|
||||||
|
ariaLabel="积分管理标签"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 各面板保持挂载(display 切换):规则未保存修改与用户列表状态不因切标签丢失 */}
|
||||||
|
<div style={tab === "overview" ? undefined : { display: "none" }}>
|
||||||
|
{stats ? (
|
||||||
|
<EconomyOverview stats={stats} />
|
||||||
|
) : (
|
||||||
|
<div className="panel p-8 text-center">
|
||||||
|
<p className="meta">暂时无法加载积分数据</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canManage ? (
|
||||||
|
<>
|
||||||
|
<div style={tab === "rules" ? undefined : { display: "none" }}>
|
||||||
|
{settings ? <EconomySettings initial={settings} /> : null}
|
||||||
|
</div>
|
||||||
|
<div style={tab === "users" ? undefined : { display: "none" }}>
|
||||||
|
<UserPointsTab />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
477
frontend/app/admin/points/UserPointsTab.tsx
Normal file
477
frontend/app/admin/points/UserPointsTab.tsx
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronDown, ChevronUp, ChevronsUpDown, History, SlidersHorizontal, Users as UsersIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
apiAdminAdjustUserPoints,
|
||||||
|
apiAdminListUsers,
|
||||||
|
apiAdminUserPointLedger,
|
||||||
|
type AdminUser,
|
||||||
|
type PointLedgerItem,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import Avatar from "@/components/Avatar";
|
||||||
|
import LevelBadge from "@/components/LevelBadge";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import { AdminPageHeader, AdminPager, AdminSearchField, AdminStepper } from "@/components/admin";
|
||||||
|
import { pointReasonLabel } from "@/lib/points";
|
||||||
|
import { formatRelative } from "@/lib/format";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const MAX_DELTA = 100000; // 与后端 adminAdjustMaxDelta 一致
|
||||||
|
const fmt = (n?: number) => (Number(n) || 0).toLocaleString("zh-CN");
|
||||||
|
|
||||||
|
type PointsSortKey = "id" | "points" | "total_points";
|
||||||
|
|
||||||
|
/** 表头排序按钮:未激活显示双向箭头,激活显示单向箭头并高亮 */
|
||||||
|
function SortHeader({
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
dir,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
dir: "asc" | "desc";
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 transition-colors hover:text-[var(--accent)]"
|
||||||
|
style={{ color: active ? "var(--accent)" : "inherit" }}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={`按${label}排序`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{active ? (
|
||||||
|
dir === "asc" ? (
|
||||||
|
<ChevronUp size={13} />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={13} />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ChevronsUpDown size={13} className="opacity-50" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户积分标签:搜索用户 → 余额/累计/等级一览;行内「调整」弹窗(加分计累计可升级,
|
||||||
|
* 扣分仅扣余额不降级,理由必填)与「流水」弹窗(分页查看该用户积分流水)。
|
||||||
|
*/
|
||||||
|
export default function UserPointsTab() {
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [appliedQ, setAppliedQ] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [sort, setSort] = useState<PointsSortKey>("id");
|
||||||
|
const [dir, setDir] = useState<"asc" | "desc">("desc");
|
||||||
|
|
||||||
|
const [adjustTarget, setAdjustTarget] = useState<AdminUser | null>(null);
|
||||||
|
const [ledgerTarget, setLedgerTarget] = useState<AdminUser | null>(null);
|
||||||
|
|
||||||
|
const seqRef = useRef(0);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
// 排序走 ref:load 身份保持稳定,防抖搜索与切页自动沿用当前排序
|
||||||
|
const sortRef = useRef<{ sort: PointsSortKey; dir: "asc" | "desc" }>({ sort: "id", dir: "desc" });
|
||||||
|
|
||||||
|
const load = useCallback(async (opts: { page: number; q: string }) => {
|
||||||
|
const seq = ++seqRef.current;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiAdminListUsers({
|
||||||
|
page: opts.page,
|
||||||
|
size: PAGE_SIZE,
|
||||||
|
q: opts.q || undefined,
|
||||||
|
sort: sortRef.current.sort,
|
||||||
|
order: sortRef.current.dir,
|
||||||
|
});
|
||||||
|
if (seq !== seqRef.current) return;
|
||||||
|
setUsers(res.users);
|
||||||
|
setTotal(res.total);
|
||||||
|
setPage(opts.page);
|
||||||
|
setAppliedQ(opts.q);
|
||||||
|
setError("");
|
||||||
|
setLoaded(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (seq !== seqRef.current) return;
|
||||||
|
setError(e instanceof Error ? e.message : "获取用户列表失败");
|
||||||
|
} finally {
|
||||||
|
if (seq === seqRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load({ page: 1, q: "" });
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// 搜索 400ms 防抖(对齐 UsersAdmin)
|
||||||
|
const onKeywordChange = useCallback(
|
||||||
|
(v: string) => {
|
||||||
|
setKeyword(v);
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
void load({ page: 1, q: v.trim() });
|
||||||
|
}, 400);
|
||||||
|
},
|
||||||
|
[load]
|
||||||
|
);
|
||||||
|
|
||||||
|
const patchUser = useCallback((id: number, patch: Partial<AdminUser>) => {
|
||||||
|
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, ...patch } : u)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 切换排序:点未激活列 → 该列降序;点已激活列 → 升降切换;始终回到第 1 页
|
||||||
|
const toggleSort = useCallback(
|
||||||
|
(key: PointsSortKey) => {
|
||||||
|
const cur = sortRef.current;
|
||||||
|
const next = cur.sort === key ? { sort: key, dir: cur.dir === "desc" ? ("asc" as const) : ("desc" as const) } : { sort: key, dir: "desc" as const };
|
||||||
|
sortRef.current = next;
|
||||||
|
setSort(next.sort);
|
||||||
|
setDir(next.dir);
|
||||||
|
void load({ page: 1, q: appliedQ });
|
||||||
|
},
|
||||||
|
[load, appliedQ]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AdminPageHeader
|
||||||
|
icon={UsersIcon}
|
||||||
|
title="用户积分"
|
||||||
|
description="查看与管理用户积分:加分计入累计获得(可升级),扣分仅扣余额不降级"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4 max-w-xs">
|
||||||
|
<AdminSearchField
|
||||||
|
value={keyword}
|
||||||
|
onChange={onKeywordChange}
|
||||||
|
placeholder="搜索用户名 / 昵称 / 邮箱"
|
||||||
|
ariaLabel="搜索用户"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel p-0 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[13px]" style={{ borderCollapse: "collapse" }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: "1px solid var(--line)" }}>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium" style={{ color: "var(--ink-3)" }}>
|
||||||
|
用户
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-right font-medium" style={{ color: "var(--ink-3)" }}>
|
||||||
|
<SortHeader label="余额" active={sort === "points"} dir={dir} onClick={() => toggleSort("points")} />
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-right font-medium" style={{ color: "var(--ink-3)" }}>
|
||||||
|
<SortHeader label="累计获得" active={sort === "total_points"} dir={dir} onClick={() => toggleSort("total_points")} />
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-left font-medium" style={{ color: "var(--ink-3)" }}>
|
||||||
|
等级
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2.5 text-right font-medium" style={{ color: "var(--ink-3)" }}>
|
||||||
|
操作
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id} style={{ borderBottom: "1px solid var(--line)" }}>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<Link href={`/u/${u.id}`} className="flex items-center gap-2.5 min-w-0 group">
|
||||||
|
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={30} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span
|
||||||
|
className="block truncate text-[13.5px] font-medium group-hover:text-[var(--accent)] transition-colors"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{u.nickname || u.username}
|
||||||
|
</span>
|
||||||
|
<span className="block meta text-[11.5px] truncate">@{u.username}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums" style={{ color: "var(--ink)" }}>
|
||||||
|
{fmt(u.points)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right tabular-nums" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{fmt(u.total_points)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<LevelBadge level={u.level} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => setLedgerTarget(u)}
|
||||||
|
>
|
||||||
|
<History size={13} /> 流水
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => setAdjustTarget(u)}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={13} /> 调整
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{loading ? <p className="meta text-center py-4">加载中…</p> : null}
|
||||||
|
{!loading && error ? <p className="text-center py-6 text-[13px]" style={{ color: "var(--danger)" }}>{error}</p> : null}
|
||||||
|
{!loading && !error && loaded && users.length === 0 ? (
|
||||||
|
<p className="meta text-center py-10">没有匹配的用户</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!error && users.length > 0 ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<AdminPager page={page} total={total} pageSize={PAGE_SIZE} loading={loading} onChange={(p) => void load({ page: p, q: appliedQ })} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{adjustTarget ? (
|
||||||
|
<AdjustModal
|
||||||
|
user={adjustTarget}
|
||||||
|
onClose={() => setAdjustTarget(null)}
|
||||||
|
onAdjusted={(balance, totalPoints, level) => {
|
||||||
|
patchUser(adjustTarget.id, { points: balance, total_points: totalPoints, level });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{ledgerTarget ? (
|
||||||
|
<LedgerModal user={ledgerTarget} onClose={() => setLedgerTarget(null)} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调整弹窗:方向(加/减)+ 数量 + 理由;成功后回填余额/累计/等级 */
|
||||||
|
function AdjustModal({
|
||||||
|
user,
|
||||||
|
onClose,
|
||||||
|
onAdjusted,
|
||||||
|
}: {
|
||||||
|
user: AdminUser;
|
||||||
|
onClose: () => void;
|
||||||
|
onAdjusted: (balance: number, totalPoints: number, level: number) => void;
|
||||||
|
}) {
|
||||||
|
const [dir, setDir] = useState<"add" | "sub">("add");
|
||||||
|
const [amount, setAmount] = useState(10);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (saving) return;
|
||||||
|
if (!Number.isInteger(amount) || amount < 1 || amount > MAX_DELTA) {
|
||||||
|
toast(`积分数量须为 1–${MAX_DELTA} 的整数`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = note.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
toast("请填写调整理由");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const delta = dir === "add" ? amount : -amount;
|
||||||
|
const res = await apiAdminAdjustUserPoints(user.id, delta, trimmed);
|
||||||
|
onAdjusted(res.balance, res.total_points, res.level);
|
||||||
|
toast(
|
||||||
|
`已${dir === "add" ? "增加" : "扣减"} ${fmt(amount)} 积分:余额 ${fmt(res.balance)},当前 Lv.${res.level}`,
|
||||||
|
"ok"
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "积分调整失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
title={`调整积分 - ${user.nickname || user.username}`}
|
||||||
|
description={`当前余额 ${fmt(user.points)} · 累计获得 ${fmt(user.total_points)} · Lv.${user.level ?? 1}`}
|
||||||
|
onClose={onClose}
|
||||||
|
busy={saving}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-line btn-sm" onClick={onClose} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" onClick={() => void submit()} disabled={saving}>
|
||||||
|
{saving ? "提交中…" : "确认调整"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs mb-1.5" style={{ color: "var(--ink-2)" }}>
|
||||||
|
方向
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDir("add")}
|
||||||
|
className={`btn btn-sm flex-1 ${dir === "add" ? "btn-primary" : "btn-line"}`}
|
||||||
|
>
|
||||||
|
增加
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDir("sub")}
|
||||||
|
className={`btn btn-sm flex-1 ${dir === "sub" ? "btn-danger" : "btn-line"}`}
|
||||||
|
>
|
||||||
|
扣减
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="meta text-[11.5px] mt-1.5">
|
||||||
|
{dir === "add"
|
||||||
|
? "加分同时计入累计获得,可能触发升级"
|
||||||
|
: "扣分仅扣减余额,不改变累计获得与等级"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs mb-1.5" style={{ color: "var(--ink-2)" }}>
|
||||||
|
数量(1–{MAX_DELTA})
|
||||||
|
</p>
|
||||||
|
<AdminStepper
|
||||||
|
value={amount}
|
||||||
|
onChange={setAmount}
|
||||||
|
min={1}
|
||||||
|
max={MAX_DELTA}
|
||||||
|
unit="分"
|
||||||
|
ariaLabel="调整数量"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs mb-1.5" style={{ color: "var(--ink-2)" }}>
|
||||||
|
调整理由(必填,写入积分流水)
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 text-[13px] rounded-[var(--r-sm)] outline-none focus:border-[var(--accent)] transition-colors"
|
||||||
|
style={{ background: "var(--panel-2)", border: "1px solid var(--line-2)", color: "var(--ink)" }}
|
||||||
|
placeholder="例如:活动奖励补偿 / 违规扣减"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 流水弹窗:懒加载分页查询该用户积分流水 */
|
||||||
|
function LedgerModal({ user, onClose }: { user: AdminUser; onClose: () => void }) {
|
||||||
|
const [items, setItems] = useState<PointLedgerItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async (p: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiAdminUserPointLedger(user.id, p);
|
||||||
|
setItems(res.items);
|
||||||
|
setTotal(res.total);
|
||||||
|
setPage(p);
|
||||||
|
setError("");
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "获取积分流水失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [user.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load(1);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
title={`积分流水 - ${user.nickname || user.username}`}
|
||||||
|
description={`余额 ${fmt(user.points)} · 累计获得 ${fmt(user.total_points)} · 共 ${fmt(total)} 条`}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidthClass="max-w-2xl"
|
||||||
|
>
|
||||||
|
{loading && items.length === 0 ? (
|
||||||
|
<p className="meta text-center py-8">加载中…</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-center py-8 text-[13px]" style={{ color: "var(--danger)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="meta text-center py-8">暂无积分流水</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="flex flex-col max-h-[50vh] overflow-y-auto">
|
||||||
|
{items.map((it) => {
|
||||||
|
const delta = Number(it.delta) || 0;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={it.id}
|
||||||
|
className="flex items-center gap-3 py-2.5"
|
||||||
|
style={{ borderBottom: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium select-none"
|
||||||
|
style={{
|
||||||
|
background: delta > 0 ? "var(--ok-soft)" : "var(--danger-soft)",
|
||||||
|
color: delta > 0 ? "var(--ok)" : "var(--danger)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{delta > 0 ? `+${fmt(delta)}` : fmt(delta)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-[13px] truncate" style={{ color: "var(--ink)" }}>
|
||||||
|
{pointReasonLabel(it.reason)}
|
||||||
|
{it.note ? (
|
||||||
|
<span className="ml-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{it.note}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="block meta text-[11.5px] mt-0.5">
|
||||||
|
变动后余额 {fmt(it.balance)} · {formatRelative(it.created_at)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-4">
|
||||||
|
<AdminPager
|
||||||
|
page={page}
|
||||||
|
total={total}
|
||||||
|
pageSize={20}
|
||||||
|
loading={loading}
|
||||||
|
onChange={(p) => void load(p)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
frontend/app/admin/points/page.tsx
Normal file
44
frontend/app/admin/points/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { fetchAdminEconomyStats, type EconomyStats, type PublicSettings } from "@/lib/api";
|
||||||
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
|
import { isAdminOrAbove, isSuperOrOwner } from "@/lib/roles";
|
||||||
|
import Forbidden from "../Forbidden";
|
||||||
|
import PointsTabs from "./PointsTabs";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "积分管理",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AdminPointsPage() {
|
||||||
|
const cookie = authCookieHeader(await cookies());
|
||||||
|
const me = await getMeCached(cookie || undefined);
|
||||||
|
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||||
|
return <Forbidden text="积分管理仅管理员及以上可查看。" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canManage = isSuperOrOwner(me.user.role);
|
||||||
|
|
||||||
|
// 经济总览预取(管理员及以上可读);失败降级为空态,不阻塞规则/用户积分标签
|
||||||
|
let stats: Awaited<ReturnType<typeof fetchAdminEconomyStats>> | null = null;
|
||||||
|
try {
|
||||||
|
stats = await fetchAdminEconomyStats(cookie);
|
||||||
|
} catch {
|
||||||
|
stats = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 积分规则初始值(公开设置缓存,超管/站长的规则标签用)
|
||||||
|
let settings: PublicSettings | null = null;
|
||||||
|
if (canManage) {
|
||||||
|
settings = await getPublicSettingsCached();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PointsTabs
|
||||||
|
stats={stats}
|
||||||
|
settings={settings}
|
||||||
|
canManage={canManage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
237
frontend/app/admin/settings/EconomySettings.tsx
Normal file
237
frontend/app/admin/settings/EconomySettings.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { CalendarCheck, Flame, MessageSquarePlus, PenLine, Star } from "lucide-react";
|
||||||
|
import { AdminSettingsGroup, AdminSettingsRow, AdminStepper } from "@/components/admin";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||||
|
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
|
||||||
|
/** 积分规则 8 键(0–100,后端按 0–100 裁剪) */
|
||||||
|
type PointsKeys = Pick<
|
||||||
|
PublicSettings,
|
||||||
|
| "points_checkin_base"
|
||||||
|
| "points_streak_every_days"
|
||||||
|
| "points_streak_bonus"
|
||||||
|
| "points_post_reward"
|
||||||
|
| "points_post_daily_cap"
|
||||||
|
| "points_reply_reward"
|
||||||
|
| "points_reply_daily_cap"
|
||||||
|
| "points_recommend_reward"
|
||||||
|
>;
|
||||||
|
|
||||||
|
const POINTS_KEYS: (keyof PointsKeys)[] = [
|
||||||
|
"points_checkin_base",
|
||||||
|
"points_streak_every_days",
|
||||||
|
"points_streak_bonus",
|
||||||
|
"points_post_reward",
|
||||||
|
"points_post_daily_cap",
|
||||||
|
"points_reply_reward",
|
||||||
|
"points_reply_daily_cap",
|
||||||
|
"points_recommend_reward",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 管理员可配置的积分增长规则:签到基础分 / 连续签到加成 / 发帖奖励 / 回复奖励(各带每日上限)/ 推荐奖励 */
|
||||||
|
export default function EconomySettings({ initial }: { initial: PublicSettings }) {
|
||||||
|
const pickInitial = (): PointsKeys =>
|
||||||
|
Object.fromEntries(POINTS_KEYS.map((k) => [k, initial[k]])) as PointsKeys;
|
||||||
|
|
||||||
|
const [form, setForm] = useState<PointsKeys>(pickInitial);
|
||||||
|
const [saved, setSaved] = useState<PointsKeys>(pickInitial);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const dirty = useMemo(() => POINTS_KEYS.some((k) => form[k] !== saved[k]), [form, saved]);
|
||||||
|
const leave = useUnsavedGuard(dirty);
|
||||||
|
|
||||||
|
const set = (k: keyof PointsKeys, v: number) => setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (saving || !dirty) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = Object.fromEntries(POINTS_KEYS.map((k) => [k, form[k]]));
|
||||||
|
const res = await apiUpdateSiteSettings(payload);
|
||||||
|
const next = Object.fromEntries(POINTS_KEYS.map((k) => [k, res[k]])) as PointsKeys;
|
||||||
|
setForm(next);
|
||||||
|
setSaved(next);
|
||||||
|
toast("积分规则已保存", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-settings-stack">
|
||||||
|
<AdminSettingsGroup title="签到">
|
||||||
|
<AdminSettingsRow
|
||||||
|
label={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<CalendarCheck size={15} /> 签到基础分
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
hintBelow="每日首次签到固定获得的积分(0=关闭,上限 100)。"
|
||||||
|
>
|
||||||
|
<AdminStepper
|
||||||
|
value={form.points_checkin_base}
|
||||||
|
onChange={(v) => set("points_checkin_base", v)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
unit="分"
|
||||||
|
ariaLabel="签到基础分"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</AdminSettingsRow>
|
||||||
|
<AdminSettingsRow
|
||||||
|
label={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Flame size={15} /> 连续签到触发天数
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
hintBelow="连续签到每满 N 天额外发放一次加成(0=关闭,上限 100)。"
|
||||||
|
>
|
||||||
|
<AdminStepper
|
||||||
|
value={form.points_streak_every_days}
|
||||||
|
onChange={(v) => set("points_streak_every_days", v)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
unit="天"
|
||||||
|
ariaLabel="连续签到触发天数"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</AdminSettingsRow>
|
||||||
|
<AdminSettingsRow
|
||||||
|
label={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Flame size={15} /> 连续签到加成
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
hintBelow="触发加成时额外获得的积分(0=关闭,上限 100);与触发天数配合使用。"
|
||||||
|
>
|
||||||
|
<AdminStepper
|
||||||
|
value={form.points_streak_bonus}
|
||||||
|
onChange={(v) => set("points_streak_bonus", v)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
unit="分"
|
||||||
|
ariaLabel="连续签到加成分值"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</AdminSettingsRow>
|
||||||
|
</AdminSettingsGroup>
|
||||||
|
|
||||||
|
<AdminSettingsGroup title="发帖与回复">
|
||||||
|
<AdminSettingsRow
|
||||||
|
label={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<PenLine size={15} /> 发帖奖励
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
hintBelow="帖子发布成功后发放(0=关闭,上限 100);仅公开可见的帖子计奖,删除不回追。"
|
||||||
|
>
|
||||||
|
<AdminStepper
|
||||||
|
value={form.points_post_reward}
|
||||||
|
onChange={(v) => set("points_post_reward", v)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
unit="分"
|
||||||
|
ariaLabel="发帖奖励分值"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</AdminSettingsRow>
|
||||||
|
<AdminSettingsRow
|
||||||
|
label={<span className="inline-flex items-center gap-1.5">发帖奖励每日上限</span>}
|
||||||
|
hintBelow="每人每天最多计奖的帖子数(0=不限,上限 100),防止刷分。"
|
||||||
|
>
|
||||||
|
<AdminStepper
|
||||||
|
value={form.points_post_daily_cap}
|
||||||
|
onChange={(v) => set("points_post_daily_cap", v)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
unit="帖"
|
||||||
|
ariaLabel="发帖奖励每日上限"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</AdminSettingsRow>
|
||||||
|
<AdminSettingsRow
|
||||||
|
label={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<MessageSquarePlus size={15} /> 回复奖励
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
hintBelow="评论发布成功后发放(0=关闭,上限 100);仅公开可见的评论计奖。"
|
||||||
|
>
|
||||||
|
<AdminStepper
|
||||||
|
value={form.points_reply_reward}
|
||||||
|
onChange={(v) => set("points_reply_reward", v)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
unit="分"
|
||||||
|
ariaLabel="回复奖励分值"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</AdminSettingsRow>
|
||||||
|
<AdminSettingsRow
|
||||||
|
label={<span className="inline-flex items-center gap-1.5">回复奖励每日上限</span>}
|
||||||
|
hintBelow="每人每天最多计奖的评论数(0=不限,上限 100),防止刷分。"
|
||||||
|
>
|
||||||
|
<AdminStepper
|
||||||
|
value={form.points_reply_daily_cap}
|
||||||
|
onChange={(v) => set("points_reply_daily_cap", v)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
unit="条"
|
||||||
|
ariaLabel="回复奖励每日上限"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</AdminSettingsRow>
|
||||||
|
</AdminSettingsGroup>
|
||||||
|
|
||||||
|
<AdminSettingsGroup title="帖子推荐">
|
||||||
|
<AdminSettingsRow
|
||||||
|
label={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Star size={15} /> 推荐奖励
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
hintBelow="帖子被管理员推荐(加精)时,作者一次性获得该积分(0=关闭,上限 100);推荐人为作者本人时不发放,同一帖子只发放一次。"
|
||||||
|
>
|
||||||
|
<AdminStepper
|
||||||
|
value={form.points_recommend_reward}
|
||||||
|
onChange={(v) => set("points_recommend_reward", v)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
unit="分"
|
||||||
|
ariaLabel="推荐奖励分值"
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
</AdminSettingsRow>
|
||||||
|
</AdminSettingsGroup>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-solid btn-sm"
|
||||||
|
disabled={saving || !dirty}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
{saving ? "保存中…" : "保存积分规则"}
|
||||||
|
</button>
|
||||||
|
<span className="admin-settings-instant">
|
||||||
|
{dirty ? "有未保存的修改" : "已保存的规则对后续签到/发帖/回复/推荐生效"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={leave.leaveOpen}
|
||||||
|
title="有未保存的修改"
|
||||||
|
message="离开后,当前尚未保存的积分规则修改会丢失。确定离开?"
|
||||||
|
confirmLabel="离开"
|
||||||
|
danger
|
||||||
|
onCancel={leave.cancelLeave}
|
||||||
|
onConfirm={leave.confirmLeave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,6 +49,8 @@ import { canOverseeChat, isOwner as isSiteOwner } from "@/lib/roles";
|
|||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { scrollBehavior } from "@/lib/motion";
|
import { scrollBehavior } from "@/lib/motion";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
|
import LevelBadge from "@/components/LevelBadge";
|
||||||
|
import BadgeChip from "@/components/BadgeChip";
|
||||||
import AutolinkText from "@/components/AutolinkText";
|
import AutolinkText from "@/components/AutolinkText";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
||||||
@@ -1420,6 +1422,14 @@ export default function ChatRoomClient({
|
|||||||
>
|
>
|
||||||
{m.sender.nickname}
|
{m.sender.nickname}
|
||||||
</span>
|
</span>
|
||||||
|
{!isDirect && (
|
||||||
|
<>
|
||||||
|
<LevelBadge level={m.sender.level} />
|
||||||
|
{(m.sender.badges ?? []).slice(0, 1).map((ub) => (
|
||||||
|
<BadgeChip key={ub.id} badge={ub.badge} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span className="meta text-[10.5px]">
|
<span className="meta text-[10.5px]">
|
||||||
{formatTime(m.created_at)}
|
{formatTime(m.created_at)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -32,6 +32,14 @@
|
|||||||
--ok: #137333;
|
--ok: #137333;
|
||||||
--ok-soft: rgba(19, 115, 51, 0.1);
|
--ok-soft: rgba(19, 115, 51, 0.1);
|
||||||
|
|
||||||
|
/* 等级徽章与自定义徽章扩展色板 */
|
||||||
|
--info: #1c6ea8;
|
||||||
|
--info-soft: rgba(28, 110, 168, 0.1);
|
||||||
|
--purple: #6d3fc0;
|
||||||
|
--purple-soft: rgba(109, 63, 192, 0.1);
|
||||||
|
--teal: #0f7a6e;
|
||||||
|
--teal-soft: rgba(15, 122, 110, 0.1);
|
||||||
|
|
||||||
/* 积分看板最近流水:金额与类型标签 */
|
/* 积分看板最近流水:金额与类型标签 */
|
||||||
--ledger-in: #16a34a;
|
--ledger-in: #16a34a;
|
||||||
--ledger-out: #dc2626;
|
--ledger-out: #dc2626;
|
||||||
@@ -103,6 +111,18 @@
|
|||||||
--rail-top: calc(var(--header-h) + var(--main-pad-y));
|
--rail-top: calc(var(--header-h) + var(--main-pad-y));
|
||||||
--rail-max-h: calc(100vh - var(--rail-top) - 1rem);
|
--rail-max-h: calc(100vh - var(--rail-top) - 1rem);
|
||||||
|
|
||||||
|
/* 等级徽章默认色兜底(layout.tsx 会按后台配置注入 --lv-N-* 覆盖此处;
|
||||||
|
与 lib/levels.ts 色板保持同步:灰/蓝/青/绿/红牌/黑金/鎏金,
|
||||||
|
特效档为含高光段的流动渐变,流光由高光段随 position 流动呈现) */
|
||||||
|
--lv-1-fg: #9aa3b2; --lv-1-bg: rgba(148,158,176,0.16); --lv-1-ring: transparent;
|
||||||
|
--lv-2-fg: #4d8dff; --lv-2-bg: rgba(59,118,240,0.15); --lv-2-ring: transparent;
|
||||||
|
--lv-3-fg: #12a89b; --lv-3-bg: rgba(14,148,136,0.15); --lv-3-ring: transparent;
|
||||||
|
--lv-4-fg: #27a94f; --lv-4-bg: rgba(31,156,71,0.15); --lv-4-ring: transparent;
|
||||||
|
--lv-5-fg: #ffffff; --lv-5-bg: linear-gradient(90deg, #b91c1c, #ef4444 25%, #fecaca 38%, #dc2626 52%, #991b1b 78%, #b91c1c) 0% 0% / 200% 100%; --lv-5-ring: transparent;
|
||||||
|
--lv-6-fg: #e9c46a; --lv-6-bg: linear-gradient(90deg, #16171c, #23252c 22%, #4a3d1c 36%, #23252c 52%, #16171c 78%) 0% 0% / 200% 100%; --lv-6-ring: rgba(233, 196, 106, 0.5);
|
||||||
|
--lv-7-fg: transparent; --lv-7-bg: linear-gradient(180deg, #221b0f, #100d07); --lv-7-ring: rgba(240, 200, 120, 0.45);
|
||||||
|
--lv-7-text-image: linear-gradient(90deg, #c9973f, #ffe9a8 30%, #fff6d8 46%, #f0c964 66%, #c9973f); --lv-7-text-position: 0% 0%; --lv-7-text-size: 200% 100%;
|
||||||
|
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +153,14 @@
|
|||||||
--ok: #58d18a;
|
--ok: #58d18a;
|
||||||
--ok-soft: rgba(88, 209, 138, 0.15);
|
--ok-soft: rgba(88, 209, 138, 0.15);
|
||||||
|
|
||||||
|
/* 等级徽章与自定义徽章扩展色板 */
|
||||||
|
--info: #5db8f0;
|
||||||
|
--info-soft: rgba(93, 184, 240, 0.16);
|
||||||
|
--purple: #c39bf5;
|
||||||
|
--purple-soft: rgba(195, 155, 245, 0.16);
|
||||||
|
--teal: #4fd8c4;
|
||||||
|
--teal-soft: rgba(79, 216, 196, 0.16);
|
||||||
|
|
||||||
--ledger-in: #4ade80;
|
--ledger-in: #4ade80;
|
||||||
--ledger-out: #f87171;
|
--ledger-out: #f87171;
|
||||||
--ledger-checkin-bg: rgba(22, 163, 74, 0.22);
|
--ledger-checkin-bg: rgba(22, 163, 74, 0.22);
|
||||||
@@ -279,6 +307,51 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 等级徽章特效(LevelBadge / .lv-badge):底色/前景由 SSR 注入的 --lv-N-* 变量驱动。
|
||||||
|
特效档背景为水平多段渐变(首尾同色)+ background-size 200%,
|
||||||
|
lv-flow 位移 0→200% 恰好一个图块周期(百分比 position 非线性:偏移=(100%-size)×p,
|
||||||
|
size=200% 时 p=200% 的偏移=-200% 即一个图块宽),循环首尾严格重合、无接缝。
|
||||||
|
.lv-text 为文字渐变层(--lv-N-text 存在时经 background-clip:text 呈现鎏金字,
|
||||||
|
同样走 lv-flow 流动;无渐变档 background 为 none,文字按 --lv-N-fg 正常着色) */
|
||||||
|
@keyframes lv-flow {
|
||||||
|
to {
|
||||||
|
background-position: 200% 0%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.lv-badge {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: lv-flow 6s linear infinite;
|
||||||
|
}
|
||||||
|
.lv-text {
|
||||||
|
animation: lv-flow 6s linear infinite;
|
||||||
|
}
|
||||||
|
/* 站点特效总开关(levels_fx 设置,layout SSR 注入 html[data-lv-fx]):
|
||||||
|
关闭时停用流光动画,徽章退化为静态配色 */
|
||||||
|
html[data-lv-fx="off"] .lv-badge,
|
||||||
|
html[data-lv-fx="off"] .lv-text {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.lv-badge,
|
||||||
|
.lv-text {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 徽章类统一禁止鼠标选择:拖选/双击正文时避免把小胶囊文字带进选区 */
|
||||||
|
.lv-badge,
|
||||||
|
.cmt-badge,
|
||||||
|
.badge-pending,
|
||||||
|
.badge-type,
|
||||||
|
.badge-access,
|
||||||
|
.badge-status,
|
||||||
|
.badge-accepted,
|
||||||
|
.j13-board-tag {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* 全站悬停即显提示气泡(配合 TipHost + data-tip,替代原生 title 延迟) */
|
/* 全站悬停即显提示气泡(配合 TipHost + data-tip,替代原生 title 延迟) */
|
||||||
.j13-tip-bubble {
|
.j13-tip-bubble {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -3079,6 +3152,9 @@ body:has([data-admin-viewport]) {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
animation: admin-drawer-in 0.18s ease-out;
|
animation: admin-drawer-in 0.18s ease-out;
|
||||||
|
/* 覆盖 .admin-rail 的上下内边距并叠加刘海/手势区避让 */
|
||||||
|
padding-top: calc(1rem + env(safe-area-inset-top, 0px));
|
||||||
|
padding-bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-rail-brand {
|
.admin-rail-brand {
|
||||||
@@ -3265,6 +3341,7 @@ body:has([data-admin-viewport]) {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-drawer-backdrop {
|
.admin-drawer-backdrop {
|
||||||
@@ -3273,7 +3350,8 @@ body:has([data-admin-viewport]) {
|
|||||||
border: 0;
|
border: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: color-mix(in srgb, var(--ink) 28%, transparent);
|
background: color-mix(in srgb, var(--ink) 32%, transparent);
|
||||||
|
animation: admin-fade-in 0.18s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes admin-drawer-in {
|
@keyframes admin-drawer-in {
|
||||||
@@ -3287,8 +3365,49 @@ body:has([data-admin-viewport]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes admin-drawer-out {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes admin-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes admin-fade-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 退场:面板滑出 + 背板淡出,结束后由组件卸载 */
|
||||||
|
.admin-drawer.is-closing .admin-drawer-backdrop {
|
||||||
|
animation: admin-fade-out 0.18s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-drawer.is-closing .admin-drawer-panel {
|
||||||
|
animation: admin-drawer-out 0.18s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.admin-drawer-panel {
|
.admin-drawer-panel,
|
||||||
|
.admin-drawer-backdrop,
|
||||||
|
.admin-drawer.is-closing .admin-drawer-panel,
|
||||||
|
.admin-drawer.is-closing .admin-drawer-backdrop {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
|||||||
import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies";
|
import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies";
|
||||||
import { accentStyleCSS } from "@/lib/theme";
|
import { accentStyleCSS } from "@/lib/theme";
|
||||||
import { bgConfigFromSettings, bgStyleCSS } from "@/lib/bg";
|
import { bgConfigFromSettings, bgStyleCSS } from "@/lib/bg";
|
||||||
|
import { levelVarsCSS } from "@/lib/levels";
|
||||||
import { siteDocumentTitle } from "@/lib/brand";
|
import { siteDocumentTitle } from "@/lib/brand";
|
||||||
import { motionConfigFromSettings } from "@/lib/motion";
|
import { motionConfigFromSettings } from "@/lib/motion";
|
||||||
import { peekPublicMetaSnapshot, publishPublicMetaSnapshot } from "@/lib/publicMetaSnapshot";
|
import { peekPublicMetaSnapshot, publishPublicMetaSnapshot } from "@/lib/publicMetaSnapshot";
|
||||||
@@ -126,6 +127,7 @@ export default async function RootLayout({
|
|||||||
<html
|
<html
|
||||||
lang="zh-CN"
|
lang="zh-CN"
|
||||||
data-j13-chrome={isAdmin ? "admin" : "site"}
|
data-j13-chrome={isAdmin ? "admin" : "site"}
|
||||||
|
data-lv-fx={settings.levels_fx === false ? "off" : "on"}
|
||||||
className={`${theme === "dark" ? "dark" : ""} ${motionHtmlClass} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`.trim()}
|
className={`${theme === "dark" ? "dark" : ""} ${motionHtmlClass} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`.trim()}
|
||||||
>
|
>
|
||||||
<body className="min-h-screen flex flex-col">
|
<body className="min-h-screen flex flex-col">
|
||||||
@@ -144,6 +146,12 @@ export default async function RootLayout({
|
|||||||
dangerouslySetInnerHTML={{ __html: bgCSS }}
|
dangerouslySetInnerHTML={{ __html: bgCSS }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{/* 等级配色变量(--lv-N-fg/-bg,引用设计 token 随明暗主题切换);恒渲染兼作兜底 */}
|
||||||
|
<style
|
||||||
|
id="j13-level-vars"
|
||||||
|
data-level-source="ssr"
|
||||||
|
dangerouslySetInnerHTML={{ __html: levelVarsCSS(settings.levels) }}
|
||||||
|
/>
|
||||||
<a href="#main" className="skip-link">
|
<a href="#main" className="skip-link">
|
||||||
跳到主要内容
|
跳到主要内容
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList } from "lucide-react";
|
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award } from "lucide-react";
|
||||||
import { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api";
|
import { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,11 +20,13 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
|
|||||||
list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item))
|
list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// @提及通知跳到群聊房间;其余跳到帖子/评论
|
// @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);其余跳到帖子/评论
|
||||||
if (n.type === "mention" && n.room_id) {
|
if (n.type === "mention" && n.room_id) {
|
||||||
router.push(`/chat/${n.room_id}`);
|
router.push(`/chat/${n.room_id}`);
|
||||||
} else if (n.type === "pending_review") {
|
} else if (n.type === "pending_review") {
|
||||||
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending");
|
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending");
|
||||||
|
} else if (n.type === "badge") {
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
router.push(`/post/${n.post_id}`);
|
router.push(`/post/${n.post_id}`);
|
||||||
}
|
}
|
||||||
@@ -84,6 +86,8 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
|
|||||||
? <AtSign size={16} />
|
? <AtSign size={16} />
|
||||||
: n.type === "pending_review"
|
: n.type === "pending_review"
|
||||||
? <ClipboardList size={16} />
|
? <ClipboardList size={16} />
|
||||||
|
: n.type === "badge"
|
||||||
|
? <Award size={16} />
|
||||||
: <MessageCircle size={16} />;
|
: <MessageCircle size={16} />;
|
||||||
const iconColor = n.type === "approved"
|
const iconColor = n.type === "approved"
|
||||||
? "var(--ok)"
|
? "var(--ok)"
|
||||||
@@ -91,6 +95,8 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
|
|||||||
? "var(--danger)"
|
? "var(--danger)"
|
||||||
: n.type === "like"
|
: n.type === "like"
|
||||||
? "var(--clay)"
|
? "var(--clay)"
|
||||||
|
: n.type === "badge"
|
||||||
|
? "var(--gold)"
|
||||||
: "var(--accent)";
|
: "var(--accent)";
|
||||||
const iconBg = n.type === "approved"
|
const iconBg = n.type === "approved"
|
||||||
? "var(--ok-soft)"
|
? "var(--ok-soft)"
|
||||||
@@ -98,6 +104,8 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
|
|||||||
? "var(--danger-soft)"
|
? "var(--danger-soft)"
|
||||||
: n.type === "like"
|
: n.type === "like"
|
||||||
? "var(--clay-soft)"
|
? "var(--clay-soft)"
|
||||||
|
: n.type === "badge"
|
||||||
|
? "var(--gold-soft)"
|
||||||
: "var(--accent-soft)";
|
: "var(--accent-soft)";
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -145,6 +153,8 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
|
|||||||
? "有新内容待审核"
|
? "有新内容待审核"
|
||||||
: n.type === "deleted"
|
: n.type === "deleted"
|
||||||
? "删除了你的帖子"
|
? "删除了你的帖子"
|
||||||
|
: n.type === "badge"
|
||||||
|
? "向你颁布了徽章"
|
||||||
: "点赞了你的帖子"}
|
: "点赞了你的帖子"}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { SiteDocBreadcrumb } from "@/components/site-doc";
|
|||||||
import MobilePostBar from "@/components/MobilePostBar";
|
import MobilePostBar from "@/components/MobilePostBar";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import RoleBadge from "@/components/RoleBadge";
|
import RoleBadge from "@/components/RoleBadge";
|
||||||
|
import LevelBadge from "@/components/LevelBadge";
|
||||||
|
import BadgeChip from "@/components/BadgeChip";
|
||||||
import MarkdownBody from "@/components/MarkdownBody";
|
import MarkdownBody from "@/components/MarkdownBody";
|
||||||
import { publicMarkdownForSeo } from "@/lib/hideBlocks";
|
import { publicMarkdownForSeo } from "@/lib/hideBlocks";
|
||||||
import PostAttachments from "@/components/PostAttachments";
|
import PostAttachments from "@/components/PostAttachments";
|
||||||
@@ -185,6 +187,10 @@ function AuthorCardStacked({
|
|||||||
>
|
>
|
||||||
{u.nickname}
|
{u.nickname}
|
||||||
</Link>
|
</Link>
|
||||||
|
<LevelBadge level={u.level} />
|
||||||
|
{(u.badges ?? []).slice(0, 2).map((ub) => (
|
||||||
|
<BadgeChip key={ub.id} badge={ub.badge} />
|
||||||
|
))}
|
||||||
{u.role !== "user" && <RoleBadge role={u.role} boardIds={u.board_ids} />}
|
{u.role !== "user" && <RoleBadge role={u.role} boardIds={u.board_ids} />}
|
||||||
</div>
|
</div>
|
||||||
<p className="meta mt-1 flex items-center gap-1.5 flex-wrap">
|
<p className="meta mt-1 flex items-center gap-1.5 flex-wrap">
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import PasswordForm from "./PasswordForm";
|
|||||||
import LoginDevices from "./LoginDevices";
|
import LoginDevices from "./LoginDevices";
|
||||||
import ProfileHeroAvatar from "./ProfileHeroAvatar";
|
import ProfileHeroAvatar from "./ProfileHeroAvatar";
|
||||||
import RoleBadge from "@/components/RoleBadge";
|
import RoleBadge from "@/components/RoleBadge";
|
||||||
|
import LevelBadge from "@/components/LevelBadge";
|
||||||
|
import BadgeChip from "@/components/BadgeChip";
|
||||||
import { roleMeta } from "@/lib/roles";
|
import { roleMeta } from "@/lib/roles";
|
||||||
import StartDMButton from "./StartDMButton";
|
import StartDMButton from "./StartDMButton";
|
||||||
import StaffUserMenu from "@/components/StaffUserMenu";
|
import StaffUserMenu from "@/components/StaffUserMenu";
|
||||||
@@ -253,6 +255,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
>
|
>
|
||||||
{u.nickname || u.username}
|
{u.nickname || u.username}
|
||||||
</h1>
|
</h1>
|
||||||
|
<LevelBadge level={u.level} />
|
||||||
{u.role !== "user" && (
|
{u.role !== "user" && (
|
||||||
<RoleBadge role={u.role} boardIds={u.board_ids} />
|
<RoleBadge role={u.role} boardIds={u.board_ids} />
|
||||||
)}
|
)}
|
||||||
@@ -265,6 +268,18 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 徽章墙:管理员颁布的全部徽章,悬浮显示授予时间 */}
|
||||||
|
{(u.badges ?? []).length > 0 && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1.5 flex-wrap">
|
||||||
|
{(u.badges ?? []).map((ub) => (
|
||||||
|
<BadgeChip
|
||||||
|
key={ub.id}
|
||||||
|
badge={ub.badge}
|
||||||
|
tip={`${ub.badge.name} · ${new Date(ub.awarded_at).toLocaleDateString("zh-CN")} 颁布`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="mt-1.5 text-[14px] leading-snug flex flex-wrap items-center" style={{ color: "var(--ink-2)" }}>
|
<p className="mt-1.5 text-[14px] leading-snug flex flex-wrap items-center" style={{ color: "var(--ink-2)" }}>
|
||||||
<span>
|
<span>
|
||||||
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>{data.stats.post_count}</span>
|
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>{data.stats.post_count}</span>
|
||||||
|
|||||||
33
frontend/components/BadgeChip.tsx
Normal file
33
frontend/components/BadgeChip.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { badgeIcon } from "@/lib/badgeIcons";
|
||||||
|
import type { Badge } from "@/lib/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 徽章 pill:图标 + 名称,配色由后台设定的 CSS token 决定
|
||||||
|
* (color: gold/accent/ok/danger/info/purple/teal → var(--token))。
|
||||||
|
* 仅色板内的值生效,异常值回落 accent。
|
||||||
|
*/
|
||||||
|
export default function BadgeChip({
|
||||||
|
badge,
|
||||||
|
tip,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
badge: Pick<Badge, "name" | "icon" | "color">;
|
||||||
|
/** 悬浮提示(默认徽章名;徽章墙可传名称+授予时间) */
|
||||||
|
tip?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const Icon = badgeIcon(badge.icon);
|
||||||
|
const color = `var(--${badge.color}, var(--accent))`;
|
||||||
|
const soft = `var(--${badge.color}-soft, var(--accent-soft))`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6] whitespace-nowrap select-none ${className}`}
|
||||||
|
style={{ background: soft, color }}
|
||||||
|
data-tip={tip ?? badge.name}
|
||||||
|
>
|
||||||
|
<Icon size={11} className="shrink-0" />
|
||||||
|
{badge.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import { Calendar, Crown, MessageSquare, VolumeX } from "lucide-react";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import RoleBadge from "@/components/RoleBadge";
|
import RoleBadge from "@/components/RoleBadge";
|
||||||
|
import LevelBadge from "@/components/LevelBadge";
|
||||||
|
import BadgeChip from "@/components/BadgeChip";
|
||||||
import ImageLightbox from "@/components/markdown/ImageLightbox";
|
import ImageLightbox from "@/components/markdown/ImageLightbox";
|
||||||
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
|
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
|
||||||
import { apiGetUserProfile, apiOpenDirectChat, type UserProfile, type User } from "@/lib/api";
|
import { apiGetUserProfile, apiOpenDirectChat, type UserProfile, type User } from "@/lib/api";
|
||||||
@@ -105,6 +107,10 @@ export default function ChatUserCard({
|
|||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</p>
|
</p>
|
||||||
|
<LevelBadge level={user.level} />
|
||||||
|
{(user.badges ?? []).slice(0, 2).map((ub) => (
|
||||||
|
<BadgeChip key={ub.id} badge={ub.badge} />
|
||||||
|
))}
|
||||||
{user.role && user.role !== "user" && (
|
{user.role && user.role !== "user" && (
|
||||||
<RoleBadge role={user.role} boardIds={user.board_ids} />
|
<RoleBadge role={user.role} boardIds={user.board_ids} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -29,6 +29,8 @@ import { useAllowComments, useCommentsRequireLogin } from "@/components/Comments
|
|||||||
import CommentsUnsupported from "@/components/CommentsUnsupported";
|
import CommentsUnsupported from "@/components/CommentsUnsupported";
|
||||||
import CommentsLoginRequired from "@/components/CommentsLoginRequired";
|
import CommentsLoginRequired from "@/components/CommentsLoginRequired";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
import LevelBadge from "./LevelBadge";
|
||||||
|
import BadgeChip from "./BadgeChip";
|
||||||
import Pagination from "./Pagination";
|
import Pagination from "./Pagination";
|
||||||
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
||||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||||
@@ -994,6 +996,11 @@ export default function CommentSection({
|
|||||||
) : (
|
) : (
|
||||||
<span className="cmt-name">{authorName}</span>
|
<span className="cmt-name">{authorName}</span>
|
||||||
)}
|
)}
|
||||||
|
{author && <LevelBadge level={author.level} />}
|
||||||
|
{author &&
|
||||||
|
(author.badges ?? []).slice(0, 2).map((ub) => (
|
||||||
|
<BadgeChip key={ub.id} badge={ub.badge} />
|
||||||
|
))}
|
||||||
{isOp && <span className="cmt-badge">楼主</span>}
|
{isOp && <span className="cmt-badge">楼主</span>}
|
||||||
{c.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
|
{c.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
|
||||||
{acceptedCommentId === c.id && <span className="badge-accepted shrink-0">已采纳</span>}
|
{acceptedCommentId === c.id && <span className="badge-accepted shrink-0">已采纳</span>}
|
||||||
@@ -1090,6 +1097,11 @@ export default function CommentSection({
|
|||||||
) : (
|
) : (
|
||||||
<span className="cmt-name">{authorName}</span>
|
<span className="cmt-name">{authorName}</span>
|
||||||
)}
|
)}
|
||||||
|
{author && <LevelBadge level={author.level} />}
|
||||||
|
{author &&
|
||||||
|
(author.badges ?? []).slice(0, 2).map((ub) => (
|
||||||
|
<BadgeChip key={ub.id} badge={ub.badge} />
|
||||||
|
))}
|
||||||
{isOp && <span className="cmt-badge">楼主</span>}
|
{isOp && <span className="cmt-badge">楼主</span>}
|
||||||
{c.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
|
{c.status === "pending" && <span className="badge-pending shrink-0">待审</span>}
|
||||||
{acceptedCommentId === c.id && <span className="badge-accepted shrink-0">已采纳</span>}
|
{acceptedCommentId === c.id && <span className="badge-accepted shrink-0">已采纳</span>}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
|||||||
import NotificationBell from "./NotificationBell";
|
import NotificationBell from "./NotificationBell";
|
||||||
import ThemeToggle from "./ThemeToggle";
|
import ThemeToggle from "./ThemeToggle";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
import LevelBadge from "./LevelBadge";
|
||||||
|
import BadgeChip from "./BadgeChip";
|
||||||
import BrandLockup from "./BrandLockup";
|
import BrandLockup from "./BrandLockup";
|
||||||
import { useAllowMessages } from "./MessagesPolicyProvider";
|
import { useAllowMessages } from "./MessagesPolicyProvider";
|
||||||
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
|
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
|
||||||
@@ -536,8 +538,12 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
|||||||
>
|
>
|
||||||
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={38} />
|
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={38} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[14px] truncate" style={{ color: "var(--ink)" }}>
|
<p className="text-[14px] truncate flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
|
||||||
{user.nickname}
|
<span className="truncate">{user.nickname}</span>
|
||||||
|
<LevelBadge level={user.level} />
|
||||||
|
{(user.badges ?? []).slice(0, 2).map((ub) => (
|
||||||
|
<BadgeChip key={ub.id} badge={ub.badge} />
|
||||||
|
))}
|
||||||
</p>
|
</p>
|
||||||
<p className="meta text-xs truncate">@ {user.username}</p>
|
<p className="meta text-xs truncate">@ {user.username}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
45
frontend/components/LevelBadge.tsx
Normal file
45
frontend/components/LevelBadge.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { clampLevel, LEVEL_BADGE_CLASS } from "@/lib/levels";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等级徽章:仅显示 Lv.n,颜色/特效由根布局 SSR 注入的 --lv-N-* 变量驱动
|
||||||
|
* (--lv-N-fg 前景 / --lv-N-bg 背景 / --lv-N-ring 内描边 / --lv-N-text 文字渐变层,
|
||||||
|
* text 存在时 fg 为 transparent,文字以流动金渐变呈现——鎏金字)。
|
||||||
|
* 等级数值由后端按累计获得积分计算,前端零计算;不弹悬浮提示。
|
||||||
|
* 流动动画见 globals.css 的 .lv-badge / .lv-text。
|
||||||
|
*/
|
||||||
|
export default function LevelBadge({
|
||||||
|
level,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
level?: number | null;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const n = clampLevel(level);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`${LEVEL_BADGE_CLASS} ${className}`}
|
||||||
|
style={{
|
||||||
|
background: `var(--lv-${n}-bg)`,
|
||||||
|
color: `var(--lv-${n}-fg)`,
|
||||||
|
boxShadow: `inset 0 0 0 1px var(--lv-${n}-ring, transparent)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 内层承载可选文字渐变。background 全长写(React 禁止简写与 backgroundClip
|
||||||
|
等长写混用);clip 同为长写,无渐变档 image=none 时 clip 无副作用 */}
|
||||||
|
<span
|
||||||
|
className="lv-text"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `var(--lv-${n}-text-image, none)`,
|
||||||
|
backgroundPosition: `var(--lv-${n}-text-position, 0% 0%)`,
|
||||||
|
backgroundSize: `var(--lv-${n}-text-size, auto)`,
|
||||||
|
backgroundClip: "text",
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
color: `var(--lv-${n}-fg)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Lv.{n}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Bell, CheckCheck, Heart, MessageCircle, CheckCircle2, XCircle, AtSign, ClipboardList } from "lucide-react";
|
import { Bell, CheckCheck, Heart, MessageCircle, CheckCircle2, XCircle, AtSign, ClipboardList, Award } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
apiFetchNotifications,
|
apiFetchNotifications,
|
||||||
apiUnreadCount,
|
apiUnreadCount,
|
||||||
@@ -99,11 +99,13 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
// @提及通知跳到群聊房间;其余跳到帖子/评论
|
// @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);其余跳到帖子/评论
|
||||||
if (n.type === "mention" && n.room_id) {
|
if (n.type === "mention" && n.room_id) {
|
||||||
router.push(`/chat/${n.room_id}`);
|
router.push(`/chat/${n.room_id}`);
|
||||||
} else if (n.type === "pending_review") {
|
} else if (n.type === "pending_review") {
|
||||||
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending");
|
router.push(n.post_id ? `/post/${n.post_id}` : "/admin/content?status=pending");
|
||||||
|
} else if (n.type === "badge") {
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
router.push(`/post/${n.post_id}`);
|
router.push(`/post/${n.post_id}`);
|
||||||
}
|
}
|
||||||
@@ -134,6 +136,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
|||||||
if (n.type === "mention") return "在消息中提到了你";
|
if (n.type === "mention") return "在消息中提到了你";
|
||||||
if (n.type === "pending_review") return "有新内容待审核";
|
if (n.type === "pending_review") return "有新内容待审核";
|
||||||
if (n.type === "deleted") return "删除了你的帖子";
|
if (n.type === "deleted") return "删除了你的帖子";
|
||||||
|
if (n.type === "badge") return "向你颁布了徽章";
|
||||||
return "点赞了你的帖子";
|
return "点赞了你的帖子";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,6 +154,8 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
|||||||
return { color: "var(--accent)", bg: "var(--accent-soft)", node: <AtSign size={15} /> };
|
return { color: "var(--accent)", bg: "var(--accent-soft)", node: <AtSign size={15} /> };
|
||||||
if (n.type === "pending_review")
|
if (n.type === "pending_review")
|
||||||
return { color: "var(--accent)", bg: "var(--accent-soft)", node: <ClipboardList size={15} /> };
|
return { color: "var(--accent)", bg: "var(--accent-soft)", node: <ClipboardList size={15} /> };
|
||||||
|
if (n.type === "badge")
|
||||||
|
return { color: "var(--gold)", bg: "var(--gold-soft)", node: <Award size={15} /> };
|
||||||
return { color: "var(--accent)", bg: "var(--accent-soft)", node: <MessageCircle size={15} /> };
|
return { color: "var(--accent)", bg: "var(--accent-soft)", node: <MessageCircle size={15} /> };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getPublicSettingsCached } from "@/lib/serverData";
|
|||||||
import { normalizePostType, postTypeLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
|
import { normalizePostType, postTypeLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
|
||||||
import LikeButton from "./LikeButton";
|
import LikeButton from "./LikeButton";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
import BadgeChip from "./BadgeChip";
|
||||||
import { IconComment, IconEye } from "./Icons";
|
import { IconComment, IconEye } from "./Icons";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,6 +104,9 @@ export default async function PostRow({
|
|||||||
>
|
>
|
||||||
{authorName}
|
{authorName}
|
||||||
</Link>
|
</Link>
|
||||||
|
{(post.user.badges ?? []).slice(0, 2).map((ub) => (
|
||||||
|
<BadgeChip key={ub.id} badge={ub.badge} />
|
||||||
|
))}
|
||||||
<span aria-hidden>·</span>
|
<span aria-hidden>·</span>
|
||||||
<span className="tabular-nums">{formatRelative(post.created_at)}</span>
|
<span className="tabular-nums">{formatRelative(post.created_at)}</span>
|
||||||
{replied && (
|
{replied && (
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function RoleBadge({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6] ${
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6] select-none ${
|
||||||
truncate ? "max-w-full whitespace-nowrap" : "whitespace-nowrap"
|
truncate ? "max-w-full whitespace-nowrap" : "whitespace-nowrap"
|
||||||
} ${className}`}
|
} ${className}`}
|
||||||
style={{ background: `var(${meta.softToken})`, color: `var(${meta.colorToken})` }}
|
style={{ background: `var(${meta.softToken})`, color: `var(${meta.colorToken})` }}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
Columns3,
|
Columns3,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Award,
|
||||||
|
Layers,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { User } from "@/lib/api";
|
import type { User } from "@/lib/api";
|
||||||
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
||||||
@@ -115,15 +117,27 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
visible: (u) => isSuperOrOwner(u.role),
|
visible: (u) => isSuperOrOwner(u.role),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/economy",
|
href: "/admin/points",
|
||||||
label: "积分看板",
|
label: "积分管理",
|
||||||
icon: Coins,
|
icon: Coins,
|
||||||
visible: (u) => isAdminOrAbove(u.role),
|
visible: (u) => isAdminOrAbove(u.role),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/levels",
|
||||||
|
label: "等级管理",
|
||||||
|
icon: Layers,
|
||||||
|
visible: (u) => isSuperOrOwner(u.role),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/badges",
|
||||||
|
label: "徽章管理",
|
||||||
|
icon: Award,
|
||||||
|
visible: (u) => isSuperOrOwner(u.role),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "板块",
|
title: "站点",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
href: "/admin/boards",
|
href: "/admin/boards",
|
||||||
@@ -131,11 +145,6 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
icon: Columns3,
|
icon: Columns3,
|
||||||
visible: (u) => isOwner(u.role),
|
visible: (u) => isOwner(u.role),
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "站点",
|
|
||||||
items: [
|
|
||||||
{
|
{
|
||||||
href: "/admin/settings",
|
href: "/admin/settings",
|
||||||
label: "站点设置",
|
label: "站点设置",
|
||||||
@@ -401,9 +410,24 @@ export default function AdminShell({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [user, setUser] = useState(initialUser);
|
const [user, setUser] = useState(initialUser);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
// 移动抽屉三态:closing 期间播放退场动画,结束后卸载
|
||||||
|
const [drawer, setDrawer] = useState<"closed" | "open" | "closing">("closed");
|
||||||
|
const drawerCloseBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const menuBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const wasDrawerOpenRef = useRef(false);
|
||||||
const brand = siteName.trim() || "姜十三论坛";
|
const brand = siteName.trim() || "姜十三论坛";
|
||||||
|
|
||||||
|
const drawerOpen = drawer !== "closed";
|
||||||
|
|
||||||
|
const closeDrawer = useCallback(() => {
|
||||||
|
setDrawer((prev) => {
|
||||||
|
if (prev !== "open") return prev;
|
||||||
|
// 减少动态偏好:跳过退场动画直接卸载
|
||||||
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return "closed";
|
||||||
|
return "closing";
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUser(initialUser);
|
setUser(initialUser);
|
||||||
}, [initialUser]);
|
}, [initialUser]);
|
||||||
@@ -413,7 +437,7 @@ export default function AdminShell({
|
|||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
useEffect(() => onForceLogout(() => {
|
useEffect(() => onForceLogout(() => {
|
||||||
setDrawerOpen(false);
|
setDrawer("closed");
|
||||||
apiLogout()
|
apiLogout()
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -424,18 +448,36 @@ export default function AdminShell({
|
|||||||
|
|
||||||
// 路由切换时关闭移动抽屉
|
// 路由切换时关闭移动抽屉
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDrawerOpen(false);
|
closeDrawer();
|
||||||
}, [pathname]);
|
}, [pathname, closeDrawer]);
|
||||||
|
|
||||||
// 抽屉打开时锁 body 滚(壳层本身已 overflow:hidden,这里防焦点穿透)
|
// 退场动画结束后卸载;timeout 兜底防 animationend 丢失
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!drawerOpen) return;
|
if (drawer !== "closing") return;
|
||||||
|
const t = setTimeout(() => setDrawer("closed"), 240);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [drawer]);
|
||||||
|
|
||||||
|
// 抽屉打开时聚焦关闭按钮;关闭后焦点归还顶栏汉堡按钮
|
||||||
|
useEffect(() => {
|
||||||
|
if (drawer === "open") {
|
||||||
|
wasDrawerOpenRef.current = true;
|
||||||
|
drawerCloseBtnRef.current?.focus();
|
||||||
|
} else if (drawer === "closed" && wasDrawerOpenRef.current) {
|
||||||
|
wasDrawerOpenRef.current = false;
|
||||||
|
menuBtnRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [drawer]);
|
||||||
|
|
||||||
|
// 抽屉打开时监听 Esc 关闭
|
||||||
|
useEffect(() => {
|
||||||
|
if (drawer !== "open") return;
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") setDrawerOpen(false);
|
if (e.key === "Escape") closeDrawer();
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", onKey);
|
document.addEventListener("keydown", onKey);
|
||||||
return () => document.removeEventListener("keydown", onKey);
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
}, [drawerOpen]);
|
}, [drawer, closeDrawer]);
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -458,7 +500,7 @@ export default function AdminShell({
|
|||||||
const railInner = (
|
const railInner = (
|
||||||
<>
|
<>
|
||||||
<AdminBrand siteName={brand} />
|
<AdminBrand siteName={brand} />
|
||||||
<AdminRailNav user={user} onNavigate={() => setDrawerOpen(false)} />
|
<AdminRailNav user={user} onNavigate={closeDrawer} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -470,28 +512,34 @@ export default function AdminShell({
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 移动抽屉 */}
|
{/* 移动抽屉 */}
|
||||||
{drawerOpen && (
|
{drawer !== "closed" && (
|
||||||
<div className="admin-drawer lg:hidden" role="dialog" aria-modal="true" aria-label="后台导航">
|
<div
|
||||||
|
className={`admin-drawer lg:hidden${drawer === "closing" ? " is-closing" : ""}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="后台导航"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="admin-drawer-backdrop"
|
className="admin-drawer-backdrop"
|
||||||
aria-label="关闭导航"
|
aria-label="关闭导航"
|
||||||
onClick={() => setDrawerOpen(false)}
|
onClick={closeDrawer}
|
||||||
/>
|
/>
|
||||||
<aside className="admin-rail admin-drawer-panel">
|
<aside className="admin-rail admin-drawer-panel">
|
||||||
<div className="flex items-center justify-between gap-2 mb-1">
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
<AdminBrand siteName={brand} />
|
<AdminBrand siteName={brand} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
ref={drawerCloseBtnRef}
|
||||||
className={iconBtn}
|
className={iconBtn}
|
||||||
style={iconBtnStyle}
|
style={iconBtnStyle}
|
||||||
aria-label="关闭导航"
|
aria-label="关闭导航"
|
||||||
onClick={() => setDrawerOpen(false)}
|
onClick={closeDrawer}
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<AdminRailNav user={user} onNavigate={() => setDrawerOpen(false)} />
|
<AdminRailNav user={user} onNavigate={closeDrawer} />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -501,11 +549,12 @@ export default function AdminShell({
|
|||||||
<div className="admin-topbar-left">
|
<div className="admin-topbar-left">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
ref={menuBtnRef}
|
||||||
className={`${iconBtn} lg:hidden`}
|
className={`${iconBtn} lg:hidden`}
|
||||||
style={iconBtnStyle}
|
style={iconBtnStyle}
|
||||||
aria-label="打开导航"
|
aria-label="打开导航"
|
||||||
aria-expanded={drawerOpen}
|
aria-expanded={drawerOpen}
|
||||||
onClick={() => setDrawerOpen(true)}
|
onClick={() => setDrawer("open")}
|
||||||
>
|
>
|
||||||
<Menu size={18} />
|
<Menu size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { UserProfile, PostListItem, Board } from "@/lib/api";
|
|||||||
import BoardIcon, { boardColor } from "@/components/BoardIcon";
|
import BoardIcon, { boardColor } from "@/components/BoardIcon";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import RoleBadge from "@/components/RoleBadge";
|
import RoleBadge from "@/components/RoleBadge";
|
||||||
|
import LevelBadge from "@/components/LevelBadge";
|
||||||
|
|
||||||
// formatYMD 帖子详情页左栏「作者其他帖」日期
|
// formatYMD 帖子详情页左栏「作者其他帖」日期
|
||||||
function formatYMD(dateStr: string) {
|
function formatYMD(dateStr: string) {
|
||||||
@@ -77,6 +78,7 @@ export function AuthorRailCard({
|
|||||||
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={48} />
|
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={48} />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<Link
|
<Link
|
||||||
href={`/u/${u.id}`}
|
href={`/u/${u.id}`}
|
||||||
className="block font-semibold text-[14.5px] truncate hover:text-[var(--accent)] transition-colors"
|
className="block font-semibold text-[14.5px] truncate hover:text-[var(--accent)] transition-colors"
|
||||||
@@ -84,6 +86,8 @@ export function AuthorRailCard({
|
|||||||
>
|
>
|
||||||
{u.nickname}
|
{u.nickname}
|
||||||
</Link>
|
</Link>
|
||||||
|
<LevelBadge level={u.level} className="shrink-0" />
|
||||||
|
</div>
|
||||||
<p className="meta mt-0.5 truncate">@{u.username}</p>
|
<p className="meta mt-0.5 truncate">@{u.username}</p>
|
||||||
{u.role !== "user" && (
|
{u.role !== "user" && (
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type FooterLink,
|
type FooterLink,
|
||||||
type FooterLinksAlign,
|
type FooterLinksAlign,
|
||||||
} from "./brand";
|
} from "./brand";
|
||||||
|
import { DEFAULT_LEVEL_DEFS, LEVEL_COLOR_KEYS, MAX_LEVEL_COUNT, type LevelDef } from "./levels";
|
||||||
import { CSRF_COOKIE } from "./cookies";
|
import { CSRF_COOKIE } from "./cookies";
|
||||||
import { publishPublicMetaSnapshot } from "./publicMetaSnapshot";
|
import { publishPublicMetaSnapshot } from "./publicMetaSnapshot";
|
||||||
import { emitForceLogout } from "./userEvents";
|
import { emitForceLogout } from "./userEvents";
|
||||||
@@ -47,6 +48,27 @@ function ssrInit(headers?: HeadersInit): RequestInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 类型定义
|
// 类型定义
|
||||||
|
export interface Badge {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
/** lucide 图标名(前端白名单渲染,未知回落 Medal) */
|
||||||
|
icon: string;
|
||||||
|
/** CSS token 色板键(gold/accent/ok/danger/info/purple/teal) */
|
||||||
|
color: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 徽章授予记录(badge 为嵌套定义,后端 Preload 填充) */
|
||||||
|
export interface UserBadgeView {
|
||||||
|
id: number;
|
||||||
|
badge_id: number;
|
||||||
|
user_id: number;
|
||||||
|
badge: Badge;
|
||||||
|
awarded_by: number;
|
||||||
|
awarded_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -62,6 +84,11 @@ export interface User {
|
|||||||
// 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息)
|
// 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息)
|
||||||
email?: string;
|
email?: string;
|
||||||
signature?: string;
|
signature?: string;
|
||||||
|
// 用户等级(后端按累计获得积分计算;1 起)与累计获得积分
|
||||||
|
level?: number;
|
||||||
|
total_points?: number;
|
||||||
|
// 管理员颁布的徽章(/me、用户资料页全量;作者点处仅展示前 2 枚)
|
||||||
|
badges?: UserBadgeView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// /api/me 响应:用户信息 + 未读通知数(SSR layout 一次请求直出)
|
// /api/me 响应:用户信息 + 未读通知数(SSR layout 一次请求直出)
|
||||||
@@ -544,6 +571,11 @@ export interface UserProfile {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
/** 仅超管/站长访客可见(公开资料不下发) */
|
/** 仅超管/站长访客可见(公开资料不下发) */
|
||||||
banned?: boolean;
|
banned?: boolean;
|
||||||
|
// 用户等级(后端按累计获得积分计算)与累计获得积分
|
||||||
|
level?: number;
|
||||||
|
total_points?: number;
|
||||||
|
// 徽章墙(全量,含授予时间)
|
||||||
|
badges?: UserBadgeView[];
|
||||||
};
|
};
|
||||||
stats: {
|
stats: {
|
||||||
post_count: number;
|
post_count: number;
|
||||||
@@ -576,7 +608,7 @@ export interface NotificationItem {
|
|||||||
id: number;
|
id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
actor_id: number;
|
actor_id: number;
|
||||||
type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted";
|
type: "comment" | "reply" | "like" | "approved" | "rejected" | "mention" | "pending_review" | "deleted" | "badge";
|
||||||
post_id: number;
|
post_id: number;
|
||||||
comment_id: number;
|
comment_id: number;
|
||||||
room_id?: number;
|
room_id?: number;
|
||||||
@@ -797,6 +829,26 @@ export interface PublicSettings {
|
|||||||
bg_admin_url: string;
|
bg_admin_url: string;
|
||||||
/** 后台铺放模式 */
|
/** 后台铺放模式 */
|
||||||
bg_admin_mode: string;
|
bg_admin_mode: string;
|
||||||
|
/** 签到基础分;0=关闭;缺省 5 */
|
||||||
|
points_checkin_base: number;
|
||||||
|
/** 每满 N 天连续签到触发加成;0=关闭;缺省 7 */
|
||||||
|
points_streak_every_days: number;
|
||||||
|
/** 连续签到加成分值;缺省 0 */
|
||||||
|
points_streak_bonus: number;
|
||||||
|
/** 发帖奖励;0=关闭;缺省 0 */
|
||||||
|
points_post_reward: number;
|
||||||
|
/** 发帖奖励每日上限笔数;0=不限;缺省 0 */
|
||||||
|
points_post_daily_cap: number;
|
||||||
|
/** 回复奖励;0=关闭;缺省 0 */
|
||||||
|
points_reply_reward: number;
|
||||||
|
/** 回复奖励每日上限笔数;0=不限;缺省 0 */
|
||||||
|
points_reply_daily_cap: number;
|
||||||
|
/** 帖子被推荐奖励(每帖仅一次);0=关闭;缺省 0 */
|
||||||
|
points_recommend_reward: number;
|
||||||
|
/** 用户等级体系(数量/每级阈值/每级色板 key);等级数值由后端计算 */
|
||||||
|
levels: LevelDef[];
|
||||||
|
/** 等级特效动画总开关(流光动画);缺省开 */
|
||||||
|
levels_fx: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 与后端 DefaultAttachmentExts 保持同步 */
|
/** 与后端 DefaultAttachmentExts 保持同步 */
|
||||||
@@ -847,6 +899,16 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
|||||||
bg_site_mode: "cover",
|
bg_site_mode: "cover",
|
||||||
bg_admin_url: "",
|
bg_admin_url: "",
|
||||||
bg_admin_mode: "cover",
|
bg_admin_mode: "cover",
|
||||||
|
points_checkin_base: 5,
|
||||||
|
points_streak_every_days: 7,
|
||||||
|
points_streak_bonus: 0,
|
||||||
|
points_post_reward: 0,
|
||||||
|
points_post_daily_cap: 0,
|
||||||
|
points_reply_reward: 0,
|
||||||
|
points_reply_daily_cap: 0,
|
||||||
|
points_recommend_reward: 0,
|
||||||
|
levels: [...DEFAULT_LEVEL_DEFS],
|
||||||
|
levels_fx: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeAttachmentExts(raw: unknown): string[] {
|
function normalizeAttachmentExts(raw: unknown): string[] {
|
||||||
@@ -932,9 +994,70 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
|
|||||||
bg_site_mode: typeof data?.bg_site_mode === "string" ? data.bg_site_mode : "cover",
|
bg_site_mode: typeof data?.bg_site_mode === "string" ? data.bg_site_mode : "cover",
|
||||||
bg_admin_url: typeof data?.bg_admin_url === "string" ? data.bg_admin_url : "",
|
bg_admin_url: typeof data?.bg_admin_url === "string" ? data.bg_admin_url : "",
|
||||||
bg_admin_mode: typeof data?.bg_admin_mode === "string" ? data.bg_admin_mode : "cover",
|
bg_admin_mode: typeof data?.bg_admin_mode === "string" ? data.bg_admin_mode : "cover",
|
||||||
|
...normalizePointsSettings(data),
|
||||||
|
levels: normalizeLevels(data?.levels),
|
||||||
|
levels_fx: data?.levels_fx !== false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 积分规则 8 键:0–100 有效整数,越界/异常回落默认值 */
|
||||||
|
function normalizePointsSettings(
|
||||||
|
data: Partial<PublicSettings> | null | undefined,
|
||||||
|
): Pick<
|
||||||
|
PublicSettings,
|
||||||
|
| "points_checkin_base"
|
||||||
|
| "points_streak_every_days"
|
||||||
|
| "points_streak_bonus"
|
||||||
|
| "points_post_reward"
|
||||||
|
| "points_post_daily_cap"
|
||||||
|
| "points_reply_reward"
|
||||||
|
| "points_reply_daily_cap"
|
||||||
|
| "points_recommend_reward"
|
||||||
|
> {
|
||||||
|
const num = (key: keyof PublicSettings): number | undefined => {
|
||||||
|
const v = data?.[key];
|
||||||
|
return typeof v === "number" && v >= 0 && v <= 100 ? Math.round(v) : undefined;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
points_checkin_base: num("points_checkin_base") ?? DEFAULT_PUBLIC_SETTINGS.points_checkin_base,
|
||||||
|
points_streak_every_days:
|
||||||
|
num("points_streak_every_days") ?? DEFAULT_PUBLIC_SETTINGS.points_streak_every_days,
|
||||||
|
points_streak_bonus: num("points_streak_bonus") ?? DEFAULT_PUBLIC_SETTINGS.points_streak_bonus,
|
||||||
|
points_post_reward: num("points_post_reward") ?? DEFAULT_PUBLIC_SETTINGS.points_post_reward,
|
||||||
|
points_post_daily_cap:
|
||||||
|
num("points_post_daily_cap") ?? DEFAULT_PUBLIC_SETTINGS.points_post_daily_cap,
|
||||||
|
points_reply_reward: num("points_reply_reward") ?? DEFAULT_PUBLIC_SETTINGS.points_reply_reward,
|
||||||
|
points_reply_daily_cap:
|
||||||
|
num("points_reply_daily_cap") ?? DEFAULT_PUBLIC_SETTINGS.points_reply_daily_cap,
|
||||||
|
points_recommend_reward:
|
||||||
|
num("points_recommend_reward") ?? DEFAULT_PUBLIC_SETTINGS.points_recommend_reward,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等级配置:2–12 级、阈值非负且严格递增、首级 0、色板 key 合法;异常回落默认 */
|
||||||
|
function normalizeLevels(raw: unknown): LevelDef[] {
|
||||||
|
if (!Array.isArray(raw) || raw.length < 2 || raw.length > MAX_LEVEL_COUNT) {
|
||||||
|
return [...DEFAULT_LEVEL_DEFS];
|
||||||
|
}
|
||||||
|
const out: LevelDef[] = [];
|
||||||
|
let prev = -1;
|
||||||
|
for (const item of raw) {
|
||||||
|
const threshold =
|
||||||
|
typeof item?.threshold === "number" && Number.isFinite(item.threshold) && item.threshold >= 0
|
||||||
|
? Math.round(item.threshold)
|
||||||
|
: NaN;
|
||||||
|
const color =
|
||||||
|
typeof item?.color === "string" && LEVEL_COLOR_KEYS.includes(item.color) ? item.color : "";
|
||||||
|
if (Number.isNaN(threshold) || !color || threshold <= prev) {
|
||||||
|
return [...DEFAULT_LEVEL_DEFS];
|
||||||
|
}
|
||||||
|
out.push({ threshold, color });
|
||||||
|
prev = threshold;
|
||||||
|
}
|
||||||
|
if (out[0]?.threshold !== 0) return [...DEFAULT_LEVEL_DEFS];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// SSR 读取公开站点设置(主题色注入用);后端不可用时降级为默认色,不阻塞渲染
|
// SSR 读取公开站点设置(主题色注入用);后端不可用时降级为默认色,不阻塞渲染
|
||||||
export async function fetchPublicSettings(): Promise<PublicSettings> {
|
export async function fetchPublicSettings(): Promise<PublicSettings> {
|
||||||
try {
|
try {
|
||||||
@@ -2139,6 +2262,98 @@ export async function apiAdminToggleAnnouncementPin(
|
|||||||
return data as { announcement: Announcement };
|
return data as { announcement: Announcement };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 后台徽章管理 ----------
|
||||||
|
|
||||||
|
export interface BadgeWithHolders extends Badge {
|
||||||
|
holders: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeHolder {
|
||||||
|
user_id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
awarded_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminListBadges(): Promise<{ badges: BadgeWithHolders[] }> {
|
||||||
|
const res = await fetchWithRefresh("/api/admin/badges", { headers: clientHeaders() });
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "获取徽章列表失败");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeInput {
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBadge(method: "POST" | "PUT", url: string, input: BadgeInput): Promise<Badge> {
|
||||||
|
const res = await fetchWithRefresh(url, {
|
||||||
|
method,
|
||||||
|
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || "保存徽章失败");
|
||||||
|
return (data.badge ?? data) as Badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiAdminCreateBadge(input: BadgeInput) {
|
||||||
|
return saveBadge("POST", "/api/admin/badges", input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiAdminUpdateBadge(id: number, input: BadgeInput) {
|
||||||
|
return saveBadge("PUT", `/api/admin/badges/${id}`, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminDeleteBadge(id: number): Promise<void> {
|
||||||
|
const res = await fetchWithRefresh(`/api/admin/badges/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: clientHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "删除徽章失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminGrantBadge(id: number, userId: number): Promise<void> {
|
||||||
|
const res = await fetchWithRefresh(`/api/admin/badges/${id}/grant`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||||
|
body: JSON.stringify({ user_id: userId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "授予失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminRevokeBadge(id: number, userId: number): Promise<void> {
|
||||||
|
const res = await fetchWithRefresh(`/api/admin/badges/${id}/grant/${userId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: clientHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "撤销失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminBadgeHolders(id: number): Promise<{ holders: BadgeHolder[] }> {
|
||||||
|
const res = await fetchWithRefresh(`/api/admin/badges/${id}/holders`, {
|
||||||
|
headers: clientHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "获取持有者失败");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
/** SSR / 公开:分页公告列表 */
|
/** SSR / 公开:分页公告列表 */
|
||||||
export async function fetchAnnouncementsPage(
|
export async function fetchAnnouncementsPage(
|
||||||
page = 1,
|
page = 1,
|
||||||
@@ -2257,6 +2472,8 @@ export interface AdminUser {
|
|||||||
banned: boolean;
|
banned: boolean;
|
||||||
can_manage_messages?: boolean;
|
can_manage_messages?: boolean;
|
||||||
points?: number;
|
points?: number;
|
||||||
|
total_points?: number;
|
||||||
|
level?: number;
|
||||||
post_count: number;
|
post_count: number;
|
||||||
comment_count: number;
|
comment_count: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -2397,6 +2614,41 @@ export async function apiAdminSetUserMessages(
|
|||||||
return data as { user: AdminUser };
|
return data as { user: AdminUser };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 管理员手动调整用户积分(加分计累计可升级;扣分仅扣余额不降级),理由必填
|
||||||
|
export interface AdminAdjustPointsResult {
|
||||||
|
balance: number;
|
||||||
|
total_points: number;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminAdjustUserPoints(
|
||||||
|
id: number,
|
||||||
|
delta: number,
|
||||||
|
note: string
|
||||||
|
): Promise<AdminAdjustPointsResult> {
|
||||||
|
const res = await fetchWithRefresh(`/api/admin/users/${id}/points`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||||
|
body: JSON.stringify({ delta, note }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || "积分调整失败");
|
||||||
|
return data as AdminAdjustPointsResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理端查询某用户积分流水(分页,id DESC)
|
||||||
|
export async function apiAdminUserPointLedger(
|
||||||
|
id: number,
|
||||||
|
page = 1
|
||||||
|
): Promise<{ items: PointLedgerItem[]; total: number; page: number; size: number }> {
|
||||||
|
const res = await fetchWithRefresh(`/api/admin/users/${id}/points/ledger?page=${page}`, {
|
||||||
|
headers: clientHeaders(),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || "获取积分流水失败");
|
||||||
|
return data as { items: PointLedgerItem[]; total: number; page: number; size: number };
|
||||||
|
}
|
||||||
|
|
||||||
// 某用户的登录历史(IP/UA/成败)
|
// 某用户的登录历史(IP/UA/成败)
|
||||||
export async function apiAdminUserLoginLogs(
|
export async function apiAdminUserLoginLogs(
|
||||||
id: number,
|
id: number,
|
||||||
@@ -2759,6 +3011,17 @@ export type UpdateSiteSettingsBody = {
|
|||||||
comments_require_login?: boolean;
|
comments_require_login?: boolean;
|
||||||
allow_messages?: boolean;
|
allow_messages?: boolean;
|
||||||
post_cooldown_hours?: number;
|
post_cooldown_hours?: number;
|
||||||
|
points_checkin_base?: number;
|
||||||
|
points_streak_every_days?: number;
|
||||||
|
points_streak_bonus?: number;
|
||||||
|
points_post_reward?: number;
|
||||||
|
points_post_daily_cap?: number;
|
||||||
|
points_reply_reward?: number;
|
||||||
|
points_reply_daily_cap?: number;
|
||||||
|
points_recommend_reward?: number;
|
||||||
|
/** 用户等级体系(数量/每级阈值/每级色板 key);整体替换保存 */
|
||||||
|
levels?: LevelDef[];
|
||||||
|
levels_fx?: boolean;
|
||||||
code_block_auto_fold?: boolean;
|
code_block_auto_fold?: boolean;
|
||||||
code_block_fold_lines?: number;
|
code_block_fold_lines?: number;
|
||||||
ui_animations?: boolean;
|
ui_animations?: boolean;
|
||||||
|
|||||||
47
frontend/lib/badgeIcons.ts
Normal file
47
frontend/lib/badgeIcons.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Medal,
|
||||||
|
Award,
|
||||||
|
Trophy,
|
||||||
|
Crown,
|
||||||
|
Star,
|
||||||
|
Heart,
|
||||||
|
Zap,
|
||||||
|
Flame,
|
||||||
|
Gem,
|
||||||
|
Shield,
|
||||||
|
Rocket,
|
||||||
|
Sparkles,
|
||||||
|
ThumbsUp,
|
||||||
|
BookOpen,
|
||||||
|
Code,
|
||||||
|
Wrench,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 徽章图标白名单:后台管理以 lucide 图标名存储,前端仅渲染此表内的图标,
|
||||||
|
* 未知/空名回落 Medal(与后端 model.Badge.Icon 注释约定一致)。
|
||||||
|
*/
|
||||||
|
export const BADGE_ICONS: Record<string, LucideIcon> = {
|
||||||
|
medal: Medal,
|
||||||
|
award: Award,
|
||||||
|
trophy: Trophy,
|
||||||
|
crown: Crown,
|
||||||
|
star: Star,
|
||||||
|
heart: Heart,
|
||||||
|
zap: Zap,
|
||||||
|
flame: Flame,
|
||||||
|
gem: Gem,
|
||||||
|
shield: Shield,
|
||||||
|
rocket: Rocket,
|
||||||
|
sparkles: Sparkles,
|
||||||
|
thumbs_up: ThumbsUp,
|
||||||
|
book_open: BookOpen,
|
||||||
|
code: Code,
|
||||||
|
wrench: Wrench,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 取徽章图标组件;未知/空名回落 Medal */
|
||||||
|
export function badgeIcon(name?: string | null): LucideIcon {
|
||||||
|
return (name && BADGE_ICONS[name]) || Medal;
|
||||||
|
}
|
||||||
137
frontend/lib/levels.ts
Normal file
137
frontend/lib/levels.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// 用户等级展示映射:等级体系(数量/阈值/颜色)由后台配置、后端下发
|
||||||
|
// (PublicSettings.levels),本模块只负责把它翻译成 CSS 变量(--lv-N-fg/-bg),
|
||||||
|
// 不持有阈值逻辑;等级数值由后端按累计获得积分计算(user.level),前端零计算。
|
||||||
|
|
||||||
|
/** 单级配置:threshold 为该级累计积分下限(Lv1 恒为 0),color 为色板 key */
|
||||||
|
export interface LevelDef {
|
||||||
|
threshold: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单级色规格:bg 为完整 background 简写值(特效档为含高光段的流动渐变) */
|
||||||
|
export interface LevelColorSpec {
|
||||||
|
fg: string;
|
||||||
|
bg: string;
|
||||||
|
/** 内描边色(黑金档金边用),默认透明 */
|
||||||
|
ring?: string;
|
||||||
|
/** 文字渐变层(完整 background 值):存在时 fg 应为 transparent,
|
||||||
|
* 文字以流动渐变呈现(鎏金字,金属质感的来源),见 .lv-text */
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等级色板:低段淡雅、高段金属流光,等级专属固定色,不随明暗主题切换。
|
||||||
|
* 段位语义:danger=红牌、accent=曜黑(黑金流光)、purple=鎏紫、gold=鎏金(暗底金渐变字)。
|
||||||
|
* 流光不靠扫光伪元素,由渐变内嵌高光段随 background-position 流动自然扫过。
|
||||||
|
*/
|
||||||
|
export const LEVEL_COLOR_TOKENS: Record<string, LevelColorSpec> = {
|
||||||
|
neutral: { fg: "#9aa3b2", bg: "rgba(148,158,176,0.16)" },
|
||||||
|
info: { fg: "#4d8dff", bg: "rgba(59,118,240,0.15)" },
|
||||||
|
teal: { fg: "#12a89b", bg: "rgba(14,148,136,0.15)" },
|
||||||
|
ok: { fg: "#27a94f", bg: "rgba(31,156,71,0.15)" },
|
||||||
|
danger: {
|
||||||
|
fg: "#ffffff",
|
||||||
|
// 90deg 水平渐变且首尾同色;size 固定 200%(配 lv-flow 位移 0→200% 严格无缝循环)
|
||||||
|
bg: "linear-gradient(90deg, #b91c1c, #ef4444 25%, #fecaca 38%, #dc2626 52%, #991b1b 78%, #b91c1c) 0% 0% / 200% 100%",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
fg: "#e9c46a",
|
||||||
|
bg: "linear-gradient(90deg, #16171c, #23252c 22%, #4a3d1c 36%, #23252c 52%, #16171c 78%) 0% 0% / 200% 100%",
|
||||||
|
ring: "rgba(233, 196, 106, 0.5)",
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
fg: "#ffffff",
|
||||||
|
bg: "linear-gradient(90deg, #6d28d9, #a855f7 24%, #e9d5ff 37%, #8b5cf6 52%, #6d28d9 78%) 0% 0% / 200% 100%",
|
||||||
|
},
|
||||||
|
gold: {
|
||||||
|
// 鎏金字:暗底衬托,亮金流动渐变只在文字上(金底小面积易显廉价);
|
||||||
|
// 高光占比放大让 11px 小字保持干净亮泽
|
||||||
|
fg: "transparent",
|
||||||
|
bg: "linear-gradient(180deg, #221b0f, #100d07)",
|
||||||
|
ring: "rgba(240, 200, 120, 0.45)",
|
||||||
|
text: "linear-gradient(90deg, #c9973f, #ffe9a8 30%, #fff6d8 46%, #f0c964 66%, #c9973f) 0% 0% / 200% 100%",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LEVEL_COLOR_KEYS = Object.keys(LEVEL_COLOR_TOKENS);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等级徽章共享类:LevelBadge 与后台等级管理预览必须逐字同用,
|
||||||
|
* 保证前后台胶囊大小完全一致(尺寸基准:text-11px + 紧凑行高 + px-2 圆角胶囊)
|
||||||
|
*/
|
||||||
|
export const LEVEL_BADGE_CLASS =
|
||||||
|
"lv-badge inline-flex items-center justify-center rounded-full px-2 py-[2px] text-[11px] font-semibold leading-[1.35] whitespace-nowrap";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 background 简写值拆为长写属性(React inline style 不允许简写与
|
||||||
|
* backgroundClip 等长写混用)。支持三种形态:
|
||||||
|
* "none" / 纯色(rgba、#hex)/ "linear-gradient(...) <position> / <size>"
|
||||||
|
*/
|
||||||
|
export function splitBackground(value: string): {
|
||||||
|
image: string;
|
||||||
|
color?: string;
|
||||||
|
position: string;
|
||||||
|
size: string;
|
||||||
|
} {
|
||||||
|
const v = value.trim();
|
||||||
|
if (v === "" || v === "none") {
|
||||||
|
return { image: "none", position: "0% 0%", size: "auto" };
|
||||||
|
}
|
||||||
|
// 渐变内部 stops 均为 hex 色,无嵌套括号,取到首个右括号即渐变结束
|
||||||
|
const m = v.match(/^(linear-gradient\([^)]*\))\s*(.*)$/);
|
||||||
|
if (m) {
|
||||||
|
const rest = m[2].trim();
|
||||||
|
const [pos, sizePart] = rest ? rest.split("/").map((s) => s.trim()) : [];
|
||||||
|
return {
|
||||||
|
image: m[1],
|
||||||
|
position: pos || "0% 0%",
|
||||||
|
size: sizePart || "auto",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { image: "none", color: v, position: "0% 0%", size: "auto" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等级数量上限(CSS 变量按此档数固定下发;与后端 model.MaxLevelCount 同步) */
|
||||||
|
export const MAX_LEVEL_COUNT = 12;
|
||||||
|
|
||||||
|
/** 默认 7 级(与后端 model.DefaultLevelDefs 保持同步,异常回落用) */
|
||||||
|
export const DEFAULT_LEVEL_DEFS: LevelDef[] = [
|
||||||
|
{ threshold: 0, color: "neutral" },
|
||||||
|
{ threshold: 50, color: "info" },
|
||||||
|
{ threshold: 200, color: "teal" },
|
||||||
|
{ threshold: 500, color: "ok" },
|
||||||
|
{ threshold: 1200, color: "accent" },
|
||||||
|
{ threshold: 3000, color: "purple" },
|
||||||
|
{ threshold: 8000, color: "gold" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 等级号钳制到 1..MAX_LEVEL_COUNT(游客/异常数据回落 Lv1) */
|
||||||
|
export function clampLevel(level?: number | null): number {
|
||||||
|
return Math.min(Math.max(Math.trunc(level ?? 1), 1), MAX_LEVEL_COUNT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokensOf(color: string) {
|
||||||
|
return LEVEL_COLOR_TOKENS[color] ?? LEVEL_COLOR_TOKENS.neutral;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成等级颜色 CSS(:root 下的 --lv-N-fg / --lv-N-bg)。
|
||||||
|
* 固定输出 1..MAX_LEVEL_COUNT 全部档位:超配置数量的档位重复末级颜色,
|
||||||
|
* 保证配置缩级后旧数据/越界场景徽章仍有着色。
|
||||||
|
*/
|
||||||
|
export function levelVarsCSS(levels?: LevelDef[] | null): string {
|
||||||
|
const defs = levels && levels.length > 0 ? levels : DEFAULT_LEVEL_DEFS;
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let n = 1; n <= MAX_LEVEL_COUNT; n++) {
|
||||||
|
const idx = Math.min(n, defs.length) - 1;
|
||||||
|
const t = tokensOf(defs[idx]?.color ?? "");
|
||||||
|
// 文字渐变拆成 image/position/size 三个变量下发,
|
||||||
|
// 供 inline style 全长写引用(避免 React 简写/长写混用警告)
|
||||||
|
const text = splitBackground(t.text ?? "none");
|
||||||
|
lines.push(
|
||||||
|
`--lv-${n}-fg:${t.fg};--lv-${n}-bg:${t.bg};--lv-${n}-ring:${t.ring ?? "transparent"};` +
|
||||||
|
`--lv-${n}-text-image:${text.image};--lv-${n}-text-position:${text.position};--lv-${n}-text-size:${text.size}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `:root{${lines.join(";")}}`;
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ const REASON_LABELS: Record<string, string> = {
|
|||||||
bounty_escrow: "悬赏托管",
|
bounty_escrow: "悬赏托管",
|
||||||
bounty_refund: "悬赏退回",
|
bounty_refund: "悬赏退回",
|
||||||
bounty_award: "悬赏采纳",
|
bounty_award: "悬赏采纳",
|
||||||
|
post_reward: "发帖奖励",
|
||||||
|
reply_reward: "回复奖励",
|
||||||
|
streak_bonus: "连续签到加成",
|
||||||
|
recommend_reward: "帖子被推荐",
|
||||||
|
admin_adjust: "管理员调整",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function pointReasonLabel(reason: string): string {
|
export function pointReasonLabel(reason: string): string {
|
||||||
|
|||||||
4
frontend/next-env.d.ts
vendored
4
frontend/next-env.d.ts
vendored
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
import "./.next/types/root-params.d.ts";
|
import "./.next/dev/types/root-params.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
Reference in New Issue
Block a user