feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -28,6 +28,8 @@ type Handlers struct {
|
||||
AdminUser *service.AdminUserService
|
||||
Moderation *service.ModerationService
|
||||
Chat *service.ChatService
|
||||
Telemetry *service.TelemetryService
|
||||
Analytics *service.AnalyticsService
|
||||
}
|
||||
|
||||
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
56
backend/handler/telemetry.go
Normal file
56
backend/handler/telemetry.go
Normal 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)
|
||||
}
|
||||
@@ -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{},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
224
backend/service/analytics.go
Normal file
224
backend/service/analytics.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
88
backend/service/telemetry.go
Normal file
88
backend/service/telemetry.go
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
244
frontend/app/admin/DashboardClient.tsx
Normal file
244
frontend/app/admin/DashboardClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,6 +54,156 @@ const EMPTY_FORM: FormState = {
|
||||
published: true,
|
||||
};
|
||||
|
||||
/** 粗略去掉 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,
|
||||
}: {
|
||||
form: FormState;
|
||||
setForm: (f: FormState) => void;
|
||||
saving: boolean;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="panel p-5">
|
||||
<p className="font-semibold text-[15px] mb-4" style={{ color: "var(--ink)" }}>
|
||||
{form.id == null ? "新建公告" : "编辑公告"}
|
||||
</p>
|
||||
|
||||
<AdminField
|
||||
id="anno-title"
|
||||
className="mb-4"
|
||||
label="标题"
|
||||
maxLength={200}
|
||||
value={form.title}
|
||||
onChange={(title) => setForm({ ...form, title })}
|
||||
placeholder="请输入公告标题"
|
||||
/>
|
||||
|
||||
<div className="flex items-end gap-4 mb-4 flex-wrap">
|
||||
<div className="w-36">
|
||||
<AdminField
|
||||
id="anno-tag"
|
||||
label="标签"
|
||||
maxLength={32}
|
||||
value={form.tag}
|
||||
onChange={(tag) => setForm({ ...form, tag })}
|
||||
placeholder="公告"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="field-label">标签颜色</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
data-tip={c.label}
|
||||
aria-label={c.label}
|
||||
aria-pressed={form.tag_color === c.value}
|
||||
className={`j13-anno-dot is-${c.value}`}
|
||||
onClick={() => setForm({ ...form, tag_color: c.value })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="field-label">发布状态</p>
|
||||
<div className="mb-4">
|
||||
<AdminSegmented<PublishKey>
|
||||
role="group"
|
||||
ariaLabel="发布状态"
|
||||
value={form.published ? "published" : "draft"}
|
||||
onChange={(key) => setForm({ ...form, published: key === "published" })}
|
||||
options={[
|
||||
{ key: "published", label: "立即发布" },
|
||||
{ key: "draft", label: "存为草稿" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminField
|
||||
id="anno-content"
|
||||
label="正文(支持 Markdown)"
|
||||
as="textarea"
|
||||
minHeight="260px"
|
||||
value={form.content}
|
||||
onChange={(content) => setForm({ ...form, content })}
|
||||
placeholder="请输入公告正文,支持 Markdown 语法…"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2.5 mt-4">
|
||||
<button type="button" onClick={onSave} disabled={saving} className="btn btn-primary flex-1 justify-center">
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 size={15} className="animate-spin" /> 保存中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={15} /> {form.id == null ? "发布公告" : "保存修改"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
className="btn btn-line"
|
||||
aria-label="取消编辑"
|
||||
>
|
||||
<X size={15} /> 取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -62,10 +214,26 @@ export default function AnnouncementAdmin({
|
||||
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();
|
||||
@@ -76,8 +244,13 @@ export default function AnnouncementAdmin({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCreate = () => setForm({ ...EMPTY_FORM });
|
||||
const startEdit = (a: Announcement) =>
|
||||
const startCreate = () => {
|
||||
setForm({ ...EMPTY_FORM });
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
const startEdit = (a: Announcement) => {
|
||||
setSelectedId(a.id);
|
||||
setForm({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
@@ -86,6 +259,12 @@ export default function AnnouncementAdmin({
|
||||
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;
|
||||
@@ -103,13 +282,16 @@ export default function AnnouncementAdmin({
|
||||
setSaving(true);
|
||||
try {
|
||||
if (form.id == null) {
|
||||
await apiAdminCreateAnnouncement(payload);
|
||||
toast("公告已发布", "ok");
|
||||
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);
|
||||
}
|
||||
setForm(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
@@ -122,7 +304,11 @@ export default function AnnouncementAdmin({
|
||||
setDeletingId(a.id);
|
||||
try {
|
||||
await apiAdminDeleteAnnouncement(a.id);
|
||||
setList((l) => l.filter((x) => x.id !== 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) {
|
||||
@@ -140,187 +326,189 @@ export default function AnnouncementAdmin({
|
||||
title="站点公告管理"
|
||||
description="发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色"
|
||||
actions={
|
||||
form?.id == null ? (
|
||||
form?.id == null && form != null ? undefined : (
|
||||
<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 ? "新建公告" : "编辑公告"}
|
||||
</p>
|
||||
{/* 计数条 */}
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||
<div
|
||||
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>
|
||||
|
||||
<AdminField
|
||||
id="anno-title"
|
||||
className="mb-4"
|
||||
label="标题"
|
||||
maxLength={200}
|
||||
value={form.title}
|
||||
onChange={(title) => setForm({ ...form, title })}
|
||||
placeholder="请输入公告标题"
|
||||
/>
|
||||
|
||||
<div className="flex items-end gap-4 mb-4 flex-wrap">
|
||||
<div className="w-36">
|
||||
<AdminField
|
||||
id="anno-tag"
|
||||
label="标签"
|
||||
maxLength={32}
|
||||
value={form.tag}
|
||||
onChange={(tag) => setForm({ ...form, tag })}
|
||||
placeholder="公告"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="field-label">标签颜色</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
data-tip={c.label}
|
||||
aria-label={c.label}
|
||||
aria-pressed={form.tag_color === c.value}
|
||||
className={`j13-anno-dot is-${c.value}`}
|
||||
onClick={() => setForm({ ...form, tag_color: c.value })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="field-label">发布状态</p>
|
||||
<div className="mb-4">
|
||||
<AdminSegmented<PublishKey>
|
||||
role="group"
|
||||
ariaLabel="发布状态"
|
||||
value={form.published ? "published" : "draft"}
|
||||
onChange={(key) => setForm({ ...form, published: key === "published" })}
|
||||
options={[
|
||||
{ key: "published", label: "立即发布" },
|
||||
{ key: "draft", label: "存为草稿" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminField
|
||||
id="anno-content"
|
||||
label="正文(支持 Markdown)"
|
||||
as="textarea"
|
||||
minHeight="260px"
|
||||
value={form.content}
|
||||
onChange={(content) => setForm({ ...form, content })}
|
||||
placeholder="请输入公告正文,支持 Markdown 语法…"
|
||||
/>
|
||||
|
||||
<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">
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 size={15} className="animate-spin" /> 保存中…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={15} /> {form.id == null ? "发布公告" : "保存修改"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm(null)}
|
||||
disabled={saving}
|
||||
className="btn btn-line"
|
||||
aria-label="取消编辑"
|
||||
>
|
||||
<X size={15} /> 取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 公告列表 */}
|
||||
<div className="panel overflow-hidden">
|
||||
<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.5 flex items-center justify-between"
|
||||
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) => (
|
||||
<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)" }}
|
||||
>
|
||||
<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>
|
||||
filtered.map((a, idx) => {
|
||||
const active = selectedId === a.id || form?.id === a.id;
|
||||
const summary = plainSummary(a.content);
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
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-1.5 flex-wrap">
|
||||
<p
|
||||
className="text-[14px] font-medium truncate max-w-full"
|
||||
style={{ color: "var(--ink)" }}
|
||||
data-tip={a.title}
|
||||
>
|
||||
{a.title}
|
||||
</p>
|
||||
<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={14} />
|
||||
</AdminIconButton>
|
||||
) : null}
|
||||
<AdminIconButton tip="编辑" ariaLabel="编辑公告" onClick={() => startEdit(a)}>
|
||||
<Pencil size={14} />
|
||||
</AdminIconButton>
|
||||
<AdminIconButton
|
||||
tip="删除"
|
||||
ariaLabel="删除公告"
|
||||
tone="danger"
|
||||
disabled={deletingId === a.id}
|
||||
onClick={() => setDeleteTarget(a)}
|
||||
>
|
||||
{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>
|
||||
<p
|
||||
className="mt-1 truncate text-[14px]"
|
||||
style={{ color: "var(--ink)" }}
|
||||
data-tip={a.title}
|
||||
>
|
||||
{a.title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{a.published && (
|
||||
<AdminIconButton
|
||||
href={`/announcement/${a.id}`}
|
||||
tip="查看"
|
||||
ariaLabel="查看公告"
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</AdminIconButton>
|
||||
)}
|
||||
<AdminIconButton tip="编辑" ariaLabel="编辑公告" onClick={() => startEdit(a)}>
|
||||
<Pencil size={15} />
|
||||
</AdminIconButton>
|
||||
<AdminIconButton
|
||||
tip="删除"
|
||||
ariaLabel="删除公告"
|
||||
tone="danger"
|
||||
disabled={deletingId === a.id}
|
||||
onClick={() => setDeleteTarget(a)}
|
||||
>
|
||||
{deletingId === a.id ? <Loader2 size={15} className="animate-spin" /> : <Trash2 size={15} />}
|
||||
</AdminIconButton>
|
||||
</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="删除公告"
|
||||
|
||||
@@ -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,305 +163,311 @@ 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">
|
||||
<AdminSection
|
||||
title="预设主题色"
|
||||
description="浅/暗两套色板均已调校,保证文字对比度达标"
|
||||
>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
|
||||
{ACCENT_PRESETS.map((p) => {
|
||||
const active = normalized === p.light;
|
||||
const isDefault = p.light === DEFAULT_ACCENT;
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
onClick={() => setDraft(p.light)}
|
||||
className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors"
|
||||
style={{
|
||||
borderColor: active ? "var(--accent)" : "var(--line)",
|
||||
background: active ? "var(--accent-soft)" : "var(--panel)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
aria-pressed={active}
|
||||
data-tip={isDefault ? `${p.name}(默认)` : p.name}
|
||||
>
|
||||
<span className="relative flex items-center justify-center">
|
||||
<span className="w-9 h-9 rounded-full overflow-hidden grid grid-rows-2 border-2 border-[var(--panel)] shadow-sm">
|
||||
<span style={{ background: p.light }} />
|
||||
<span style={{ background: p.dark }} />
|
||||
</span>
|
||||
{active && (
|
||||
<span
|
||||
className="absolute -bottom-1 -right-1 min-w-[18px] min-h-[18px] w-[18px] h-[18px] rounded-full flex items-center justify-center"
|
||||
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
|
||||
>
|
||||
<Check size={11} strokeWidth={3} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="text-[12px] leading-none"
|
||||
style={{ color: active ? "var(--accent)" : "var(--ink-2)" }}
|
||||
>
|
||||
{p.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AdminSection>
|
||||
|
||||
<AdminSection
|
||||
title={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Pipette size={15} /> 自定义颜色
|
||||
</span>
|
||||
}
|
||||
description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正,保证按钮与文字可读"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label
|
||||
className="relative w-11 h-11 rounded-[var(--r-sm)] overflow-hidden border cursor-pointer shrink-0"
|
||||
style={{ borderColor: "var(--line-2)" }}
|
||||
data-tip="打开取色器"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 w-[200%] h-[200%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0 bg-transparent"
|
||||
value={normalized || DEFAULT_ACCENT}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
aria-label="自定义主题色取色器"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div
|
||||
className={`admin-field-shell px-3 h-11 font-mono text-[14px] w-40${invalid ? " is-invalid" : ""}`}
|
||||
>
|
||||
<span style={{ color: "var(--ink-3)" }}>#</span>
|
||||
<input
|
||||
value={draft.startsWith("#") ? draft.slice(1) : draft}
|
||||
onChange={(e) => setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""))}
|
||||
className="admin-field-input flex-1 min-w-0 px-0 py-0 ml-1 uppercase"
|
||||
maxLength={7}
|
||||
spellCheck={false}
|
||||
aria-label="主题色 HEX 值"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{pair && (
|
||||
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-3.5 h-3.5 rounded-full border"
|
||||
style={{ background: pair.light, borderColor: "var(--line)" }}
|
||||
/>
|
||||
浅色 {pair.light}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-3.5 h-3.5 rounded-full border"
|
||||
style={{ background: pair.dark, borderColor: "var(--line)" }}
|
||||
/>
|
||||
暗色 {pair.dark}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{invalid && (
|
||||
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--danger)" }}>
|
||||
颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)
|
||||
</p>
|
||||
)}
|
||||
{pair?.adjusted && (
|
||||
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--gold)" }}>
|
||||
该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "}
|
||||
{pair.dark})
|
||||
</p>
|
||||
)}
|
||||
{preset && preset.light === DEFAULT_ACCENT && (
|
||||
<p className="mt-2.5 text-[12.5px] meta">当前为内置默认色,保存时会清除自定义配置</p>
|
||||
)}
|
||||
</AdminSection>
|
||||
|
||||
<AdminSection title="实时预览">
|
||||
<div
|
||||
className="rounded-[var(--r-sm)] border p-4 space-y-4"
|
||||
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
|
||||
<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="浅/暗两套色板均已调校,保证文字对比度达标"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<button type="button" className="btn btn-primary btn-sm">
|
||||
<Save size={14} /> 主按钮
|
||||
</button>
|
||||
<button type="button" className="btn btn-line btn-sm">
|
||||
次要按钮
|
||||
</button>
|
||||
<a
|
||||
className="text-[13px]"
|
||||
style={{ color: "var(--accent)" }}
|
||||
href="#preview"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
文字链接示例
|
||||
</a>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
|
||||
{ACCENT_PRESETS.map((p) => {
|
||||
const active = normalized === p.light;
|
||||
const isDefault = p.light === DEFAULT_ACCENT;
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
onClick={() => setDraft(p.light)}
|
||||
className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors"
|
||||
style={{
|
||||
borderColor: active ? "var(--accent)" : "var(--line)",
|
||||
background: active ? "var(--accent-soft)" : "var(--panel)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
aria-pressed={active}
|
||||
data-tip={isDefault ? `${p.name}(默认)` : p.name}
|
||||
>
|
||||
<span className="relative flex items-center justify-center">
|
||||
<span className="w-9 h-9 rounded-full overflow-hidden grid grid-rows-2 border-2 border-[var(--panel)] shadow-sm">
|
||||
<span style={{ background: p.light }} />
|
||||
<span style={{ background: p.dark }} />
|
||||
</span>
|
||||
{active && (
|
||||
<span
|
||||
className="absolute -bottom-1 -right-1 min-w-[18px] min-h-[18px] w-[18px] h-[18px] rounded-full flex items-center justify-center"
|
||||
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
|
||||
>
|
||||
<Check size={11} strokeWidth={3} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="text-[12px] leading-none"
|
||||
style={{ color: active ? "var(--accent)" : "var(--ink-2)" }}
|
||||
>
|
||||
{p.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="admin-seg">未选中</span>
|
||||
<span className="admin-seg" aria-selected="true">
|
||||
选中标签
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-3.5 py-2 text-[13.5px]"
|
||||
style={{ background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }}
|
||||
>
|
||||
导航选中态
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-w-xs">
|
||||
<div className="j13-bcard" aria-current="page">
|
||||
<span
|
||||
className="j13-bcard-ico"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<Palette size={16} />
|
||||
</span>
|
||||
<span className="j13-bcard-name text-[13px] flex-1">选中的板块卡片</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSection>
|
||||
</AdminSection>
|
||||
|
||||
<AdminSettingRow
|
||||
title={
|
||||
<>
|
||||
<Code2 size={15} /> 代码块自动折叠
|
||||
</>
|
||||
}
|
||||
description="开启后,帖子与评论中超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。关闭则始终完整展示。"
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={codeAutoFold}
|
||||
disabled={togglingFold}
|
||||
onChange={() => void toggleCodeFold()}
|
||||
label="代码块自动折叠"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{codeAutoFold ? (
|
||||
<>
|
||||
<label className="field-label" htmlFor="fold-lines">
|
||||
折叠行数阈值
|
||||
<AdminSection
|
||||
title={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Pipette size={15} /> 自定义颜色
|
||||
</span>
|
||||
}
|
||||
description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label
|
||||
className="relative w-11 h-11 rounded-[var(--r-sm)] overflow-hidden border cursor-pointer shrink-0"
|
||||
style={{ borderColor: "var(--line-2)" }}
|
||||
data-tip="打开取色器"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
className="absolute inset-0 w-[200%] h-[200%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0 bg-transparent"
|
||||
value={normalized || DEFAULT_ACCENT}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
aria-label="自定义主题色取色器"
|
||||
/>
|
||||
</label>
|
||||
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">
|
||||
代码块行数超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<AdminStepper
|
||||
id="fold-lines"
|
||||
value={foldLines}
|
||||
onChange={setFoldLines}
|
||||
min={0}
|
||||
max={100}
|
||||
unit="行"
|
||||
ariaLabel="折叠行数阈值"
|
||||
disabled={savingFoldLines}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={savingFoldLines}
|
||||
onClick={() => void saveFoldLines()}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div
|
||||
className={`admin-field-shell px-3 h-11 font-mono text-[14px] w-40${invalid ? " is-invalid" : ""}`}
|
||||
>
|
||||
{savingFoldLines ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Save size={14} />
|
||||
)}
|
||||
保存
|
||||
</button>
|
||||
<span style={{ color: "var(--ink-3)" }}>#</span>
|
||||
<input
|
||||
value={draft.startsWith("#") ? draft.slice(1) : draft}
|
||||
onChange={(e) => setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""))}
|
||||
className="admin-field-input flex-1 min-w-0 px-0 py-0 ml-1 uppercase"
|
||||
maxLength={7}
|
||||
spellCheck={false}
|
||||
aria-label="主题色 HEX 值"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</AdminSettingRow>
|
||||
{pair && (
|
||||
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-3.5 h-3.5 rounded-full border"
|
||||
style={{ background: pair.light, borderColor: "var(--line)" }}
|
||||
/>
|
||||
浅色 {pair.light}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-3.5 h-3.5 rounded-full border"
|
||||
style={{ background: pair.dark, borderColor: "var(--line)" }}
|
||||
/>
|
||||
暗色 {pair.dark}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{invalid && (
|
||||
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--danger)" }}>
|
||||
颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)
|
||||
</p>
|
||||
)}
|
||||
{pair?.adjusted && (
|
||||
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--gold)" }}>
|
||||
该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "}
|
||||
{pair.dark})
|
||||
</p>
|
||||
)}
|
||||
{preset && preset.light === DEFAULT_ACCENT && (
|
||||
<p className="mt-2.5 text-[12.5px] meta">当前为内置默认色,保存时会清除自定义配置</p>
|
||||
)}
|
||||
</AdminSection>
|
||||
|
||||
<AdminSettingRow
|
||||
title={
|
||||
<>
|
||||
<Sparkles size={15} /> 界面动画
|
||||
</>
|
||||
}
|
||||
description="总开关关闭后全站即时切换(等同减少动态效果)。系统若已开启「减少动态效果」,始终优先生效。开启总开关后可细调子项。"
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={uiAnimations}
|
||||
disabled={togglingMotion}
|
||||
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)}
|
||||
label="界面动画总开关"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{uiAnimations ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
代码折叠动画
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
||||
代码块展开/收起的高度过渡与跟滚。
|
||||
</p>
|
||||
</div>
|
||||
<AdminSwitch
|
||||
checked={animCodeFold}
|
||||
disabled={togglingMotion}
|
||||
onChange={() => void toggleMotionField("anim_code_fold", !animCodeFold)}
|
||||
label="代码折叠动画"
|
||||
/>
|
||||
<AdminSection title="实时预览">
|
||||
<div
|
||||
className="rounded-[var(--r-sm)] border p-4 space-y-4"
|
||||
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<button type="button" className="btn btn-primary btn-sm">
|
||||
<Save size={14} /> 主按钮
|
||||
</button>
|
||||
<button type="button" className="btn btn-line btn-sm">
|
||||
次要按钮
|
||||
</button>
|
||||
<a
|
||||
className="text-[13px]"
|
||||
style={{ color: "var(--accent)" }}
|
||||
href="#preview"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
文字链接示例
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
平滑滚动
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
||||
目录跳转、回顶、新内容提示等 scroll 行为。
|
||||
</p>
|
||||
</div>
|
||||
<AdminSwitch
|
||||
checked={animSmoothScroll}
|
||||
disabled={togglingMotion}
|
||||
onChange={() =>
|
||||
void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
|
||||
}
|
||||
label="平滑滚动"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="admin-seg">未选中</span>
|
||||
<span className="admin-seg" aria-selected="true">
|
||||
选中标签
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-3.5 py-2 text-[13.5px]"
|
||||
style={{ background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }}
|
||||
>
|
||||
导航选中态
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
装饰入场
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
||||
列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。
|
||||
</p>
|
||||
<div className="max-w-xs">
|
||||
<div className="j13-bcard" aria-current="page">
|
||||
<span
|
||||
className="j13-bcard-ico"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<Palette size={16} />
|
||||
</span>
|
||||
<span className="j13-bcard-name text-[13px] flex-1">选中的板块卡片</span>
|
||||
</div>
|
||||
<AdminSwitch
|
||||
checked={animChrome}
|
||||
disabled={togglingMotion}
|
||||
onChange={() => void toggleMotionField("anim_chrome", !animChrome)}
|
||||
label="装饰入场"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminSettingRow>
|
||||
</AdminSection>
|
||||
</div>
|
||||
|
||||
{/* 代码折叠 + 动画列 */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<AdminSettingRow
|
||||
title={
|
||||
<>
|
||||
<Code2 size={15} /> 代码块自动折叠
|
||||
</>
|
||||
}
|
||||
description="超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。"
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={codeAutoFold}
|
||||
disabled={togglingFold}
|
||||
onChange={() => void toggleCodeFold()}
|
||||
label="代码块自动折叠"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{codeAutoFold ? (
|
||||
<>
|
||||
<label className="field-label" htmlFor="fold-lines">
|
||||
折叠行数阈值
|
||||
</label>
|
||||
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">
|
||||
超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<AdminStepper
|
||||
id="fold-lines"
|
||||
value={foldLines}
|
||||
onChange={setFoldLines}
|
||||
min={0}
|
||||
max={100}
|
||||
unit="行"
|
||||
ariaLabel="折叠行数阈值"
|
||||
disabled={savingFoldLines}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={savingFoldLines}
|
||||
onClick={() => void saveFoldLines()}
|
||||
>
|
||||
{savingFoldLines ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Save size={14} />
|
||||
)}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</AdminSettingRow>
|
||||
|
||||
<AdminSettingRow
|
||||
title={
|
||||
<>
|
||||
<Sparkles size={15} /> 界面动画
|
||||
</>
|
||||
}
|
||||
description="总开关关闭后全站即时切换。系统「减少动态效果」始终优先。"
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={uiAnimations}
|
||||
disabled={togglingMotion}
|
||||
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)}
|
||||
label="界面动画总开关"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{uiAnimations ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
代码折叠动画
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
||||
代码块展开/收起的高度过渡与跟滚。
|
||||
</p>
|
||||
</div>
|
||||
<AdminSwitch
|
||||
checked={animCodeFold}
|
||||
disabled={togglingMotion}
|
||||
onChange={() => void toggleMotionField("anim_code_fold", !animCodeFold)}
|
||||
label="代码折叠动画"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
平滑滚动
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
||||
目录跳转、回顶、新内容提示等 scroll 行为。
|
||||
</p>
|
||||
</div>
|
||||
<AdminSwitch
|
||||
checked={animSmoothScroll}
|
||||
disabled={togglingMotion}
|
||||
onChange={() =>
|
||||
void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
|
||||
}
|
||||
label="平滑滚动"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
装饰入场
|
||||
</p>
|
||||
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
||||
列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。
|
||||
</p>
|
||||
</div>
|
||||
<AdminSwitch
|
||||
checked={animChrome}
|
||||
disabled={togglingMotion}
|
||||
onChange={() => void toggleMotionField("anim_chrome", !animChrome)}
|
||||
label="装饰入场"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminSettingRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 sticky bottom-5 panel !p-3.5 pl-5 z-10">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 }))
|
||||
.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 }))
|
||||
.catch((e) => ({
|
||||
ok: false as const,
|
||||
error: e instanceof Error ? e.message : "获取内容列表失败",
|
||||
})),
|
||||
fetchAdminContentCounts(cookie || undefined),
|
||||
]);
|
||||
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();
|
||||
// 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 : "获取帖子失败",
|
||||
})),
|
||||
fetchAdminContentComments(1, initialStatus, cookie || undefined)
|
||||
.then((res) => ({ ok: true as const, res }))
|
||||
.catch((e) => ({
|
||||
ok: false as const,
|
||||
error: e instanceof Error ? e.message : "获取评论失败",
|
||||
})),
|
||||
fetchAdminContentCounts(cookie || undefined),
|
||||
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
|
||||
|
||||
170
frontend/app/admin/economy/EconomyClient.tsx
Normal file
170
frontend/app/admin/economy/EconomyClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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,42 +15,51 @@ 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">
|
||||
<span
|
||||
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
|
||||
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
|
||||
>
|
||||
<ShieldAlert size={26} />
|
||||
</span>
|
||||
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
||||
需要管理员权限
|
||||
</h1>
|
||||
<p className="meta mt-2 text-[13px]">此区域仅供站点管理员访问,如有疑问请联系站长。</p>
|
||||
<Link href="/" className="btn btn-primary mt-6">
|
||||
返回首页
|
||||
</Link>
|
||||
<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)" }}
|
||||
>
|
||||
<ShieldAlert size={26} />
|
||||
</span>
|
||||
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
||||
需要管理员权限
|
||||
</h1>
|
||||
<p className="meta mt-2 text-[13px]">此区域仅供站点管理员访问,如有疑问请联系站长。</p>
|
||||
<Link href="/" className="btn btn-primary mt-6">
|
||||
返回首页
|
||||
</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 }}
|
||||
>
|
||||
<AdminSidebar user={me.user} />
|
||||
</aside>
|
||||
<section className="flex-1 min-w-0">{children}</section>
|
||||
</div>
|
||||
</div>
|
||||
<AdminShell
|
||||
user={me.user}
|
||||
siteName={settings.site_name || "姜十三论坛"}
|
||||
initialTheme={theme}
|
||||
initialUnread={unread}
|
||||
>
|
||||
{children}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
543
frontend/app/admin/messages/MessagesAdmin.tsx
Normal file
543
frontend/app/admin/messages/MessagesAdmin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/app/admin/messages/page.tsx
Normal file
22
frontend/app/admin/messages/page.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -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"
|
||||
style={{
|
||||
background: idle ? "var(--tint)" : "var(--ok-soft)",
|
||||
color: idle ? "var(--ink-3)" : "var(--ok)",
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
<ClipboardCheck size={20} />
|
||||
<span
|
||||
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={18} />
|
||||
</span>
|
||||
<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)" }}>
|
||||
待审内容
|
||||
</span>
|
||||
{idle ? (
|
||||
<span className="admin-status is-muted">暂无待审</span>
|
||||
) : (
|
||||
<span className="admin-status is-ok tabular-nums">{fmt(total)} 项</span>
|
||||
)}
|
||||
<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 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 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)" }}>
|
||||
待审内容
|
||||
</p>
|
||||
{idle ? (
|
||||
<span
|
||||
className="admin-status is-muted"
|
||||
>
|
||||
暂无待审
|
||||
</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)" }}>
|
||||
{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)" }}>
|
||||
{fmt(counts.comments)}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight
|
||||
size={18}
|
||||
className="shrink-0 mt-1 transition-transform group-hover:translate-x-0.5"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
/>
|
||||
</div>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className="shrink-0 transition-transform group-hover:translate-x-0.5"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
/>
|
||||
</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)]">
|
||||
<AdminStatCard
|
||||
label="积分看板"
|
||||
value="查看"
|
||||
hint="余额合计、悬赏托管与流水"
|
||||
icon={<Coins size={16} />}
|
||||
color="var(--gold)"
|
||||
soft="var(--gold-soft)"
|
||||
/>
|
||||
</Link>
|
||||
{stats && (
|
||||
<AdminStatCard
|
||||
label="注册成员"
|
||||
value={fmt(stats.users)}
|
||||
hint="全站用户总量"
|
||||
icon={<Users size={16} />}
|
||||
/>
|
||||
)}
|
||||
</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)",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
<AdminStatCard
|
||||
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)"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="在线"
|
||||
value={fmt(stats?.online ?? 0)}
|
||||
hint={`峰值 ${fmt(stats?.peak_online ?? 0)}`}
|
||||
icon={<Activity size={16} />}
|
||||
color="var(--clay)"
|
||||
soft="var(--clay-soft)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canMod && pendingCounts && <PendingStrip counts={pendingCounts} />}
|
||||
|
||||
<DashboardClient
|
||||
initialAnalytics={analytics}
|
||||
newUsers={newUsers}
|
||||
totalUsers={stats?.users}
|
||||
canUsers={canUsers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,84 +199,196 @@ 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">
|
||||
<AdminSection title="基本信息" description="用于页眉品牌、页脚版权与 SEO 元信息">
|
||||
<AdminField
|
||||
id="site-name"
|
||||
className="mb-4"
|
||||
label="站点名称"
|
||||
maxLength={32}
|
||||
value={siteName}
|
||||
onChange={setSiteName}
|
||||
placeholder="姜十三论坛"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<AdminField
|
||||
id="site-desc"
|
||||
className="mb-4"
|
||||
label="站点简介"
|
||||
as="textarea"
|
||||
minHeight="96px"
|
||||
maxLength={160}
|
||||
value={siteDescription}
|
||||
onChange={setSiteDescription}
|
||||
placeholder="一句话介绍社区定位…"
|
||||
/>
|
||||
<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"
|
||||
className="mb-4"
|
||||
label="站点名称"
|
||||
maxLength={32}
|
||||
value={siteName}
|
||||
onChange={setSiteName}
|
||||
placeholder="姜十三论坛"
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => void saveIdentity()}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存基本信息
|
||||
</button>
|
||||
<AdminField
|
||||
id="site-desc"
|
||||
className="mb-4"
|
||||
label="站点简介"
|
||||
as="textarea"
|
||||
minHeight="96px"
|
||||
maxLength={160}
|
||||
value={siteDescription}
|
||||
onChange={setSiteDescription}
|
||||
placeholder="一句话介绍社区定位…"
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => void saveIdentity()}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
保存基本信息
|
||||
</button>
|
||||
</div>
|
||||
</AdminSection>
|
||||
</div>
|
||||
</AdminSection>
|
||||
|
||||
<AdminSettingRow
|
||||
title={
|
||||
<>
|
||||
<UserPlus size={15} /> 开放注册
|
||||
</>
|
||||
}
|
||||
description="关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。"
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={allowRegister}
|
||||
disabled={togglingReg}
|
||||
onChange={() => void toggleRegister()}
|
||||
label="开放注册"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div id="settings-content" className="scroll-mt-4">
|
||||
<AdminSection title="内容与互动" description="控制注册、评论与链接打开方式">
|
||||
<SwitchRow
|
||||
bordered
|
||||
title={
|
||||
<>
|
||||
<UserPlus size={15} /> 开放注册
|
||||
</>
|
||||
}
|
||||
description="关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。"
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={allowRegister}
|
||||
disabled={togglingReg}
|
||||
onChange={() => void toggleRegister()}
|
||||
label="开放注册"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SwitchRow
|
||||
bordered
|
||||
title={
|
||||
<>
|
||||
<MessageSquare size={15} /> 开放评论
|
||||
</>
|
||||
}
|
||||
description="关闭后前台不展示评论入口、评论数量与已有评论,并提示本站不支持评论。"
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={allowComments}
|
||||
disabled={togglingComments}
|
||||
onChange={() => void toggleComments()}
|
||||
label="开放评论"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SwitchRow
|
||||
title={
|
||||
<>
|
||||
<ExternalLink size={15} /> 外链新标签打开
|
||||
</>
|
||||
}
|
||||
description="开启后,帖子与评论中的 Markdown 外链在新标签打开;关闭则当前页跳转。"
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={postLinkNewTab}
|
||||
disabled={togglingLink}
|
||||
onChange={() => void togglePostLinkNewTab()}
|
||||
label="外链新标签打开"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</AdminSection>
|
||||
</div>
|
||||
|
||||
<AdminSettingRow
|
||||
title={
|
||||
<>
|
||||
<MessageSquare size={15} /> 开放评论
|
||||
</>
|
||||
}
|
||||
description="关闭后前台不展示评论入口、评论数量与已有评论,并提示本站不支持评论。适用于备案审核等合规需要。"
|
||||
control={
|
||||
<AdminSwitch
|
||||
checked={allowComments}
|
||||
disabled={togglingComments}
|
||||
onChange={() => void toggleComments()}
|
||||
label="开放评论"
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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,28 +668,32 @@ export default function UsersAdmin({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 搜索 + 筛选 + 用户列表 */}
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="admin-toolbar">
|
||||
<AdminSearchField
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
onSubmit={submitSearch}
|
||||
onClear={clearSearch}
|
||||
placeholder="搜索用户名、昵称或邮箱…"
|
||||
ariaLabel="搜索用户"
|
||||
/>
|
||||
<AdminSegmented<UserFilter>
|
||||
role="group"
|
||||
ariaLabel="用户筛选"
|
||||
value={filter}
|
||||
onChange={changeFilter}
|
||||
options={FILTER_TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
count: tabCount(t.key),
|
||||
}))}
|
||||
/>
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:items-center w-full">
|
||||
<AdminSearchField
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
onSubmit={submitSearch}
|
||||
onClear={clearSearch}
|
||||
placeholder="搜索用户名、昵称或邮箱…"
|
||||
ariaLabel="搜索用户"
|
||||
/>
|
||||
<div className="flex items-center gap-2 flex-wrap shrink-0">
|
||||
<AdminSegmented<UserFilter>
|
||||
role="group"
|
||||
ariaLabel="用户筛选"
|
||||
value={filter}
|
||||
onChange={changeFilter}
|
||||
options={FILTER_TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
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)" }}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}>
|
||||
<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}
|
||||
>
|
||||
{children}
|
||||
<SiteChrome
|
||||
initialIsAdmin={isAdmin}
|
||||
user={user}
|
||||
unread={unread}
|
||||
chatUnread={chatUnread}
|
||||
theme={theme}
|
||||
siteName={settings.site_name}
|
||||
allowRegister={settings.allow_register}
|
||||
>
|
||||
{children}
|
||||
</SiteChrome>
|
||||
</CodeFoldProvider>
|
||||
</CommentsPolicyProvider>
|
||||
</MotionProvider>
|
||||
</main>
|
||||
<Footer siteName={settings.site_name} />
|
||||
</PostLinkProvider>
|
||||
</CommentsPolicyProvider>
|
||||
</MotionProvider>
|
||||
<Toaster />
|
||||
<TipHost />
|
||||
<AccountGuard />
|
||||
<ThemeSync initialAccent={accent || ""} />
|
||||
<VisitBeacon />
|
||||
<RealtimeProvider userId={user ? user.id : null} />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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} /> 退出
|
||||
|
||||
72
frontend/components/SiteChrome.tsx
Normal file
72
frontend/components/SiteChrome.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
79
frontend/components/ThemeToggle.tsx
Normal file
79
frontend/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/components/VisitBeacon.tsx
Normal file
23
frontend/components/VisitBeacon.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
493
frontend/components/admin/AdminShell.tsx
Normal file
493
frontend/components/admin/AdminShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
frontend/components/admin/charts.tsx
Normal file
238
frontend/components/admin/charts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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" }
|
||||
: {})}
|
||||
>
|
||||
|
||||
@@ -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 传空串恢复默认主题色
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
399
frontend/package-lock.json
generated
399
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user