feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -28,6 +28,8 @@ type Handlers struct {
|
|||||||
AdminUser *service.AdminUserService
|
AdminUser *service.AdminUserService
|
||||||
Moderation *service.ModerationService
|
Moderation *service.ModerationService
|
||||||
Chat *service.ChatService
|
Chat *service.ChatService
|
||||||
|
Telemetry *service.TelemetryService
|
||||||
|
Analytics *service.AnalyticsService
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePublishStatus 决定新帖/新评的初始状态:
|
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type updateSettingsRequest struct {
|
|||||||
AnimCodeFold *bool `json:"anim_code_fold"`
|
AnimCodeFold *bool `json:"anim_code_fold"`
|
||||||
AnimSmoothScroll *bool `json:"anim_smooth_scroll"`
|
AnimSmoothScroll *bool `json:"anim_smooth_scroll"`
|
||||||
AnimChrome *bool `json:"anim_chrome"`
|
AnimChrome *bool `json:"anim_chrome"`
|
||||||
|
PostLinkNewTab *bool `json:"post_link_new_tab"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||||
@@ -51,6 +52,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
|||||||
"anim_code_fold": saved.AnimCodeFold,
|
"anim_code_fold": saved.AnimCodeFold,
|
||||||
"anim_smooth_scroll": saved.AnimSmoothScroll,
|
"anim_smooth_scroll": saved.AnimSmoothScroll,
|
||||||
"anim_chrome": saved.AnimChrome,
|
"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.SiteDescription == nil && req.AllowRegister == nil && req.AllowComments == nil &&
|
||||||
req.PostCooldownHours == nil && req.CodeBlockAutoFold == nil && req.CodeBlockFoldLines == nil &&
|
req.PostCooldownHours == nil && req.CodeBlockAutoFold == nil && req.CodeBlockFoldLines == nil &&
|
||||||
req.UIAnimations == nil && req.AnimCodeFold == nil && req.AnimSmoothScroll == 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": "未提供任何可更新的设置项"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "未提供任何可更新的设置项"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -168,6 +170,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
return
|
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()
|
saved, err := h.Setting.Public()
|
||||||
if err != nil {
|
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(
|
if err := db.AutoMigrate(
|
||||||
&User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
|
&User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
|
||||||
&Announcement{}, &SiteSetting{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
&Announcement{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||||
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
||||||
&PostPollVote{}, &PostLotteryEntry{},
|
&PostPollVote{}, &PostLotteryEntry{},
|
||||||
|
|||||||
@@ -400,6 +400,21 @@ type SiteSetting struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
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 (
|
const (
|
||||||
AttachmentKindAvatar = "avatar" // 用户头像(正方形 WebP)
|
AttachmentKindAvatar = "avatar" // 用户头像(正方形 WebP)
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
adminUserSvc := service.NewAdminUserService(model.DB)
|
adminUserSvc := service.NewAdminUserService(model.DB)
|
||||||
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
||||||
chatSvc := service.NewChatService(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 {
|
if err := uploadSvc.EnsureDir(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -82,6 +84,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
AdminUser: adminUserSvc,
|
AdminUser: adminUserSvc,
|
||||||
Moderation: moderationSvc,
|
Moderation: moderationSvc,
|
||||||
Chat: chatSvc,
|
Chat: chatSvc,
|
||||||
|
Telemetry: telemetrySvc,
|
||||||
|
Analytics: analyticsSvc,
|
||||||
}
|
}
|
||||||
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
||||||
notifSvc.OnNotifyNew = func(userID uint) {
|
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", h.AnnouncementsList)
|
||||||
pubAPI.GET("/announcements/:id", h.AnnouncementDetail)
|
pubAPI.GET("/announcements/:id", h.AnnouncementDetail)
|
||||||
pubAPI.GET("/settings", h.PublicSettings)
|
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("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
|
||||||
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
|
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
|
||||||
// refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护)
|
// 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)
|
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 := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
||||||
announceAPI.GET("/announcements", h.AdminListAnnouncements)
|
announceAPI.GET("/announcements", h.AdminListAnnouncements)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type AdminUserItem struct {
|
|||||||
Banned bool `json:"banned"`
|
Banned bool `json:"banned"`
|
||||||
PostCount int64 `json:"post_count"`
|
PostCount int64 `json:"post_count"`
|
||||||
CommentCount int64 `json:"comment_count"`
|
CommentCount int64 `json:"comment_count"`
|
||||||
|
Points int `json:"points"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
LastSeenAt *time.Time `json:"last_seen_at"`
|
LastSeenAt *time.Time `json:"last_seen_at"`
|
||||||
Online bool `json:"online"`
|
Online bool `json:"online"`
|
||||||
@@ -188,6 +189,7 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem {
|
|||||||
Role: string(u.Role),
|
Role: string(u.Role),
|
||||||
BoardIDs: []uint{},
|
BoardIDs: []uint{},
|
||||||
Banned: u.Banned,
|
Banned: u.Banned,
|
||||||
|
Points: u.Points,
|
||||||
CreatedAt: u.CreatedAt,
|
CreatedAt: u.CreatedAt,
|
||||||
LastSeenAt: u.LastSeenAt,
|
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
|
return out, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeltaDay 单日积分发放与消耗
|
||||||
|
type DeltaDay struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Granted int64 `json:"granted"`
|
||||||
|
Spent int64 `json:"spent"`
|
||||||
|
}
|
||||||
|
|
||||||
// PointsEconomyStats 全站积分与类型帖概览(管理端)
|
// PointsEconomyStats 全站积分与类型帖概览(管理端)
|
||||||
type PointsEconomyStats struct {
|
type PointsEconomyStats struct {
|
||||||
TotalBalance int64 `json:"total_balance"` // 用户余额合计
|
TotalBalance int64 `json:"total_balance"`
|
||||||
LedgerCount int64 `json:"ledger_count"` // 流水条数
|
LedgerCount int64 `json:"ledger_count"`
|
||||||
CheckinToday int64 `json:"checkin_today"` // 今日签到人数
|
CheckinToday int64 `json:"checkin_today"`
|
||||||
OpenBounties int64 `json:"open_bounties"` // 未结算悬赏帖
|
OpenBounties int64 `json:"open_bounties"`
|
||||||
EscrowedPoints int64 `json:"escrowed_points"` // 托管中悬赏积分(估算)
|
EscrowedPoints int64 `json:"escrowed_points"`
|
||||||
PostsByType map[string]int64 `json:"posts_by_type"` // 各类型帖数量
|
PostsByType map[string]int64 `json:"posts_by_type"`
|
||||||
RecentLedger []LedgerItem `json:"recent_ledger"` // 最近全局流水(脱敏 note)
|
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 管理端经济看板
|
// AdminEconomyStats 管理端经济看板
|
||||||
func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
|
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").
|
if err := s.db.Model(&model.User{}).Where("deleted_at IS NULL").
|
||||||
Select("COALESCE(SUM(points),0)").Scan(&st.TotalBalance).Error; err != nil {
|
Select("COALESCE(SUM(points),0)").Scan(&st.TotalBalance).Error; err != nil {
|
||||||
return nil, err
|
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 {
|
if err := s.db.Model(&model.PointLedger{}).Count(&st.LedgerCount).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
today := time.Now().Truncate(24 * time.Hour)
|
|
||||||
// 用日期字符串更稳妥(与 checkin 一致用 date)
|
|
||||||
if err := s.db.Model(&model.Checkin{}).
|
if err := s.db.Model(&model.Checkin{}).
|
||||||
Where("checkin_date::date = CURRENT_DATE").
|
Where("checkin_date::date = CURRENT_DATE").
|
||||||
Count(&st.CheckinToday).Error; err != nil {
|
Count(&st.CheckinToday).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = today
|
|
||||||
|
|
||||||
type row struct {
|
type row struct {
|
||||||
PostType string
|
PostType string
|
||||||
@@ -198,7 +211,6 @@ func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
|
|||||||
st.PostsByType[pt] += r.Cnt
|
st.PostsByType[pt] += r.Cnt
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未结算悬赏:type_meta 含 escrowed true 且未 accepted/refunded —— 用简易扫描估算
|
|
||||||
var bountyPosts []model.Post
|
var bountyPosts []model.Post
|
||||||
if err := s.db.Select("id, type_meta").
|
if err := s.db.Select("id, type_meta").
|
||||||
Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty).
|
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,
|
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
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ const (
|
|||||||
SettingKeyAnimSmoothScroll = "anim_smooth_scroll"
|
SettingKeyAnimSmoothScroll = "anim_smooth_scroll"
|
||||||
// SettingKeyAnimChrome 装饰入场(列表/Toast/点赞等);缺行视为开启
|
// SettingKeyAnimChrome 装饰入场(列表/Toast/点赞等);缺行视为开启
|
||||||
SettingKeyAnimChrome = "anim_chrome"
|
SettingKeyAnimChrome = "anim_chrome"
|
||||||
|
// SettingKeyPostLinkNewTab 帖子/评论 Markdown 外链是否新标签打开;缺行视为开启(聊天链接始终新开,不受此键控制)
|
||||||
|
SettingKeyPostLinkNewTab = "post_link_new_tab"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -72,6 +74,7 @@ type PublicSiteSettings struct {
|
|||||||
AnimCodeFold bool `json:"anim_code_fold"`
|
AnimCodeFold bool `json:"anim_code_fold"`
|
||||||
AnimSmoothScroll bool `json:"anim_smooth_scroll"`
|
AnimSmoothScroll bool `json:"anim_smooth_scroll"`
|
||||||
AnimChrome bool `json:"anim_chrome"`
|
AnimChrome bool `json:"anim_chrome"`
|
||||||
|
PostLinkNewTab bool `json:"post_link_new_tab"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingService 站点级键值设置
|
// SettingService 站点级键值设置
|
||||||
@@ -139,6 +142,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
|||||||
AnimCodeFold: true,
|
AnimCodeFold: true,
|
||||||
AnimSmoothScroll: true,
|
AnimSmoothScroll: true,
|
||||||
AnimChrome: true,
|
AnimChrome: true,
|
||||||
|
PostLinkNewTab: true,
|
||||||
}
|
}
|
||||||
accent, err := s.AccentColor()
|
accent, err := s.AccentColor()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -224,6 +228,12 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
out.AnimChrome = animChrome
|
out.AnimChrome = animChrome
|
||||||
|
|
||||||
|
linkNewTab, err := s.PostLinkNewTab()
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.PostLinkNewTab = linkNewTab
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,3 +443,16 @@ func (s *SettingService) AnimChrome() (bool, error) {
|
|||||||
func (s *SettingService) SetAnimChrome(on bool) error {
|
func (s *SettingService) SetAnimChrome(on bool) error {
|
||||||
return s.setBoolDefaultTrue(SettingKeyAnimChrome, on)
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
type AnnouncementColor,
|
type AnnouncementColor,
|
||||||
type AnnouncementInput,
|
type AnnouncementInput,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatRelative } from "@/lib/format";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
AdminIconButton,
|
AdminIconButton,
|
||||||
AdminPageHeader,
|
AdminPageHeader,
|
||||||
AdminSegmented,
|
AdminSegmented,
|
||||||
|
AdminStatusChip,
|
||||||
} from "@/components/admin";
|
} from "@/components/admin";
|
||||||
|
|
||||||
const COLORS: { value: AnnouncementColor; label: string }[] = [
|
const COLORS: { value: AnnouncementColor; label: string }[] = [
|
||||||
@@ -42,6 +43,7 @@ const COLORS: { value: AnnouncementColor; label: string }[] = [
|
|||||||
|
|
||||||
type FormState = AnnouncementInput & { id: number | null };
|
type FormState = AnnouncementInput & { id: number | null };
|
||||||
type PublishKey = "published" | "draft";
|
type PublishKey = "published" | "draft";
|
||||||
|
type StatusFilter = "all" | "published" | "draft";
|
||||||
|
|
||||||
const EMPTY_FORM: FormState = {
|
const EMPTY_FORM: FormState = {
|
||||||
id: null,
|
id: null,
|
||||||
@@ -52,6 +54,156 @@ const EMPTY_FORM: FormState = {
|
|||||||
published: true,
|
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({
|
export default function AnnouncementAdmin({
|
||||||
initial,
|
initial,
|
||||||
initError,
|
initError,
|
||||||
@@ -62,10 +214,26 @@ export default function AnnouncementAdmin({
|
|||||||
const [list, setList] = useState(initial);
|
const [list, setList] = useState(initial);
|
||||||
const [loadError, setLoadError] = useState(initError);
|
const [loadError, setLoadError] = useState(initError);
|
||||||
const [form, setForm] = useState<FormState | null>(null);
|
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 [saving, setSaving] = useState(false);
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Announcement | 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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiAdminListAnnouncements();
|
const res = await apiAdminListAnnouncements();
|
||||||
@@ -76,8 +244,13 @@ export default function AnnouncementAdmin({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startCreate = () => setForm({ ...EMPTY_FORM });
|
const startCreate = () => {
|
||||||
const startEdit = (a: Announcement) =>
|
setForm({ ...EMPTY_FORM });
|
||||||
|
setSelectedId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (a: Announcement) => {
|
||||||
|
setSelectedId(a.id);
|
||||||
setForm({
|
setForm({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
title: a.title,
|
title: a.title,
|
||||||
@@ -86,6 +259,12 @@ export default function AnnouncementAdmin({
|
|||||||
tag_color: a.tag_color,
|
tag_color: a.tag_color,
|
||||||
published: a.published,
|
published: a.published,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectRow = (a: Announcement) => {
|
||||||
|
setSelectedId(a.id);
|
||||||
|
if (form && form.id !== a.id) setForm(null);
|
||||||
|
};
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
@@ -103,13 +282,16 @@ export default function AnnouncementAdmin({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (form.id == null) {
|
if (form.id == null) {
|
||||||
await apiAdminCreateAnnouncement(payload);
|
const res = await apiAdminCreateAnnouncement(payload);
|
||||||
toast("公告已发布", "ok");
|
toast(payload.published ? "公告已发布" : "草稿已保存", "ok");
|
||||||
|
setForm(null);
|
||||||
|
setSelectedId(res.announcement?.id ?? null);
|
||||||
} else {
|
} else {
|
||||||
await apiAdminUpdateAnnouncement(form.id, payload);
|
await apiAdminUpdateAnnouncement(form.id, payload);
|
||||||
toast("公告已更新", "ok");
|
toast("公告已更新", "ok");
|
||||||
|
setForm(null);
|
||||||
|
setSelectedId(form.id);
|
||||||
}
|
}
|
||||||
setForm(null);
|
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : "保存失败");
|
toast(e instanceof Error ? e.message : "保存失败");
|
||||||
@@ -122,7 +304,11 @@ export default function AnnouncementAdmin({
|
|||||||
setDeletingId(a.id);
|
setDeletingId(a.id);
|
||||||
try {
|
try {
|
||||||
await apiAdminDeleteAnnouncement(a.id);
|
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);
|
if (form?.id === a.id) setForm(null);
|
||||||
toast("公告已删除", "ok");
|
toast("公告已删除", "ok");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -140,187 +326,189 @@ export default function AnnouncementAdmin({
|
|||||||
title="站点公告管理"
|
title="站点公告管理"
|
||||||
description="发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色"
|
description="发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色"
|
||||||
actions={
|
actions={
|
||||||
form?.id == null ? (
|
form?.id == null && form != null ? undefined : (
|
||||||
<button type="button" onClick={startCreate} className="btn btn-primary">
|
<button type="button" onClick={startCreate} className="btn btn-primary">
|
||||||
<Plus size={15} /> 新建公告
|
<Plus size={15} /> 新建公告
|
||||||
</button>
|
</button>
|
||||||
) : undefined
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 items-start">
|
{/* 计数条 */}
|
||||||
{/* 编辑/新建表单:仅在新建或编辑时展开,空闲不占位 */}
|
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||||
{form && (
|
<div
|
||||||
<div className="panel p-5">
|
className="inline-flex items-center gap-4 rounded-lg px-3.5 py-2 text-[13px]"
|
||||||
<p className="font-semibold text-[15px] mb-4" style={{ color: "var(--ink)" }}>
|
style={{ background: "var(--panel-2)", color: "var(--ink-2)" }}
|
||||||
{form.id == null ? "新建公告" : "编辑公告"}
|
>
|
||||||
</p>
|
<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
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||||
id="anno-title"
|
{/* 左侧列表 */}
|
||||||
className="mb-4"
|
<div className="panel overflow-hidden min-w-0">
|
||||||
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
|
<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)" }}
|
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>
|
||||||
<span className="meta text-[12px] tabular-nums">共 {list.length} 条</span>
|
<span className="meta text-[12px] tabular-nums">共 {filtered.length} 条</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadError ? (
|
{loadError ? (
|
||||||
<p className="alert-error m-4">{loadError}</p>
|
<p className="alert-error m-4">{loadError}</p>
|
||||||
) : list.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<AdminEmpty
|
<AdminEmpty
|
||||||
icon={<Megaphone size={22} />}
|
icon={<Megaphone size={22} />}
|
||||||
title="还没有公告"
|
title={list.length === 0 ? "还没有公告" : "该状态下暂无公告"}
|
||||||
description="点击「新建公告」发布第一条吧"
|
description={list.length === 0 ? "发布第一条站点公告,会出现在首页右栏" : "试试切换筛选条件"}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
list.map((a) => (
|
filtered.map((a, idx) => {
|
||||||
<div
|
const active = selectedId === a.id || form?.id === a.id;
|
||||||
key={a.id}
|
const summary = plainSummary(a.content);
|
||||||
className="group flex items-center gap-3 px-4 sm:px-5 py-3.5"
|
return (
|
||||||
style={{ borderTop: "1px solid var(--line)" }}
|
<div
|
||||||
>
|
key={a.id}
|
||||||
<div className="min-w-0 flex-1">
|
role="button"
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
tabIndex={0}
|
||||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
onClick={() => selectRow(a)}
|
||||||
{!a.published && (
|
onKeyDown={(e) => {
|
||||||
<span
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
className="inline-flex items-center rounded-full px-2 py-px text-[10.5px] leading-[1.55]"
|
e.preventDefault();
|
||||||
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
|
selectRow(a);
|
||||||
>
|
}
|
||||||
草稿
|
}}
|
||||||
</span>
|
className="px-4 sm:px-5 py-3.5 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||||
)}
|
style={{
|
||||||
<span className="meta text-[11px] tabular-nums">{formatDate(a.created_at)}</span>
|
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>
|
</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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 移动端编辑时:若只在右侧显示表单,列表仍在上方可见,符合双栏降级 */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
title="删除公告"
|
title="删除公告"
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import {
|
|||||||
export default function AppearanceAdmin({ initial }: { initial: PublicSettings }) {
|
export default function AppearanceAdmin({ initial }: { initial: PublicSettings }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const initialAccent = initial.accent || "";
|
const initialAccent = initial.accent || "";
|
||||||
// 已保存值:空串=内置默认(靛蓝)
|
|
||||||
const [savedHex, setSavedHex] = useState(initialAccent);
|
const [savedHex, setSavedHex] = useState(initialAccent);
|
||||||
const [draft, setDraft] = useState(initialAccent || DEFAULT_ACCENT);
|
const [draft, setDraft] = useState(initialAccent || DEFAULT_ACCENT);
|
||||||
const [codeAutoFold, setCodeAutoFold] = useState(initial.code_block_auto_fold !== false);
|
const [codeAutoFold, setCodeAutoFold] = useState(initial.code_block_auto_fold !== false);
|
||||||
@@ -62,12 +61,10 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
[normalized]
|
[normalized]
|
||||||
);
|
);
|
||||||
const preset = useMemo(() => (normalized ? findPreset(normalized) : null), [normalized]);
|
const preset = useMemo(() => (normalized ? findPreset(normalized) : null), [normalized]);
|
||||||
// 保存提交值:默认靛蓝落库为空串(前端不注入覆盖,保持内置令牌单一来源)
|
|
||||||
const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : "";
|
const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : "";
|
||||||
const dirty = (savedHex || DEFAULT_ACCENT) !== (normalized || "");
|
const dirty = (savedHex || DEFAULT_ACCENT) !== (normalized || "");
|
||||||
const invalid = draft.trim() !== "" && !normalized;
|
const invalid = draft.trim() !== "" && !normalized;
|
||||||
|
|
||||||
// 实时预览:合法颜色即应用;离开页面时还原为已保存值,避免未保存预览残留
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (normalized) applyAccentTheme(normalized);
|
if (normalized) applyAccentTheme(normalized);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -166,305 +163,311 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl">
|
<div>
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
icon={Palette}
|
icon={Palette}
|
||||||
title="外观设置"
|
title="外观设置"
|
||||||
description="配置站点主题色、代码块折叠与界面动画"
|
description="配置站点主题色、代码块折叠与界面动画"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 mb-6">
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 mb-6">
|
||||||
<AdminSection
|
{/* 主题色列 */}
|
||||||
title="预设主题色"
|
<div className="flex flex-col gap-4 xl:col-span-2">
|
||||||
description="浅/暗两套色板均已调校,保证文字对比度达标"
|
<AdminSection
|
||||||
>
|
title="预设主题色"
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
|
description="浅/暗两套色板均已调校,保证文字对比度达标"
|
||||||
{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="flex flex-wrap items-center gap-2.5">
|
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
|
||||||
<button type="button" className="btn btn-primary btn-sm">
|
{ACCENT_PRESETS.map((p) => {
|
||||||
<Save size={14} /> 主按钮
|
const active = normalized === p.light;
|
||||||
</button>
|
const isDefault = p.light === DEFAULT_ACCENT;
|
||||||
<button type="button" className="btn btn-line btn-sm">
|
return (
|
||||||
次要按钮
|
<button
|
||||||
</button>
|
key={p.key}
|
||||||
<a
|
type="button"
|
||||||
className="text-[13px]"
|
onClick={() => setDraft(p.light)}
|
||||||
style={{ color: "var(--accent)" }}
|
className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors"
|
||||||
href="#preview"
|
style={{
|
||||||
onClick={(e) => e.preventDefault()}
|
borderColor: active ? "var(--accent)" : "var(--line)",
|
||||||
>
|
background: active ? "var(--accent-soft)" : "var(--panel)",
|
||||||
文字链接示例
|
cursor: "pointer",
|
||||||
</a>
|
}}
|
||||||
|
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>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
</AdminSection>
|
||||||
<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>
|
|
||||||
|
|
||||||
<AdminSettingRow
|
<AdminSection
|
||||||
title={
|
title={
|
||||||
<>
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Code2 size={15} /> 代码块自动折叠
|
<Pipette size={15} /> 自定义颜色
|
||||||
</>
|
</span>
|
||||||
}
|
}
|
||||||
description="开启后,帖子与评论中超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。关闭则始终完整展示。"
|
description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正"
|
||||||
control={
|
>
|
||||||
<AdminSwitch
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
checked={codeAutoFold}
|
<label
|
||||||
disabled={togglingFold}
|
className="relative w-11 h-11 rounded-[var(--r-sm)] overflow-hidden border cursor-pointer shrink-0"
|
||||||
onChange={() => void toggleCodeFold()}
|
style={{ borderColor: "var(--line-2)" }}
|
||||||
label="代码块自动折叠"
|
data-tip="打开取色器"
|
||||||
/>
|
>
|
||||||
}
|
<input
|
||||||
>
|
type="color"
|
||||||
{codeAutoFold ? (
|
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}
|
||||||
<label className="field-label" htmlFor="fold-lines">
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
折叠行数阈值
|
aria-label="自定义主题色取色器"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">
|
<div className="flex flex-col gap-1">
|
||||||
代码块行数超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。
|
<div
|
||||||
</p>
|
className={`admin-field-shell px-3 h-11 font-mono text-[14px] w-40${invalid ? " is-invalid" : ""}`}
|
||||||
<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 ? (
|
<span style={{ color: "var(--ink-3)" }}>#</span>
|
||||||
<Loader2 size={14} className="animate-spin" />
|
<input
|
||||||
) : (
|
value={draft.startsWith("#") ? draft.slice(1) : draft}
|
||||||
<Save size={14} />
|
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}
|
||||||
</button>
|
spellCheck={false}
|
||||||
|
aria-label="主题色 HEX 值"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
{pair && (
|
||||||
) : null}
|
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||||
</AdminSettingRow>
|
<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
|
<AdminSection title="实时预览">
|
||||||
title={
|
<div
|
||||||
<>
|
className="rounded-[var(--r-sm)] border p-4 space-y-4"
|
||||||
<Sparkles size={15} /> 界面动画
|
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
|
||||||
</>
|
>
|
||||||
}
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
description="总开关关闭后全站即时切换(等同减少动态效果)。系统若已开启「减少动态效果」,始终优先生效。开启总开关后可细调子项。"
|
<button type="button" className="btn btn-primary btn-sm">
|
||||||
control={
|
<Save size={14} /> 主按钮
|
||||||
<AdminSwitch
|
</button>
|
||||||
checked={uiAnimations}
|
<button type="button" className="btn btn-line btn-sm">
|
||||||
disabled={togglingMotion}
|
次要按钮
|
||||||
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)}
|
</button>
|
||||||
label="界面动画总开关"
|
<a
|
||||||
/>
|
className="text-[13px]"
|
||||||
}
|
style={{ color: "var(--accent)" }}
|
||||||
>
|
href="#preview"
|
||||||
{uiAnimations ? (
|
onClick={(e) => e.preventDefault()}
|
||||||
<div className="space-y-4">
|
>
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
文字链接示例
|
||||||
<div className="min-w-0 flex-1">
|
</a>
|
||||||
<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>
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<span className="admin-seg">未选中</span>
|
||||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
<span className="admin-seg" aria-selected="true">
|
||||||
平滑滚动
|
选中标签
|
||||||
</p>
|
</span>
|
||||||
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
<span
|
||||||
目录跳转、回顶、新内容提示等 scroll 行为。
|
className="inline-flex items-center rounded-full px-3.5 py-2 text-[13.5px]"
|
||||||
</p>
|
style={{ background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }}
|
||||||
</div>
|
>
|
||||||
<AdminSwitch
|
导航选中态
|
||||||
checked={animSmoothScroll}
|
</span>
|
||||||
disabled={togglingMotion}
|
|
||||||
onChange={() =>
|
|
||||||
void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
|
|
||||||
}
|
|
||||||
label="平滑滚动"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="max-w-xs">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="j13-bcard" aria-current="page">
|
||||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
<span
|
||||||
装饰入场
|
className="j13-bcard-ico"
|
||||||
</p>
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
>
|
||||||
列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。
|
<Palette size={16} />
|
||||||
</p>
|
</span>
|
||||||
|
<span className="j13-bcard-name text-[13px] flex-1">选中的板块卡片</span>
|
||||||
</div>
|
</div>
|
||||||
<AdminSwitch
|
|
||||||
checked={animChrome}
|
|
||||||
disabled={togglingMotion}
|
|
||||||
onChange={() => void toggleMotionField("anim_chrome", !animChrome)}
|
|
||||||
label="装饰入场"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</AdminSection>
|
||||||
</AdminSettingRow>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3 sticky bottom-5 panel !p-3.5 pl-5 z-10">
|
<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 {
|
function parseKind(v?: string): ContentKind {
|
||||||
return v === "comments" ? "comments" : "posts";
|
if (v === "comments") return "comments";
|
||||||
|
return "posts";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContentPageProps {
|
interface ContentPageProps {
|
||||||
@@ -48,35 +49,37 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp
|
|||||||
let initialComments: AdminContentComment[] = [];
|
let initialComments: AdminContentComment[] = [];
|
||||||
let initialCommentsTotal = 0;
|
let initialCommentsTotal = 0;
|
||||||
let initError = "";
|
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 (
|
return (
|
||||||
<ContentAdmin
|
<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 type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { Coins, Flame, Gift, Layers } from "lucide-react";
|
|
||||||
import { fetchAdminEconomyStats } from "@/lib/api";
|
import { fetchAdminEconomyStats } from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
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 Forbidden from "../Forbidden";
|
||||||
|
import EconomyClient from "./EconomyClient";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "积分看板",
|
title: "积分看板",
|
||||||
};
|
};
|
||||||
|
|
||||||
const fmt = (n: number) => n.toLocaleString("zh-CN");
|
|
||||||
|
|
||||||
export default async function AdminEconomyPage() {
|
export default async function AdminEconomyPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
@@ -40,121 +33,5 @@ export default async function AdminEconomyPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeEntries = Object.entries(stats.posts_by_type || {}).sort(
|
return <EconomyClient stats={stats} />;
|
||||||
(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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import type { Metadata } from "next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { ShieldAlert } from "lucide-react";
|
import { ShieldAlert } from "lucide-react";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader, TOKEN_COOKIE } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { isStaff } from "@/lib/roles";
|
import { isStaff } from "@/lib/roles";
|
||||||
import { AdminSidebar, AdminNavMobile } from "./AdminNav";
|
import AdminShell from "@/components/admin/AdminShell";
|
||||||
|
|
||||||
// 整个 /admin 树不进入索引(子页面无需重复声明)
|
// 整个 /admin 树不进入索引(子页面无需重复声明)
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -15,42 +15,51 @@ export const metadata: Metadata = {
|
|||||||
// 权限以 Go 端 RequireStaff 为唯一判定;这里仅决定管理界面是否直出,
|
// 权限以 Go 端 RequireStaff 为唯一判定;这里仅决定管理界面是否直出,
|
||||||
// 非管理团队访问任意 /admin/* 时 SSR 统一给出无权限提示(所有写接口仍由后端 403 兜底)。
|
// 非管理团队访问任意 /admin/* 时 SSR 统一给出无权限提示(所有写接口仍由后端 403 兜底)。
|
||||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
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 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)) {
|
if (!me.user || !isStaff(me.user.role)) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto panel p-8 text-center mt-6 lg:mt-10">
|
<div
|
||||||
<span
|
data-admin-viewport
|
||||||
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
|
id="main"
|
||||||
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
|
tabIndex={-1}
|
||||||
>
|
className="min-h-screen flex items-center justify-center px-4 outline-none"
|
||||||
<ShieldAlert size={26} />
|
style={{ background: "var(--bg)" }}
|
||||||
</span>
|
>
|
||||||
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
<div className="max-w-md w-full panel p-8 text-center">
|
||||||
需要管理员权限
|
<span
|
||||||
</h1>
|
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
|
||||||
<p className="meta mt-2 text-[13px]">此区域仅供站点管理员访问,如有疑问请联系站长。</p>
|
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
|
||||||
<Link href="/" className="btn btn-primary mt-6">
|
>
|
||||||
返回首页
|
<ShieldAlert size={26} />
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 未读仅在有 access cookie 时才可信;壳层铃铛用 SSR 直出避免挂载后才出红点
|
||||||
|
const unread = cookieStore.get(TOKEN_COOKIE)?.value ? me.unread_count : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AdminShell
|
||||||
<AdminNavMobile user={me.user} />
|
user={me.user}
|
||||||
<div className="flex items-start gap-6 xl:gap-7">
|
siteName={settings.site_name || "姜十三论坛"}
|
||||||
{/* sticky 必须钉在 aside 自身:内层与侧栏同高时等于没钉住,会跟着主栏一起滚 */}
|
initialTheme={theme}
|
||||||
<aside
|
initialUnread={unread}
|
||||||
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 }}
|
{children}
|
||||||
>
|
</AdminShell>
|
||||||
<AdminSidebar user={me.user} />
|
|
||||||
</aside>
|
|
||||||
<section className="flex-1 min-w-0">{children}</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 Link from "next/link";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import {
|
import {
|
||||||
UserPlus,
|
|
||||||
LayoutGrid,
|
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
MessageSquareText,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
Users,
|
MessageSquareText,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Coins,
|
Coins,
|
||||||
|
Users,
|
||||||
|
Activity,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
fetchOverview,
|
fetchOverview,
|
||||||
fetchAdminPendingCounts,
|
fetchAdminPendingCounts,
|
||||||
|
fetchAdminAnalytics,
|
||||||
type PendingCounts,
|
type PendingCounts,
|
||||||
|
type AnalyticsOverview,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { canModerateAny, isSuperOrOwner, isAdminOrAbove } from "@/lib/roles";
|
import { canModerateAny, isSuperOrOwner, isAdminOrAbove } from "@/lib/roles";
|
||||||
import Avatar from "@/components/Avatar";
|
|
||||||
import { AdminPageHeader, AdminStatCard } from "@/components/admin";
|
import { AdminPageHeader, AdminStatCard } from "@/components/admin";
|
||||||
import { formatRelative } from "@/lib/format";
|
import DashboardClient from "./DashboardClient";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "管理仪表盘",
|
title: "管理仪表盘",
|
||||||
@@ -38,93 +39,76 @@ function greeting(hour: number) {
|
|||||||
return "晚上好";
|
return "晚上好";
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelHead({ title, extra }: { title: string; extra?: string }) {
|
/** 紧凑待审条:跳转内容管理待审筛选 */
|
||||||
return (
|
function PendingStrip({ counts }: { counts: PendingCounts }) {
|
||||||
<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 }) {
|
|
||||||
const total = counts.posts + counts.comments;
|
const total = counts.posts + counts.comments;
|
||||||
const idle = total === 0;
|
const idle = total === 0;
|
||||||
|
const href =
|
||||||
|
counts.posts > 0 || counts.comments === 0
|
||||||
|
? "/admin/content?status=pending"
|
||||||
|
: "/admin/content?status=pending&kind=comments";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={href}
|
||||||
counts.posts > 0 || counts.comments === 0
|
className="panel hover-lift mb-4 px-4 sm:px-5 py-3 flex items-center gap-3 group"
|
||||||
? "/admin/content?status=pending"
|
|
||||||
: "/admin/content?status=pending&kind=comments"
|
|
||||||
}
|
|
||||||
className="panel hover-lift relative block p-4 sm:p-5 mb-4 group"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3.5">
|
<span
|
||||||
<span
|
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
|
||||||
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0"
|
style={{
|
||||||
style={{
|
background: idle ? "var(--tint)" : "var(--ok-soft)",
|
||||||
background: idle ? "var(--tint)" : "var(--ok-soft)",
|
color: idle ? "var(--ink-3)" : "var(--ok)",
|
||||||
color: idle ? "var(--ink-3)" : "var(--ok)",
|
}}
|
||||||
}}
|
aria-hidden
|
||||||
aria-hidden
|
>
|
||||||
>
|
<ClipboardCheck size={18} />
|
||||||
<ClipboardCheck size={20} />
|
</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>
|
</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>
|
</div>
|
||||||
|
<ChevronRight
|
||||||
|
size={16}
|
||||||
|
className="shrink-0 transition-transform group-hover:translate-x-0.5"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
/>
|
||||||
</Link>
|
</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() {
|
export default async function AdminDashboardPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
@@ -132,11 +116,12 @@ export default async function AdminDashboardPage() {
|
|||||||
const canUsers = isSuperOrOwner(me.user?.role);
|
const canUsers = isSuperOrOwner(me.user?.role);
|
||||||
const canEconomy = isAdminOrAbove(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),
|
fetchOverview(cookie).catch(() => null),
|
||||||
canMod
|
canMod
|
||||||
? fetchAdminPendingCounts(cookie).catch(() => ({ posts: 0, comments: 0 }))
|
? fetchAdminPendingCounts(cookie).catch(() => ({ posts: 0, comments: 0 }))
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
|
fetchAdminAnalytics(cookie, "7d").catch(() => emptyAnalytics()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -148,10 +133,7 @@ export default async function AdminDashboardPage() {
|
|||||||
}).format(now);
|
}).format(now);
|
||||||
|
|
||||||
const stats = overview?.stats;
|
const stats = overview?.stats;
|
||||||
const boards = overview?.boards ?? [];
|
|
||||||
const newUsers = overview?.new_users ?? [];
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -159,138 +141,58 @@ export default async function AdminDashboardPage() {
|
|||||||
icon={LayoutDashboard}
|
icon={LayoutDashboard}
|
||||||
title={`${greeting(now.getHours())},${me.user?.nickname ?? ""}`}
|
title={`${greeting(now.getHours())},${me.user?.nickname ?? ""}`}
|
||||||
description={`${dateLabel} · 站点管理`}
|
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} />}
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||||
|
<AdminStatCard
|
||||||
{canEconomy && (
|
label="今日 PV"
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
|
value={fmt(analytics.today_pv)}
|
||||||
<Link href="/admin/economy" className="block hover-lift rounded-[var(--r)]">
|
hint={`独立访客 UV ${fmt(analytics.today_uv)}`}
|
||||||
<AdminStatCard
|
icon={<Eye size={16} />}
|
||||||
label="积分看板"
|
/>
|
||||||
value="查看"
|
<AdminStatCard
|
||||||
hint="余额合计、悬赏托管与流水"
|
label="今日 UV"
|
||||||
icon={<Coins size={16} />}
|
value={fmt(analytics.today_uv)}
|
||||||
color="var(--gold)"
|
hint="独立访客"
|
||||||
soft="var(--gold-soft)"
|
icon={<Users size={16} />}
|
||||||
/>
|
color="var(--ok)"
|
||||||
</Link>
|
soft="var(--ok-soft)"
|
||||||
{stats && (
|
/>
|
||||||
<AdminStatCard
|
<AdminStatCard
|
||||||
label="注册成员"
|
label="今日内容"
|
||||||
value={fmt(stats.users)}
|
value={fmt(stats?.today_posts ?? 0)}
|
||||||
hint="全站用户总量"
|
sub="帖"
|
||||||
icon={<Users size={16} />}
|
hint={`评论 ${fmt(stats?.today_comments ?? 0)} · 注册 ${fmt(stats?.today_users ?? 0)}`}
|
||||||
/>
|
icon={<FileText size={16} />}
|
||||||
)}
|
color="var(--gold)"
|
||||||
</div>
|
soft="var(--gold-soft)"
|
||||||
)}
|
/>
|
||||||
|
<AdminStatCard
|
||||||
<div className="grid lg:grid-cols-2 gap-4 items-start">
|
label="在线"
|
||||||
<div className="panel overflow-hidden">
|
value={fmt(stats?.online ?? 0)}
|
||||||
<PanelHead
|
hint={`峰值 ${fmt(stats?.peak_online ?? 0)}`}
|
||||||
title="最新注册成员"
|
icon={<Activity size={16} />}
|
||||||
extra={stats ? `共 ${fmt(stats.users)} 人` : undefined}
|
color="var(--clay)"
|
||||||
/>
|
soft="var(--clay-soft)"
|
||||||
{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>
|
</div>
|
||||||
|
|
||||||
|
{canMod && pendingCounts && <PendingStrip counts={pendingCounts} />}
|
||||||
|
|
||||||
|
<DashboardClient
|
||||||
|
initialAnalytics={analytics}
|
||||||
|
newUsers={newUsers}
|
||||||
|
totalUsers={stats?.users}
|
||||||
|
canUsers={canUsers}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,149 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Globe, Loader2, MessageSquare, Save, UserPlus } from "lucide-react";
|
import {
|
||||||
import { useState } from "react";
|
Globe,
|
||||||
|
Loader2,
|
||||||
|
MessageSquare,
|
||||||
|
Save,
|
||||||
|
UserPlus,
|
||||||
|
ExternalLink,
|
||||||
|
Shield,
|
||||||
|
Mail,
|
||||||
|
HardDrive,
|
||||||
|
Ban,
|
||||||
|
Wrench,
|
||||||
|
Palette,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
AdminField,
|
AdminField,
|
||||||
AdminPageHeader,
|
AdminPageHeader,
|
||||||
AdminSection,
|
AdminSection,
|
||||||
AdminSettingRow,
|
|
||||||
AdminSwitch,
|
AdminSwitch,
|
||||||
} from "@/components/admin";
|
} from "@/components/admin";
|
||||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast";
|
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 }) {
|
export default function SettingsAdmin({ initial }: { initial: PublicSettings }) {
|
||||||
const [siteName, setSiteName] = useState(initial.site_name);
|
const [siteName, setSiteName] = useState(initial.site_name);
|
||||||
const [siteDescription, setSiteDescription] = useState(initial.site_description);
|
const [siteDescription, setSiteDescription] = useState(initial.site_description);
|
||||||
const [allowRegister, setAllowRegister] = useState(initial.allow_register);
|
const [allowRegister, setAllowRegister] = useState(initial.allow_register);
|
||||||
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
|
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 [savedName, setSavedName] = useState(initial.site_name);
|
||||||
const [savedDesc, setSavedDesc] = useState(initial.site_description);
|
const [savedDesc, setSavedDesc] = useState(initial.site_description);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [togglingReg, setTogglingReg] = useState(false);
|
const [togglingReg, setTogglingReg] = useState(false);
|
||||||
const [togglingComments, setTogglingComments] = 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;
|
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 () => {
|
const saveIdentity = async () => {
|
||||||
if (saving) return;
|
if (saving) return;
|
||||||
const name = siteName.trim();
|
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 (
|
return (
|
||||||
<div className="max-w-3xl">
|
<div>
|
||||||
<AdminPageHeader
|
<AdminPageHeader
|
||||||
icon={Globe}
|
icon={Globe}
|
||||||
title="站点设置"
|
title="站点设置"
|
||||||
description="配置站点名称、简介与注册/评论开关;主题色、代码折叠与动画请到「外观」页调整"
|
description="配置站点名称、互动开关与后续扩展分区;主题色请到「外观」页调整"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
|
||||||
<AdminSection title="基本信息" description="用于页眉品牌、页脚版权与 SEO 元信息">
|
<nav
|
||||||
<AdminField
|
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"
|
||||||
id="site-name"
|
aria-label="设置分区"
|
||||||
className="mb-4"
|
>
|
||||||
label="站点名称"
|
{NAV.map((item) => {
|
||||||
maxLength={32}
|
const isActive = active === item.id;
|
||||||
value={siteName}
|
return (
|
||||||
onChange={setSiteName}
|
<button
|
||||||
placeholder="姜十三论坛"
|
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
|
<div className="flex-1 min-w-0 flex flex-col gap-4 w-full">
|
||||||
id="site-desc"
|
<div id="settings-basic" className="scroll-mt-4">
|
||||||
className="mb-4"
|
<AdminSection title="基本信息" description="用于页眉品牌、页脚版权与 SEO 元信息">
|
||||||
label="站点简介"
|
<AdminField
|
||||||
as="textarea"
|
id="site-name"
|
||||||
minHeight="96px"
|
className="mb-4"
|
||||||
maxLength={160}
|
label="站点名称"
|
||||||
value={siteDescription}
|
maxLength={32}
|
||||||
onChange={setSiteDescription}
|
value={siteName}
|
||||||
placeholder="一句话介绍社区定位…"
|
onChange={setSiteName}
|
||||||
/>
|
placeholder="姜十三论坛"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<AdminField
|
||||||
<button
|
id="site-desc"
|
||||||
type="button"
|
className="mb-4"
|
||||||
className="btn btn-primary btn-sm"
|
label="站点简介"
|
||||||
disabled={!dirty || saving}
|
as="textarea"
|
||||||
onClick={() => void saveIdentity()}
|
minHeight="96px"
|
||||||
>
|
maxLength={160}
|
||||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
value={siteDescription}
|
||||||
保存基本信息
|
onChange={setSiteDescription}
|
||||||
</button>
|
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>
|
</div>
|
||||||
</AdminSection>
|
|
||||||
|
|
||||||
<AdminSettingRow
|
<div id="settings-content" className="scroll-mt-4">
|
||||||
title={
|
<AdminSection title="内容与互动" description="控制注册、评论与链接打开方式">
|
||||||
<>
|
<SwitchRow
|
||||||
<UserPlus size={15} /> 开放注册
|
bordered
|
||||||
</>
|
title={
|
||||||
}
|
<>
|
||||||
description="关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。"
|
<UserPlus size={15} /> 开放注册
|
||||||
control={
|
</>
|
||||||
<AdminSwitch
|
}
|
||||||
checked={allowRegister}
|
description="关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。"
|
||||||
disabled={togglingReg}
|
control={
|
||||||
onChange={() => void toggleRegister()}
|
<AdminSwitch
|
||||||
label="开放注册"
|
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
|
<div id="settings-brand" className="scroll-mt-4">
|
||||||
title={
|
<ComingSoonCard
|
||||||
<>
|
title="品牌与 SEO"
|
||||||
<MessageSquare size={15} /> 开放评论
|
description="站点图标、OG 图与结构化元数据"
|
||||||
</>
|
icon={<Palette size={22} />}
|
||||||
}
|
|
||||||
description="关闭后前台不展示评论入口、评论数量与已有评论,并提示本站不支持评论。适用于备案审核等合规需要。"
|
|
||||||
control={
|
|
||||||
<AdminSwitch
|
|
||||||
checked={allowComments}
|
|
||||||
disabled={togglingComments}
|
|
||||||
onChange={() => void toggleComments()}
|
|
||||||
label="开放评论"
|
|
||||||
/>
|
/>
|
||||||
}
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
|
Columns3,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FolderSearch,
|
FolderSearch,
|
||||||
History,
|
History,
|
||||||
@@ -47,6 +48,47 @@ import {
|
|||||||
export type UserFilter = "all" | "staff" | "banned";
|
export type UserFilter = "all" | "staff" | "banned";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
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 }[] = [
|
const FILTER_TABS: { key: UserFilter; label: string }[] = [
|
||||||
{ key: "all", label: "全部" },
|
{ key: "all", label: "全部" },
|
||||||
@@ -60,6 +102,90 @@ function filterToParams(filter: UserFilter): { role?: string; status?: string }
|
|||||||
return {};
|
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({
|
export default function UsersAdmin({
|
||||||
initial,
|
initial,
|
||||||
initError,
|
initError,
|
||||||
@@ -93,19 +219,29 @@ export default function UsersAdmin({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(initError);
|
const [error, setError] = useState(initError);
|
||||||
const [actingId, setActingId] = useState<number | null>(null);
|
const [actingId, setActingId] = useState<number | null>(null);
|
||||||
// 角色与账号弹窗 / 登录历史弹窗
|
|
||||||
const [roleTarget, setRoleTarget] = useState<AdminUser | null>(null);
|
const [roleTarget, setRoleTarget] = useState<AdminUser | null>(null);
|
||||||
const [logsTarget, setLogsTarget] = useState<AdminUser | null>(null);
|
const [logsTarget, setLogsTarget] = useState<AdminUser | null>(null);
|
||||||
|
|
||||||
// 板块字典(角色弹窗勾选 + 徽章板块名)
|
|
||||||
const [boards, setBoards] = useState<Board[]>([]);
|
const [boards, setBoards] = useState<Board[]>([]);
|
||||||
|
const [optCols, setOptCols] = useState<Record<OptionalCol, boolean>>(DEFAULT_OPTIONAL);
|
||||||
|
|
||||||
// 防止快速翻页/输入时旧响应覆盖新结果
|
|
||||||
const seqRef = useRef(0);
|
const seqRef = useRef(0);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const urlFirstRun = useRef(true);
|
const urlFirstRun = useRef(true);
|
||||||
const router = useRouter();
|
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 boardNameMap = useMemo(() => {
|
||||||
const m = new Map<number, string>();
|
const m = new Map<number, string>();
|
||||||
boards.forEach((b) => m.set(b.id, b.name));
|
boards.forEach((b) => m.set(b.id, b.name));
|
||||||
@@ -118,8 +254,6 @@ export default function UsersAdmin({
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 在线状态实时化:WS hello 全量快照校正当前页绿点,之后 presence:update 增量跳变;
|
|
||||||
// 后端在线判定(有活跃 WS 连接)比列表接口的 5 分钟 last_seen 启发式更精确
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const reconcile = (ids: ReadonlySet<number>) => {
|
const reconcile = (ids: ReadonlySet<number>) => {
|
||||||
if (ids.size === 0) return;
|
if (ids.size === 0) return;
|
||||||
@@ -162,7 +296,6 @@ export default function UsersAdmin({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 搜索框防抖(首次渲染因 keyword===appliedQ 不会触发请求)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
@@ -177,7 +310,6 @@ export default function UsersAdmin({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [keyword]);
|
}, [keyword]);
|
||||||
|
|
||||||
// 查询状态同步到 URL(首屏由 SSR 给出,跳过首次),刷新/分享可复现当前视图
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (urlFirstRun.current) {
|
if (urlFirstRun.current) {
|
||||||
urlFirstRun.current = false;
|
urlFirstRun.current = false;
|
||||||
@@ -191,14 +323,12 @@ export default function UsersAdmin({
|
|||||||
router.replace(`/admin/users${qs ? `?${qs}` : ""}`, { scroll: false });
|
router.replace(`/admin/users${qs ? `?${qs}` : ""}`, { scroll: false });
|
||||||
}, [appliedQ, filter, page, router]);
|
}, [appliedQ, filter, page, router]);
|
||||||
|
|
||||||
// Enter 立即提交,跳过防抖
|
|
||||||
const submitSearch = () => {
|
const submitSearch = () => {
|
||||||
const q = keyword.trim();
|
const q = keyword.trim();
|
||||||
if (q === appliedQ) return;
|
if (q === appliedQ) return;
|
||||||
load({ page: 1, filter, q });
|
load({ page: 1, filter, q });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 清除按钮:立即复位查询并重拉,不等待防抖,保证标题与列表同步
|
|
||||||
const clearSearch = () => {
|
const clearSearch = () => {
|
||||||
setKeyword("");
|
setKeyword("");
|
||||||
if (appliedQ !== "" || page !== 1) {
|
if (appliedQ !== "" || page !== 1) {
|
||||||
@@ -221,12 +351,85 @@ export default function UsersAdmin({
|
|||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
const tabCount = (f: UserFilter) =>
|
const tabCount = (f: UserFilter) =>
|
||||||
f === "all" ? summary.total : f === "staff" ? summary.admins : summary.banned;
|
f === "all" ? summary.total : f === "staff" ? summary.admins : summary.banned;
|
||||||
// 有关键词或非默认筛选时,空态文案与"无成员"区分
|
|
||||||
const hasFilter = appliedQ !== "" || filter !== "all";
|
const hasFilter = appliedQ !== "" || filter !== "all";
|
||||||
|
|
||||||
// 角色授权成功 / 封禁成功后的统一回刷
|
|
||||||
const reload = () => load({ page, filter, q: appliedQ });
|
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) => {
|
const renderStatus = (u: AdminUser) => {
|
||||||
if (u.banned) {
|
if (u.banned) {
|
||||||
return (
|
return (
|
||||||
@@ -240,6 +443,19 @@ export default function UsersAdmin({
|
|||||||
</span>
|
</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) {
|
if (u.online) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -301,7 +517,6 @@ export default function UsersAdmin({
|
|||||||
</AdminIconButton>
|
</AdminIconButton>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
// 站长账号:不可改角色/封禁,保留主页与(站长自己的)内容档案
|
|
||||||
if (u.role === ROLES.OWNER) {
|
if (u.role === ROLES.OWNER) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 justify-end">
|
<div className="flex items-center gap-1 justify-end">
|
||||||
@@ -350,11 +565,66 @@ export default function UsersAdmin({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 桌面端列模板(成员列收窄,新增在线状态与上次登录 IP;xl 起展示内容/注册列)
|
const renderCell = (c: ColKey, u: AdminUser, isSelf: boolean) => {
|
||||||
const rowGrid =
|
switch (c) {
|
||||||
"hidden md:grid gap-3 items-center px-4 sm:px-5 py-3.5 " +
|
case "member":
|
||||||
"md:grid-cols-[minmax(0,1fr)_78px_minmax(108px,0.95fr)_146px_136px] " +
|
return (
|
||||||
"xl:grid-cols-[minmax(0,1.25fr)_78px_minmax(108px,0.95fr)_146px_116px_136px]";
|
<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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -364,7 +634,6 @@ export default function UsersAdmin({
|
|||||||
description="检索全部成员;在「角色与账号」中配置管理角色、板块授权与封禁;站长账号受保护"
|
description="检索全部成员;在「角色与账号」中配置管理角色、板块授权与封禁;站长账号受保护"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 汇总:用户运营独有指标(与仪表盘站点总量区分) */}
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||||
<AdminStatCard
|
<AdminStatCard
|
||||||
label="当前在线"
|
label="当前在线"
|
||||||
@@ -399,28 +668,32 @@ export default function UsersAdmin({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 搜索 + 筛选 + 用户列表 */}
|
|
||||||
<div className="panel overflow-hidden">
|
<div className="panel overflow-hidden">
|
||||||
<div className="admin-toolbar">
|
<div className="admin-toolbar">
|
||||||
<AdminSearchField
|
<div className="flex flex-col sm:flex-row gap-3 sm:items-center w-full">
|
||||||
value={keyword}
|
<AdminSearchField
|
||||||
onChange={setKeyword}
|
value={keyword}
|
||||||
onSubmit={submitSearch}
|
onChange={setKeyword}
|
||||||
onClear={clearSearch}
|
onSubmit={submitSearch}
|
||||||
placeholder="搜索用户名、昵称或邮箱…"
|
onClear={clearSearch}
|
||||||
ariaLabel="搜索用户"
|
placeholder="搜索用户名、昵称或邮箱…"
|
||||||
/>
|
ariaLabel="搜索用户"
|
||||||
<AdminSegmented<UserFilter>
|
/>
|
||||||
role="group"
|
<div className="flex items-center gap-2 flex-wrap shrink-0">
|
||||||
ariaLabel="用户筛选"
|
<AdminSegmented<UserFilter>
|
||||||
value={filter}
|
role="group"
|
||||||
onChange={changeFilter}
|
ariaLabel="用户筛选"
|
||||||
options={FILTER_TABS.map((t) => ({
|
value={filter}
|
||||||
key: t.key,
|
onChange={changeFilter}
|
||||||
label: t.label,
|
options={FILTER_TABS.map((t) => ({
|
||||||
count: tabCount(t.key),
|
key: t.key,
|
||||||
}))}
|
label: t.label,
|
||||||
/>
|
count: tabCount(t.key),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<ColumnConfigPopover cols={optCols} onChange={persistCols} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -445,18 +718,21 @@ export default function UsersAdmin({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 桌面表头 */}
|
|
||||||
{users.length > 0 && (
|
{users.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className={`${rowGrid} text-[11.5px] uppercase tracking-wide`}
|
className="hidden md:grid gap-3 items-center px-4 sm:px-5 py-3.5 text-[11.5px] uppercase tracking-wide"
|
||||||
style={{ color: "var(--ink-3)", borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
|
style={{
|
||||||
|
...gridStyle,
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
borderBottom: "1px solid var(--line)",
|
||||||
|
background: "var(--panel-2)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span>成员</span>
|
{visibleCols.map((c) => (
|
||||||
<span>状态</span>
|
<span key={c} className={c === "actions" ? "text-right" : undefined}>
|
||||||
<span>角色</span>
|
{colHeader(c)}
|
||||||
<span>上次登录 IP</span>
|
</span>
|
||||||
<span className="xl:block hidden">内容 / 注册</span>
|
))}
|
||||||
<span className="text-right">操作</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -474,41 +750,14 @@ export default function UsersAdmin({
|
|||||||
const isSelf = u.id === currentUserId;
|
const isSelf = u.id === currentUserId;
|
||||||
return (
|
return (
|
||||||
<div key={u.id} style={{ borderTop: "1px solid var(--line)" }}>
|
<div key={u.id} style={{ borderTop: "1px solid var(--line)" }}>
|
||||||
{/* 桌面行 */}
|
<div className="hidden md:grid gap-3 items-center px-4 sm:px-5 py-3.5" style={gridStyle}>
|
||||||
<div className={rowGrid}>
|
{visibleCols.map((c) => (
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<div key={c} className="min-w-0">
|
||||||
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={34} />
|
{renderCell(c, u, isSelf)}
|
||||||
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 移动卡片:空间紧,保留 btn-line btn-sm */}
|
|
||||||
<div className="md:hidden px-4 py-3.5">
|
<div className="md:hidden px-4 py-3.5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={38} />
|
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={38} />
|
||||||
@@ -531,17 +780,18 @@ export default function UsersAdmin({
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-[11.5px] truncate mt-0.5" style={{ color: "var(--ink-3)" }}>
|
<p className="text-[11.5px] truncate mt-0.5" style={{ color: "var(--ink-3)" }}>
|
||||||
@{u.username}
|
@{u.username}
|
||||||
{u.email ? ` · ${u.email}` : ""}
|
{optCols.email && u.email ? ` · ${u.email}` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{renderStatus(u)}
|
{renderStatus(u)}
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p className="mt-2.5 text-[11.5px] tabular-nums flex flex-wrap gap-x-3 gap-y-1" style={{ color: "var(--ink-3)" }}>
|
||||||
className="mt-2.5 text-[11.5px] tabular-nums"
|
<span>积分 {(u.points ?? 0).toLocaleString("zh-CN")}</span>
|
||||||
style={{ color: "var(--ink-3)" }}
|
<span data-tip="仅统计已发布内容">
|
||||||
data-tip="仅统计已发布内容;驳回/删除见内容档案"
|
帖 {u.post_count} · 评 {u.comment_count}
|
||||||
>
|
</span>
|
||||||
帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)}
|
<span>注册 {formatDate(u.created_at)}</span>
|
||||||
|
{optCols.online && <span>{u.online ? "在线" : "离线"}</span>}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[11.5px] tabular-nums flex items-center gap-1.5" style={{ color: "var(--ink-3)" }}>
|
<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" />
|
<Clock size={12} className="shrink-0" />
|
||||||
@@ -620,8 +870,6 @@ export default function UsersAdmin({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 登录历史弹窗 =====
|
|
||||||
|
|
||||||
function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClose: () => void }) {
|
function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClose: () => void }) {
|
||||||
const [logs, setLogs] = useState<LoginLog[]>([]);
|
const [logs, setLogs] = useState<LoginLog[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
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"
|
panelClassName="p-6 max-h-[88vh] flex flex-col"
|
||||||
ariaLabel="登录历史"
|
ariaLabel="登录历史"
|
||||||
footer={
|
footer={
|
||||||
// Modal footer 不好塞 AdminPager,保留原紧凑按钮
|
|
||||||
total > pageSize ? (
|
total > pageSize ? (
|
||||||
<>
|
<>
|
||||||
<span className="meta text-[11.5px] tabular-nums mr-auto" style={{ color: "var(--ink-3)" }}>
|
<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;
|
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 {
|
::selection {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--accent-on);
|
color: var(--accent-on);
|
||||||
@@ -1913,20 +2171,244 @@ a.j13-mention-token:hover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 内容管理工具条:类型 / 状态 / 搜索同一行,避免三行左对齐或等分顶栏留白 */
|
/* 内容管理页:窄屏可整页滚;桌面高度由上方未分层的 canvas:has 规则锁定 */
|
||||||
.admin-content-toolbar {
|
.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;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem 1rem;
|
gap: 0.65rem 0.85rem;
|
||||||
padding: 0.75rem 1rem;
|
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);
|
border-bottom: 1px solid var(--line);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
@media (min-width: 640px) {
|
.admin-content-action {
|
||||||
.admin-content-toolbar {
|
display: inline-flex;
|
||||||
padding: 0.75rem 1.25rem;
|
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 {
|
.admin-content-kind {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1979,35 +2461,6 @@ a.j13-mention-token:hover {
|
|||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
color: var(--accent);
|
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 {
|
.admin-field-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2059,14 +2512,16 @@ a.j13-mention-token:hover {
|
|||||||
.admin-search-shell {
|
.admin-search-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.625rem;
|
||||||
height: 2.5rem;
|
min-height: 2.875rem;
|
||||||
padding: 0 0.75rem;
|
height: 2.875rem;
|
||||||
|
padding: 0 0.875rem;
|
||||||
}
|
}
|
||||||
.admin-search-shell .admin-field-input {
|
.admin-search-shell .admin-field-input {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-stepper {
|
.admin-stepper {
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
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 { Plus_Jakarta_Sans, Noto_Sans_SC, JetBrains_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import Header from "@/components/Header";
|
import SiteChrome from "@/components/SiteChrome";
|
||||||
import Footer from "@/components/Footer";
|
|
||||||
import Toaster from "@/components/Toaster";
|
import Toaster from "@/components/Toaster";
|
||||||
import TipHost from "@/components/TipHost";
|
import TipHost from "@/components/TipHost";
|
||||||
import AccountGuard from "@/components/AccountGuard";
|
import AccountGuard from "@/components/AccountGuard";
|
||||||
import ThemeSync from "@/components/ThemeSync";
|
import ThemeSync from "@/components/ThemeSync";
|
||||||
import RealtimeProvider from "@/components/RealtimeProvider";
|
import RealtimeProvider from "@/components/RealtimeProvider";
|
||||||
|
import VisitBeacon from "@/components/VisitBeacon";
|
||||||
|
import PostLinkProvider from "@/components/PostLinkProvider";
|
||||||
import CodeFoldProvider from "@/components/markdown/CodeFoldProvider";
|
import CodeFoldProvider from "@/components/markdown/CodeFoldProvider";
|
||||||
import MotionProvider from "@/components/MotionProvider";
|
import MotionProvider from "@/components/MotionProvider";
|
||||||
import CommentsPolicyProvider from "@/components/CommentsPolicyProvider";
|
import CommentsPolicyProvider from "@/components/CommentsPolicyProvider";
|
||||||
@@ -65,6 +66,11 @@ export default async function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const cookieStore = await cookies();
|
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
|
// 浅色优先的设计系统,仅在显式选择暗色时挂 .dark
|
||||||
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
|
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 href="#main" className="skip-link">
|
||||||
跳到主要内容
|
跳到主要内容
|
||||||
</a>
|
</a>
|
||||||
<Header
|
<MotionProvider initial={motionCfg}>
|
||||||
initialUser={user}
|
<CommentsPolicyProvider allowComments={settings.allow_comments !== false}>
|
||||||
initialUnread={unread}
|
<PostLinkProvider openInNewTab={settings.post_link_new_tab !== false}>
|
||||||
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}>
|
|
||||||
<CodeFoldProvider
|
<CodeFoldProvider
|
||||||
initialEnabled={settings.code_block_auto_fold}
|
initialEnabled={settings.code_block_auto_fold}
|
||||||
initialFoldLines={settings.code_block_fold_lines}
|
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>
|
</CodeFoldProvider>
|
||||||
</CommentsPolicyProvider>
|
</PostLinkProvider>
|
||||||
</MotionProvider>
|
</CommentsPolicyProvider>
|
||||||
</main>
|
</MotionProvider>
|
||||||
<Footer siteName={settings.site_name} />
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<TipHost />
|
<TipHost />
|
||||||
<AccountGuard />
|
<AccountGuard />
|
||||||
<ThemeSync initialAccent={accent || ""} />
|
<ThemeSync initialAccent={accent || ""} />
|
||||||
|
<VisitBeacon />
|
||||||
<RealtimeProvider userId={user ? user.id : null} />
|
<RealtimeProvider userId={user ? user.id : null} />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { useState, useEffect, useRef } from "react";
|
|||||||
import {
|
import {
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
Sun,
|
|
||||||
Moon,
|
|
||||||
LogIn,
|
LogIn,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -29,6 +27,7 @@ import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
|
|||||||
import type { RtChatUnreadData } from "@/lib/realtime";
|
import type { RtChatUnreadData } from "@/lib/realtime";
|
||||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||||
import NotificationBell from "./NotificationBell";
|
import NotificationBell from "./NotificationBell";
|
||||||
|
import ThemeToggle from "./ThemeToggle";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
export default function Header({
|
export default function Header({
|
||||||
@@ -49,7 +48,6 @@ export default function Header({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [theme, setTheme] = useState<"light" | "dark">(initialTheme);
|
|
||||||
// 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正
|
// 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正
|
||||||
const [user, setUser] = useState<User | null>(initialUser);
|
const [user, setUser] = useState<User | null>(initialUser);
|
||||||
const [unread, setUnread] = useState(initialUnread);
|
const [unread, setUnread] = useState(initialUnread);
|
||||||
@@ -148,11 +146,6 @@ export default function Header({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
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() || "");
|
setSearchQuery(new URLSearchParams(window.location.search).get("q")?.trim() || "");
|
||||||
}, [pathname]);
|
}, [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 () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
await apiLogout();
|
await apiLogout();
|
||||||
@@ -280,15 +266,11 @@ export default function Header({
|
|||||||
</kbd>
|
</kbd>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<button
|
<ThemeToggle
|
||||||
onClick={toggleTheme}
|
initialTheme={initialTheme}
|
||||||
className={`${iconBtn} hidden lg:inline-flex`}
|
className={`${iconBtn} hidden lg:inline-flex`}
|
||||||
style={iconBtnStyle}
|
style={iconBtnStyle}
|
||||||
aria-label="切换主题"
|
/>
|
||||||
data-tip={theme === "dark" ? "切换浅色" : "切换深色"}
|
|
||||||
>
|
|
||||||
{theme === "dark" ? <Sun size={17} /> : <Moon size={17} />}
|
|
||||||
</button>
|
|
||||||
{user && (
|
{user && (
|
||||||
<Link
|
<Link
|
||||||
href="/chat"
|
href="/chat"
|
||||||
@@ -407,10 +389,12 @@ export default function Header({
|
|||||||
{!user && allowRegister && <MobileLink href="/register">注册</MobileLink>}
|
{!user && allowRegister && <MobileLink href="/register">注册</MobileLink>}
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
|
<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">
|
<ThemeToggle
|
||||||
{theme === "dark" ? <Sun size={14} /> : <Moon size={14} />}
|
initialTheme={initialTheme}
|
||||||
{theme === "dark" ? "浅色模式" : "深色模式"}
|
labelMode="labeled"
|
||||||
</button>
|
iconSize={14}
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
/>
|
||||||
{user && (
|
{user && (
|
||||||
<button onClick={handleLogout} className="btn btn-danger btn-sm">
|
<button onClick={handleLogout} className="btn btn-danger btn-sm">
|
||||||
<LogOut size={14} /> 退出
|
<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 { Search, X } from "lucide-react";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
|
|
||||||
/** 左图标、可清除、Enter 提交;壳层聚焦与聊天搜索一致 */
|
/** 左图标、可清除、Enter 提交;加高壳层便于后台工具条点按 */
|
||||||
export default function AdminSearchField({
|
export default function AdminSearchField({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -30,9 +30,13 @@ export default function AdminSearchField({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<Search
|
||||||
size={15}
|
size={17}
|
||||||
className="shrink-0 pointer-events-none"
|
className="shrink-0 pointer-events-none"
|
||||||
style={{ color: value ? "var(--accent)" : "var(--ink-3)" }}
|
style={{ color: value ? "var(--accent)" : "var(--ink-3)" }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -50,10 +54,10 @@ export default function AdminSearchField({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={clear}
|
onClick={clear}
|
||||||
aria-label="清除搜索"
|
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)" }}
|
style={{ color: "var(--ink-3)" }}
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={15} />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</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 AdminIconButton } from "./AdminIconButton";
|
||||||
export { default as AdminStatusChip, contentStatusChip } from "./AdminStatusChip";
|
export { default as AdminStatusChip, contentStatusChip } from "./AdminStatusChip";
|
||||||
export type { AdminStatusTone } from "./AdminStatusChip";
|
export type { AdminStatusTone } from "./AdminStatusChip";
|
||||||
|
export { default as AdminShell } from "./AdminShell";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { AnchorHTMLAttributes } from "react";
|
import type { AnchorHTMLAttributes } from "react";
|
||||||
|
import { usePostLinkNewTab } from "@/components/PostLinkProvider";
|
||||||
|
|
||||||
type MdLinkProps = AnchorHTMLAttributes<HTMLAnchorElement>;
|
type MdLinkProps = AnchorHTMLAttributes<HTMLAnchorElement>;
|
||||||
|
|
||||||
@@ -21,14 +22,15 @@ function isExternalHref(href?: string): boolean {
|
|||||||
return /^https?:\/\//i.test(href);
|
return /^https?:\/\//i.test(href);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Markdown 链接:外链新开页 */
|
/** Markdown 链接:外链是否新开由站点设置 post_link_new_tab 控制 */
|
||||||
export default function MdLink({ href, children, ...rest }: MdLinkProps) {
|
export default function MdLink({ href, children, ...rest }: MdLinkProps) {
|
||||||
|
const openInNewTab = usePostLinkNewTab();
|
||||||
const external = isExternalHref(href);
|
const external = isExternalHref(href);
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
{...rest}
|
{...rest}
|
||||||
href={href}
|
href={href}
|
||||||
{...(external
|
{...(external && openInNewTab
|
||||||
? { target: "_blank", rel: "noopener noreferrer" }
|
? { target: "_blank", rel: "noopener noreferrer" }
|
||||||
: {})}
|
: {})}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -609,6 +609,8 @@ export interface PublicSettings {
|
|||||||
anim_smooth_scroll: boolean;
|
anim_smooth_scroll: boolean;
|
||||||
/** 装饰入场子开关;缺省 true */
|
/** 装饰入场子开关;缺省 true */
|
||||||
anim_chrome: boolean;
|
anim_chrome: boolean;
|
||||||
|
/** 帖内 Markdown 外链是否新标签打开;缺省 true */
|
||||||
|
post_link_new_tab: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
||||||
@@ -625,6 +627,7 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
|||||||
anim_code_fold: true,
|
anim_code_fold: true,
|
||||||
anim_smooth_scroll: true,
|
anim_smooth_scroll: true,
|
||||||
anim_chrome: true,
|
anim_chrome: true,
|
||||||
|
post_link_new_tab: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizePublicSettings(data: Partial<PublicSettings> | null | undefined): PublicSettings {
|
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_code_fold: data?.anim_code_fold !== false,
|
||||||
anim_smooth_scroll: data?.anim_smooth_scroll !== false,
|
anim_smooth_scroll: data?.anim_smooth_scroll !== false,
|
||||||
anim_chrome: data?.anim_chrome !== 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;
|
escrowed_points: number;
|
||||||
posts_by_type: Record<string, number>;
|
posts_by_type: Record<string, number>;
|
||||||
recent_ledger: PointLedgerItem[];
|
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 }> {
|
export async function apiAdminEconomyStats(): Promise<EconomyStats & { error?: string }> {
|
||||||
@@ -1325,6 +1456,7 @@ export interface AdminUser {
|
|||||||
role: string; // user | board_admin | admin | super_admin | owner
|
role: string; // user | board_admin | admin | super_admin | owner
|
||||||
board_ids: number[]; // 板块管理员的授权板块
|
board_ids: number[]; // 板块管理员的授权板块
|
||||||
banned: boolean;
|
banned: boolean;
|
||||||
|
points?: number;
|
||||||
post_count: number;
|
post_count: number;
|
||||||
comment_count: number;
|
comment_count: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -1619,6 +1751,7 @@ export type UpdateSiteSettingsBody = {
|
|||||||
anim_code_fold?: boolean;
|
anim_code_fold?: boolean;
|
||||||
anim_smooth_scroll?: boolean;
|
anim_smooth_scroll?: boolean;
|
||||||
anim_chrome?: boolean;
|
anim_chrome?: boolean;
|
||||||
|
post_link_new_tab?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新站点设置(字段可选);accent 传空串恢复默认主题色
|
// 更新站点设置(字段可选);accent 传空串恢复默认主题色
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export interface RtSettingsData {
|
|||||||
anim_code_fold?: boolean;
|
anim_code_fold?: boolean;
|
||||||
anim_smooth_scroll?: boolean;
|
anim_smooth_scroll?: boolean;
|
||||||
anim_chrome?: boolean;
|
anim_chrome?: boolean;
|
||||||
|
post_link_new_tab?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 群聊新消息:data 为落库的 ChatMessage(含 sender)
|
// 群聊新消息: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()];
|
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) {
|
export async function middleware(req: NextRequest) {
|
||||||
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
|
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
|
||||||
const accessToken = req.cookies.get(TOKEN_COOKIE)?.value;
|
const accessToken = req.cookies.get(TOKEN_COOKIE)?.value;
|
||||||
|
|
||||||
// 游客、access 仍有效、或服务端地址未配置:直接放行
|
// 游客、access 仍有效、或服务端地址未配置:直接放行
|
||||||
if (!refreshToken || !isAccessTokenExpired(accessToken) || !API_BASE) {
|
if (!refreshToken || !isAccessTokenExpired(accessToken) || !API_BASE) {
|
||||||
return NextResponse.next();
|
return withPathname(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
const csrf = req.cookies.get(CSRF_COOKIE)?.value ?? "";
|
const csrf = req.cookies.get(CSRF_COOKIE)?.value ?? "";
|
||||||
@@ -154,7 +161,7 @@ export async function middleware(req: NextRequest) {
|
|||||||
const headers = new Headers(req.headers);
|
const headers = new Headers(req.headers);
|
||||||
headers.set("Cookie", [...jar].map(([k, v]) => `${k}=${v}`).join("; "));
|
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 等属性全部以后端为准
|
// 原样透传,HttpOnly/Path/SameSite/Secure 等属性全部以后端为准
|
||||||
for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
|
for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
|
||||||
return res;
|
return res;
|
||||||
@@ -162,7 +169,7 @@ export async function middleware(req: NextRequest) {
|
|||||||
|
|
||||||
if (setCookies.length > 0) {
|
if (setCookies.length > 0) {
|
||||||
// refresh 已失效:透传后端的清 cookie 指令,避免之后每次请求都白轮转
|
// refresh 已失效:透传后端的清 cookie 指令,避免之后每次请求都白轮转
|
||||||
const res = NextResponse.next();
|
const res = withPathname(req);
|
||||||
for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
|
for (const sc of setCookies) res.headers.append("Set-Cookie", sc);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@@ -170,7 +177,7 @@ export async function middleware(req: NextRequest) {
|
|||||||
// 后端不可达:降级匿名渲染,客户端 fetchWithRefresh 仍可兜底
|
// 后端不可达:降级匿名渲染,客户端 fetchWithRefresh 仍可兜底
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return withPathname(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
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-dom": "^19.0.0",
|
||||||
"react-easy-crop": "^6.2.3",
|
"react-easy-crop": "^6.2.3",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
"rehype-pretty-code": "^0.14.5",
|
"rehype-pretty-code": "^0.14.5",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
@@ -800,6 +801,32 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@shikijs/core": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz",
|
||||||
@@ -900,6 +927,18 @@
|
|||||||
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
|
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.23",
|
"version": "0.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||||
@@ -1192,6 +1231,69 @@
|
|||||||
"tailwindcss": "4.3.3"
|
"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": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.13",
|
"version": "4.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||||
@@ -1275,6 +1377,12 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@ungap/structured-clone": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
||||||
@@ -1379,6 +1487,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/comma-separated-tokens": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
"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==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"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": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
"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"
|
"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": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||||
@@ -1505,6 +1761,12 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/extend": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
@@ -1682,12 +1944,31 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/inline-style-parser": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||||
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
|
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/is-alphabetical": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||||
@@ -3149,6 +3430,13 @@
|
|||||||
"react-dom": ">=16.4.0"
|
"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": {
|
"node_modules/react-markdown": {
|
||||||
"version": "9.1.0",
|
"version": "9.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz",
|
||||||
@@ -3176,6 +3464,74 @@
|
|||||||
"react": ">=18"
|
"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": {
|
"node_modules/regex": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
|
||||||
@@ -3301,6 +3657,12 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.28.0",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||||
@@ -3484,6 +3846,12 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/trim-lines": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||||
@@ -3618,6 +3986,15 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/vfile": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||||
@@ -3660,6 +4037,28 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/web-namespaces": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-easy-crop": "^6.2.3",
|
"react-easy-crop": "^6.2.3",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
"rehype-pretty-code": "^0.14.5",
|
"rehype-pretty-code": "^0.14.5",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user