feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 04:00:14 +08:00
parent cda5fad2e5
commit 9a55c2f6f2
41 changed files with 5655 additions and 1810 deletions

View File

@@ -28,6 +28,8 @@ type Handlers struct {
AdminUser *service.AdminUserService
Moderation *service.ModerationService
Chat *service.ChatService
Telemetry *service.TelemetryService
Analytics *service.AnalyticsService
}
// resolvePublishStatus 决定新帖/新评的初始状态:

View File

@@ -34,6 +34,7 @@ type updateSettingsRequest struct {
AnimCodeFold *bool `json:"anim_code_fold"`
AnimSmoothScroll *bool `json:"anim_smooth_scroll"`
AnimChrome *bool `json:"anim_chrome"`
PostLinkNewTab *bool `json:"post_link_new_tab"`
}
func settingsPayload(saved service.PublicSiteSettings) gin.H {
@@ -51,6 +52,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"anim_code_fold": saved.AnimCodeFold,
"anim_smooth_scroll": saved.AnimSmoothScroll,
"anim_chrome": saved.AnimChrome,
"post_link_new_tab": saved.PostLinkNewTab,
}
}
@@ -65,7 +67,7 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
req.SiteDescription == nil && req.AllowRegister == nil && req.AllowComments == nil &&
req.PostCooldownHours == nil && req.CodeBlockAutoFold == nil && req.CodeBlockFoldLines == nil &&
req.UIAnimations == nil && req.AnimCodeFold == nil && req.AnimSmoothScroll == nil &&
req.AnimChrome == nil {
req.AnimChrome == nil && req.PostLinkNewTab == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "未提供任何可更新的设置项"})
return
}
@@ -168,6 +170,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
return
}
}
if req.PostLinkNewTab != nil {
if err := h.Setting.SetPostLinkNewTab(*req.PostLinkNewTab); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
saved, err := h.Setting.Public()
if err != nil {

View File

@@ -0,0 +1,56 @@
package handler
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
const visitorCookie = "j13_vid"
func newVisitorID() string {
var b [16]byte
_, _ = rand.Read(b[:])
// 伪 UUID 形态,满足 ValidVisitorID
return fmt.Sprintf("%s-%s-%s-%s-%s",
hex.EncodeToString(b[0:4]),
hex.EncodeToString(b[4:6]),
hex.EncodeToString(b[6:8]),
hex.EncodeToString(b[8:10]),
hex.EncodeToString(b[10:16]),
)
}
// POST /api/telemetry/pageview — 轻量 PV/UV 埋点(公开,需 CSRF)
func (h *Handlers) TelemetryPageView(c *gin.Context) {
vid, _ := c.Cookie(visitorCookie)
if !service.ValidVisitorID(vid) {
vid = newVisitorID()
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
// HttpOnly:仅服务端读 UV,前端埋点不依赖可读 cookie
c.SetCookie(visitorCookie, vid, 365*24*3600, "/", "", secure, true)
}
loggedIn := middleware.CurrentUser(c) != nil
if err := h.Telemetry.RecordPageView(vid, loggedIn); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "记录失败"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// GET /api/admin/analytics/overview?range=7d|30d
func (h *Handlers) AdminAnalyticsOverview(c *gin.Context) {
rangeQ := c.DefaultQuery("range", "7d")
data, err := h.Analytics.Overview(rangeQ)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取分析数据失败"})
return
}
c.JSON(http.StatusOK, data)
}

View File

@@ -37,7 +37,7 @@ func InitDB(dsn string) error {
if err := db.AutoMigrate(
&User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
&Announcement{}, &SiteSetting{}, &Attachment{}, &UserBoard{}, &LoginLog{},
&Announcement{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
&PostPollVote{}, &PostLotteryEntry{},

View File

@@ -400,6 +400,21 @@ type SiteSetting struct {
UpdatedAt time.Time `json:"updated_at"`
}
// SiteDailyStats 站点日维度访问量(轻量 PV/UV)
type SiteDailyStats struct {
Date time.Time `gorm:"type:date;primaryKey" json:"date"`
PV int64 `gorm:"not null;default:0" json:"pv"`
UV int64 `gorm:"not null;default:0" json:"uv"`
LoggedInPV int64 `gorm:"not null;default:0" json:"logged_in_pv"`
UpdatedAt time.Time `json:"updated_at"`
}
// SiteDailyVisitor 当日访客去重(vid 哈希);插入成功才给 UV +1
type SiteDailyVisitor struct {
Date time.Time `gorm:"type:date;primaryKey" json:"date"`
VidHash string `gorm:"size:64;primaryKey" json:"-"`
}
// 附件类型
const (
AttachmentKindAvatar = "avatar" // 用户头像(正方形 WebP)

View File

@@ -55,6 +55,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adminUserSvc := service.NewAdminUserService(model.DB)
moderationSvc := service.NewModerationService(model.DB, notifSvc)
chatSvc := service.NewChatService(model.DB, notifSvc)
telemetrySvc := service.NewTelemetryService(model.DB)
analyticsSvc := service.NewAnalyticsService(model.DB)
if err := uploadSvc.EnsureDir(); err != nil {
return nil, err
}
@@ -82,6 +84,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
AdminUser: adminUserSvc,
Moderation: moderationSvc,
Chat: chatSvc,
Telemetry: telemetrySvc,
Analytics: analyticsSvc,
}
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
notifSvc.OnNotifyNew = func(userID uint) {
@@ -122,6 +126,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/announcements", h.AnnouncementsList)
pubAPI.GET("/announcements/:id", h.AnnouncementDetail)
pubAPI.GET("/settings", h.PublicSettings)
pubAPI.POST("/telemetry/pageview", middleware.CSRFMiddleware(), h.TelemetryPageView)
pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
// refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护)
@@ -223,6 +228,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 积分与类型帖概览(管理员及以上)
staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats)
// 站点分析概览(任意 staff)
staffAPI.GET("/analytics/overview", h.AdminAnalyticsOverview)
// 群聊监管队列(任意 staff;撤回权限仍按 oversee 规则)
staffAPI.GET("/chat/messages", h.AdminChatMessages)
staffAPI.DELETE("/chat/rooms/:roomId/messages/:mid", h.AdminRecallChatMessage)
// 站点公告文章管理(管理员及以上)
announceAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
announceAPI.GET("/announcements", h.AdminListAnnouncements)

View File

@@ -52,6 +52,7 @@ type AdminUserItem struct {
Banned bool `json:"banned"`
PostCount int64 `json:"post_count"`
CommentCount int64 `json:"comment_count"`
Points int `json:"points"`
CreatedAt time.Time `json:"created_at"`
LastSeenAt *time.Time `json:"last_seen_at"`
Online bool `json:"online"`
@@ -188,6 +189,7 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem {
Role: string(u.Role),
BoardIDs: []uint{},
Banned: u.Banned,
Points: u.Points,
CreatedAt: u.CreatedAt,
LastSeenAt: u.LastSeenAt,
}

View File

@@ -0,0 +1,224 @@
package service
import (
"strconv"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// AnalyticsService 管理端聚合分析
type AnalyticsService struct {
db *gorm.DB
}
func NewAnalyticsService(db *gorm.DB) *AnalyticsService {
return &AnalyticsService{db: db}
}
// DayCount 按日计数点
type DayCount struct {
Date string `json:"date"`
Count int64 `json:"count"`
}
// VisitDay 访问日序列
type VisitDay struct {
Date string `json:"date"`
PV int64 `json:"pv"`
UV int64 `json:"uv"`
}
// NamedCount 命名计数(板块/类型等)
type NamedCount struct {
Key string `json:"key"`
Label string `json:"label"`
Count int64 `json:"count"`
}
// TopViewPost 高浏览帖
type TopViewPost struct {
ID uint `json:"id"`
Title string `json:"title"`
ViewCount int `json:"view_count"`
BoardID uint `json:"board_id"`
}
// AnalyticsOverview 仪表盘聚合
type AnalyticsOverview struct {
RangeDays int `json:"range_days"`
TodayPV int64 `json:"today_pv"`
TodayUV int64 `json:"today_uv"`
Visits []VisitDay `json:"visits"`
PostsSeries []DayCount `json:"posts_series"`
CommentsSeries []DayCount `json:"comments_series"`
RegistersSeries []DayCount `json:"registers_series"`
Boards []NamedCount `json:"boards"`
PostTypes []NamedCount `json:"post_types"`
TopViewed []TopViewPost `json:"top_viewed"`
}
func parseRangeDays(rangeQ string) int {
switch rangeQ {
case "30d", "30":
return 30
default:
return 7
}
}
func dateKey(t time.Time) string {
return t.Format("2006-01-02")
}
func fillDaySeries(start, end time.Time, got map[string]int64) []DayCount {
out := make([]DayCount, 0)
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
k := dateKey(d)
out = append(out, DayCount{Date: k, Count: got[k]})
}
return out
}
// Overview 返回近 N 日访问与内容趋势
func (s *AnalyticsService) Overview(rangeQ string) (*AnalyticsOverview, error) {
days := parseRangeDays(rangeQ)
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
start := today.AddDate(0, 0, -(days - 1))
end := today
out := &AnalyticsOverview{
RangeDays: days,
Visits: make([]VisitDay, 0, days),
Boards: make([]NamedCount, 0),
PostTypes: make([]NamedCount, 0),
TopViewed: make([]TopViewPost, 0),
}
var stats []model.SiteDailyStats
if err := s.db.Where("date >= ? AND date <= ?", start, end).
Order("date ASC").Find(&stats).Error; err != nil {
return nil, err
}
visitMap := map[string]model.SiteDailyStats{}
for _, st := range stats {
visitMap[dateKey(st.Date)] = st
}
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
k := dateKey(d)
st := visitMap[k]
out.Visits = append(out.Visits, VisitDay{Date: k, PV: st.PV, UV: st.UV})
if k == dateKey(today) {
out.TodayPV = st.PV
out.TodayUV = st.UV
}
}
type dayRow struct {
Day string
Cnt int64
}
scanDayCounts := func(sql string, args ...interface{}) (map[string]int64, error) {
var rows []dayRow
if err := s.db.Raw(sql, args...).Scan(&rows).Error; err != nil {
return nil, err
}
m := map[string]int64{}
for _, r := range rows {
m[r.Day] = r.Cnt
}
return m, nil
}
postMap, err := scanDayCounts(`
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
FROM posts
WHERE deleted_at IS NULL AND status = ? AND created_at >= ? AND created_at < ?
GROUP BY day ORDER BY day`,
model.ContentStatusPublished, start, end.AddDate(0, 0, 1))
if err != nil {
return nil, err
}
out.PostsSeries = fillDaySeries(start, end, postMap)
commentMap, err := scanDayCounts(`
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
FROM comments
WHERE deleted_at IS NULL AND status = ? AND created_at >= ? AND created_at < ?
GROUP BY day ORDER BY day`,
model.ContentStatusPublished, start, end.AddDate(0, 0, 1))
if err != nil {
return nil, err
}
out.CommentsSeries = fillDaySeries(start, end, commentMap)
regMap, err := scanDayCounts(`
SELECT to_char(created_at, 'YYYY-MM-DD') AS day, count(*) AS cnt
FROM users
WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?
GROUP BY day ORDER BY day`,
start, end.AddDate(0, 0, 1))
if err != nil {
return nil, err
}
out.RegistersSeries = fillDaySeries(start, end, regMap)
type boardRow struct {
ID uint
Name string
PostCount int64
}
var boards []boardRow
if err := s.db.Raw(`
SELECT b.id, b.name, count(p.id) AS post_count
FROM boards b
LEFT JOIN posts p ON p.board_id = b.id AND p.deleted_at IS NULL AND p.status = ?
WHERE b.deleted_at IS NULL
GROUP BY b.id, b.name
ORDER BY post_count DESC`, model.ContentStatusPublished).Scan(&boards).Error; err != nil {
return nil, err
}
for _, b := range boards {
out.Boards = append(out.Boards, NamedCount{
Key: strconv.FormatUint(uint64(b.ID), 10),
Label: b.Name,
Count: b.PostCount,
})
}
type typeRow struct {
PostType string
Cnt int64
}
var types []typeRow
if err := s.db.Model(&model.Post{}).
Select("post_type, count(*) as cnt").
Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished).
Group("post_type").Scan(&types).Error; err != nil {
return nil, err
}
for _, t := range types {
pt := model.NormalizePostType(t.PostType)
out.PostTypes = append(out.PostTypes, NamedCount{
Key: pt,
Label: pt,
Count: t.Cnt,
})
}
var tops []model.Post
if err := s.db.Select("id, title, view_count, board_id").
Where("deleted_at IS NULL AND status = ?", model.ContentStatusPublished).
Order("view_count DESC").Limit(8).Find(&tops).Error; err != nil {
return nil, err
}
for _, p := range tops {
out.TopViewed = append(out.TopViewed, TopViewPost{
ID: p.ID, Title: p.Title, ViewCount: p.ViewCount, BoardID: p.BoardID,
})
}
return out, nil
}

View File

@@ -152,20 +152,36 @@ func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64
return out, total, nil
}
// DeltaDay 单日积分发放与消耗
type DeltaDay struct {
Date string `json:"date"`
Granted int64 `json:"granted"`
Spent int64 `json:"spent"`
}
// PointsEconomyStats 全站积分与类型帖概览(管理端)
type PointsEconomyStats struct {
TotalBalance int64 `json:"total_balance"` // 用户余额合计
LedgerCount int64 `json:"ledger_count"` // 流水条数
CheckinToday int64 `json:"checkin_today"` // 今日签到人数
OpenBounties int64 `json:"open_bounties"` // 未结算悬赏帖
EscrowedPoints int64 `json:"escrowed_points"` // 托管中悬赏积分(估算)
PostsByType map[string]int64 `json:"posts_by_type"` // 各类型帖数量
RecentLedger []LedgerItem `json:"recent_ledger"` // 最近全局流水(脱敏 note)
TotalBalance int64 `json:"total_balance"`
LedgerCount int64 `json:"ledger_count"`
CheckinToday int64 `json:"checkin_today"`
OpenBounties int64 `json:"open_bounties"`
EscrowedPoints int64 `json:"escrowed_points"`
PostsByType map[string]int64 `json:"posts_by_type"`
RecentLedger []LedgerItem `json:"recent_ledger"`
CheckinSeries []DayCount `json:"checkin_series"`
LedgerByReason []NamedCount `json:"ledger_by_reason"`
DeltaSeries []DeltaDay `json:"delta_series"`
NetDelta7d int64 `json:"net_delta_7d"`
}
// AdminEconomyStats 管理端经济看板
func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
st := &PointsEconomyStats{PostsByType: map[string]int64{}}
st := &PointsEconomyStats{
PostsByType: map[string]int64{},
CheckinSeries: make([]DayCount, 0),
LedgerByReason: make([]NamedCount, 0),
DeltaSeries: make([]DeltaDay, 0),
}
if err := s.db.Model(&model.User{}).Where("deleted_at IS NULL").
Select("COALESCE(SUM(points),0)").Scan(&st.TotalBalance).Error; err != nil {
return nil, err
@@ -173,14 +189,11 @@ func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
if err := s.db.Model(&model.PointLedger{}).Count(&st.LedgerCount).Error; err != nil {
return nil, err
}
today := time.Now().Truncate(24 * time.Hour)
// 用日期字符串更稳妥(与 checkin 一致用 date)
if err := s.db.Model(&model.Checkin{}).
Where("checkin_date::date = CURRENT_DATE").
Count(&st.CheckinToday).Error; err != nil {
return nil, err
}
_ = today
type row struct {
PostType string
@@ -198,7 +211,6 @@ func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
st.PostsByType[pt] += r.Cnt
}
// 未结算悬赏:type_meta 含 escrowed true 且未 accepted/refunded —— 用简易扫描估算
var bountyPosts []model.Post
if err := s.db.Select("id, type_meta").
Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty).
@@ -227,5 +239,77 @@ func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt,
})
}
now := time.Now()
day0 := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
start14 := day0.AddDate(0, 0, -13)
start7 := day0.AddDate(0, 0, -6)
type dayCnt struct {
Day string
Cnt int64
}
var checkinRows []dayCnt
if err := s.db.Raw(`
SELECT to_char(checkin_date, 'YYYY-MM-DD') AS day, count(*) AS cnt
FROM checkins
WHERE checkin_date >= ? AND checkin_date <= ?
GROUP BY day ORDER BY day`, start14, day0).Scan(&checkinRows).Error; err != nil {
return nil, err
}
checkinMap := map[string]int64{}
for _, r := range checkinRows {
checkinMap[r.Day] = r.Cnt
}
st.CheckinSeries = fillDaySeries(start14, day0, checkinMap)
type reasonRow struct {
Reason string
Cnt int64
}
var reasons []reasonRow
if err := s.db.Model(&model.PointLedger{}).
Select("reason, count(*) as cnt").
Group("reason").
Order("cnt DESC").
Limit(12).Scan(&reasons).Error; err != nil {
return nil, err
}
for _, r := range reasons {
st.LedgerByReason = append(st.LedgerByReason, NamedCount{
Key: r.Reason, Label: r.Reason, Count: r.Cnt,
})
}
type deltaRow struct {
Day string
Granted int64
Spent int64
}
var deltas []deltaRow
if err := s.db.Raw(`
SELECT to_char(created_at, 'YYYY-MM-DD') AS day,
COALESCE(SUM(CASE WHEN delta > 0 THEN delta ELSE 0 END), 0) AS granted,
COALESCE(SUM(CASE WHEN delta < 0 THEN -delta ELSE 0 END), 0) AS spent
FROM point_ledgers
WHERE created_at >= ? AND created_at < ?
GROUP BY day ORDER BY day`,
start14, day0.AddDate(0, 0, 1)).Scan(&deltas).Error; err != nil {
return nil, err
}
deltaMap := map[string]DeltaDay{}
for _, r := range deltas {
deltaMap[r.Day] = DeltaDay{Date: r.Day, Granted: r.Granted, Spent: r.Spent}
}
for d := start14; !d.After(day0); d = d.AddDate(0, 0, 1) {
k := dateKey(d)
dd := deltaMap[k]
dd.Date = k
st.DeltaSeries = append(st.DeltaSeries, dd)
if !d.Before(start7) {
st.NetDelta7d += dd.Granted - dd.Spent
}
}
return st, nil
}

View File

@@ -35,6 +35,8 @@ const (
SettingKeyAnimSmoothScroll = "anim_smooth_scroll"
// SettingKeyAnimChrome 装饰入场(列表/Toast/点赞等);缺行视为开启
SettingKeyAnimChrome = "anim_chrome"
// SettingKeyPostLinkNewTab 帖子/评论 Markdown 外链是否新标签打开;缺行视为开启(聊天链接始终新开,不受此键控制)
SettingKeyPostLinkNewTab = "post_link_new_tab"
)
const (
@@ -72,6 +74,7 @@ type PublicSiteSettings struct {
AnimCodeFold bool `json:"anim_code_fold"`
AnimSmoothScroll bool `json:"anim_smooth_scroll"`
AnimChrome bool `json:"anim_chrome"`
PostLinkNewTab bool `json:"post_link_new_tab"`
}
// SettingService 站点级键值设置
@@ -139,6 +142,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
AnimCodeFold: true,
AnimSmoothScroll: true,
AnimChrome: true,
PostLinkNewTab: true,
}
accent, err := s.AccentColor()
if err != nil {
@@ -224,6 +228,12 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
return out, err
}
out.AnimChrome = animChrome
linkNewTab, err := s.PostLinkNewTab()
if err != nil {
return out, err
}
out.PostLinkNewTab = linkNewTab
return out, nil
}
@@ -433,3 +443,16 @@ func (s *SettingService) AnimChrome() (bool, error) {
func (s *SettingService) SetAnimChrome(on bool) error {
return s.setBoolDefaultTrue(SettingKeyAnimChrome, on)
}
// PostLinkNewTab 帖子/评论 Markdown 外链是否新标签打开。缺行视为开启。
func (s *SettingService) PostLinkNewTab() (bool, error) {
v, found, err := s.getValue(SettingKeyPostLinkNewTab)
if err != nil {
return true, err
}
return parseBoolDefaultTrue(v, found), nil
}
func (s *SettingService) SetPostLinkNewTab(on bool) error {
return s.setBoolDefaultTrue(SettingKeyPostLinkNewTab, on)
}

View File

@@ -0,0 +1,88 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"regexp"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
vidUUIDRe = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
)
// TelemetryService 轻量页面访问埋点
type TelemetryService struct {
db *gorm.DB
}
func NewTelemetryService(db *gorm.DB) *TelemetryService {
return &TelemetryService{db: db}
}
func todayDate() time.Time {
now := time.Now()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
}
func hashVid(vid string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(strings.ToLower(vid))))
return hex.EncodeToString(sum[:])
}
// ValidVisitorID 校验浏览器访客 ID(UUID)
func ValidVisitorID(vid string) bool {
return vidUUIDRe.MatchString(strings.TrimSpace(vid))
}
// RecordPageView 记录一次页面浏览:PV 必增;合法 vid 首次出现当日才 UV+1
func (s *TelemetryService) RecordPageView(vid string, loggedIn bool) error {
day := todayDate()
err := s.db.Transaction(func(tx *gorm.DB) error {
var st model.SiteDailyStats
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("date = ?", day).First(&st).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
st = model.SiteDailyStats{Date: day}
if err := tx.Create(&st).Error; err != nil {
return err
}
// 重新锁住新建行
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("date = ?", day).First(&st).Error; err != nil {
return err
}
} else if err != nil {
return err
}
updates := map[string]interface{}{
"pv": st.PV + 1,
"updated_at": time.Now(),
}
if loggedIn {
updates["logged_in_pv"] = st.LoggedInPV + 1
}
if ValidVisitorID(vid) {
vis := model.SiteDailyVisitor{Date: day, VidHash: hashVid(vid)}
res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&vis)
if res.Error != nil {
return res.Error
}
if res.RowsAffected > 0 {
updates["uv"] = st.UV + 1
}
}
return tx.Model(&model.SiteDailyStats{}).Where("date = ?", day).Updates(updates).Error
})
return err
}

View File

@@ -1,300 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import type { LucideIcon } from "lucide-react";
import {
LayoutDashboard,
Users,
Megaphone,
Palette,
ShieldCheck,
ArrowLeft,
Coins,
Globe,
FolderOpen,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts } from "@/lib/api";
import {
canModerateAny,
isAdminOrAbove,
isSuperOrOwner,
type RoleUserLike,
} from "@/lib/roles";
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
interface NavItem {
href: string;
label: string;
icon: LucideIcon;
exact?: boolean;
visible: (u: RoleUserLike) => boolean;
pendingBadge?: boolean;
}
interface NavGroup {
title: string;
items: NavItem[];
}
// 分组导航:概览 → 内容运营 → 成员经济 → 站点配置,便于后期在组内扩展
const NAV_GROUPS: NavGroup[] = [
{
title: "概览",
items: [
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
],
},
{
title: "内容",
items: [
{
href: "/admin/content",
label: "内容管理",
icon: FolderOpen,
visible: (u) => canModerateAny(u),
pendingBadge: true,
},
{
href: "/admin/announcements",
label: "公告管理",
icon: Megaphone,
visible: (u) => isAdminOrAbove(u.role),
},
],
},
{
title: "成员与经济",
items: [
{
href: "/admin/users",
label: "用户权限",
icon: Users,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/economy",
label: "积分看板",
icon: Coins,
visible: (u) => isAdminOrAbove(u.role),
},
],
},
{
title: "站点",
items: [
{
href: "/admin/settings",
label: "站点设置",
icon: Globe,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/appearance",
label: "外观",
icon: Palette,
visible: (u) => isSuperOrOwner(u.role),
},
],
},
];
function isActive(pathname: string, href: string, exact?: boolean) {
return exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
}
function usePendingCounts(enabled: boolean) {
const [counts, setCounts] = useState({ posts: 0, comments: 0 });
useEffect(() => {
if (!enabled) return;
let alive = true;
const load = () => {
apiAdminPendingCounts()
.then((c) => {
if (alive) setCounts(c);
})
.catch(() => {});
};
load();
const off = realtime.on(RT_MODERATION_CHANGED, () => load());
const offVisible = onDocumentVisible(load);
return () => {
alive = false;
off();
offVisible();
};
}, [enabled]);
return counts.posts + counts.comments;
}
function PendingBadge({ count }: { count: number }) {
if (count <= 0) return null;
return (
<span
className="ml-auto shrink-0 min-w-[20px] h-5 px-1.5 rounded-full inline-flex items-center justify-center text-[10.5px] font-medium tabular-nums"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
aria-label={`${count} 条待审核`}
>
{count > 99 ? "99+" : count}
</span>
);
}
function visibleGroups(user: RoleUserLike) {
return NAV_GROUPS.map((g) => ({
...g,
items: g.items.filter((i) => i.visible(user)),
})).filter((g) => g.items.length > 0);
}
function flatItems(user: RoleUserLike) {
return visibleGroups(user).flatMap((g) => g.items);
}
export function AdminSidebar({ user }: { user: User }) {
const pathname = usePathname();
const groups = visibleGroups(user);
const pendingTotal = usePendingCounts(flatItems(user).some((i) => i.pendingBadge));
return (
<div className="panel p-3">
<div className="flex items-center gap-2.5 px-2 pt-1.5 pb-3.5" style={{ borderBottom: "1px solid var(--line)" }}>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<ShieldCheck size={18} />
</span>
<span className="leading-tight min-w-0">
<span className="block text-[14.5px] font-extrabold tracking-tight truncate" style={{ color: "var(--ink)" }}>
管理面板
</span>
<span className="block text-[10.5px] mt-0.5 tracking-wide" style={{ color: "var(--ink-3)" }}>
J13 ADMIN
</span>
</span>
</div>
<nav className="flex flex-col gap-3 pt-3 pb-2" aria-label="后台导航">
{groups.map((group) => (
<div key={group.title}>
<p className="field-label px-2 pb-1.5" style={{ marginBottom: 0 }}>
{group.title}
</p>
<div className="flex flex-col gap-1">
{group.items.map((item) => {
const active = isActive(pathname, item.href, item.exact);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] font-medium transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)" }
}
>
<Icon
size={16}
className="shrink-0"
style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }}
/>
{item.label}
{item.pendingBadge && <PendingBadge count={pendingTotal} />}
</Link>
);
})}
</div>
</div>
))}
</nav>
<div className="pt-2" style={{ borderTop: "1px solid var(--line)" }}>
<Link
href="/"
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-2)" }}
>
<ArrowLeft size={16} className="shrink-0" style={{ color: "var(--ink-3)" }} />
返回站点
</Link>
</div>
<div className="flex items-center gap-2.5 mt-2 px-2.5 py-2.5 rounded-xl" style={{ background: "var(--panel-2)" }}>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={32} />
<div className="min-w-0 leading-tight">
<p className="text-[13px] font-medium truncate" style={{ color: "var(--ink)" }}>
{user.nickname}
</p>
<p className="text-[11px] truncate" style={{ color: "var(--ink-3)" }}>
@{user.username}
</p>
</div>
<span className="ml-auto shrink-0">
<RoleBadge role={user.role} boardIds={user.board_ids} size={11} />
</span>
</div>
</div>
);
}
export function AdminNavMobile({ user }: { user: User }) {
const pathname = usePathname();
const items = flatItems(user);
const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge));
return (
<div
className="lg:hidden sticky z-30 -mx-4 sm:-mx-6 px-4 sm:px-6 py-2.5 mb-4"
style={{
top: 68,
background: "color-mix(in srgb, var(--bg) 88%, transparent)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
borderBottom: "1px solid var(--line)",
}}
>
<nav className="flex items-center gap-2 overflow-x-auto" aria-label="后台导航">
{items.map((item) => {
const active = isActive(pathname, item.href, item.exact);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className="admin-seg shrink-0"
style={
active
? undefined
: { background: "var(--panel)", border: "1px solid var(--line)" }
}
>
<Icon size={14} />
{item.label}
{item.pendingBadge && pendingTotal > 0 && (
<span className="admin-seg-count">{pendingTotal > 99 ? "99+" : pendingTotal}</span>
)}
</Link>
);
})}
<Link
href="/"
className="admin-seg shrink-0"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<ArrowLeft size={14} />
返回站点
</Link>
</nav>
</div>
);
}

View File

@@ -0,0 +1,244 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { ChevronRight, Eye, UserPlus, Users } from "lucide-react";
import {
apiAdminAnalytics,
type AnalyticsOverview,
type NewUser,
} from "@/lib/api";
import { postTypeLabel } from "@/lib/postMeta";
import { formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar";
import { AdminSegmented } from "@/components/admin";
import {
AdminChartCard,
DualLineChart,
NamedBarChart,
NamedPieChart,
SingleAreaChart,
VisitTrendChart,
} from "@/components/admin/charts";
type RangeKey = "7d" | "30d";
const fmt = (n: number) => n.toLocaleString("zh-CN");
function PanelHead({ title, extra }: { title: string; extra?: string }) {
return (
<div
className="px-4 sm:px-5 py-3.5 flex items-center justify-between gap-2"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-medium text-[15px]" style={{ color: "var(--ink)" }}>
{title}
</span>
{extra && (
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{extra}
</span>
)}
</div>
);
}
function mergePostCommentSeries(data: AnalyticsOverview) {
const map = new Map<string, { date: string; posts: number; comments: number }>();
for (const row of data.posts_series) {
map.set(row.date, { date: row.date, posts: row.count, comments: 0 });
}
for (const row of data.comments_series) {
const prev = map.get(row.date);
if (prev) prev.comments = row.count;
else map.set(row.date, { date: row.date, posts: 0, comments: row.count });
}
return [...map.values()].sort((a, b) => a.date.localeCompare(b.date));
}
export default function DashboardClient({
initialAnalytics,
newUsers,
totalUsers,
canUsers,
}: {
initialAnalytics: AnalyticsOverview;
newUsers: NewUser[];
totalUsers?: number;
canUsers: boolean;
}) {
const [range, setRange] = useState<RangeKey>("7d");
const [data, setData] = useState(initialAnalytics);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const switchRange = (next: RangeKey) => {
if (next === range || pending) return;
setRange(next);
setError(null);
startTransition(async () => {
try {
const res = await apiAdminAnalytics(next);
setData(res);
} catch {
setError("趋势数据加载失败,请稍后重试");
}
});
};
const activitySeries = mergePostCommentSeries(data);
const registerSeries = data.registers_series.map((r) => ({
date: r.date,
count: r.count,
}));
const boardBars = data.boards.map((b) => ({
label: b.label || b.key,
count: b.count,
}));
const typeSlices = data.post_types.map((t) => ({
label: postTypeLabel(t.key),
count: t.count,
}));
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="meta text-[12.5px]" style={{ color: "var(--ink-3)" }}>
{pending ? "正在切换区间…" : `近 ${data.range_days} 日趋势`}
</p>
<AdminSegmented<RangeKey>
ariaLabel="分析时间范围"
size="sm"
role="group"
value={range}
onChange={switchRange}
options={[
{ key: "7d", label: "近 7 日" },
{ key: "30d", label: "近 30 日" },
]}
/>
</div>
{error && (
<p className="text-[13px] px-1" style={{ color: "var(--danger)" }} role="alert">
{error}
</p>
)}
<AdminChartCard title="访问趋势" hint="页面浏览量(PV)与独立访客(UV)">
<VisitTrendChart data={data.visits} />
</AdminChartCard>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<AdminChartCard title="发帖与评论" hint="按日新增">
<DualLineChart
data={activitySeries}
aKey="posts"
bKey="comments"
aName="发帖"
bName="评论"
/>
</AdminChartCard>
<AdminChartCard title="新注册" hint="按日新增成员">
<SingleAreaChart data={registerSeries} dataKey="count" name="注册" color="var(--ok)" />
</AdminChartCard>
<AdminChartCard title="板块发帖分布" hint="区间内发帖量">
<NamedBarChart data={boardBars} layout="horizontal" />
</AdminChartCard>
<AdminChartCard title="帖子类型" hint="区间内发帖构成">
<NamedPieChart data={typeSlices} />
</AdminChartCard>
</div>
<div className="grid lg:grid-cols-2 gap-4 items-start">
<div className="panel overflow-hidden">
<PanelHead
title="最新注册成员"
extra={totalUsers != null ? `共 ${fmt(totalUsers)} 人` : undefined}
/>
{newUsers.length === 0 ? (
<p className="meta text-center py-14 text-[13px]">暂无成员数据</p>
) : (
newUsers.slice(0, 8).map((u) => (
<Link
key={u.id}
href={`/u/${u.id}`}
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }}
>
<Avatar
name={u.nickname || u.username}
src={u.avatar || undefined}
size={34}
/>
<span className="min-w-0 flex-1 leading-tight">
<span className="block text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</span>
<span className="block text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
</span>
</span>
<span
className="shrink-0 inline-flex items-center gap-1 text-[11.5px]"
style={{ color: "var(--ink-3)" }}
>
<UserPlus size={12} aria-hidden />
{formatRelative(u.created_at)}加入
</span>
</Link>
))
)}
{canUsers && (
<Link
href="/admin/users"
className="flex items-center justify-center gap-1.5 px-4 py-3 text-[13px] transition-colors hover:bg-[var(--panel-2)]"
style={{
borderTop: "1px solid var(--line)",
color: "var(--accent)",
}}
>
<Users size={14} aria-hidden />
前往用户管理
<ChevronRight size={14} aria-hidden />
</Link>
)}
</div>
<div className="panel overflow-hidden">
<PanelHead title="热门浏览" extra={`近 ${data.range_days} 日`} />
{data.top_viewed.length === 0 ? (
<p className="meta text-center py-14 text-[13px]">暂无浏览数据</p>
) : (
data.top_viewed.map((p, i) => (
<Link
key={p.id}
href={`/post/${p.id}`}
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }}
>
<span
className="w-7 h-7 rounded-md flex items-center justify-center shrink-0 text-[12px] font-bold tabular-nums"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
aria-hidden
>
{i + 1}
</span>
<span className="min-w-0 flex-1 text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{p.title}
</span>
<span
className="shrink-0 inline-flex items-center gap-1 text-[12px] tabular-nums"
style={{ color: "var(--ink-3)" }}
>
<Eye size={13} aria-hidden />
{fmt(p.view_count)}
</span>
</Link>
))
)}
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import {
Plus,
Pencil,
@@ -20,7 +20,7 @@ import {
type AnnouncementColor,
type AnnouncementInput,
} from "@/lib/api";
import { formatDate } from "@/lib/format";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
@@ -29,6 +29,7 @@ import {
AdminIconButton,
AdminPageHeader,
AdminSegmented,
AdminStatusChip,
} from "@/components/admin";
const COLORS: { value: AnnouncementColor; label: string }[] = [
@@ -42,6 +43,7 @@ const COLORS: { value: AnnouncementColor; label: string }[] = [
type FormState = AnnouncementInput & { id: number | null };
type PublishKey = "published" | "draft";
type StatusFilter = "all" | "published" | "draft";
const EMPTY_FORM: FormState = {
id: null,
@@ -52,105 +54,32 @@ const EMPTY_FORM: FormState = {
published: true,
};
export default function AnnouncementAdmin({
initial,
initError,
/** 粗略去掉 Markdown,取一行摘要 */
function plainSummary(md: string, max = 96): string {
const text = md
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[#>*_`~\-|=]/g, "")
.replace(/\s+/g, " ")
.trim();
return text.length > max ? `${text.slice(0, max)}…` : text;
}
function AnnouncementForm({
form,
setForm,
saving,
onSave,
onCancel,
}: {
initial: Announcement[];
initError: string;
form: FormState;
setForm: (f: FormState) => void;
saving: boolean;
onSave: () => void;
onCancel: () => void;
}) {
const [list, setList] = useState(initial);
const [loadError, setLoadError] = useState(initError);
const [form, setForm] = useState<FormState | null>(null);
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Announcement | null>(null);
const load = useCallback(async () => {
try {
const res = await apiAdminListAnnouncements();
setList(res.announcements);
setLoadError("");
} catch (e) {
setLoadError(e instanceof Error ? e.message : "加载公告失败");
}
}, []);
const startCreate = () => setForm({ ...EMPTY_FORM });
const startEdit = (a: Announcement) =>
setForm({
id: a.id,
title: a.title,
content: a.content,
tag: a.tag,
tag_color: a.tag_color,
published: a.published,
});
async function save() {
if (!form) return;
const payload: AnnouncementInput = {
title: form.title.trim(),
content: form.content.trim(),
tag: form.tag.trim() || "公告",
tag_color: form.tag_color,
published: form.published,
};
if (!payload.title || !payload.content) {
toast("标题和内容不能为空");
return;
}
setSaving(true);
try {
if (form.id == null) {
await apiAdminCreateAnnouncement(payload);
toast("公告已发布", "ok");
} else {
await apiAdminUpdateAnnouncement(form.id, payload);
toast("公告已更新", "ok");
}
setForm(null);
await load();
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
}
async function remove(a: Announcement) {
setDeletingId(a.id);
try {
await apiAdminDeleteAnnouncement(a.id);
setList((l) => l.filter((x) => x.id !== a.id));
if (form?.id === a.id) setForm(null);
toast("公告已删除", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "删除失败");
} finally {
setDeletingId(null);
setDeleteTarget(null);
}
}
return (
<div>
<AdminPageHeader
icon={Megaphone}
title="站点公告管理"
description="发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色"
actions={
form?.id == null ? (
<button type="button" onClick={startCreate} className="btn btn-primary">
<Plus size={15} /> 新建公告
</button>
) : undefined
}
/>
<div className="grid grid-cols-1 gap-6 items-start">
{/* 编辑/新建表单:仅在新建或编辑时展开,空闲不占位 */}
{form && (
<div className="panel p-5">
<p className="font-semibold text-[15px] mb-4" style={{ color: "var(--ink)" }}>
{form.id == null ? "新建公告" : "编辑公告"}
@@ -220,7 +149,7 @@ export default function AnnouncementAdmin({
/>
<div className="flex items-center gap-2.5 mt-4">
<button type="button" onClick={save} disabled={saving} className="btn btn-primary flex-1 justify-center">
<button type="button" onClick={onSave} disabled={saving} className="btn btn-primary flex-1 justify-center">
{saving ? (
<>
<Loader2 size={15} className="animate-spin" /> 保存中…
@@ -233,7 +162,7 @@ export default function AnnouncementAdmin({
</button>
<button
type="button"
onClick={() => setForm(null)}
onClick={onCancel}
disabled={saving}
className="btn btn-line"
aria-label="取消编辑"
@@ -242,68 +171,279 @@ export default function AnnouncementAdmin({
</button>
</div>
</div>
)}
);
}
{/* 公告列表 */}
<div className="panel overflow-hidden">
function AnnouncementPreview({ a }: { a: Announcement }) {
return (
<div className="panel p-5">
<div className="flex items-center gap-2 flex-wrap mb-3">
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
<AdminStatusChip tone={a.published ? "ok" : "warn"}>
{a.published ? "已发布" : "草稿"}
</AdminStatusChip>
<span className="meta text-[11.5px] tabular-nums">{formatRelative(a.updated_at || a.created_at)}</span>
</div>
<h2 className="text-[17px] font-semibold leading-snug" style={{ color: "var(--ink)" }}>
{a.title}
</h2>
<p className="mt-3 text-[13.5px] leading-relaxed whitespace-pre-wrap" style={{ color: "var(--ink-2)" }}>
{a.content}
</p>
{a.published ? (
<a
href={`/announcement/${a.id}`}
className="btn btn-line btn-sm mt-4 inline-flex"
target="_blank"
rel="noreferrer"
>
<ExternalLink size={14} /> 查看前台页
</a>
) : null}
</div>
);
}
export default function AnnouncementAdmin({
initial,
initError,
}: {
initial: Announcement[];
initError: string;
}) {
const [list, setList] = useState(initial);
const [loadError, setLoadError] = useState(initError);
const [form, setForm] = useState<FormState | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(initial[0]?.id ?? null);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Announcement | null>(null);
const publishedCount = useMemo(() => list.filter((a) => a.published).length, [list]);
const draftCount = useMemo(() => list.filter((a) => !a.published).length, [list]);
const filtered = useMemo(() => {
if (statusFilter === "published") return list.filter((a) => a.published);
if (statusFilter === "draft") return list.filter((a) => !a.published);
return list;
}, [list, statusFilter]);
const selected = useMemo(
() => (selectedId == null ? null : list.find((a) => a.id === selectedId) ?? null),
[list, selectedId]
);
const load = useCallback(async () => {
try {
const res = await apiAdminListAnnouncements();
setList(res.announcements);
setLoadError("");
} catch (e) {
setLoadError(e instanceof Error ? e.message : "加载公告失败");
}
}, []);
const startCreate = () => {
setForm({ ...EMPTY_FORM });
setSelectedId(null);
};
const startEdit = (a: Announcement) => {
setSelectedId(a.id);
setForm({
id: a.id,
title: a.title,
content: a.content,
tag: a.tag,
tag_color: a.tag_color,
published: a.published,
});
};
const selectRow = (a: Announcement) => {
setSelectedId(a.id);
if (form && form.id !== a.id) setForm(null);
};
async function save() {
if (!form) return;
const payload: AnnouncementInput = {
title: form.title.trim(),
content: form.content.trim(),
tag: form.tag.trim() || "公告",
tag_color: form.tag_color,
published: form.published,
};
if (!payload.title || !payload.content) {
toast("标题和内容不能为空");
return;
}
setSaving(true);
try {
if (form.id == null) {
const res = await apiAdminCreateAnnouncement(payload);
toast(payload.published ? "公告已发布" : "草稿已保存", "ok");
setForm(null);
setSelectedId(res.announcement?.id ?? null);
} else {
await apiAdminUpdateAnnouncement(form.id, payload);
toast("公告已更新", "ok");
setForm(null);
setSelectedId(form.id);
}
await load();
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
}
async function remove(a: Announcement) {
setDeletingId(a.id);
try {
await apiAdminDeleteAnnouncement(a.id);
setList((l) => {
const next = l.filter((x) => x.id !== a.id);
if (selectedId === a.id) setSelectedId(next[0]?.id ?? null);
return next;
});
if (form?.id === a.id) setForm(null);
toast("公告已删除", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "删除失败");
} finally {
setDeletingId(null);
setDeleteTarget(null);
}
}
return (
<div>
<AdminPageHeader
icon={Megaphone}
title="站点公告管理"
description="发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色"
actions={
form?.id == null && form != null ? undefined : (
<button type="button" onClick={startCreate} className="btn btn-primary">
<Plus size={15} /> 新建公告
</button>
)
}
/>
{/* 计数条 */}
<div className="flex items-center gap-3 mb-4 flex-wrap">
<div
className="px-4 sm:px-5 py-3.5 flex items-center justify-between"
className="inline-flex items-center gap-4 rounded-lg px-3.5 py-2 text-[13px]"
style={{ background: "var(--panel-2)", color: "var(--ink-2)" }}
>
<span>
草稿{" "}
<strong className="tabular-nums" style={{ color: "var(--ink)" }}>
{draftCount}
</strong>
</span>
<span aria-hidden style={{ color: "var(--line)" }}>
|
</span>
<span>
已发布{" "}
<strong className="tabular-nums" style={{ color: "var(--ink)" }}>
{publishedCount}
</strong>
</span>
</div>
<AdminSegmented<StatusFilter>
ariaLabel="公告状态筛选"
size="sm"
value={statusFilter}
onChange={setStatusFilter}
options={[
{ key: "all", label: "全部", count: list.length },
{ key: "published", label: "已发布", count: publishedCount },
{ key: "draft", label: "草稿", count: draftCount },
]}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* 左侧列表 */}
<div className="panel overflow-hidden min-w-0">
<div
className="px-4 sm:px-5 py-3 flex items-center justify-between"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-medium text-[15px]" style={{ color: "var(--ink)" }}>
全部公告
<span className="font-medium text-[14px]" style={{ color: "var(--ink)" }}>
公告列表
</span>
<span className="meta text-[12px] tabular-nums">共 {list.length} 条</span>
<span className="meta text-[12px] tabular-nums">共 {filtered.length} 条</span>
</div>
{loadError ? (
<p className="alert-error m-4">{loadError}</p>
) : list.length === 0 ? (
) : filtered.length === 0 ? (
<AdminEmpty
icon={<Megaphone size={22} />}
title="还没有公告"
description="点击「新建公告」发布第一条吧"
title={list.length === 0 ? "还没有公告" : "该状态下暂无公告"}
description={list.length === 0 ? "发布第一条站点公告,会出现在首页右栏" : "试试切换筛选条件"}
/>
) : (
list.map((a) => (
filtered.map((a, idx) => {
const active = selectedId === a.id || form?.id === a.id;
const summary = plainSummary(a.content);
return (
<div
key={a.id}
className="group flex items-center gap-3 px-4 sm:px-5 py-3.5"
style={{ borderTop: "1px solid var(--line)" }}
role="button"
tabIndex={0}
onClick={() => selectRow(a)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectRow(a);
}
}}
className="px-4 sm:px-5 py-3.5 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
style={{
borderTop: idx === 0 ? undefined : "1px solid var(--line)",
background: active ? "var(--accent-soft)" : undefined,
}}
>
<div className="flex items-start gap-2 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
{!a.published && (
<span
className="inline-flex items-center rounded-full px-2 py-px text-[10.5px] leading-[1.55]"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
草稿
</span>
)}
<span className="meta text-[11px] tabular-nums">{formatDate(a.created_at)}</span>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<p
className="mt-1 truncate text-[14px]"
className="text-[14px] font-medium truncate max-w-full"
style={{ color: "var(--ink)" }}
data-tip={a.title}
>
{a.title}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{a.published && (
<AdminStatusChip tone={a.published ? "ok" : "warn"}>
{a.published ? "已发布" : "草稿"}
</AdminStatusChip>
<span className="meta text-[11px] tabular-nums shrink-0">
{formatRelative(a.updated_at || a.created_at)}
</span>
<span
className="inline-flex items-center gap-0.5 shrink-0"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{a.published ? (
<AdminIconButton
href={`/announcement/${a.id}`}
tip="查看"
ariaLabel="查看公告"
>
<ExternalLink size={15} />
<ExternalLink size={14} />
</AdminIconButton>
)}
) : null}
<AdminIconButton tip="编辑" ariaLabel="编辑公告" onClick={() => startEdit(a)}>
<Pencil size={15} />
<Pencil size={14} />
</AdminIconButton>
<AdminIconButton
tip="删除"
@@ -312,15 +452,63 @@ export default function AnnouncementAdmin({
disabled={deletingId === a.id}
onClick={() => setDeleteTarget(a)}
>
{deletingId === a.id ? <Loader2 size={15} className="animate-spin" /> : <Trash2 size={15} />}
{deletingId === a.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</AdminIconButton>
</span>
</div>
{summary ? (
<p className="meta text-[12.5px] mt-1 line-clamp-1">{summary}</p>
) : null}
</div>
</div>
</div>
);
})
)}
{list.length === 0 && !loadError ? (
<div className="px-4 sm:px-5 pb-5 -mt-4 flex justify-center">
<button type="button" onClick={startCreate} className="btn btn-primary">
<Plus size={15} /> 新建第一条公告
</button>
</div>
) : null}
</div>
{/* 右侧:表单 / 预览 / 占位 */}
<div className="min-w-0 lg:sticky lg:top-4">
{form ? (
<AnnouncementForm
form={form}
setForm={setForm}
saving={saving}
onSave={() => void save()}
onCancel={() => setForm(null)}
/>
) : selected ? (
<AnnouncementPreview a={selected} />
) : (
<div className="panel p-8">
<AdminEmpty
icon={<Megaphone size={22} />}
title="选择或新建公告"
description="在左侧点选一条查看预览,或新建公告"
/>
<div className="flex justify-center -mt-4 pb-2">
<button type="button" onClick={startCreate} className="btn btn-primary">
<Plus size={15} /> 新建公告
</button>
</div>
</div>
))
)}
</div>
</div>
{/* 移动端编辑时:若只在右侧显示表单,列表仍在上方可见,符合双栏降级 */}
<ConfirmDialog
open={!!deleteTarget}
title="删除公告"

View File

@@ -36,7 +36,6 @@ import {
export default function AppearanceAdmin({ initial }: { initial: PublicSettings }) {
const router = useRouter();
const initialAccent = initial.accent || "";
// 已保存值:空串=内置默认(靛蓝)
const [savedHex, setSavedHex] = useState(initialAccent);
const [draft, setDraft] = useState(initialAccent || DEFAULT_ACCENT);
const [codeAutoFold, setCodeAutoFold] = useState(initial.code_block_auto_fold !== false);
@@ -62,12 +61,10 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
[normalized]
);
const preset = useMemo(() => (normalized ? findPreset(normalized) : null), [normalized]);
// 保存提交值:默认靛蓝落库为空串(前端不注入覆盖,保持内置令牌单一来源)
const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : "";
const dirty = (savedHex || DEFAULT_ACCENT) !== (normalized || "");
const invalid = draft.trim() !== "" && !normalized;
// 实时预览:合法颜色即应用;离开页面时还原为已保存值,避免未保存预览残留
useEffect(() => {
if (normalized) applyAccentTheme(normalized);
return () => {
@@ -166,14 +163,16 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
};
return (
<div className="max-w-3xl">
<div>
<AdminPageHeader
icon={Palette}
title="外观设置"
description="配置站点主题色、代码块折叠与界面动画"
/>
<div className="flex flex-col gap-4 mb-6">
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 mb-6">
{/* 主题色列 */}
<div className="flex flex-col gap-4 xl:col-span-2">
<AdminSection
title="预设主题色"
description="浅/暗两套色板均已调校,保证文字对比度达标"
@@ -228,7 +227,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
<Pipette size={15} /> 自定义颜色
</span>
}
description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正,保证按钮与文字可读"
description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正"
>
<div className="flex flex-wrap items-center gap-3">
<label
@@ -340,14 +339,17 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
</div>
</div>
</AdminSection>
</div>
{/* 代码折叠 + 动画列 */}
<div className="flex flex-col gap-4">
<AdminSettingRow
title={
<>
<Code2 size={15} /> 代码块自动折叠
</>
}
description="开启后,帖子与评论中超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。关闭则始终完整展示。"
description="超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。"
control={
<AdminSwitch
checked={codeAutoFold}
@@ -363,7 +365,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
折叠行数阈值
</label>
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">
代码块行数超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。
超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。
</p>
<div className="flex flex-wrap items-center gap-2.5">
<AdminStepper
@@ -400,7 +402,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
<Sparkles size={15} /> 界面动画
</>
}
description="总开关关闭后全站即时切换(等同减少动态效果)。系统若已开启「减少动态效果」,始终优先生效。开启总开关后可细调子项。"
description="总开关关闭后全站即时切换。系统「减少动态效果」始终优先。"
control={
<AdminSwitch
checked={uiAnimations}
@@ -466,6 +468,7 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
) : null}
</AdminSettingRow>
</div>
</div>
<div className="flex items-center justify-between gap-3 sticky bottom-5 panel !p-3.5 pl-5 z-10">
<p className="text-[12.5px] meta hidden sm:block">

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,8 @@ function parseStatus(v?: string): AdminContentStatus {
}
function parseKind(v?: string): ContentKind {
return v === "comments" ? "comments" : "posts";
if (v === "comments") return "comments";
return "posts";
}
interface ContentPageProps {
@@ -48,35 +49,37 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp
let initialComments: AdminContentComment[] = [];
let initialCommentsTotal = 0;
let initError = "";
const [listSettled, counts] = await Promise.all([
initialKind === "comments"
? fetchAdminContentComments(1, initialStatus, cookie || undefined)
.then((res) => ({ ok: true as const, kind: "comments" as const, res }))
// PC 双栏同时展示:SSR 并行预取帖子与评论
const [postsSettled, commentsSettled, counts, settings] = await Promise.all([
fetchAdminContentPosts(1, initialStatus, cookie || undefined)
.then((res) => ({ ok: true as const, res }))
.catch((e) => ({
ok: false as const,
error: e instanceof Error ? e.message : "获取内容列表失败",
}))
: fetchAdminContentPosts(1, initialStatus, cookie || undefined)
.then((res) => ({ ok: true as const, kind: "posts" as const, res }))
error: e instanceof Error ? e.message : "获取帖子失败",
})),
fetchAdminContentComments(1, initialStatus, cookie || undefined)
.then((res) => ({ ok: true as const, res }))
.catch((e) => ({
ok: false as const,
error: e instanceof Error ? e.message : "获取内容列表失败",
error: e instanceof Error ? e.message : "获取评论失败",
})),
fetchAdminContentCounts(cookie || undefined),
getPublicSettingsCached(),
]);
if (listSettled.ok) {
if (listSettled.kind === "posts") {
initialPosts = listSettled.res.posts;
initialPostsTotal = listSettled.res.total;
} else {
initialComments = listSettled.res.comments;
initialCommentsTotal = listSettled.res.total;
}
} else {
initError = listSettled.error;
}
const settings = await getPublicSettingsCached();
if (postsSettled.ok) {
initialPosts = postsSettled.res.posts;
initialPostsTotal = postsSettled.res.total;
} else {
initError = postsSettled.error;
}
if (commentsSettled.ok) {
initialComments = commentsSettled.res.comments;
initialCommentsTotal = commentsSettled.res.total;
} else if (!initError) {
initError = commentsSettled.error;
}
return (
<ContentAdmin

View File

@@ -0,0 +1,170 @@
"use client";
import Link from "next/link";
import { Coins, Flame, Gift, TrendingUp } from "lucide-react";
import type { EconomyStats } from "@/lib/api";
import { postTypeLabel } from "@/lib/postMeta";
import { pointReasonLabel } from "@/lib/points";
import { formatRelative } from "@/lib/format";
import { AdminPageHeader, AdminStatCard } from "@/components/admin";
import {
AdminChartCard,
DeltaBarChart,
NamedBarChart,
NamedPieChart,
SingleAreaChart,
} from "@/components/admin/charts";
const fmt = (n: number) => n.toLocaleString("zh-CN");
function netDeltaLabel(n: number) {
if (n > 0) return `+${fmt(n)}`;
return fmt(n);
}
export default function EconomyClient({ stats }: { stats: EconomyStats }) {
const checkinSeries = (stats.checkin_series || []).map((d) => ({
date: d.date,
count: d.count,
}));
const ledgerByReason = (stats.ledger_by_reason || []).map((r) => ({
label: pointReasonLabel(r.key || r.label),
count: r.count,
}));
const deltaSeries = stats.delta_series || [];
const postsByType = Object.entries(stats.posts_by_type || {})
.map(([t, n]) => ({ label: postTypeLabel(t), count: Number(n) || 0 }))
.filter((d) => d.count > 0)
.sort((a, b) => b.count - a.count);
const net = stats.net_delta_7d ?? 0;
return (
<div>
<AdminPageHeader
icon={Coins}
title="积分与类型帖看板"
description="观察全站积分流通、悬赏托管与帖子类型分布"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 mb-5">
<AdminStatCard
label="用户余额合计"
value={fmt(stats.total_balance)}
hint="全站可用积分之和"
icon={<Coins size={16} />}
/>
<AdminStatCard
label="今日签到"
value={fmt(stats.checkin_today)}
hint="今日已签到人数"
icon={<Flame size={16} />}
color="var(--clay)"
soft="var(--clay-soft)"
/>
<AdminStatCard
label="托管中悬赏"
value={`${fmt(stats.open_bounties)} / ${fmt(stats.escrowed_points)}`}
hint="未结算帖数 / 托管积分"
icon={<Gift size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<AdminStatCard
label="近 7 日净变动"
value={netDeltaLabel(net)}
hint="发放减去消耗"
icon={<TrendingUp size={16} />}
color={net >= 0 ? "var(--ok)" : "var(--danger)"}
soft={net >= 0 ? "var(--ok-soft)" : "var(--danger-soft)"}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<AdminChartCard title="签到趋势" hint="近 14 日每日签到人数">
{checkinSeries.length === 0 ? (
<p className="meta text-center py-16">暂无签到数据</p>
) : (
<SingleAreaChart
data={checkinSeries}
dataKey="count"
name="签到"
color="var(--clay)"
/>
)}
</AdminChartCard>
<AdminChartCard title="流水原因分布" hint="按入账原因聚合条数">
{ledgerByReason.length === 0 ? (
<p className="meta text-center py-16">暂无流水</p>
) : (
<NamedBarChart data={ledgerByReason} layout="horizontal" />
)}
</AdminChartCard>
<AdminChartCard title="发放 / 消耗" hint="近 14 日积分进出">
{deltaSeries.length === 0 ? (
<p className="meta text-center py-16">暂无数据</p>
) : (
<DeltaBarChart data={deltaSeries} />
)}
</AdminChartCard>
<AdminChartCard title="帖子类型分布" hint="全站帖子按类型占比">
{postsByType.length === 0 ? (
<p className="meta text-center py-16">暂无数据</p>
) : (
<NamedPieChart data={postsByType} />
)}
</AdminChartCard>
</div>
<section className="panel overflow-hidden">
<div
className="px-5 py-3.5 font-medium text-[15px]"
style={{ borderBottom: "1px solid var(--line)", color: "var(--ink)" }}
>
最近流水
</div>
{(stats.recent_ledger || []).length === 0 ? (
<p className="meta text-center py-10">暂无流水</p>
) : (
<ul>
{stats.recent_ledger.map((it, idx) => (
<li
key={it.id}
className="px-5 py-3 flex items-start justify-between gap-3"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<div className="min-w-0">
<p className="text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{pointReasonLabel(it.reason)}
{it.note ? ` · ${it.note}` : ""}
</p>
<p className="meta text-[12px] mt-0.5">{formatRelative(it.created_at)}</p>
</div>
<span
className="tabular-nums font-medium shrink-0"
style={{ color: it.delta >= 0 ? "var(--accent)" : "var(--danger)" }}
>
{it.delta >= 0 ? `+${it.delta}` : it.delta}
</span>
</li>
))}
</ul>
)}
</section>
<p className="meta text-[12.5px] mt-5">
用户可在{" "}
<Link href="/points" className="hover:text-[var(--accent)]">
/points
</Link>{" "}
查看个人流水。
</p>
</div>
);
}

View File

@@ -1,23 +1,16 @@
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import { Coins, Flame, Gift, Layers } from "lucide-react";
import { fetchAdminEconomyStats } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { postTypeLabel } from "@/lib/postMeta";
import { pointReasonLabel } from "@/lib/points";
import { formatRelative } from "@/lib/format";
import { AdminPageHeader, AdminStatCard } from "@/components/admin";
import Forbidden from "../Forbidden";
import EconomyClient from "./EconomyClient";
export const metadata: Metadata = {
title: "积分看板",
};
const fmt = (n: number) => n.toLocaleString("zh-CN");
export default async function AdminEconomyPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
@@ -40,121 +33,5 @@ export default async function AdminEconomyPage() {
);
}
const typeEntries = Object.entries(stats.posts_by_type || {}).sort(
(a, b) => b[1] - a[1]
);
return (
<div>
<AdminPageHeader
icon={Coins}
title="积分与类型帖看板"
description="观察全站积分流通、悬赏托管与帖子类型分布"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 mb-5">
<AdminStatCard
label="用户余额合计"
value={fmt(stats.total_balance)}
hint="全站可用积分之和"
icon={<Coins size={16} />}
/>
<AdminStatCard
label="今日签到"
value={fmt(stats.checkin_today)}
hint="今日已签到人数"
icon={<Flame size={16} />}
color="var(--clay)"
soft="var(--clay-soft)"
/>
<AdminStatCard
label="托管中悬赏"
value={`${fmt(stats.open_bounties)} / ${fmt(stats.escrowed_points)}`}
hint="未结算帖数 / 托管积分"
icon={<Gift size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<AdminStatCard
label="流水条数"
value={fmt(stats.ledger_count)}
hint="历史入账出账记录"
icon={<Layers size={16} />}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<section className="panel overflow-hidden">
<div
className="px-5 py-3.5 font-medium text-[15px]"
style={{ borderBottom: "1px solid var(--line)", color: "var(--ink)" }}
>
帖子类型分布
</div>
{typeEntries.length === 0 ? (
<p className="meta text-center py-10">暂无数据</p>
) : (
<ul>
{typeEntries.map(([t, n], idx) => (
<li
key={t}
className="px-5 py-3 flex items-center justify-between"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<span className="text-[14px]" style={{ color: "var(--ink)" }}>
{postTypeLabel(t)}
</span>
<span className="tabular-nums meta">{fmt(n)}</span>
</li>
))}
</ul>
)}
</section>
<section className="panel overflow-hidden">
<div
className="px-5 py-3.5 font-medium text-[15px]"
style={{ borderBottom: "1px solid var(--line)", color: "var(--ink)" }}
>
最近流水
</div>
{(stats.recent_ledger || []).length === 0 ? (
<p className="meta text-center py-10">暂无流水</p>
) : (
<ul>
{stats.recent_ledger.map((it, idx) => (
<li
key={it.id}
className="px-5 py-3 flex items-start justify-between gap-3"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<div className="min-w-0">
<p className="text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{pointReasonLabel(it.reason)}
{it.note ? ` · ${it.note}` : ""}
</p>
<p className="meta text-[12px] mt-0.5">{formatRelative(it.created_at)}</p>
</div>
<span
className="tabular-nums font-medium shrink-0"
style={{ color: it.delta >= 0 ? "var(--accent)" : "var(--danger)" }}
>
{it.delta >= 0 ? `+${it.delta}` : it.delta}
</span>
</li>
))}
</ul>
)}
</section>
</div>
<p className="meta text-[12.5px] mt-5">
用户可在{" "}
<Link href="/points" className="hover:text-[var(--accent)]">
/points
</Link>{" "}
查看个人流水。
</p>
</div>
);
return <EconomyClient stats={stats} />;
}

View File

@@ -2,10 +2,10 @@ import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import { ShieldAlert } from "lucide-react";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { authCookieHeader, TOKEN_COOKIE } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isStaff } from "@/lib/roles";
import { AdminSidebar, AdminNavMobile } from "./AdminNav";
import AdminShell from "@/components/admin/AdminShell";
// 整个 /admin 树不进入索引(子页面无需重复声明)
export const metadata: Metadata = {
@@ -15,12 +15,22 @@ export const metadata: Metadata = {
// 权限以 Go 端 RequireStaff 为唯一判定;这里仅决定管理界面是否直出,
// 非管理团队访问任意 /admin/* 时 SSR 统一给出无权限提示(所有写接口仍由后端 403 兜底)。
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const cookie = authCookieHeader(await cookies());
const cookieStore = await cookies();
const cookie = authCookieHeader(cookieStore);
const me = await getMeCached(cookie || undefined);
const settings = await getPublicSettingsCached();
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
if (!me.user || !isStaff(me.user.role)) {
return (
<div className="max-w-md mx-auto panel p-8 text-center mt-6 lg:mt-10">
<div
data-admin-viewport
id="main"
tabIndex={-1}
className="min-h-screen flex items-center justify-center px-4 outline-none"
style={{ background: "var(--bg)" }}
>
<div className="max-w-md w-full panel p-8 text-center">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
@@ -35,22 +45,21 @@ export default async function AdminLayout({ children }: { children: React.ReactN
返回首页
</Link>
</div>
</div>
);
}
// 未读仅在有 access cookie 时才可信;壳层铃铛用 SSR 直出避免挂载后才出红点
const unread = cookieStore.get(TOKEN_COOKIE)?.value ? me.unread_count : 0;
return (
<div>
<AdminNavMobile user={me.user} />
<div className="flex items-start gap-6 xl:gap-7">
{/* sticky 必须钉在 aside 自身:内层与侧栏同高时等于没钉住,会跟着主栏一起滚 */}
<aside
className="hidden lg:block w-60 xl:w-64 shrink-0 sticky self-start z-20 max-h-[calc(100vh-6.5rem)] overflow-y-auto"
style={{ top: 84 }}
<AdminShell
user={me.user}
siteName={settings.site_name || "姜十三论坛"}
initialTheme={theme}
initialUnread={unread}
>
<AdminSidebar user={me.user} />
</aside>
<section className="flex-1 min-w-0">{children}</section>
</div>
</div>
{children}
</AdminShell>
);
}

View File

@@ -0,0 +1,543 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { Loader2, MessageCircle, MessagesSquare, Undo2, Users } from "lucide-react";
import {
apiAdminChatMessages,
apiAdminRecallChatMessage,
type AdminChatMessage,
type AdminChatRoomType,
} from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
AdminEmpty,
AdminPageHeader,
AdminPager,
AdminSearchField,
AdminStatusChip,
} from "@/components/admin";
const PAGE_SIZE = 15;
const CLAMP_TOGGLE_CHARS = 90;
const pagerClass = "admin-content-col-pager px-3 sm:px-3.5";
type MsgKind = AdminChatRoomType;
function needsClampToggle(text: string): boolean {
if (text.length > CLAMP_TOGGLE_CHARS) return true;
return (text.match(/\n/g) || []).length >= 2;
}
function ClampText({
text,
className = "",
style,
}: {
text: string;
className?: string;
style?: CSSProperties;
}) {
const [open, setOpen] = useState(false);
const showToggle = needsClampToggle(text);
const body = text || "(空)";
return (
<div className={className}>
<p
className={`text-[12.5px] leading-relaxed whitespace-pre-wrap break-words admin-content-clamp${open ? " is-open" : ""}`}
style={style}
>
<AutolinkText text={body} forceNewTab />
</p>
{showToggle ? (
<button
type="button"
className="meta text-[11.5px] mt-0.5 hover:underline"
style={{ color: "var(--accent)" }}
onClick={() => setOpen((v) => !v)}
>
{open ? "收起" : "展开"}
</button>
) : null}
</div>
);
}
function ColumnShell({
icon,
title,
count,
toolbar,
body,
pager,
className = "",
}: {
icon: ReactNode;
title: string;
count?: number;
toolbar?: ReactNode;
body: ReactNode;
pager?: ReactNode;
className?: string;
}) {
return (
<section className={`panel admin-content-col ${className}`.trim()}>
<header
className="admin-content-col-head flex items-center gap-2 px-3.5 sm:px-4 py-3"
style={{ borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<span className="shrink-0" style={{ color: "var(--accent)" }} aria-hidden>
{icon}
</span>
<h2 className="text-[14px] font-semibold truncate" style={{ color: "var(--ink)" }}>
{title}
</h2>
{typeof count === "number" ? (
<span className="meta text-[12px] tabular-nums shrink-0">{count}</span>
) : null}
</header>
{toolbar}
<div className="admin-content-col-body">{body}</div>
{pager}
</section>
);
}
type DmPeer = { id: number; name: string; avatar: string };
/** 解析私聊:发送者在前、接收者在后 */
function resolveDmPair(m: AdminChatMessage): { sender: DmPeer; peer: DmPeer | null } {
const sender: DmPeer = {
id: m.sender_id,
name: m.sender_name || "?",
avatar: m.sender_avatar || "",
};
const fromParts = m.participants?.find((p) => p.id === m.sender_id);
if (fromParts) {
sender.name = fromParts.name || sender.name;
sender.avatar = fromParts.avatar || sender.avatar;
}
const peer =
m.participants?.find((p) => p.id !== m.sender_id) ||
m.participants?.find((p) => p.name !== sender.name) ||
null;
return {
sender,
peer: peer
? { id: peer.id, name: peer.name || "?", avatar: peer.avatar || "" }
: null,
};
}
/** 私聊方向条:头像贴在各自名字左侧,发送者在前 */
function DmDirection({
sender,
peer,
}: {
sender: DmPeer;
peer: DmPeer | null;
}) {
return (
<span className="admin-dm-pair-names">
<span className="admin-dm-person">
<Avatar name={sender.name} src={sender.avatar || undefined} size={22} />
<span className="admin-dm-person-name" style={{ color: "var(--ink)" }} data-tip={sender.name}>
{sender.name}
</span>
</span>
{peer ? (
<>
<span className="admin-dm-arrow" aria-hidden>
→
</span>
<span className="admin-dm-person">
<Avatar name={peer.name} src={peer.avatar || undefined} size={22} />
<span className="admin-dm-person-name" style={{ color: "var(--ink-2)" }} data-tip={peer.name}>
{peer.name}
</span>
</span>
</>
) : null}
</span>
);
}
function RecallButton({
busy,
onClick,
}: {
busy: boolean;
onClick: () => void;
}) {
return (
<button type="button" className="admin-content-action is-danger" disabled={busy} onClick={onClick}>
{busy ? <Loader2 size={13} className="animate-spin" /> : <Undo2 size={13} />}
撤回
</button>
);
}
function useMessageColumn(roomType: MsgKind) {
const [messages, setMessages] = useState<AdminChatMessage[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState("");
const [appliedQ, setAppliedQ] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const seq = useRef(0);
const load = useCallback(
async (p: number, q: string) => {
const s = ++seq.current;
setLoading(true);
try {
const res = await apiAdminChatMessages(p, q, roomType);
if (s !== seq.current) return;
setMessages(Array.isArray(res.messages) ? res.messages : []);
setTotal(typeof res.total === "number" ? res.total : 0);
setPage(res.page || p);
setAppliedQ(q);
setError("");
} catch (e) {
if (s !== seq.current) return;
setError(e instanceof Error ? e.message : "获取消息失败");
} finally {
if (s === seq.current) setLoading(false);
}
},
[roomType]
);
useEffect(() => {
void load(1, "");
return () => {
seq.current += 1;
};
}, [load]);
const markRecalled = (id: number) => {
setMessages((prev) =>
prev.map((x) => (x.id === id ? { ...x, recalled_at: new Date().toISOString() } : x))
);
};
return {
messages,
total,
page,
keyword,
setKeyword,
appliedQ,
loading,
error,
load,
markRecalled,
};
}
/** 群聊(含大厅)| 私聊 双栏监管 */
export default function MessagesAdmin() {
const [mobileKind, setMobileKind] = useState<MsgKind>("group");
const group = useMessageColumn("group");
const direct = useMessageColumn("direct");
const [actingKey, setActingKey] = useState<string | null>(null);
const [confirm, setConfirm] = useState<{
title: string;
message: string;
run: () => Promise<void>;
} | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const askRecall = (m: AdminChatMessage, markRecalled: (id: number) => void) => {
const key = `m-${m.id}`;
setConfirm({
title: "撤回这条消息?",
message: "撤回后会话中将显示为已撤回,此操作不可撤销。",
run: async () => {
setActingKey(key);
try {
await apiAdminRecallChatMessage(m.room_id, m.id);
toast("消息已撤回", "ok");
markRecalled(m.id);
} finally {
setActingKey(null);
}
},
});
};
const groupToolbar = (
<div className="admin-content-col-toolbar">
<p className="meta text-[11.5px] leading-snug">含全站大厅与普通群聊</p>
<AdminSearchField
value={group.keyword}
onChange={group.setKeyword}
onSubmit={() => void group.load(1, group.keyword.trim())}
onClear={() => {
group.setKeyword("");
void group.load(1, "");
}}
placeholder="搜索内容、群名或用户…"
ariaLabel="搜索群聊消息"
/>
</div>
);
const directToolbar = (
<div className="admin-content-col-toolbar">
<p className="meta text-[11.5px] leading-snug">一对一私信;按双方会话归档</p>
<AdminSearchField
value={direct.keyword}
onChange={direct.setKeyword}
onSubmit={() => void direct.load(1, direct.keyword.trim())}
onClear={() => {
direct.setKeyword("");
void direct.load(1, "");
}}
placeholder="搜索内容或参与者…"
ariaLabel="搜索私聊消息"
/>
</div>
);
const groupBody = (
<>
{group.error ? <p className="alert-error m-3 text-[12.5px]">{group.error}</p> : null}
{group.loading && group.messages.length === 0 ? (
<div className="flex justify-center py-14">
<Loader2 size={20} className="animate-spin" style={{ color: "var(--ink-3)" }} />
</div>
) : group.messages.length === 0 ? (
<AdminEmpty
icon={<Users size={20} />}
title="暂无群聊消息"
description="大厅与群聊消息会按时间出现在这里"
/>
) : (
group.messages.map((m, idx) => {
const key = `m-${m.id}`;
const busy = actingKey === key;
const recalled = !!m.recalled_at;
return (
<div
key={m.id}
className="flex items-start gap-2.5 px-3 sm:px-3.5 py-3"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<Avatar
name={m.sender_name || "?"}
src={m.sender_avatar || undefined}
size={30}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span
className="text-[12.5px] font-medium truncate max-w-[9rem]"
style={{ color: "var(--ink)" }}
>
{m.sender_name || "?"}
</span>
{recalled ? <AdminStatusChip tone="muted">已撤回</AdminStatusChip> : null}
{m.is_default ? (
<AdminStatusChip tone="muted">大厅</AdminStatusChip>
) : (
<span className="meta text-[11px] truncate max-w-[10rem]" data-tip={m.room_name}>
{m.room_name}
</span>
)}
<span className="meta text-[11px] tabular-nums">
{formatRelative(m.created_at)}
</span>
</div>
<ClampText
text={m.content || "(空消息)"}
className="mt-1"
style={{
color: recalled ? "var(--ink-3)" : "var(--ink)",
textDecoration: recalled ? "line-through" : undefined,
}}
/>
{!recalled ? (
<div className="mt-1.5">
<RecallButton busy={busy} onClick={() => askRecall(m, group.markRecalled)} />
</div>
) : null}
</div>
</div>
);
})
)}
</>
);
const directBody = (
<>
{direct.error ? <p className="alert-error m-3 text-[12.5px]">{direct.error}</p> : null}
{direct.loading && direct.messages.length === 0 ? (
<div className="flex justify-center py-14">
<Loader2 size={20} className="animate-spin" style={{ color: "var(--ink-3)" }} />
</div>
) : direct.messages.length === 0 ? (
<AdminEmpty
icon={<MessageCircle size={20} />}
title="暂无私聊消息"
description="用户一对一私信会出现在这里,可搜索参与者"
/>
) : (
direct.messages.map((m, idx) => {
const key = `m-${m.id}`;
const busy = actingKey === key;
const recalled = !!m.recalled_at;
const { sender, peer } = resolveDmPair(m);
return (
<div
key={m.id}
className="admin-dm-row px-3 sm:px-3.5 py-3"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<article className="admin-dm-card">
<header className="admin-dm-card-head">
<div className="admin-dm-pair-meta min-w-0">
<DmDirection sender={sender} peer={peer} />
{recalled ? <AdminStatusChip tone="muted">已撤回</AdminStatusChip> : null}
<span className="meta text-[11px] tabular-nums shrink-0">
{formatRelative(m.created_at)}
</span>
</div>
</header>
<div className="admin-dm-card-body">
<ClampText
text={m.content || "(空消息)"}
style={{
color: recalled ? "var(--ink-3)" : "var(--ink)",
textDecoration: recalled ? "line-through" : undefined,
}}
/>
{!recalled ? (
<div className="mt-1.5">
<RecallButton busy={busy} onClick={() => askRecall(m, direct.markRecalled)} />
</div>
) : null}
</div>
</article>
</div>
);
})
)}
</>
);
const groupCol = (
<ColumnShell
icon={<Users size={15} />}
title="群聊"
count={!group.loading || group.total > 0 ? group.total : undefined}
toolbar={groupToolbar}
body={groupBody}
pager={
<AdminPager
page={group.page}
total={group.total}
pageSize={PAGE_SIZE}
loading={group.loading}
onChange={(p) => void group.load(p, group.appliedQ)}
className={pagerClass}
/>
}
className={mobileKind === "group" ? "max-lg:flex" : "max-lg:hidden"}
/>
);
const directCol = (
<ColumnShell
icon={<MessageCircle size={15} />}
title="私聊"
count={!direct.loading || direct.total > 0 ? direct.total : undefined}
toolbar={directToolbar}
body={directBody}
pager={
<AdminPager
page={direct.page}
total={direct.total}
pageSize={PAGE_SIZE}
loading={direct.loading}
onChange={(p) => void direct.load(p, direct.appliedQ)}
className={pagerClass}
/>
}
className={mobileKind === "direct" ? "max-lg:flex" : "max-lg:hidden"}
/>
);
return (
<div className="admin-content-page">
<AdminPageHeader
icon={MessagesSquare}
title="消息管理"
description="分栏监管群聊与私聊;可搜索并撤回违规内容"
/>
<div className="admin-content-kind lg:hidden shrink-0" role="tablist" aria-label="消息类型">
<button
type="button"
role="tab"
aria-selected={mobileKind === "group"}
className="admin-content-kind-btn"
onClick={() => setMobileKind("group")}
>
<Users size={14} aria-hidden />
群聊
<span className="admin-content-kind-count">{group.total}</span>
</button>
<button
type="button"
role="tab"
aria-selected={mobileKind === "direct"}
className="admin-content-kind-btn"
onClick={() => setMobileKind("direct")}
>
<MessageCircle size={14} aria-hidden />
私聊
<span className="admin-content-kind-count">{direct.total}</span>
</button>
</div>
<div className="admin-content-triptych">
{groupCol}
{directCol}
</div>
<ConfirmDialog
open={!!confirm}
title={confirm?.title || ""}
message={confirm?.message || ""}
danger
busy={confirmBusy}
confirmLabel="确认撤回"
onCancel={() => !confirmBusy && setConfirm(null)}
onConfirm={() => {
if (!confirm) return;
setConfirmBusy(true);
confirm
.run()
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => {
setConfirmBusy(false);
setConfirm(null);
});
}}
/>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isStaff } from "@/lib/roles";
import Forbidden from "../Forbidden";
import MessagesAdmin from "./MessagesAdmin";
export const metadata: Metadata = {
title: "消息管理",
};
/** 群聊(含大厅)与私聊分栏监管;鉴权与 staff API 一致 */
export default async function AdminMessagesPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isStaff(me.user.role)) {
return <Forbidden text="消息管理仅管理团队成员可进入。" />;
}
return <MessagesAdmin />;
}

View File

@@ -2,27 +2,28 @@ import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import {
UserPlus,
LayoutGrid,
ClipboardCheck,
ChevronRight,
MessageSquareText,
Eye,
FileText,
Users,
MessageSquareText,
LayoutDashboard,
Coins,
Users,
Activity,
} from "lucide-react";
import {
fetchOverview,
fetchAdminPendingCounts,
fetchAdminAnalytics,
type PendingCounts,
type AnalyticsOverview,
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { canModerateAny, isSuperOrOwner, isAdminOrAbove } from "@/lib/roles";
import Avatar from "@/components/Avatar";
import { AdminPageHeader, AdminStatCard } from "@/components/admin";
import { formatRelative } from "@/lib/format";
import DashboardClient from "./DashboardClient";
export const metadata: Metadata = {
title: "管理仪表盘",
@@ -38,93 +39,76 @@ function greeting(hour: number) {
return "晚上好";
}
function PanelHead({ title, extra }: { title: string; extra?: string }) {
return (
<div
className="px-4 sm:px-5 py-3.5 flex items-center justify-between gap-2"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-medium text-[15px]" style={{ color: "var(--ink)" }}>
{title}
</span>
{extra && (
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{extra}
</span>
)}
</div>
);
}
/** 待办:待审帖子 / 评论,进入内容管理的待审筛选 */
function PendingWorkPanel({ counts }: { counts: PendingCounts }) {
/** 紧凑待审条:跳转内容管理待审筛选 */
function PendingStrip({ counts }: { counts: PendingCounts }) {
const total = counts.posts + counts.comments;
const idle = total === 0;
const href =
counts.posts > 0 || counts.comments === 0
? "/admin/content?status=pending"
: "/admin/content?status=pending&kind=comments";
return (
<Link
href={
counts.posts > 0 || counts.comments === 0
? "/admin/content?status=pending"
: "/admin/content?status=pending&kind=comments"
}
className="panel hover-lift relative block p-4 sm:p-5 mb-4 group"
href={href}
className="panel hover-lift mb-4 px-4 sm:px-5 py-3 flex items-center gap-3 group"
>
<div className="flex items-start gap-3.5">
<span
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0"
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{
background: idle ? "var(--tint)" : "var(--ok-soft)",
color: idle ? "var(--ink-3)" : "var(--ok)",
}}
aria-hidden
>
<ClipboardCheck size={20} />
<ClipboardCheck size={18} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
<div className="min-w-0 flex-1 flex items-center gap-3 sm:gap-5 flex-wrap">
<span className="text-[13.5px] font-semibold" style={{ color: "var(--ink)" }}>
待审内容
</p>
</span>
{idle ? (
<span
className="admin-status is-muted"
>
暂无待审
</span>
<span className="admin-status is-muted">暂无待审</span>
) : (
<span className="admin-status is-ok tabular-nums">
{fmt(total)} 项待处理
</span>
<span className="admin-status is-ok tabular-nums">{fmt(total)} 项</span>
)}
</div>
<div className="mt-3 flex flex-wrap gap-3 sm:gap-5">
<span className="inline-flex items-center gap-1.5 text-[13px]" style={{ color: "var(--ink-2)" }}>
<FileText size={14} style={{ color: "var(--ink-3)" }} aria-hidden />
待审帖子
<strong className="tabular-nums font-extrabold" style={{ color: "var(--ink)" }}>
<span className="inline-flex items-center gap-1 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
<FileText size={13} style={{ color: "var(--ink-3)" }} aria-hidden />
帖子
<strong className="tabular-nums font-bold" style={{ color: "var(--ink)" }}>
{fmt(counts.posts)}
</strong>
</span>
<span className="inline-flex items-center gap-1.5 text-[13px]" style={{ color: "var(--ink-2)" }}>
<MessageSquareText size={14} style={{ color: "var(--ink-3)" }} aria-hidden />
待审评论
<strong className="tabular-nums font-extrabold" style={{ color: "var(--ink)" }}>
<span className="inline-flex items-center gap-1 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
<MessageSquareText size={13} style={{ color: "var(--ink-3)" }} aria-hidden />
评论
<strong className="tabular-nums font-bold" style={{ color: "var(--ink)" }}>
{fmt(counts.comments)}
</strong>
</span>
</div>
</div>
<ChevronRight
size={18}
className="shrink-0 mt-1 transition-transform group-hover:translate-x-0.5"
size={16}
className="shrink-0 transition-transform group-hover:translate-x-0.5"
style={{ color: "var(--ink-3)" }}
/>
</div>
</Link>
);
}
const emptyAnalytics = (): AnalyticsOverview => ({
range_days: 7,
today_pv: 0,
today_uv: 0,
visits: [],
posts_series: [],
comments_series: [],
registers_series: [],
boards: [],
post_types: [],
top_viewed: [],
});
export default async function AdminDashboardPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
@@ -132,11 +116,12 @@ export default async function AdminDashboardPage() {
const canUsers = isSuperOrOwner(me.user?.role);
const canEconomy = isAdminOrAbove(me.user?.role);
const [overview, pendingCounts] = await Promise.all([
const [overview, pendingCounts, analytics] = await Promise.all([
fetchOverview(cookie).catch(() => null),
canMod
? fetchAdminPendingCounts(cookie).catch(() => ({ posts: 0, comments: 0 }))
: Promise.resolve(null),
fetchAdminAnalytics(cookie, "7d").catch(() => emptyAnalytics()),
]);
const now = new Date();
@@ -148,10 +133,7 @@ export default async function AdminDashboardPage() {
}).format(now);
const stats = overview?.stats;
const boards = overview?.boards ?? [];
const newUsers = overview?.new_users ?? [];
const topBoards = [...boards].sort((a, b) => b.post_count - a.post_count).slice(0, 6);
const maxBoardCount = Math.max(1, ...topBoards.map((b) => b.post_count));
return (
<div>
@@ -159,138 +141,58 @@ export default async function AdminDashboardPage() {
icon={LayoutDashboard}
title={`${greeting(now.getHours())},${me.user?.nickname ?? ""}`}
description={`${dateLabel} · 站点管理`}
actions={
canEconomy ? (
<Link href="/admin/economy" className="btn btn-ghost text-[13px] gap-1.5">
<Coins size={15} aria-hidden />
积分看板
</Link>
) : undefined
}
/>
{canMod && pendingCounts && <PendingWorkPanel counts={pendingCounts} />}
{canEconomy && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
<Link href="/admin/economy" className="block hover-lift rounded-[var(--r)]">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<AdminStatCard
label="积分看板"
value="查看"
hint="余额合计、悬赏托管与流水"
icon={<Coins size={16} />}
label="今日 PV"
value={fmt(analytics.today_pv)}
hint={`独立访客 UV ${fmt(analytics.today_uv)}`}
icon={<Eye size={16} />}
/>
<AdminStatCard
label="今日 UV"
value={fmt(analytics.today_uv)}
hint="独立访客"
icon={<Users size={16} />}
color="var(--ok)"
soft="var(--ok-soft)"
/>
<AdminStatCard
label="今日内容"
value={fmt(stats?.today_posts ?? 0)}
sub="帖"
hint={`评论 ${fmt(stats?.today_comments ?? 0)} · 注册 ${fmt(stats?.today_users ?? 0)}`}
icon={<FileText size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
</Link>
{stats && (
<AdminStatCard
label="注册成员"
value={fmt(stats.users)}
hint="全站用户总量"
icon={<Users size={16} />}
label="在线"
value={fmt(stats?.online ?? 0)}
hint={`峰值 ${fmt(stats?.peak_online ?? 0)}`}
icon={<Activity size={16} />}
color="var(--clay)"
soft="var(--clay-soft)"
/>
)}
</div>
)}
<div className="grid lg:grid-cols-2 gap-4 items-start">
<div className="panel overflow-hidden">
<PanelHead
title="最新注册成员"
extra={stats ? `共 ${fmt(stats.users)} 人` : undefined}
/>
{newUsers.length === 0 ? (
<p className="meta text-center py-14 text-[13px]">暂无成员数据</p>
) : (
newUsers.slice(0, 6).map((u) => (
<Link
key={u.id}
href={`/u/${u.id}`}
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }}
>
<Avatar
name={u.nickname || u.username}
src={u.avatar || undefined}
size={34}
/>
<span className="min-w-0 flex-1 leading-tight">
<span className="block text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</span>
<span className="block text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
</span>
</span>
<span
className="shrink-0 inline-flex items-center gap-1 text-[11.5px]"
style={{ color: "var(--ink-3)" }}
>
<UserPlus size={12} aria-hidden />
{formatRelative(u.created_at)}加入
</span>
</Link>
))
)}
{canUsers && (
<Link
href="/admin/users"
className="flex items-center justify-center gap-1.5 px-4 py-3 text-[13px] transition-colors hover:bg-[var(--panel-2)]"
style={{
borderTop: "1px solid var(--line)",
color: "var(--accent)",
}}
>
<Users size={14} aria-hidden />
前往用户权限
<ChevronRight size={14} aria-hidden />
</Link>
)}
</div>
<div className="panel overflow-hidden">
<PanelHead title="板块话题分布" extra={`${boards.length} 个板块`} />
{topBoards.length === 0 ? (
<p className="meta text-center py-14 text-[13px]">暂无板块数据</p>
) : (
topBoards.map((b) => (
<Link
key={b.id}
href={`/?board=${b.id}`}
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }}
>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: "var(--panel-2)", color: "var(--accent)" }}
aria-hidden
>
<LayoutGrid size={14} />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{b.name}
</span>
<span
className="text-[12px] tabular-nums shrink-0"
style={{ color: "var(--ink-2)" }}
>
{fmt(b.post_count)}
</span>
</span>
<span
className="block mt-1.5 h-1 rounded-full overflow-hidden"
style={{ background: "var(--tint)" }}
aria-hidden
>
<span
className="block h-full rounded-full"
style={{
width: `${Math.max(4, Math.round((b.post_count / maxBoardCount) * 100))}%`,
background: "var(--accent)",
}}
{canMod && pendingCounts && <PendingStrip counts={pendingCounts} />}
<DashboardClient
initialAnalytics={analytics}
newUsers={newUsers}
totalUsers={stats?.users}
canUsers={canUsers}
/>
</span>
</span>
</Link>
))
)}
</div>
</div>
</div>
);
}

View File

@@ -1,30 +1,149 @@
"use client";
import { Globe, Loader2, MessageSquare, Save, UserPlus } from "lucide-react";
import { useState } from "react";
import {
Globe,
Loader2,
MessageSquare,
Save,
UserPlus,
ExternalLink,
Shield,
Mail,
HardDrive,
Ban,
Wrench,
Palette,
FileText,
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import {
AdminField,
AdminPageHeader,
AdminSection,
AdminSettingRow,
AdminSwitch,
} from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { toast } from "@/lib/toast";
type SectionId =
| "basic"
| "content"
| "brand"
| "footer"
| "security"
| "mail"
| "storage"
| "words"
| "maintenance";
const NAV: { id: SectionId; label: string; ready?: boolean }[] = [
{ id: "basic", label: "基本信息", ready: true },
{ id: "content", label: "内容与互动", ready: true },
{ id: "brand", label: "品牌与 SEO" },
{ id: "footer", label: "页脚与合规" },
{ id: "security", label: "安全与限流" },
{ id: "mail", label: "邮件" },
{ id: "storage", label: "对象存储" },
{ id: "words", label: "敏感词" },
{ id: "maintenance", label: "系统维护" },
];
function SwitchRow({
title,
description,
control,
bordered,
}: {
title: ReactNode;
description: string;
control: ReactNode;
bordered?: boolean;
}) {
return (
<div
className="flex items-start justify-between gap-4 flex-wrap py-4 first:pt-0 last:pb-0"
style={bordered ? { borderBottom: "1px solid var(--line)" } : undefined}
>
<div className="min-w-0 flex-1">
<p className="text-[14.5px] font-medium flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
{title}
</p>
<p className="meta text-[12.5px] mt-1 leading-relaxed">{description}</p>
</div>
<div className="shrink-0">{control}</div>
</div>
);
}
function ComingSoonCard({
title,
description,
icon,
}: {
title: string;
description: string;
icon: ReactNode;
}) {
return (
<AdminSection title={title} description={description}>
<div
className="rounded-[var(--r-sm)] px-4 py-10 text-center"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<span className="inline-flex mb-2 opacity-80" aria-hidden>
{icon}
</span>
<p className="text-[14px]" style={{ color: "var(--ink-2)" }}>
即将推出
</p>
<p className="meta text-[12.5px] mt-1">此分区尚未开放配置</p>
</div>
</AdminSection>
);
}
export default function SettingsAdmin({ initial }: { initial: PublicSettings }) {
const [siteName, setSiteName] = useState(initial.site_name);
const [siteDescription, setSiteDescription] = useState(initial.site_description);
const [allowRegister, setAllowRegister] = useState(initial.allow_register);
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false);
const [savedName, setSavedName] = useState(initial.site_name);
const [savedDesc, setSavedDesc] = useState(initial.site_description);
const [saving, setSaving] = useState(false);
const [togglingReg, setTogglingReg] = useState(false);
const [togglingComments, setTogglingComments] = useState(false);
const [togglingLink, setTogglingLink] = useState(false);
const [active, setActive] = useState<SectionId>("basic");
const dirty = siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
useEffect(() => {
const ids = NAV.map((n) => n.id);
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible[0]?.target?.id) {
const id = visible[0].target.id.replace(/^settings-/, "") as SectionId;
if (ids.includes(id)) setActive(id);
}
},
{ rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.25, 0.5] }
);
ids.forEach((id) => {
const el = document.getElementById(`settings-${id}`);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, []);
const scrollTo = (id: SectionId) => {
setActive(id);
document.getElementById(`settings-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const saveIdentity = async () => {
if (saving) return;
const name = siteName.trim();
@@ -80,15 +199,62 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
}
};
const togglePostLinkNewTab = async () => {
if (togglingLink) return;
const next = !postLinkNewTab;
setTogglingLink(true);
try {
const res = await apiUpdateSiteSettings({ post_link_new_tab: next });
setPostLinkNewTab(res.post_link_new_tab !== false);
toast(res.post_link_new_tab !== false ? "帖子与评论外链将在新标签打开" : "帖子与评论外链将在当前页打开", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingLink(false);
}
};
return (
<div className="max-w-3xl">
<div>
<AdminPageHeader
icon={Globe}
title="站点设置"
description="配置站点名称、简介与注册/评论开关;主题色、代码折叠与动画请到「外观」页调整"
description="配置站点名称、互动开关与后续扩展分区;主题色请到「外观」页调整"
/>
<div className="flex flex-col gap-4">
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
<nav
className="w-full lg:w-52 shrink-0 lg:sticky lg:top-4 flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-1 lg:pb-0"
aria-label="设置分区"
>
{NAV.map((item) => {
const isActive = active === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => scrollTo(item.id)}
className="shrink-0 text-left px-3 py-2 rounded-[var(--r-sm)] text-[13.5px] transition-colors whitespace-nowrap"
style={{
background: isActive ? "var(--accent-soft)" : "transparent",
color: isActive ? "var(--accent)" : "var(--ink-2)",
fontWeight: isActive ? 600 : 400,
}}
aria-current={isActive ? "true" : undefined}
>
{item.label}
{!item.ready && (
<span className="ml-1.5 text-[10.5px]" style={{ color: "var(--ink-3)" }}>
即将
</span>
)}
</button>
);
})}
</nav>
<div className="flex-1 min-w-0 flex flex-col gap-4 w-full">
<div id="settings-basic" className="scroll-mt-4">
<AdminSection title="基本信息" description="用于页眉品牌、页脚版权与 SEO 元信息">
<AdminField
id="site-name"
@@ -124,8 +290,12 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
</button>
</div>
</AdminSection>
</div>
<AdminSettingRow
<div id="settings-content" className="scroll-mt-4">
<AdminSection title="内容与互动" description="控制注册、评论与链接打开方式">
<SwitchRow
bordered
title={
<>
<UserPlus size={15} /> 开放注册
@@ -141,14 +311,14 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
/>
}
/>
<AdminSettingRow
<SwitchRow
bordered
title={
<>
<MessageSquare size={15} /> 开放评论
</>
}
description="关闭后前台不展示评论入口、评论数量与已有评论,并提示本站不支持评论。适用于备案审核等合规需要。"
description="关闭后前台不展示评论入口、评论数量与已有评论,并提示本站不支持评论。"
control={
<AdminSwitch
checked={allowComments}
@@ -158,6 +328,67 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
/>
}
/>
<SwitchRow
title={
<>
<ExternalLink size={15} /> 外链新标签打开
</>
}
description="开启后,帖子与评论中的 Markdown 外链在新标签打开;关闭则当前页跳转。"
control={
<AdminSwitch
checked={postLinkNewTab}
disabled={togglingLink}
onChange={() => void togglePostLinkNewTab()}
label="外链新标签打开"
/>
}
/>
</AdminSection>
</div>
<div id="settings-brand" className="scroll-mt-4">
<ComingSoonCard
title="品牌与 SEO"
description="站点图标、OG 图与结构化元数据"
icon={<Palette size={22} />}
/>
</div>
<div id="settings-footer" className="scroll-mt-4">
<ComingSoonCard
title="页脚与合规"
description="备案号、版权声明与合规链接"
icon={<FileText size={22} />}
/>
</div>
<div id="settings-security" className="scroll-mt-4">
<ComingSoonCard
title="安全与限流"
description="登录/注册速率与发帖冷静期等"
icon={<Shield size={22} />}
/>
</div>
<div id="settings-mail" className="scroll-mt-4">
<ComingSoonCard title="邮件" description="SMTP 与通知邮件模板" icon={<Mail size={22} />} />
</div>
<div id="settings-storage" className="scroll-mt-4">
<ComingSoonCard
title="对象存储"
description="附件与头像的云端存储配置"
icon={<HardDrive size={22} />}
/>
</div>
<div id="settings-words" className="scroll-mt-4">
<ComingSoonCard title="敏感词" description="内容过滤词库与命中策略" icon={<Ban size={22} />} />
</div>
<div id="settings-maintenance" className="scroll-mt-4">
<ComingSoonCard
title="系统维护"
description="维护模式、缓存与诊断工具"
icon={<Wrench size={22} />}
/>
</div>
</div>
</div>
</div>
);

View File

@@ -7,6 +7,7 @@ import {
ChevronLeft,
ChevronRight,
Clock,
Columns3,
ExternalLink,
FolderSearch,
History,
@@ -47,6 +48,47 @@ import {
export type UserFilter = "all" | "staff" | "banned";
const PAGE_SIZE = 20;
const COLS_STORAGE_KEY = "j13-admin-user-cols";
/** 默认始终显示的列 */
const CORE_COLS = ["member", "role", "status", "points", "last_login", "actions"] as const;
/** 可选列(localStorage 开关) */
const OPTIONAL_COLS = ["online", "email", "counts", "created_at"] as const;
type CoreCol = (typeof CORE_COLS)[number];
type OptionalCol = (typeof OPTIONAL_COLS)[number];
type ColKey = CoreCol | OptionalCol;
const OPTIONAL_LABELS: Record<OptionalCol, string> = {
online: "在线",
email: "邮箱",
counts: "帖/评数",
created_at: "注册时间",
};
const DEFAULT_OPTIONAL: Record<OptionalCol, boolean> = {
online: false,
email: false,
counts: false,
created_at: false,
};
function loadOptionalCols(): Record<OptionalCol, boolean> {
if (typeof window === "undefined") return { ...DEFAULT_OPTIONAL };
try {
const raw = localStorage.getItem(COLS_STORAGE_KEY);
if (!raw) return { ...DEFAULT_OPTIONAL };
const parsed = JSON.parse(raw) as Partial<Record<OptionalCol, boolean>>;
return {
online: !!parsed.online,
email: !!parsed.email,
counts: !!parsed.counts,
created_at: !!parsed.created_at,
};
} catch {
return { ...DEFAULT_OPTIONAL };
}
}
const FILTER_TABS: { key: UserFilter; label: string }[] = [
{ key: "all", label: "全部" },
@@ -60,6 +102,90 @@ function filterToParams(filter: UserFilter): { role?: string; status?: string }
return {};
}
function ColumnConfigPopover({
cols,
onChange,
}: {
cols: Record<OptionalCol, boolean>;
onChange: (next: Record<OptionalCol, boolean>) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const enabledCount = OPTIONAL_COLS.filter((k) => cols[k]).length;
return (
<div className="relative shrink-0" ref={ref}>
<button
type="button"
className="btn btn-line btn-sm"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<Columns3 size={14} />
列
{enabledCount > 0 ? (
<span className="tabular-nums" style={{ color: "var(--accent)" }}>
+{enabledCount}
</span>
) : null}
</button>
{open && (
<div
role="dialog"
aria-label="显示列"
className="absolute right-0 top-full mt-2 z-40 w-52 p-2 j13-toast-in"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: "var(--r)",
boxShadow: "var(--shadow-lg)",
}}
>
<p className="px-2 py-1.5 text-[11.5px] uppercase tracking-wide" style={{ color: "var(--ink-3)" }}>
可选列
</p>
<ul className="flex flex-col gap-0.5">
{OPTIONAL_COLS.map((key) => (
<li key={key}>
<label className="flex items-center gap-2.5 px-2 py-2 rounded-[var(--r-sm)] cursor-pointer hover:bg-[var(--panel-2)] text-[13px]">
<input
type="checkbox"
checked={cols[key]}
onChange={() => onChange({ ...cols, [key]: !cols[key] })}
className="accent-[var(--accent)]"
/>
<span style={{ color: "var(--ink)" }}>{OPTIONAL_LABELS[key]}</span>
</label>
</li>
))}
</ul>
<p className="px-2 pt-1.5 pb-1 text-[11px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
成员 / 角色 / 状态 / 积分 / 上次登录 / 操作固定显示
</p>
</div>
)}
</div>
);
}
export default function UsersAdmin({
initial,
initError,
@@ -93,19 +219,29 @@ export default function UsersAdmin({
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initError);
const [actingId, setActingId] = useState<number | null>(null);
// 角色与账号弹窗 / 登录历史弹窗
const [roleTarget, setRoleTarget] = useState<AdminUser | null>(null);
const [logsTarget, setLogsTarget] = useState<AdminUser | null>(null);
// 板块字典(角色弹窗勾选 + 徽章板块名)
const [boards, setBoards] = useState<Board[]>([]);
const [optCols, setOptCols] = useState<Record<OptionalCol, boolean>>(DEFAULT_OPTIONAL);
// 防止快速翻页/输入时旧响应覆盖新结果
const seqRef = useRef(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const urlFirstRun = useRef(true);
const router = useRouter();
useEffect(() => {
setOptCols(loadOptionalCols());
}, []);
const persistCols = (next: Record<OptionalCol, boolean>) => {
setOptCols(next);
try {
localStorage.setItem(COLS_STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore */
}
};
const boardNameMap = useMemo(() => {
const m = new Map<number, string>();
boards.forEach((b) => m.set(b.id, b.name));
@@ -118,8 +254,6 @@ export default function UsersAdmin({
.catch(() => {});
}, []);
// 在线状态实时化:WS hello 全量快照校正当前页绿点,之后 presence:update 增量跳变;
// 后端在线判定(有活跃 WS 连接)比列表接口的 5 分钟 last_seen 启发式更精确
useEffect(() => {
const reconcile = (ids: ReadonlySet<number>) => {
if (ids.size === 0) return;
@@ -162,7 +296,6 @@ export default function UsersAdmin({
}
}, []);
// 搜索框防抖(首次渲染因 keyword===appliedQ 不会触发请求)
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
@@ -177,7 +310,6 @@ export default function UsersAdmin({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyword]);
// 查询状态同步到 URL(首屏由 SSR 给出,跳过首次),刷新/分享可复现当前视图
useEffect(() => {
if (urlFirstRun.current) {
urlFirstRun.current = false;
@@ -191,14 +323,12 @@ export default function UsersAdmin({
router.replace(`/admin/users${qs ? `?${qs}` : ""}`, { scroll: false });
}, [appliedQ, filter, page, router]);
// Enter 立即提交,跳过防抖
const submitSearch = () => {
const q = keyword.trim();
if (q === appliedQ) return;
load({ page: 1, filter, q });
};
// 清除按钮:立即复位查询并重拉,不等待防抖,保证标题与列表同步
const clearSearch = () => {
setKeyword("");
if (appliedQ !== "" || page !== 1) {
@@ -221,12 +351,85 @@ export default function UsersAdmin({
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const tabCount = (f: UserFilter) =>
f === "all" ? summary.total : f === "staff" ? summary.admins : summary.banned;
// 有关键词或非默认筛选时,空态文案与"无成员"区分
const hasFilter = appliedQ !== "" || filter !== "all";
// 角色授权成功 / 封禁成功后的统一回刷
const reload = () => load({ page, filter, q: appliedQ });
const visibleCols = useMemo(() => {
const list: ColKey[] = ["member", "role", "status", "points"];
if (optCols.online) list.push("online");
if (optCols.email) list.push("email");
list.push("last_login");
if (optCols.counts) list.push("counts");
if (optCols.created_at) list.push("created_at");
list.push("actions");
return list;
}, [optCols]);
// Tailwind 无法动态拼 grid-cols;用 style.gridTemplateColumns
const gridStyle = useMemo(() => {
const frags: string[] = [];
for (const c of visibleCols) {
switch (c) {
case "member":
frags.push("minmax(0,1.35fr)");
break;
case "role":
frags.push("minmax(96px,0.9fr)");
break;
case "status":
frags.push("72px");
break;
case "points":
frags.push("72px");
break;
case "online":
frags.push("64px");
break;
case "email":
frags.push("minmax(120px,1fr)");
break;
case "last_login":
frags.push("146px");
break;
case "counts":
frags.push("100px");
break;
case "created_at":
frags.push("100px");
break;
case "actions":
frags.push("136px");
break;
}
}
return { gridTemplateColumns: frags.join(" ") };
}, [visibleCols]);
const colHeader = (c: ColKey): string => {
switch (c) {
case "member":
return "成员";
case "role":
return "角色";
case "status":
return "状态";
case "points":
return "积分";
case "online":
return "在线";
case "email":
return "邮箱";
case "last_login":
return "上次登录";
case "counts":
return "内容";
case "created_at":
return "注册";
case "actions":
return "操作";
}
};
const renderStatus = (u: AdminUser) => {
if (u.banned) {
return (
@@ -240,6 +443,19 @@ export default function UsersAdmin({
</span>
);
}
return (
<span
className="inline-flex items-center gap-1.5 text-[12.5px]"
style={{ color: "var(--ok)" }}
data-tip="账号正常"
>
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: "var(--ok)" }} aria-hidden />
正常
</span>
);
};
const renderOnline = (u: AdminUser) => {
if (u.online) {
return (
<span
@@ -301,7 +517,6 @@ export default function UsersAdmin({
</AdminIconButton>
) : null;
// 站长账号:不可改角色/封禁,保留主页与(站长自己的)内容档案
if (u.role === ROLES.OWNER) {
return (
<div className="flex items-center gap-1 justify-end">
@@ -350,11 +565,66 @@ export default function UsersAdmin({
);
};
// 桌面端列模板(成员列收窄,新增在线状态与上次登录 IP;xl 起展示内容/注册列)
const rowGrid =
"hidden md:grid gap-3 items-center px-4 sm:px-5 py-3.5 " +
"md:grid-cols-[minmax(0,1fr)_78px_minmax(108px,0.95fr)_146px_136px] " +
"xl:grid-cols-[minmax(0,1.25fr)_78px_minmax(108px,0.95fr)_146px_116px_136px]";
const renderCell = (c: ColKey, u: AdminUser, isSelf: boolean) => {
switch (c) {
case "member":
return (
<div className="flex items-center gap-2.5 min-w-0">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={34} />
<div className="min-w-0 leading-tight">
<p className="text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</p>
<p className="text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
</p>
</div>
</div>
);
case "role":
return (
<div className="min-w-0">
<RoleBadge role={u.role} boardIds={u.board_ids} boardNames={boardNameMap} truncate />
</div>
);
case "status":
return renderStatus(u);
case "points":
return (
<span className="text-[13px] tabular-nums" style={{ color: "var(--ink-2)" }}>
{(u.points ?? 0).toLocaleString("zh-CN")}
</span>
);
case "online":
return renderOnline(u);
case "email":
return (
<span className="text-[12.5px] truncate block" style={{ color: u.email ? "var(--ink-2)" : "var(--ink-3)" }}>
{u.email || "—"}
</span>
);
case "last_login":
return <div className="min-w-0">{renderLastLogin(u)}</div>;
case "counts":
return (
<p
className="text-[12.5px] tabular-nums"
style={{ color: "var(--ink-2)" }}
data-tip="仅统计已发布内容;驳回/删除见内容档案"
>
帖 {u.post_count} · 评 {u.comment_count}
</p>
);
case "created_at":
return (
<span className="text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{formatDate(u.created_at)}
</span>
);
case "actions":
return <div className="flex justify-end">{renderActions(u, isSelf)}</div>;
}
};
return (
<div>
@@ -364,7 +634,6 @@ export default function UsersAdmin({
description="检索全部成员;在「角色与账号」中配置管理角色、板块授权与封禁;站长账号受保护"
/>
{/* 汇总:用户运营独有指标(与仪表盘站点总量区分) */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<AdminStatCard
label="当前在线"
@@ -399,9 +668,9 @@ export default function UsersAdmin({
/>
</div>
{/* 搜索 + 筛选 + 用户列表 */}
<div className="panel overflow-hidden">
<div className="admin-toolbar">
<div className="flex flex-col sm:flex-row gap-3 sm:items-center w-full">
<AdminSearchField
value={keyword}
onChange={setKeyword}
@@ -410,6 +679,7 @@ export default function UsersAdmin({
placeholder="搜索用户名、昵称或邮箱…"
ariaLabel="搜索用户"
/>
<div className="flex items-center gap-2 flex-wrap shrink-0">
<AdminSegmented<UserFilter>
role="group"
ariaLabel="用户筛选"
@@ -421,6 +691,9 @@ export default function UsersAdmin({
count: tabCount(t.key),
}))}
/>
<ColumnConfigPopover cols={optCols} onChange={persistCols} />
</div>
</div>
</div>
<div
@@ -445,18 +718,21 @@ export default function UsersAdmin({
</span>
</div>
{/* 桌面表头 */}
{users.length > 0 && (
<div
className={`${rowGrid} text-[11.5px] uppercase tracking-wide`}
style={{ color: "var(--ink-3)", borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
className="hidden md:grid gap-3 items-center px-4 sm:px-5 py-3.5 text-[11.5px] uppercase tracking-wide"
style={{
...gridStyle,
color: "var(--ink-3)",
borderBottom: "1px solid var(--line)",
background: "var(--panel-2)",
}}
>
<span>成员</span>
<span>状态</span>
<span>角色</span>
<span>上次登录 IP</span>
<span className="xl:block hidden">内容 / 注册</span>
<span className="text-right">操作</span>
{visibleCols.map((c) => (
<span key={c} className={c === "actions" ? "text-right" : undefined}>
{colHeader(c)}
</span>
))}
</div>
)}
@@ -474,41 +750,14 @@ export default function UsersAdmin({
const isSelf = u.id === currentUserId;
return (
<div key={u.id} style={{ borderTop: "1px solid var(--line)" }}>
{/* 桌面行 */}
<div className={rowGrid}>
<div className="flex items-center gap-2.5 min-w-0">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={34} />
<div className="min-w-0 leading-tight">
<p className="text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</p>
<p className="text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
{u.email ? ` · ${u.email}` : ""}
</p>
<div className="hidden md:grid gap-3 items-center px-4 sm:px-5 py-3.5" style={gridStyle}>
{visibleCols.map((c) => (
<div key={c} className="min-w-0">
{renderCell(c, u, isSelf)}
</div>
</div>
<div>{renderStatus(u)}</div>
<div className="min-w-0">
<RoleBadge role={u.role} boardIds={u.board_ids} boardNames={boardNameMap} truncate />
</div>
<div className="min-w-0">{renderLastLogin(u)}</div>
<div className="hidden xl:block">
<p
className="text-[12.5px] tabular-nums"
style={{ color: "var(--ink-2)" }}
data-tip="仅统计已发布内容;驳回/删除见内容档案"
>
帖 {u.post_count} · 评 {u.comment_count}
</p>
<p className="text-[11px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{formatDate(u.created_at)}
</p>
</div>
<div className="flex justify-end">{renderActions(u, isSelf)}</div>
))}
</div>
{/* 移动卡片:空间紧,保留 btn-line btn-sm */}
<div className="md:hidden px-4 py-3.5">
<div className="flex items-center gap-3">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={38} />
@@ -531,17 +780,18 @@ export default function UsersAdmin({
</div>
<p className="text-[11.5px] truncate mt-0.5" style={{ color: "var(--ink-3)" }}>
@{u.username}
{u.email ? ` · ${u.email}` : ""}
{optCols.email && u.email ? ` · ${u.email}` : ""}
</p>
</div>
{renderStatus(u)}
</div>
<p
className="mt-2.5 text-[11.5px] tabular-nums"
style={{ color: "var(--ink-3)" }}
data-tip="仅统计已发布内容;驳回/删除见内容档案"
>
帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)}
<p className="mt-2.5 text-[11.5px] tabular-nums flex flex-wrap gap-x-3 gap-y-1" style={{ color: "var(--ink-3)" }}>
<span>积分 {(u.points ?? 0).toLocaleString("zh-CN")}</span>
<span data-tip="仅统计已发布内容">
帖 {u.post_count} · 评 {u.comment_count}
</span>
<span>注册 {formatDate(u.created_at)}</span>
{optCols.online && <span>{u.online ? "在线" : "离线"}</span>}
</p>
<p className="mt-1 text-[11.5px] tabular-nums flex items-center gap-1.5" style={{ color: "var(--ink-3)" }}>
<Clock size={12} className="shrink-0" />
@@ -620,8 +870,6 @@ export default function UsersAdmin({
);
}
// ===== 登录历史弹窗 =====
function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClose: () => void }) {
const [logs, setLogs] = useState<LoginLog[]>([]);
const [total, setTotal] = useState(0);
@@ -675,7 +923,6 @@ function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClos
panelClassName="p-6 max-h-[88vh] flex flex-col"
ariaLabel="登录历史"
footer={
// Modal footer 不好塞 AdminPager,保留原紧凑按钮
total > pageSize ? (
<>
<span className="meta text-[11.5px] tabular-nums mr-auto" style={{ color: "var(--ink-3)" }}>

View File

@@ -1439,6 +1439,264 @@ body:has([data-chat-viewport]) #main {
overflow: hidden;
}
/* ── 管理后台独立壳:全高侧栏 + 顶栏 + 可滚主区(不依赖公开 Header/Footer) ── */
body:has([data-admin-viewport]) {
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
background: var(--bg);
}
.admin-shell {
display: flex;
height: 100dvh;
max-height: 100dvh;
min-height: 0;
overflow: hidden;
background: var(--bg);
}
.admin-rail {
width: 15.5rem;
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 1rem 0.75rem 1.25rem;
background: var(--panel);
border-right: 1px solid var(--line);
}
/* 桌面常驻侧栏;窄屏改用抽屉,避免与 .admin-rail 的 display 冲突 */
.admin-rail-desktop {
display: none;
}
@media (min-width: 1024px) {
.admin-rail-desktop {
display: flex;
}
}
@media (min-width: 1280px) {
.admin-rail {
width: 16.5rem;
}
}
.admin-drawer-panel {
position: relative;
z-index: 1;
display: flex;
width: min(18rem, 86vw);
height: 100%;
box-shadow: var(--shadow-lg);
animation: admin-drawer-in 0.18s ease-out;
}
.admin-rail-brand {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.35rem 0.5rem 1rem;
margin-bottom: 0.35rem;
border-bottom: 1px solid var(--line);
}
.admin-rail-brand-mark {
width: 2.25rem;
height: 2.25rem;
border-radius: 0.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: var(--accent);
color: var(--accent-on);
}
.admin-rail-nav {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.75rem 0 0.25rem;
flex: 1;
}
.admin-rail-group-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-3);
padding: 0 0.65rem 0.4rem;
margin: 0;
}
.admin-rail-link {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.55rem 0.75rem;
border-radius: 0.75rem;
font-size: 13.5px;
font-weight: 500;
color: var(--ink-2);
transition: background-color 0.15s ease, color 0.15s ease;
}
.admin-rail-link:hover {
background: var(--panel-2);
color: var(--ink);
}
.admin-rail-link.is-active {
background: var(--nav-active-bg);
color: var(--nav-active-ink);
}
.admin-rail-link.is-active svg {
color: var(--nav-active-ink);
}
.admin-rail-link:not(.is-active) svg {
color: var(--ink-3);
}
.admin-main-col {
flex: 1 1 0;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.admin-topbar {
height: 3.5rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0 1rem;
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--line);
z-index: 30;
}
@media (min-width: 640px) {
.admin-topbar {
padding: 0 1.5rem;
}
}
.admin-topbar-left,
.admin-topbar-right {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.admin-topbar-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.75rem;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
color: var(--ink-2);
transition: background-color 0.15s ease, color 0.15s ease;
}
.admin-topbar-back:hover {
background: var(--panel);
color: var(--ink);
}
.admin-topbar-user {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: 999px;
padding: 0.2rem 0.55rem 0.2rem 0.2rem;
border: 1px solid var(--line);
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.admin-canvas {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 1.25rem 1rem 2rem;
}
/* 内容管理:桌面禁整页滚动,三栏底部落在视口内 */
@media (min-width: 1024px) {
.admin-canvas:has(.admin-content-page) {
display: flex;
flex-direction: column;
overflow: hidden;
padding-bottom: 1.25rem;
}
}
@media (min-width: 640px) {
.admin-canvas {
padding: 1.5rem 1.5rem 2.5rem;
}
}
@media (min-width: 1024px) {
.admin-canvas {
padding: 1.75rem 2rem 2.75rem;
}
.admin-canvas:has(.admin-content-page) {
padding-bottom: 1.25rem;
}
}
/* 移动端导航抽屉 */
.admin-drawer {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
}
.admin-drawer-backdrop {
position: absolute;
inset: 0;
border: 0;
padding: 0;
cursor: pointer;
background: color-mix(in srgb, var(--ink) 28%, transparent);
}
@keyframes admin-drawer-in {
from {
transform: translateX(-100%);
opacity: 0.85;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.admin-drawer-panel {
animation: none;
}
}
::selection {
background: var(--accent);
color: var(--accent-on);
@@ -1913,20 +2171,244 @@ a.j13-mention-token:hover {
}
}
/* 内容管理工具条:类型 / 状态 / 搜索同一行,避免三行左对齐或等分顶栏留白 */
.admin-content-toolbar {
/* 内容管理页:窄屏可整页滚;桌面高度由上方未分层的 canvas:has 规则锁定 */
.admin-content-page {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
}
@media (min-width: 1024px) {
.admin-content-page {
flex: 1 1 auto;
min-height: 0;
height: 100%;
}
}
.admin-content-page > .mb-5 {
margin-bottom: 0 !important;
flex-shrink: 0;
}
.admin-content-page > .mb-5 h1 {
font-size: 1.25rem;
}
@media (min-width: 640px) {
.admin-content-page > .mb-5 h1 {
font-size: 1.375rem;
}
}
.admin-content-page > .mb-5 .meta {
margin-top: 0.25rem;
}
/* 策略条:横向排布,压缩垂直占用 */
.admin-content-policy {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
padding: 0.75rem 1rem;
gap: 0.65rem 0.85rem;
flex-wrap: wrap;
flex-shrink: 0;
padding: 0.5rem 0.75rem;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel);
}
.admin-content-policy-title {
font-size: 12px;
font-weight: 650;
color: var(--ink-3);
flex-shrink: 0;
}
.admin-content-policy-row {
display: flex;
align-items: center;
gap: 0.35rem 0.5rem;
flex: 1 1 auto;
min-width: 0;
flex-wrap: wrap;
}
.admin-content-policy-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.28rem 0.55rem;
border-radius: 10px;
background: var(--panel-2);
border: 1px solid transparent;
min-height: 2.25rem;
}
.admin-content-policy-chip-label {
font-size: 12.5px;
font-weight: 600;
color: var(--ink);
white-space: nowrap;
}
/* 内容管理:PC 左中右三栏等高;仅列表区滚动 */
.admin-content-triptych {
display: grid;
gap: 0.85rem;
align-items: stretch;
min-height: 18rem;
}
@media (min-width: 1024px) {
.admin-content-triptych {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
flex: 1 1 auto;
min-height: 0;
height: auto;
}
}
.admin-messages-panel {
flex: 1 1 auto;
min-height: 0;
}
/* 私聊监管:方向条与正文同一卡片,无间隙 */
.admin-dm-row {
min-width: 0;
}
.admin-dm-card {
display: flex;
flex-direction: column;
min-width: 0;
border-radius: 0.55rem;
background: var(--panel-2);
border: 1px solid var(--line);
overflow: hidden;
}
.admin-dm-card-head {
padding: 0.4rem 0.65rem;
border-bottom: 1px solid var(--line);
}
.admin-dm-card-body {
padding: 0.55rem 0.7rem 0.65rem;
}
.admin-dm-pair-meta {
display: flex;
align-items: center;
gap: 0.45rem;
flex: 1 1 auto;
min-width: 0;
}
.admin-dm-pair-names {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
flex: 1 1 auto;
}
.admin-dm-person {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
}
.admin-dm-person-name {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 7.5rem;
}
.admin-dm-arrow {
flex-shrink: 0;
font-weight: 500;
color: var(--ink-3);
font-size: 11px;
}
.admin-content-col {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
@media (min-width: 1024px) {
.admin-content-col {
height: 100%;
}
}
.admin-content-col-head,
.admin-content-col-toolbar,
.admin-content-col-pager {
flex-shrink: 0;
}
.admin-content-col-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.admin-content-col-toolbar {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
@media (min-width: 640px) {
.admin-content-toolbar {
padding: 0.75rem 1.25rem;
.admin-content-action {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.28rem 0.55rem;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--panel);
color: var(--ink-2);
font-size: 12px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.admin-content-action:hover:not(:disabled) {
background: var(--panel-2);
color: var(--ink);
border-color: var(--line-2);
}
.admin-content-action:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.admin-content-action.is-danger {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 28%, var(--line));
background: color-mix(in srgb, var(--danger) 6%, var(--panel));
}
.admin-content-action.is-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
}
.admin-content-action.is-ok {
color: var(--ok);
border-color: color-mix(in srgb, var(--ok) 28%, var(--line));
background: color-mix(in srgb, var(--ok) 6%, var(--panel));
}
.admin-content-clamp {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
word-break: break-word;
}
.admin-content-clamp.is-open {
display: block;
-webkit-line-clamp: unset;
}
/* 三栏宽度下状态条横向滚动,避免折行难看 */
.admin-content-col-toolbar .admin-content-status-scroll {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.admin-content-col-toolbar .admin-content-status-scroll > div {
flex-wrap: nowrap !important;
}
.admin-content-col-toolbar .admin-content-status-scroll .admin-seg {
flex-shrink: 0;
}
.admin-content-kind {
display: inline-flex;
@@ -1979,35 +2461,6 @@ a.j13-mention-token:hover {
background: var(--accent-soft);
color: var(--accent);
}
.admin-content-split {
display: none;
width: 1px;
height: 1.5rem;
background: var(--line);
flex-shrink: 0;
}
.admin-content-status {
flex: 1 1 14rem;
min-width: 0;
}
.admin-content-search {
flex: 1 1 12rem;
max-width: 18rem;
min-width: 11rem;
margin-left: auto;
}
@media (min-width: 768px) {
.admin-content-split {
display: block;
}
}
@media (max-width: 767px) {
.admin-content-search {
flex-basis: 100%;
max-width: none;
margin-left: 0;
}
}
.admin-field-head {
display: flex;
@@ -2059,14 +2512,16 @@ a.j13-mention-token:hover {
.admin-search-shell {
display: flex;
align-items: center;
gap: 0.5rem;
height: 2.5rem;
padding: 0 0.75rem;
gap: 0.625rem;
min-height: 2.875rem;
height: 2.875rem;
padding: 0 0.875rem;
}
.admin-search-shell .admin-field-input {
padding: 0;
height: 100%;
font-size: 13px;
font-size: 14px;
line-height: 1.4;
}
.admin-stepper {

View File

@@ -1,14 +1,15 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
import { Plus_Jakarta_Sans, Noto_Sans_SC, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import SiteChrome from "@/components/SiteChrome";
import Toaster from "@/components/Toaster";
import TipHost from "@/components/TipHost";
import AccountGuard from "@/components/AccountGuard";
import ThemeSync from "@/components/ThemeSync";
import RealtimeProvider from "@/components/RealtimeProvider";
import VisitBeacon from "@/components/VisitBeacon";
import PostLinkProvider from "@/components/PostLinkProvider";
import CodeFoldProvider from "@/components/markdown/CodeFoldProvider";
import MotionProvider from "@/components/MotionProvider";
import CommentsPolicyProvider from "@/components/CommentsPolicyProvider";
@@ -65,6 +66,11 @@ export default async function RootLayout({
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const headerStore = await headers();
// middleware 写入 x-pathname;供 SiteChrome SSR 初值(软导航靠客户端 pathname)
const pathname = headerStore.get("x-pathname") || "";
const isAdmin = pathname === "/admin" || pathname.startsWith("/admin/");
// 浅色优先的设计系统,仅在显式选择暗色时挂 .dark
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
@@ -113,35 +119,33 @@ export default async function RootLayout({
<a href="#main" className="skip-link">
跳到主要内容
</a>
<Header
initialUser={user}
initialUnread={unread}
initialChatUnread={chatUnread}
initialTheme={theme}
siteName={settings.site_name}
allowRegister={settings.allow_register}
/>
<main
id="main"
tabIndex={-1}
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 py-7 sm:py-9 outline-none"
>
<MotionProvider initial={motionCfg}>
<CommentsPolicyProvider allowComments={settings.allow_comments !== false}>
<PostLinkProvider openInNewTab={settings.post_link_new_tab !== false}>
<CodeFoldProvider
initialEnabled={settings.code_block_auto_fold}
initialFoldLines={settings.code_block_fold_lines}
>
<SiteChrome
initialIsAdmin={isAdmin}
user={user}
unread={unread}
chatUnread={chatUnread}
theme={theme}
siteName={settings.site_name}
allowRegister={settings.allow_register}
>
{children}
</SiteChrome>
</CodeFoldProvider>
</PostLinkProvider>
</CommentsPolicyProvider>
</MotionProvider>
</main>
<Footer siteName={settings.site_name} />
<Toaster />
<TipHost />
<AccountGuard />
<ThemeSync initialAccent={accent || ""} />
<VisitBeacon />
<RealtimeProvider userId={user ? user.id : null} />
</body>
</html>

View File

@@ -6,8 +6,6 @@ import { useState, useEffect, useRef } from "react";
import {
Menu,
X,
Sun,
Moon,
LogIn,
UserPlus,
LogOut,
@@ -29,6 +27,7 @@ import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import NotificationBell from "./NotificationBell";
import ThemeToggle from "./ThemeToggle";
import Avatar from "./Avatar";
export default function Header({
@@ -49,7 +48,6 @@ export default function Header({
const router = useRouter();
const pathname = usePathname();
const [menuOpen, setMenuOpen] = useState(false);
const [theme, setTheme] = useState<"light" | "dark">(initialTheme);
// 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正
const [user, setUser] = useState<User | null>(initialUser);
const [unread, setUnread] = useState(initialUnread);
@@ -148,11 +146,6 @@ export default function Header({
};
useEffect(() => {
// cookie 为单一真相源,客户端导航后校正(预取载荷可能回滚类名)
const isDark = document.cookie.match(/(?:^|;\s*)j13-theme=dark/);
const savedTheme: "light" | "dark" = isDark ? "dark" : "light";
document.documentElement.classList.toggle("dark", savedTheme === "dark");
setTheme(savedTheme);
setSearchQuery(new URLSearchParams(window.location.search).get("q")?.trim() || "");
}, [pathname]);
@@ -180,13 +173,6 @@ export default function Header({
}
};
const toggleTheme = () => {
const newTheme = theme === "dark" ? "light" : "dark";
setTheme(newTheme);
document.documentElement.classList.toggle("dark", newTheme === "dark");
document.cookie = `j13-theme=${newTheme}; path=/; max-age=31536000`;
};
const handleLogout = async () => {
try {
await apiLogout();
@@ -280,15 +266,11 @@ export default function Header({
</kbd>
</div>
</form>
<button
onClick={toggleTheme}
<ThemeToggle
initialTheme={initialTheme}
className={`${iconBtn} hidden lg:inline-flex`}
style={iconBtnStyle}
aria-label="切换主题"
data-tip={theme === "dark" ? "切换浅色" : "切换深色"}
>
{theme === "dark" ? <Sun size={17} /> : <Moon size={17} />}
</button>
/>
{user && (
<Link
href="/chat"
@@ -407,10 +389,12 @@ export default function Header({
{!user && allowRegister && <MobileLink href="/register">注册</MobileLink>}
</div>
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
<button onClick={toggleTheme} className="btn btn-line btn-sm">
{theme === "dark" ? <Sun size={14} /> : <Moon size={14} />}
{theme === "dark" ? "浅色模式" : "深色模式"}
</button>
<ThemeToggle
initialTheme={initialTheme}
labelMode="labeled"
iconSize={14}
className="btn btn-line btn-sm"
/>
{user && (
<button onClick={handleLogout} className="btn btn-danger btn-sm">
<LogOut size={14} /> 退出

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import type { User } from "@/lib/api";
function pathIsAdmin(pathname: string | null) {
if (!pathname) return false;
return pathname === "/admin" || pathname.startsWith("/admin/");
}
/**
* 公开站 chrome 闸门:/admin 不渲染 Header/Footer/1440 main。
* 首屏用 middleware 的 x-pathname 初值保证 hydration 一致;
* 挂载后同步跟 usePathname(渲染期判定,避免 useEffect 晚一拍闪框)。
*/
export default function SiteChrome({
initialIsAdmin,
user,
unread,
chatUnread,
theme,
siteName,
allowRegister,
children,
}: {
initialIsAdmin: boolean;
user: User | null;
unread: number;
chatUnread: number;
theme: "light" | "dark";
siteName: string;
allowRegister: boolean;
children: React.ReactNode;
}) {
const pathname = usePathname();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// 首屏跟 SSR;之后在渲染期直接读 pathname,软导航无 Header 闪动
const isAdmin = mounted ? pathIsAdmin(pathname) : initialIsAdmin;
if (isAdmin) {
return <>{children}</>;
}
return (
<>
<Header
initialUser={user}
initialUnread={unread}
initialChatUnread={chatUnread}
initialTheme={theme}
siteName={siteName}
allowRegister={allowRegister}
/>
<main
id="main"
tabIndex={-1}
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 py-7 sm:py-9 outline-none"
>
{children}
</main>
<Footer siteName={siteName} />
</>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { Moon, Sun } from "lucide-react";
type Theme = "light" | "dark";
function readThemeCookie(): Theme {
if (typeof document === "undefined") return "light";
return document.cookie.match(/(?:^|;\s*)j13-theme=dark/) ? "dark" : "light";
}
function applyTheme(theme: Theme) {
document.documentElement.classList.toggle("dark", theme === "dark");
document.cookie = `j13-theme=${theme}; path=/; max-age=31536000`;
}
/**
* 明暗主题切换:cookie 为真相源;公开顶栏与后台顶栏共用。
* 首屏严格用 initialTheme,挂载后再按 cookie 校正,避免 hydration 不一致。
*/
export default function ThemeToggle({
initialTheme = "light",
className,
style,
/** icon = 仅图标按钮;labeled = 带「浅色/深色模式」文案(移动抽屉用) */
labelMode = "icon",
iconSize = 17,
}: {
initialTheme?: Theme;
className?: string;
style?: React.CSSProperties;
labelMode?: "icon" | "labeled";
iconSize?: number;
}) {
const pathname = usePathname();
const [theme, setTheme] = useState<Theme>(initialTheme);
const [ready, setReady] = useState(false);
useEffect(() => {
const saved = readThemeCookie();
document.documentElement.classList.toggle("dark", saved === "dark");
setTheme(saved);
setReady(true);
}, [pathname]);
const toggle = () => {
const next: Theme = theme === "dark" ? "light" : "dark";
setTheme(next);
applyTheme(next);
};
// 未挂载完成前始终展示 SSR 初值,保证与服务端 HTML 一致
const shown = ready ? theme : initialTheme;
if (labelMode === "labeled") {
return (
<button type="button" onClick={toggle} className={className} style={style}>
{shown === "dark" ? <Sun size={iconSize} /> : <Moon size={iconSize} />}
{shown === "dark" ? "浅色模式" : "深色模式"}
</button>
);
}
return (
<button
type="button"
onClick={toggle}
className={className}
style={style}
aria-label="切换主题"
data-tip={shown === "dark" ? "切换浅色" : "切换深色"}
suppressHydrationWarning
>
{shown === "dark" ? <Sun size={iconSize} /> : <Moon size={iconSize} />}
</button>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { apiTelemetryPageView } from "@/lib/api";
/**
* 公开站页面访问埋点:同 path 30s 内合并;跳过 /admin。
*/
export default function VisitBeacon() {
const pathname = usePathname();
const lastRef = useRef<{ path: string; at: number }>({ path: "", at: 0 });
useEffect(() => {
if (!pathname || pathname === "/admin" || pathname.startsWith("/admin/")) return;
const now = Date.now();
if (lastRef.current.path === pathname && now - lastRef.current.at < 30_000) return;
lastRef.current = { path: pathname, at: now };
void apiTelemetryPageView();
}, [pathname]);
return null;
}

View File

@@ -3,7 +3,7 @@
import { Search, X } from "lucide-react";
import type { FormEvent } from "react";
/** 左图标、可清除、Enter 提交;壳层聚焦与聊天搜索一致 */
/** 左图标、可清除、Enter 提交;加高壳层便于后台工具条点按 */
export default function AdminSearchField({
value,
onChange,
@@ -30,9 +30,13 @@ export default function AdminSearchField({
};
return (
<form onSubmit={handleSubmit} className="admin-field-shell admin-search-shell relative flex-1 min-w-0" role="search">
<form
onSubmit={handleSubmit}
className="admin-field-shell admin-search-shell relative flex-1 min-w-0"
role="search"
>
<Search
size={15}
size={17}
className="shrink-0 pointer-events-none"
style={{ color: value ? "var(--accent)" : "var(--ink-3)" }}
aria-hidden
@@ -50,10 +54,10 @@ export default function AdminSearchField({
type="button"
onClick={clear}
aria-label="清除搜索"
className="w-6 h-6 inline-flex items-center justify-center rounded-full shrink-0 transition-colors hover:bg-[var(--accent-soft)]"
className="w-7 h-7 inline-flex items-center justify-center rounded-full shrink-0 transition-colors hover:bg-[var(--accent-soft)]"
style={{ color: "var(--ink-3)" }}
>
<X size={14} />
<X size={15} />
</button>
) : null}
</form>

View File

@@ -0,0 +1,493 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import type { LucideIcon } from "lucide-react";
import {
LayoutDashboard,
Users,
Megaphone,
Palette,
ShieldCheck,
Coins,
Globe,
FolderOpen,
Menu,
MessageCircle,
X,
ArrowLeft,
LogOut,
User as UserIcon,
Settings,
ChevronDown,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
import {
canModerateAny,
isAdminOrAbove,
isStaff,
isSuperOrOwner,
type RoleUserLike,
} from "@/lib/roles";
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { onForceLogout, onUserUpdate } from "@/lib/userEvents";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import NotificationBell from "@/components/NotificationBell";
import ThemeToggle from "@/components/ThemeToggle";
interface NavItem {
href: string;
label: string;
icon: LucideIcon;
exact?: boolean;
visible: (u: RoleUserLike) => boolean;
pendingBadge?: boolean;
}
interface NavGroup {
title: string;
items: NavItem[];
}
// 分组导航:概览 → 内容运营 → 成员经济 → 站点配置
const NAV_GROUPS: NavGroup[] = [
{
title: "概览",
items: [
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
],
},
{
title: "内容",
items: [
{
href: "/admin/content",
label: "内容管理",
icon: FolderOpen,
visible: (u) => canModerateAny(u),
pendingBadge: true,
},
{
href: "/admin/messages",
label: "消息管理",
icon: MessageCircle,
visible: (u) => isStaff(u.role),
},
{
href: "/admin/announcements",
label: "公告管理",
icon: Megaphone,
visible: (u) => isAdminOrAbove(u.role),
},
],
},
{
title: "成员与经济",
items: [
{
href: "/admin/users",
label: "用户管理",
icon: Users,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/economy",
label: "积分看板",
icon: Coins,
visible: (u) => isAdminOrAbove(u.role),
},
],
},
{
title: "站点",
items: [
{
href: "/admin/settings",
label: "站点设置",
icon: Globe,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/appearance",
label: "外观",
icon: Palette,
visible: (u) => isSuperOrOwner(u.role),
},
],
},
];
function isActive(pathname: string, href: string, exact?: boolean) {
return exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
}
function usePendingCounts(enabled: boolean) {
const [counts, setCounts] = useState({ posts: 0, comments: 0 });
useEffect(() => {
if (!enabled) return;
let alive = true;
const load = () => {
apiAdminPendingCounts()
.then((c) => {
if (alive) setCounts(c);
})
.catch(() => {});
};
load();
const off = realtime.on(RT_MODERATION_CHANGED, () => load());
const offVisible = onDocumentVisible(load);
return () => {
alive = false;
off();
offVisible();
};
}, [enabled]);
return counts.posts + counts.comments;
}
function PendingBadge({ count }: { count: number }) {
if (count <= 0) return null;
return (
<span
className="ml-auto shrink-0 min-w-[20px] h-5 px-1.5 rounded-full inline-flex items-center justify-center text-[10.5px] font-medium tabular-nums"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
aria-label={`${count} 条待审核`}
>
{count > 99 ? "99+" : count}
</span>
);
}
function visibleGroups(user: RoleUserLike) {
return NAV_GROUPS.map((g) => ({
...g,
items: g.items.filter((i) => i.visible(user)),
})).filter((g) => g.items.length > 0);
}
function flatItems(user: RoleUserLike) {
return visibleGroups(user).flatMap((g) => g.items);
}
function AdminBrand({ siteName }: { siteName: string }) {
return (
<div className="admin-rail-brand">
<span className="admin-rail-brand-mark" aria-hidden>
<ShieldCheck size={18} />
</span>
<span className="leading-tight min-w-0">
<span className="block text-[14.5px] font-extrabold tracking-tight truncate" style={{ color: "var(--ink)" }}>
{siteName}
</span>
<span
className="block text-[10.5px] mt-0.5 tracking-[0.12em] uppercase"
style={{ color: "var(--ink-3)" }}
>
J13 ADMIN
</span>
</span>
</div>
);
}
function AdminRailNav({
user,
onNavigate,
}: {
user: User;
onNavigate?: () => void;
}) {
const pathname = usePathname();
const groups = visibleGroups(user);
const pendingTotal = usePendingCounts(flatItems(user).some((i) => i.pendingBadge));
return (
<nav className="admin-rail-nav" aria-label="后台导航">
{groups.map((group) => (
<div key={group.title} className="admin-rail-group">
<p className="admin-rail-group-title">{group.title}</p>
<div className="flex flex-col gap-0.5">
{group.items.map((item) => {
const active = isActive(pathname, item.href, item.exact);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
onClick={onNavigate}
className={`admin-rail-link${active ? " is-active" : ""}`}
>
<Icon size={16} className="shrink-0" aria-hidden />
{item.label}
{item.pendingBadge && <PendingBadge count={pendingTotal} />}
</Link>
);
})}
</div>
</div>
))}
</nav>
);
}
function AdminUserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const itemCls =
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]";
return (
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="admin-topbar-user"
style={{
borderColor: open ? "var(--accent)" : "var(--line)",
background: open ? "var(--accent-soft)" : "var(--panel)",
}}
>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={28} />
<span className="hidden sm:flex flex-col items-start min-w-0 leading-tight">
<span
className="text-[13px] font-medium max-w-[100px] truncate"
style={{ color: open ? "var(--accent)" : "var(--ink)" }}
>
{user.nickname}
</span>
</span>
<span className="hidden md:inline-flex shrink-0">
<RoleBadge role={user.role} boardIds={user.board_ids} size={10} />
</span>
<ChevronDown
size={14}
className="shrink-0 transition-transform duration-200"
style={{
color: "var(--ink-3)",
transform: open ? "rotate(180deg)" : "none",
}}
/>
</button>
{open && (
<div
role="menu"
className="absolute right-0 mt-2 w-60 overflow-hidden z-50 j13-toast-in"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: "var(--r)",
boxShadow: "var(--shadow-lg)",
}}
>
<div
className="flex items-center gap-3 px-4 py-3.5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={38} />
<div className="min-w-0">
<p className="text-[14px] truncate" style={{ color: "var(--ink)" }}>
{user.nickname}
</p>
<p className="meta text-xs truncate">@{user.username}</p>
</div>
</div>
<div className="p-1.5">
<Link href={`/u/${user.id}`} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页
</Link>
<Link href="/settings" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<Settings size={15} style={{ color: "var(--ink-3)" }} /> 账号设置
</Link>
</div>
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
<button
type="button"
onClick={() => {
setOpen(false);
onLogout();
}}
className={itemCls}
role="menuitem"
style={{ color: "var(--danger)" }}
>
<LogOut size={15} /> 退出登录
</button>
</div>
</div>
)}
</div>
);
}
/**
* 后台独立壳:全高品牌侧栏 + 细顶栏 + 可滚动主区。
* 不依赖公开站 Header/Footer;移动端用抽屉打开同源侧栏。
*/
export default function AdminShell({
user: initialUser,
siteName,
initialTheme = "light",
initialUnread = 0,
children,
}: {
user: User;
siteName: string;
initialTheme?: "light" | "dark";
initialUnread?: number;
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState(initialUser);
const [drawerOpen, setDrawerOpen] = useState(false);
const brand = siteName.trim() || "姜十三论坛";
useEffect(() => {
setUser(initialUser);
}, [initialUser]);
useEffect(() => onUserUpdate((patch) => {
setUser((prev) => ({ ...prev, ...patch }));
}), []);
useEffect(() => onForceLogout(() => {
setDrawerOpen(false);
apiLogout()
.catch(() => {})
.finally(() => {
router.push("/");
router.refresh();
});
}), [router]);
// 路由切换时关闭移动抽屉
useEffect(() => {
setDrawerOpen(false);
}, [pathname]);
// 抽屉打开时锁 body 滚(壳层本身已 overflow:hidden,这里防焦点穿透)
useEffect(() => {
if (!drawerOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setDrawerOpen(false);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [drawerOpen]);
const handleLogout = async () => {
try {
await apiLogout();
} catch {
/* ignore */
}
router.push("/");
router.refresh();
};
const iconBtn =
"w-10 h-10 flex items-center justify-center rounded-full transition-colors";
const iconBtnStyle = {
border: "1px solid var(--line)",
background: "var(--panel)",
color: "var(--ink-2)",
} as const;
const railInner = (
<>
<AdminBrand siteName={brand} />
<AdminRailNav user={user} onNavigate={() => setDrawerOpen(false)} />
</>
);
return (
<div data-admin-viewport className="admin-shell">
{/* 桌面侧栏:宽度断点由 CSS 控制,避免自定义 display 盖掉 Tailwind hidden */}
<aside className="admin-rail admin-rail-desktop" aria-label="管理侧栏">
{railInner}
</aside>
{/* 移动抽屉 */}
{drawerOpen && (
<div className="admin-drawer lg:hidden" role="dialog" aria-modal="true" aria-label="后台导航">
<button
type="button"
className="admin-drawer-backdrop"
aria-label="关闭导航"
onClick={() => setDrawerOpen(false)}
/>
<aside className="admin-rail admin-drawer-panel">
<div className="flex items-center justify-between gap-2 mb-1">
<AdminBrand siteName={brand} />
<button
type="button"
className={iconBtn}
style={iconBtnStyle}
aria-label="关闭导航"
onClick={() => setDrawerOpen(false)}
>
<X size={18} />
</button>
</div>
<AdminRailNav user={user} onNavigate={() => setDrawerOpen(false)} />
</aside>
</div>
)}
<div className="admin-main-col">
<header className="admin-topbar">
<div className="admin-topbar-left">
<button
type="button"
className={`${iconBtn} lg:hidden`}
style={iconBtnStyle}
aria-label="打开导航"
aria-expanded={drawerOpen}
onClick={() => setDrawerOpen(true)}
>
<Menu size={18} />
</button>
<Link href="/" className="admin-topbar-back">
<ArrowLeft size={15} aria-hidden />
返回站点
</Link>
</div>
<div className="admin-topbar-right">
<ThemeToggle initialTheme={initialTheme} className={iconBtn} style={iconBtnStyle} />
<NotificationBell initialUnread={initialUnread} />
<AdminUserMenu user={user} onLogout={handleLogout} />
</div>
</header>
<main id="main" tabIndex={-1} className="admin-canvas outline-none">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,238 @@
"use client";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
const TOOLTIP_STYLE = {
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: 12,
fontSize: 12,
color: "var(--ink)",
};
function shortDate(d: string) {
if (!d || d.length < 10) return d;
return d.slice(5);
}
export function AdminChartCard({
title,
hint,
children,
actions,
}: {
title: string;
hint?: string;
children: React.ReactNode;
actions?: React.ReactNode;
}) {
return (
<div className="panel p-4 sm:p-5 flex flex-col min-h-[260px]">
<div className="flex items-start justify-between gap-2 mb-3">
<div className="min-w-0">
<h3 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
{title}
</h3>
{hint ? (
<p className="meta text-[11.5px] mt-0.5 leading-relaxed">{hint}</p>
) : null}
</div>
{actions}
</div>
<div className="flex-1 min-h-[180px] w-full overflow-x-auto">{children}</div>
</div>
);
}
export function VisitTrendChart({
data,
}: {
data: { date: string; pv: number; uv: number }[];
}) {
const rows = data.map((d) => ({ ...d, label: shortDate(d.date) }));
return (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={rows} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="j13Pv" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--accent)" stopOpacity={0.35} />
<stop offset="100%" stopColor="var(--accent)" stopOpacity={0} />
</linearGradient>
<linearGradient id="j13Uv" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--ok)" stopOpacity={0.3} />
<stop offset="100%" stopColor="var(--ok)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} width={36} allowDecimals={false} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area type="monotone" dataKey="pv" name="PV" stroke="var(--accent)" fill="url(#j13Pv)" strokeWidth={2} />
<Area type="monotone" dataKey="uv" name="UV" stroke="var(--ok)" fill="url(#j13Uv)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
);
}
export function DualLineChart({
data,
aKey,
bKey,
aName,
bName,
aColor = "var(--accent)",
bColor = "var(--gold)",
}: {
data: { date: string; [k: string]: string | number }[];
aKey: string;
bKey: string;
aName: string;
bName: string;
aColor?: string;
bColor?: string;
}) {
const rows = data.map((d) => ({ ...d, label: shortDate(String(d.date)) }));
return (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={rows} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} width={36} allowDecimals={false} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line type="monotone" dataKey={aKey} name={aName} stroke={aColor} strokeWidth={2} dot={false} />
<Line type="monotone" dataKey={bKey} name={bName} stroke={bColor} strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
);
}
export function SingleAreaChart({
data,
dataKey,
name,
color = "var(--accent)",
}: {
data: { date: string; [k: string]: string | number }[];
dataKey: string;
name: string;
color?: string;
}) {
const rows = data.map((d) => ({ ...d, label: shortDate(String(d.date)) }));
return (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={rows} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} width={36} allowDecimals={false} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
<Area type="monotone" dataKey={dataKey} name={name} stroke={color} fill={color} fillOpacity={0.15} strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
);
}
const PIE_COLORS = [
"var(--accent)",
"var(--ok)",
"var(--gold)",
"var(--clay)",
"var(--danger)",
"#8b5cf6",
"#06b6d4",
"#84cc16",
];
export function NamedBarChart({
data,
layout = "vertical",
}: {
data: { label: string; count: number }[];
layout?: "vertical" | "horizontal";
}) {
if (layout === "horizontal") {
return (
<ResponsiveContainer width="100%" height={Math.max(180, data.length * 28)}>
<BarChart data={data} layout="vertical" margin={{ top: 4, right: 12, left: 8, bottom: 4 }}>
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} allowDecimals={false} />
<YAxis type="category" dataKey="label" width={72} tick={{ fill: "var(--ink-2)", fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
<Bar dataKey="count" name="数量" fill="var(--accent)" radius={[0, 6, 6, 0]} />
</BarChart>
</ResponsiveContainer>
);
}
return (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} width={36} allowDecimals={false} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
<Bar dataKey="count" name="数量" fill="var(--accent)" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}
export function NamedPieChart({ data }: { data: { label: string; count: number }[] }) {
const total = data.reduce((s, d) => s + d.count, 0) || 1;
return (
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={data} dataKey="count" nameKey="label" innerRadius={48} outerRadius={72} paddingAngle={2}>
{data.map((_, i) => (
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(value, name) => {
const n = typeof value === "number" ? value : Number(value) || 0;
return [`${n}(${Math.round((n / total) * 100)}%)`, String(name ?? "")];
}}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
</PieChart>
</ResponsiveContainer>
);
}
export function DeltaBarChart({
data,
}: {
data: { date: string; granted: number; spent: number }[];
}) {
const rows = data.map((d) => ({ ...d, label: shortDate(d.date) }));
return (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={rows} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid stroke="var(--line)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="label" tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: "var(--ink-3)", fontSize: 11 }} axisLine={false} tickLine={false} width={40} allowDecimals={false} />
<Tooltip contentStyle={TOOLTIP_STYLE} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="granted" name="发放" fill="var(--ok)" radius={[4, 4, 0, 0]} />
<Bar dataKey="spent" name="消耗" fill="var(--clay)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}

View File

@@ -13,3 +13,4 @@ export { default as AdminStatCard } from "./AdminStatCard";
export { default as AdminIconButton } from "./AdminIconButton";
export { default as AdminStatusChip, contentStatusChip } from "./AdminStatusChip";
export type { AdminStatusTone } from "./AdminStatusChip";
export { default as AdminShell } from "./AdminShell";

View File

@@ -1,6 +1,7 @@
"use client";
import type { AnchorHTMLAttributes } from "react";
import { usePostLinkNewTab } from "@/components/PostLinkProvider";
type MdLinkProps = AnchorHTMLAttributes<HTMLAnchorElement>;
@@ -21,14 +22,15 @@ function isExternalHref(href?: string): boolean {
return /^https?:\/\//i.test(href);
}
/** Markdown 链接:外链新开页 */
/** Markdown 链接:外链是否新开由站点设置 post_link_new_tab 控制 */
export default function MdLink({ href, children, ...rest }: MdLinkProps) {
const openInNewTab = usePostLinkNewTab();
const external = isExternalHref(href);
return (
<a
{...rest}
href={href}
{...(external
{...(external && openInNewTab
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
>

View File

@@ -609,6 +609,8 @@ export interface PublicSettings {
anim_smooth_scroll: boolean;
/** 装饰入场子开关;缺省 true */
anim_chrome: boolean;
/** 帖内 Markdown 外链是否新标签打开;缺省 true */
post_link_new_tab: boolean;
}
const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
@@ -625,6 +627,7 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
anim_code_fold: true,
anim_smooth_scroll: true,
anim_chrome: true,
post_link_new_tab: true,
};
function normalizePublicSettings(data: Partial<PublicSettings> | null | undefined): PublicSettings {
@@ -653,6 +656,7 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
anim_code_fold: data?.anim_code_fold !== false,
anim_smooth_scroll: data?.anim_smooth_scroll !== false,
anim_chrome: data?.anim_chrome !== false,
post_link_new_tab: data?.post_link_new_tab !== false,
};
}
@@ -869,6 +873,133 @@ export interface EconomyStats {
escrowed_points: number;
posts_by_type: Record<string, number>;
recent_ledger: PointLedgerItem[];
checkin_series?: { date: string; count: number }[];
ledger_by_reason?: { key: string; label: string; count: number }[];
delta_series?: { date: string; granted: number; spent: number }[];
net_delta_7d?: number;
}
export interface AnalyticsDayCount {
date: string;
count: number;
}
export interface AnalyticsVisitDay {
date: string;
pv: number;
uv: number;
}
export interface AnalyticsNamedCount {
key: string;
label: string;
count: number;
}
export interface AnalyticsTopViewPost {
id: number;
title: string;
view_count: number;
board_id: number;
}
export interface AnalyticsOverview {
range_days: number;
today_pv: number;
today_uv: number;
visits: AnalyticsVisitDay[];
posts_series: AnalyticsDayCount[];
comments_series: AnalyticsDayCount[];
registers_series: AnalyticsDayCount[];
boards: AnalyticsNamedCount[];
post_types: AnalyticsNamedCount[];
top_viewed: AnalyticsTopViewPost[];
}
export async function fetchAdminAnalytics(
cookieHeader?: string,
range: "7d" | "30d" = "7d"
): Promise<AnalyticsOverview> {
const res = await fetch(
`${API_BASE}/api/admin/analytics/overview?range=${range}`,
ssrInit(cookieHeaders(cookieHeader))
);
if (!res.ok) throw new Error("failed");
return res.json();
}
export async function apiAdminAnalytics(range: "7d" | "30d" = "7d"): Promise<AnalyticsOverview> {
const res = await fetchWithRefresh(`/api/admin/analytics/overview?range=${range}`, {
method: "GET",
headers: clientHeaders(),
});
if (!res.ok) throw new Error("failed");
return res.json();
}
export async function apiTelemetryPageView(): Promise<void> {
try {
await fetchWithRefresh("/api/telemetry/pageview", {
method: "POST",
headers: clientHeaders(),
});
} catch {
/* 埋点失败不影响浏览 */
}
}
export type AdminChatRoomType = "group" | "direct";
export interface AdminChatParticipant {
id: number;
name: string;
avatar: string;
}
export interface AdminChatMessage {
id: number;
room_id: number;
room_name: string;
room_type?: AdminChatRoomType;
is_default: boolean;
sender_id: number;
sender_name: string;
sender_avatar: string;
participants?: AdminChatParticipant[];
content: string;
recalled_at?: string | null;
created_at: string;
}
export async function apiAdminChatMessages(
page = 1,
q = "",
roomType: AdminChatRoomType = "group"
): Promise<{
messages: AdminChatMessage[];
total: number;
page: number;
}> {
const params = new URLSearchParams({
page: String(page),
room_type: roomType,
});
if (q.trim()) params.set("q", q.trim());
const res = await fetchWithRefresh(`/api/admin/chat/messages?${params}`, {
method: "GET",
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取聊天消息失败");
return data;
}
export async function apiAdminRecallChatMessage(roomId: number, mid: number) {
const res = await fetchWithRefresh(`/api/admin/chat/rooms/${roomId}/messages/${mid}`, {
method: "DELETE",
headers: clientHeaders(),
});
return res.json();
}
export async function apiAdminEconomyStats(): Promise<EconomyStats & { error?: string }> {
@@ -1325,6 +1456,7 @@ export interface AdminUser {
role: string; // user | board_admin | admin | super_admin | owner
board_ids: number[]; // 板块管理员的授权板块
banned: boolean;
points?: number;
post_count: number;
comment_count: number;
created_at: string;
@@ -1619,6 +1751,7 @@ export type UpdateSiteSettingsBody = {
anim_code_fold?: boolean;
anim_smooth_scroll?: boolean;
anim_chrome?: boolean;
post_link_new_tab?: boolean;
};
// 更新站点设置(字段可选);accent 传空串恢复默认主题色

View File

@@ -52,6 +52,7 @@ export interface RtSettingsData {
anim_code_fold?: boolean;
anim_smooth_scroll?: boolean;
anim_chrome?: boolean;
post_link_new_tab?: boolean;
}
// 群聊新消息:data 为落库的 ChatMessage(含 sender)

View File

@@ -129,13 +129,20 @@ function parseCookiePair(setCookie: string): [string, string] | null {
return [pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()];
}
/** 透传 pathname 给根布局,用于 /admin 与公开站 chrome 分叉 */
function withPathname(req: NextRequest, init?: { request?: { headers: Headers } }) {
const headers = new Headers(init?.request?.headers ?? req.headers);
headers.set("x-pathname", req.nextUrl.pathname);
return NextResponse.next({ request: { headers } });
}
export async function middleware(req: NextRequest) {
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
const accessToken = req.cookies.get(TOKEN_COOKIE)?.value;
// 游客、access 仍有效、或服务端地址未配置:直接放行
if (!refreshToken || !isAccessTokenExpired(accessToken) || !API_BASE) {
return NextResponse.next();
return withPathname(req);
}
const csrf = req.cookies.get(CSRF_COOKIE)?.value ?? "";
@@ -154,7 +161,7 @@ export async function middleware(req: NextRequest) {
const headers = new Headers(req.headers);
headers.set("Cookie", [...jar].map(([k, v]) => `${k}=${v}`).join("; "));
const res = NextResponse.next({ request: { headers } });
const res = withPathname(req, { request: { headers } });
// 原样透传,HttpOnly/Path/SameSite/Secure 等属性全部以后端为准
for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
return res;
@@ -162,7 +169,7 @@ export async function middleware(req: NextRequest) {
if (setCookies.length > 0) {
// refresh 已失效:透传后端的清 cookie 指令,避免之后每次请求都白轮转
const res = NextResponse.next();
const res = withPathname(req);
for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
return res;
}
@@ -170,7 +177,7 @@ export async function middleware(req: NextRequest) {
// 后端不可达:降级匿名渲染,客户端 fetchWithRefresh 仍可兜底
}
return NextResponse.next();
return withPathname(req);
}
export const config = {

View File

@@ -15,6 +15,7 @@
"react-dom": "^19.0.0",
"react-easy-crop": "^6.2.3",
"react-markdown": "^9.0.1",
"recharts": "^3.10.1",
"rehype-pretty-code": "^0.14.5",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
@@ -800,6 +801,32 @@
"node": ">= 10"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@shikijs/core": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz",
@@ -900,6 +927,18 @@
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
@@ -1192,6 +1231,69 @@
"tailwindcss": "4.3.3"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -1275,6 +1377,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@ungap/structured-clone": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
@@ -1379,6 +1487,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -1395,6 +1512,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1412,6 +1650,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -1483,6 +1727,18 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-toolkit": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types",
"tests/browser-compat"
]
},
"node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
@@ -1505,6 +1761,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -1682,12 +1944,31 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/immer": {
"version": "11.1.18",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
"integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"license": "MIT"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -3149,6 +3430,13 @@
"react-dom": ">=16.4.0"
}
},
"node_modules/react-is": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz",
"integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==",
"license": "MIT",
"peer": true
},
"node_modules/react-markdown": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",
@@ -3176,6 +3464,74 @@
"react": ">=18"
}
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/recharts": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
@@ -3301,6 +3657,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
@@ -3484,6 +3846,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -3618,6 +3986,15 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/use-sync-external-store": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
@@ -3660,6 +4037,28 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",

View File

@@ -16,6 +16,7 @@
"react-dom": "^19.0.0",
"react-easy-crop": "^6.2.3",
"react-markdown": "^9.0.1",
"recharts": "^3.10.1",
"rehype-pretty-code": "^0.14.5",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",