diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index 4550821..d710611 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -28,6 +28,8 @@ type Handlers struct { AdminUser *service.AdminUserService Moderation *service.ModerationService Chat *service.ChatService + Telemetry *service.TelemetryService + Analytics *service.AnalyticsService } // resolvePublishStatus 决定新帖/新评的初始状态: diff --git a/backend/handler/setting.go b/backend/handler/setting.go index ceac5b0..1829ed8 100644 --- a/backend/handler/setting.go +++ b/backend/handler/setting.go @@ -34,6 +34,7 @@ type updateSettingsRequest struct { AnimCodeFold *bool `json:"anim_code_fold"` AnimSmoothScroll *bool `json:"anim_smooth_scroll"` AnimChrome *bool `json:"anim_chrome"` + PostLinkNewTab *bool `json:"post_link_new_tab"` } func settingsPayload(saved service.PublicSiteSettings) gin.H { @@ -51,6 +52,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H { "anim_code_fold": saved.AnimCodeFold, "anim_smooth_scroll": saved.AnimSmoothScroll, "anim_chrome": saved.AnimChrome, + "post_link_new_tab": saved.PostLinkNewTab, } } @@ -65,7 +67,7 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { req.SiteDescription == nil && req.AllowRegister == nil && req.AllowComments == nil && req.PostCooldownHours == nil && req.CodeBlockAutoFold == nil && req.CodeBlockFoldLines == nil && req.UIAnimations == nil && req.AnimCodeFold == nil && req.AnimSmoothScroll == nil && - req.AnimChrome == nil { + req.AnimChrome == nil && req.PostLinkNewTab == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "未提供任何可更新的设置项"}) return } @@ -168,6 +170,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) { return } } + if req.PostLinkNewTab != nil { + if err := h.Setting.SetPostLinkNewTab(*req.PostLinkNewTab); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"}) + return + } + } saved, err := h.Setting.Public() if err != nil { diff --git a/backend/handler/telemetry.go b/backend/handler/telemetry.go new file mode 100644 index 0000000..83e807d --- /dev/null +++ b/backend/handler/telemetry.go @@ -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) +} diff --git a/backend/model/db.go b/backend/model/db.go index b3fdae7..40a0efc 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -37,7 +37,7 @@ func InitDB(dsn string) error { if err := db.AutoMigrate( &User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{}, - &Announcement{}, &SiteSetting{}, &Attachment{}, &UserBoard{}, &LoginLog{}, + &Announcement{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{}, &ChatRoom{}, &ChatRoomMember{}, &ChatMessage{}, &PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{}, &PostPollVote{}, &PostLotteryEntry{}, diff --git a/backend/model/models.go b/backend/model/models.go index f8a4815..b2fb726 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -400,6 +400,21 @@ type SiteSetting struct { UpdatedAt time.Time `json:"updated_at"` } +// SiteDailyStats 站点日维度访问量(轻量 PV/UV) +type SiteDailyStats struct { + Date time.Time `gorm:"type:date;primaryKey" json:"date"` + PV int64 `gorm:"not null;default:0" json:"pv"` + UV int64 `gorm:"not null;default:0" json:"uv"` + LoggedInPV int64 `gorm:"not null;default:0" json:"logged_in_pv"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SiteDailyVisitor 当日访客去重(vid 哈希);插入成功才给 UV +1 +type SiteDailyVisitor struct { + Date time.Time `gorm:"type:date;primaryKey" json:"date"` + VidHash string `gorm:"size:64;primaryKey" json:"-"` +} + // 附件类型 const ( AttachmentKindAvatar = "avatar" // 用户头像(正方形 WebP) diff --git a/backend/router/router.go b/backend/router/router.go index c03d68d..607286e 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -55,6 +55,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { adminUserSvc := service.NewAdminUserService(model.DB) moderationSvc := service.NewModerationService(model.DB, notifSvc) chatSvc := service.NewChatService(model.DB, notifSvc) + telemetrySvc := service.NewTelemetryService(model.DB) + analyticsSvc := service.NewAnalyticsService(model.DB) if err := uploadSvc.EnsureDir(); err != nil { return nil, err } @@ -82,6 +84,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { AdminUser: adminUserSvc, Moderation: moderationSvc, Chat: chatSvc, + Telemetry: telemetrySvc, + Analytics: analyticsSvc, } // 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用) notifSvc.OnNotifyNew = func(userID uint) { @@ -122,6 +126,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { pubAPI.GET("/announcements", h.AnnouncementsList) pubAPI.GET("/announcements/:id", h.AnnouncementDetail) pubAPI.GET("/settings", h.PublicSettings) + pubAPI.POST("/telemetry/pageview", middleware.CSRFMiddleware(), h.TelemetryPageView) pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register) pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login) // refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护) @@ -223,6 +228,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { // 积分与类型帖概览(管理员及以上) staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats) + // 站点分析概览(任意 staff) + staffAPI.GET("/analytics/overview", h.AdminAnalyticsOverview) + + // 群聊监管队列(任意 staff;撤回权限仍按 oversee 规则) + staffAPI.GET("/chat/messages", h.AdminChatMessages) + staffAPI.DELETE("/chat/rooms/:roomId/messages/:mid", h.AdminRecallChatMessage) + // 站点公告文章管理(管理员及以上) announceAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements)) announceAPI.GET("/announcements", h.AdminListAnnouncements) diff --git a/backend/service/admin_user.go b/backend/service/admin_user.go index a02bcb6..9789549 100644 --- a/backend/service/admin_user.go +++ b/backend/service/admin_user.go @@ -52,6 +52,7 @@ type AdminUserItem struct { Banned bool `json:"banned"` PostCount int64 `json:"post_count"` CommentCount int64 `json:"comment_count"` + Points int `json:"points"` CreatedAt time.Time `json:"created_at"` LastSeenAt *time.Time `json:"last_seen_at"` Online bool `json:"online"` @@ -188,6 +189,7 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem { Role: string(u.Role), BoardIDs: []uint{}, Banned: u.Banned, + Points: u.Points, CreatedAt: u.CreatedAt, LastSeenAt: u.LastSeenAt, } diff --git a/backend/service/analytics.go b/backend/service/analytics.go new file mode 100644 index 0000000..4fad808 --- /dev/null +++ b/backend/service/analytics.go @@ -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 +} diff --git a/backend/service/points.go b/backend/service/points.go index c843267..8ffc07a 100644 --- a/backend/service/points.go +++ b/backend/service/points.go @@ -152,20 +152,36 @@ func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64 return out, total, nil } +// DeltaDay 单日积分发放与消耗 +type DeltaDay struct { + Date string `json:"date"` + Granted int64 `json:"granted"` + Spent int64 `json:"spent"` +} + // PointsEconomyStats 全站积分与类型帖概览(管理端) type PointsEconomyStats struct { - TotalBalance int64 `json:"total_balance"` // 用户余额合计 - LedgerCount int64 `json:"ledger_count"` // 流水条数 - CheckinToday int64 `json:"checkin_today"` // 今日签到人数 - OpenBounties int64 `json:"open_bounties"` // 未结算悬赏帖 - EscrowedPoints int64 `json:"escrowed_points"` // 托管中悬赏积分(估算) - PostsByType map[string]int64 `json:"posts_by_type"` // 各类型帖数量 - RecentLedger []LedgerItem `json:"recent_ledger"` // 最近全局流水(脱敏 note) + TotalBalance int64 `json:"total_balance"` + LedgerCount int64 `json:"ledger_count"` + CheckinToday int64 `json:"checkin_today"` + OpenBounties int64 `json:"open_bounties"` + EscrowedPoints int64 `json:"escrowed_points"` + PostsByType map[string]int64 `json:"posts_by_type"` + RecentLedger []LedgerItem `json:"recent_ledger"` + CheckinSeries []DayCount `json:"checkin_series"` + LedgerByReason []NamedCount `json:"ledger_by_reason"` + DeltaSeries []DeltaDay `json:"delta_series"` + NetDelta7d int64 `json:"net_delta_7d"` } // AdminEconomyStats 管理端经济看板 func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) { - st := &PointsEconomyStats{PostsByType: map[string]int64{}} + st := &PointsEconomyStats{ + PostsByType: map[string]int64{}, + CheckinSeries: make([]DayCount, 0), + LedgerByReason: make([]NamedCount, 0), + DeltaSeries: make([]DeltaDay, 0), + } if err := s.db.Model(&model.User{}).Where("deleted_at IS NULL"). Select("COALESCE(SUM(points),0)").Scan(&st.TotalBalance).Error; err != nil { return nil, err @@ -173,14 +189,11 @@ func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) { if err := s.db.Model(&model.PointLedger{}).Count(&st.LedgerCount).Error; err != nil { return nil, err } - today := time.Now().Truncate(24 * time.Hour) - // 用日期字符串更稳妥(与 checkin 一致用 date) if err := s.db.Model(&model.Checkin{}). Where("checkin_date::date = CURRENT_DATE"). Count(&st.CheckinToday).Error; err != nil { return nil, err } - _ = today type row struct { PostType string @@ -198,7 +211,6 @@ func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) { st.PostsByType[pt] += r.Cnt } - // 未结算悬赏:type_meta 含 escrowed true 且未 accepted/refunded —— 用简易扫描估算 var bountyPosts []model.Post if err := s.db.Select("id, type_meta"). Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty). @@ -227,5 +239,77 @@ func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) { RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt, }) } + + now := time.Now() + day0 := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + start14 := day0.AddDate(0, 0, -13) + start7 := day0.AddDate(0, 0, -6) + + type dayCnt struct { + Day string + Cnt int64 + } + var checkinRows []dayCnt + if err := s.db.Raw(` + SELECT to_char(checkin_date, 'YYYY-MM-DD') AS day, count(*) AS cnt + FROM checkins + WHERE checkin_date >= ? AND checkin_date <= ? + GROUP BY day ORDER BY day`, start14, day0).Scan(&checkinRows).Error; err != nil { + return nil, err + } + checkinMap := map[string]int64{} + for _, r := range checkinRows { + checkinMap[r.Day] = r.Cnt + } + st.CheckinSeries = fillDaySeries(start14, day0, checkinMap) + + type reasonRow struct { + Reason string + Cnt int64 + } + var reasons []reasonRow + if err := s.db.Model(&model.PointLedger{}). + Select("reason, count(*) as cnt"). + Group("reason"). + Order("cnt DESC"). + Limit(12).Scan(&reasons).Error; err != nil { + return nil, err + } + for _, r := range reasons { + st.LedgerByReason = append(st.LedgerByReason, NamedCount{ + Key: r.Reason, Label: r.Reason, Count: r.Cnt, + }) + } + + type deltaRow struct { + Day string + Granted int64 + Spent int64 + } + var deltas []deltaRow + if err := s.db.Raw(` + SELECT to_char(created_at, 'YYYY-MM-DD') AS day, + COALESCE(SUM(CASE WHEN delta > 0 THEN delta ELSE 0 END), 0) AS granted, + COALESCE(SUM(CASE WHEN delta < 0 THEN -delta ELSE 0 END), 0) AS spent + FROM point_ledgers + WHERE created_at >= ? AND created_at < ? + GROUP BY day ORDER BY day`, + start14, day0.AddDate(0, 0, 1)).Scan(&deltas).Error; err != nil { + return nil, err + } + deltaMap := map[string]DeltaDay{} + for _, r := range deltas { + deltaMap[r.Day] = DeltaDay{Date: r.Day, Granted: r.Granted, Spent: r.Spent} + } + for d := start14; !d.After(day0); d = d.AddDate(0, 0, 1) { + k := dateKey(d) + dd := deltaMap[k] + dd.Date = k + st.DeltaSeries = append(st.DeltaSeries, dd) + if !d.Before(start7) { + st.NetDelta7d += dd.Granted - dd.Spent + } + } + return st, nil } diff --git a/backend/service/setting.go b/backend/service/setting.go index 1aa74ba..ac8228b 100644 --- a/backend/service/setting.go +++ b/backend/service/setting.go @@ -35,6 +35,8 @@ const ( SettingKeyAnimSmoothScroll = "anim_smooth_scroll" // SettingKeyAnimChrome 装饰入场(列表/Toast/点赞等);缺行视为开启 SettingKeyAnimChrome = "anim_chrome" + // SettingKeyPostLinkNewTab 帖子/评论 Markdown 外链是否新标签打开;缺行视为开启(聊天链接始终新开,不受此键控制) + SettingKeyPostLinkNewTab = "post_link_new_tab" ) const ( @@ -72,6 +74,7 @@ type PublicSiteSettings struct { AnimCodeFold bool `json:"anim_code_fold"` AnimSmoothScroll bool `json:"anim_smooth_scroll"` AnimChrome bool `json:"anim_chrome"` + PostLinkNewTab bool `json:"post_link_new_tab"` } // SettingService 站点级键值设置 @@ -139,6 +142,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { AnimCodeFold: true, AnimSmoothScroll: true, AnimChrome: true, + PostLinkNewTab: true, } accent, err := s.AccentColor() if err != nil { @@ -224,6 +228,12 @@ func (s *SettingService) Public() (PublicSiteSettings, error) { return out, err } out.AnimChrome = animChrome + + linkNewTab, err := s.PostLinkNewTab() + if err != nil { + return out, err + } + out.PostLinkNewTab = linkNewTab return out, nil } @@ -433,3 +443,16 @@ func (s *SettingService) AnimChrome() (bool, error) { func (s *SettingService) SetAnimChrome(on bool) error { return s.setBoolDefaultTrue(SettingKeyAnimChrome, on) } + +// PostLinkNewTab 帖子/评论 Markdown 外链是否新标签打开。缺行视为开启。 +func (s *SettingService) PostLinkNewTab() (bool, error) { + v, found, err := s.getValue(SettingKeyPostLinkNewTab) + if err != nil { + return true, err + } + return parseBoolDefaultTrue(v, found), nil +} + +func (s *SettingService) SetPostLinkNewTab(on bool) error { + return s.setBoolDefaultTrue(SettingKeyPostLinkNewTab, on) +} diff --git a/backend/service/telemetry.go b/backend/service/telemetry.go new file mode 100644 index 0000000..b32935c --- /dev/null +++ b/backend/service/telemetry.go @@ -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 +} diff --git a/frontend/app/admin/AdminNav.tsx b/frontend/app/admin/AdminNav.tsx deleted file mode 100644 index fce7a8d..0000000 --- a/frontend/app/admin/AdminNav.tsx +++ /dev/null @@ -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 ( - - {count > 99 ? "99+" : count} - - ); -} - -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 ( -
-
- - - - - - 管理面板 - - - J13 ADMIN - - -
- - - -
- - - 返回站点 - -
- -
- -
-

- {user.nickname} -

-

- @{user.username} -

-
- - - -
-
- ); -} - -export function AdminNavMobile({ user }: { user: User }) { - const pathname = usePathname(); - const items = flatItems(user); - const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge)); - - return ( -
- -
- ); -} diff --git a/frontend/app/admin/DashboardClient.tsx b/frontend/app/admin/DashboardClient.tsx new file mode 100644 index 0000000..e2ce11a --- /dev/null +++ b/frontend/app/admin/DashboardClient.tsx @@ -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 ( +
+ + {title} + + {extra && ( + + {extra} + + )} +
+ ); +} + +function mergePostCommentSeries(data: AnalyticsOverview) { + const map = new Map(); + 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("7d"); + const [data, setData] = useState(initialAnalytics); + const [error, setError] = useState(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 ( +
+
+

+ {pending ? "正在切换区间…" : `近 ${data.range_days} 日趋势`} +

+ + ariaLabel="分析时间范围" + size="sm" + role="group" + value={range} + onChange={switchRange} + options={[ + { key: "7d", label: "近 7 日" }, + { key: "30d", label: "近 30 日" }, + ]} + /> +
+ + {error && ( +

+ {error} +

+ )} + + + + + +
+ + + + + + + + + + + + +
+ +
+
+ + {newUsers.length === 0 ? ( +

暂无成员数据

+ ) : ( + newUsers.slice(0, 8).map((u) => ( + + + + + {u.nickname} + + + @{u.username} + + + + + {formatRelative(u.created_at)}加入 + + + )) + )} + {canUsers && ( + + + 前往用户管理 + + + )} +
+ +
+ + {data.top_viewed.length === 0 ? ( +

暂无浏览数据

+ ) : ( + data.top_viewed.map((p, i) => ( + + + {i + 1} + + + {p.title} + + + + {fmt(p.view_count)} + + + )) + )} +
+
+
+ ); +} diff --git a/frontend/app/admin/announcements/AnnouncementAdmin.tsx b/frontend/app/admin/announcements/AnnouncementAdmin.tsx index 9fa380c..94f70da 100644 --- a/frontend/app/admin/announcements/AnnouncementAdmin.tsx +++ b/frontend/app/admin/announcements/AnnouncementAdmin.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Plus, Pencil, @@ -20,7 +20,7 @@ import { type AnnouncementColor, type AnnouncementInput, } from "@/lib/api"; -import { formatDate } from "@/lib/format"; +import { formatRelative } from "@/lib/format"; import { toast } from "@/lib/toast"; import ConfirmDialog from "@/components/ConfirmDialog"; import { @@ -29,6 +29,7 @@ import { AdminIconButton, AdminPageHeader, AdminSegmented, + AdminStatusChip, } from "@/components/admin"; const COLORS: { value: AnnouncementColor; label: string }[] = [ @@ -42,6 +43,7 @@ const COLORS: { value: AnnouncementColor; label: string }[] = [ type FormState = AnnouncementInput & { id: number | null }; type PublishKey = "published" | "draft"; +type StatusFilter = "all" | "published" | "draft"; const EMPTY_FORM: FormState = { id: null, @@ -52,6 +54,156 @@ const EMPTY_FORM: FormState = { published: true, }; +/** 粗略去掉 Markdown,取一行摘要 */ +function plainSummary(md: string, max = 96): string { + const text = md + .replace(/```[\s\S]*?```/g, " ") + .replace(/!\[[^\]]*\]\([^)]+\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[#>*_`~\-|=]/g, "") + .replace(/\s+/g, " ") + .trim(); + return text.length > max ? `${text.slice(0, max)}…` : text; +} + +function AnnouncementForm({ + form, + setForm, + saving, + onSave, + onCancel, +}: { + form: FormState; + setForm: (f: FormState) => void; + saving: boolean; + onSave: () => void; + onCancel: () => void; +}) { + return ( +
+

+ {form.id == null ? "新建公告" : "编辑公告"} +

+ + setForm({ ...form, title })} + placeholder="请输入公告标题" + /> + +
+
+ setForm({ ...form, tag })} + placeholder="公告" + /> +
+
+

标签颜色

+
+ {COLORS.map((c) => ( +
+
+
+ +

发布状态

+
+ + role="group" + ariaLabel="发布状态" + value={form.published ? "published" : "draft"} + onChange={(key) => setForm({ ...form, published: key === "published" })} + options={[ + { key: "published", label: "立即发布" }, + { key: "draft", label: "存为草稿" }, + ]} + /> +
+ + setForm({ ...form, content })} + placeholder="请输入公告正文,支持 Markdown 语法…" + /> + +
+ + +
+
+ ); +} + +function AnnouncementPreview({ a }: { a: Announcement }) { + return ( +
+
+ {a.tag} + + {a.published ? "已发布" : "草稿"} + + {formatRelative(a.updated_at || a.created_at)} +
+

+ {a.title} +

+

+ {a.content} +

+ {a.published ? ( + + 查看前台页 + + ) : null} +
+ ); +} + export default function AnnouncementAdmin({ initial, initError, @@ -62,10 +214,26 @@ export default function AnnouncementAdmin({ const [list, setList] = useState(initial); const [loadError, setLoadError] = useState(initError); const [form, setForm] = useState(null); + const [selectedId, setSelectedId] = useState(initial[0]?.id ?? null); + const [statusFilter, setStatusFilter] = useState("all"); const [saving, setSaving] = useState(false); const [deletingId, setDeletingId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); + const publishedCount = useMemo(() => list.filter((a) => a.published).length, [list]); + const draftCount = useMemo(() => list.filter((a) => !a.published).length, [list]); + + const filtered = useMemo(() => { + if (statusFilter === "published") return list.filter((a) => a.published); + if (statusFilter === "draft") return list.filter((a) => !a.published); + return list; + }, [list, statusFilter]); + + const selected = useMemo( + () => (selectedId == null ? null : list.find((a) => a.id === selectedId) ?? null), + [list, selectedId] + ); + const load = useCallback(async () => { try { const res = await apiAdminListAnnouncements(); @@ -76,8 +244,13 @@ export default function AnnouncementAdmin({ } }, []); - const startCreate = () => setForm({ ...EMPTY_FORM }); - const startEdit = (a: Announcement) => + const startCreate = () => { + setForm({ ...EMPTY_FORM }); + setSelectedId(null); + }; + + const startEdit = (a: Announcement) => { + setSelectedId(a.id); setForm({ id: a.id, title: a.title, @@ -86,6 +259,12 @@ export default function AnnouncementAdmin({ tag_color: a.tag_color, published: a.published, }); + }; + + const selectRow = (a: Announcement) => { + setSelectedId(a.id); + if (form && form.id !== a.id) setForm(null); + }; async function save() { if (!form) return; @@ -103,13 +282,16 @@ export default function AnnouncementAdmin({ setSaving(true); try { if (form.id == null) { - await apiAdminCreateAnnouncement(payload); - toast("公告已发布", "ok"); + const res = await apiAdminCreateAnnouncement(payload); + toast(payload.published ? "公告已发布" : "草稿已保存", "ok"); + setForm(null); + setSelectedId(res.announcement?.id ?? null); } else { await apiAdminUpdateAnnouncement(form.id, payload); toast("公告已更新", "ok"); + setForm(null); + setSelectedId(form.id); } - setForm(null); await load(); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); @@ -122,7 +304,11 @@ export default function AnnouncementAdmin({ setDeletingId(a.id); try { await apiAdminDeleteAnnouncement(a.id); - setList((l) => l.filter((x) => x.id !== a.id)); + setList((l) => { + const next = l.filter((x) => x.id !== a.id); + if (selectedId === a.id) setSelectedId(next[0]?.id ?? null); + return next; + }); if (form?.id === a.id) setForm(null); toast("公告已删除", "ok"); } catch (e) { @@ -140,187 +326,189 @@ export default function AnnouncementAdmin({ title="站点公告管理" description="发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色" actions={ - form?.id == null ? ( + form?.id == null && form != null ? undefined : ( - ) : undefined + ) } /> -
- {/* 编辑/新建表单:仅在新建或编辑时展开,空闲不占位 */} - {form && ( -
-

- {form.id == null ? "新建公告" : "编辑公告"} -

+ {/* 计数条 */} +
+
+ + 草稿{" "} + + {draftCount} + + + + | + + + 已发布{" "} + + {publishedCount} + + +
+ + ariaLabel="公告状态筛选" + size="sm" + value={statusFilter} + onChange={setStatusFilter} + options={[ + { key: "all", label: "全部", count: list.length }, + { key: "published", label: "已发布", count: publishedCount }, + { key: "draft", label: "草稿", count: draftCount }, + ]} + /> +
- setForm({ ...form, title })} - placeholder="请输入公告标题" - /> - -
-
- setForm({ ...form, tag })} - placeholder="公告" - /> -
-
-

标签颜色

-
- {COLORS.map((c) => ( -
-
-
- -

发布状态

-
- - role="group" - ariaLabel="发布状态" - value={form.published ? "published" : "draft"} - onChange={(key) => setForm({ ...form, published: key === "published" })} - options={[ - { key: "published", label: "立即发布" }, - { key: "draft", label: "存为草稿" }, - ]} - /> -
- - setForm({ ...form, content })} - placeholder="请输入公告正文,支持 Markdown 语法…" - /> - -
- - -
-
- )} - - {/* 公告列表 */} -
+
+ {/* 左侧列表 */} +
- - 全部公告 + + 公告列表 - 共 {list.length} 条 + 共 {filtered.length} 条
{loadError ? (

{loadError}

- ) : list.length === 0 ? ( + ) : filtered.length === 0 ? ( } - title="还没有公告" - description="点击「新建公告」发布第一条吧" + title={list.length === 0 ? "还没有公告" : "该状态下暂无公告"} + description={list.length === 0 ? "发布第一条站点公告,会出现在首页右栏" : "试试切换筛选条件"} /> ) : ( - list.map((a) => ( -
-
-
- {a.tag} - {!a.published && ( - - 草稿 - - )} - {formatDate(a.created_at)} + filtered.map((a, idx) => { + const active = selectedId === a.id || form?.id === a.id; + const summary = plainSummary(a.content); + return ( +
selectRow(a)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + selectRow(a); + } + }} + className="px-4 sm:px-5 py-3.5 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]" + style={{ + borderTop: idx === 0 ? undefined : "1px solid var(--line)", + background: active ? "var(--accent-soft)" : undefined, + }} + > +
+
+
+

+ {a.title} +

+ + {a.published ? "已发布" : "草稿"} + + + {formatRelative(a.updated_at || a.created_at)} + + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {a.published ? ( + + + + ) : null} + startEdit(a)}> + + + setDeleteTarget(a)} + > + {deletingId === a.id ? ( + + ) : ( + + )} + + +
+ {summary ? ( +

{summary}

+ ) : null} +
-

- {a.title} -

-
-
- {a.published && ( - - - - )} - startEdit(a)}> - - - setDeleteTarget(a)} - > - {deletingId === a.id ? : } -
+ ); + }) + )} + + {list.length === 0 && !loadError ? ( +
+ +
+ ) : null} +
+ + {/* 右侧:表单 / 预览 / 占位 */} +
+ {form ? ( + void save()} + onCancel={() => setForm(null)} + /> + ) : selected ? ( + + ) : ( +
+ } + title="选择或新建公告" + description="在左侧点选一条查看预览,或新建公告" + /> +
+
- )) +
)}
+ {/* 移动端编辑时:若只在右侧显示表单,列表仍在上方可见,符合双栏降级 */} (normalized ? findPreset(normalized) : null), [normalized]); - // 保存提交值:默认靛蓝落库为空串(前端不注入覆盖,保持内置令牌单一来源) const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : ""; const dirty = (savedHex || DEFAULT_ACCENT) !== (normalized || ""); const invalid = draft.trim() !== "" && !normalized; - // 实时预览:合法颜色即应用;离开页面时还原为已保存值,避免未保存预览残留 useEffect(() => { if (normalized) applyAccentTheme(normalized); return () => { @@ -166,305 +163,311 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } }; return ( -
+
-
- -
- {ACCENT_PRESETS.map((p) => { - const active = normalized === p.light; - const isDefault = p.light === DEFAULT_ACCENT; - return ( - - ); - })} -
-
- - - 自定义颜色 - - } - description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正,保证按钮与文字可读" - > -
- -
-
- # - 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 值" - /> -
-
- {pair && ( -
- - - 浅色 {pair.light} - - - - 暗色 {pair.dark} - -
- )} -
- {invalid && ( -

- 颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB) -

- )} - {pair?.adjusted && ( -

- 该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "} - {pair.dark}) -

- )} - {preset && preset.light === DEFAULT_ACCENT && ( -

当前为内置默认色,保存时会清除自定义配置

- )} -
- - -
+ {/* 主题色列 */} +
+ -
- - - e.preventDefault()} - > - 文字链接示例 - +
+ {ACCENT_PRESETS.map((p) => { + const active = normalized === p.light; + const isDefault = p.light === DEFAULT_ACCENT; + return ( + + ); + })}
-
- 未选中 - - 选中标签 - - - 导航选中态 - -
-
-
- - - - 选中的板块卡片 -
-
-
-
+ - - 代码块自动折叠 - - } - description="开启后,帖子与评论中超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。关闭则始终完整展示。" - control={ - void toggleCodeFold()} - label="代码块自动折叠" - /> - } - > - {codeAutoFold ? ( - <> - + {pair && ( +
+ + + 浅色 {pair.light} + + + + 暗色 {pair.dark} + +
+ )} +
+ {invalid && ( +

+ 颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB) +

+ )} + {pair?.adjusted && ( +

+ 该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "} + {pair.dark}) +

+ )} + {preset && preset.light === DEFAULT_ACCENT && ( +

当前为内置默认色,保存时会清除自定义配置

+ )} + - - 界面动画 - - } - description="总开关关闭后全站即时切换(等同减少动态效果)。系统若已开启「减少动态效果」,始终优先生效。开启总开关后可细调子项。" - control={ - void toggleMotionField("ui_animations", !uiAnimations)} - label="界面动画总开关" - /> - } - > - {uiAnimations ? ( -
-
-
-

- 代码折叠动画 -

-

- 代码块展开/收起的高度过渡与跟滚。 -

-
- void toggleMotionField("anim_code_fold", !animCodeFold)} - label="代码折叠动画" - /> + +
+
+ + + e.preventDefault()} + > + 文字链接示例 +
-
-
-

- 平滑滚动 -

-

- 目录跳转、回顶、新内容提示等 scroll 行为。 -

-
- - void toggleMotionField("anim_smooth_scroll", !animSmoothScroll) - } - label="平滑滚动" - /> +
+ 未选中 + + 选中标签 + + + 导航选中态 +
-
-
-

- 装饰入场 -

-

- 列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。 -

+
+
+ + + + 选中的板块卡片
- void toggleMotionField("anim_chrome", !animChrome)} - label="装饰入场" - />
- ) : null} - + +
+ + {/* 代码折叠 + 动画列 */} +
+ + 代码块自动折叠 + + } + description="超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。" + control={ + void toggleCodeFold()} + label="代码块自动折叠" + /> + } + > + {codeAutoFold ? ( + <> + +

+ 超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。 +

+
+ + +
+ + ) : null} +
+ + + 界面动画 + + } + description="总开关关闭后全站即时切换。系统「减少动态效果」始终优先。" + control={ + void toggleMotionField("ui_animations", !uiAnimations)} + label="界面动画总开关" + /> + } + > + {uiAnimations ? ( +
+
+
+

+ 代码折叠动画 +

+

+ 代码块展开/收起的高度过渡与跟滚。 +

+
+ void toggleMotionField("anim_code_fold", !animCodeFold)} + label="代码折叠动画" + /> +
+
+
+

+ 平滑滚动 +

+

+ 目录跳转、回顶、新内容提示等 scroll 行为。 +

+
+ + void toggleMotionField("anim_smooth_scroll", !animSmoothScroll) + } + label="平滑滚动" + /> +
+
+
+

+ 装饰入场 +

+

+ 列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。 +

+
+ void toggleMotionField("anim_chrome", !animChrome)} + label="装饰入场" + /> +
+
+ ) : null} +
+
diff --git a/frontend/app/admin/content/ContentAdmin.tsx b/frontend/app/admin/content/ContentAdmin.tsx index d1cd596..8c1bec9 100644 --- a/frontend/app/admin/content/ContentAdmin.tsx +++ b/frontend/app/admin/content/ContentAdmin.tsx @@ -1,6 +1,13 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type CSSProperties, + type ReactNode, +} from "react"; import Link from "next/link"; import { Check, @@ -38,18 +45,17 @@ import { formatRelative } from "@/lib/format"; import { toast } from "@/lib/toast"; import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime"; import Avatar from "@/components/Avatar"; +import AutolinkText from "@/components/AutolinkText"; import ConfirmDialog from "@/components/ConfirmDialog"; import StaffDeletePostModal from "@/components/StaffDeletePostModal"; import DeleteCommentModal from "@/components/DeleteCommentModal"; import CommentEditHistoryModal from "@/components/CommentEditHistoryModal"; import { AdminEmpty, - AdminIconButton, AdminPageHeader, AdminPager, AdminSearchField, AdminSegmented, - AdminSettingRow, AdminStatusChip, AdminStepper, AdminSwitch, @@ -57,25 +63,98 @@ import { } from "@/components/admin"; const PAGE_SIZE = 15; +const CLAMP_TOGGLE_CHARS = 90; export type ContentKind = "posts" | "comments"; const STATUS_TABS: { key: AdminContentStatus; label: string }[] = [ { key: "live", label: "全部" }, - { key: "published", label: "已发布" }, + { key: "published", label: "发布" }, { key: "pending", label: "待审" }, - { key: "rejected", label: "已拒绝" }, - { key: "deleted", label: "已删除" }, + { key: "rejected", label: "拒绝" }, + { key: "deleted", label: "删除" }, ]; -function excerpt(md: string, max = 120): string { - const text = md +/** 评论摘要:去 Markdown 标记,保留换行供 ClampText 判断 */ +function commentPlain(md: string): string { + return md .replace(/```[\s\S]*?```/g, " ") .replace(/[#>*_`~\-]/g, "") .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") - .replace(/\s+/g, " ") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .replace(/[ \t]{2,}/g, " ") .trim(); - return text.length > max ? `${text.slice(0, max)}…` : text; +} + +function needsClampToggle(text: string): boolean { + if (text.length > CLAMP_TOGGLE_CHARS) return true; + const newlines = (text.match(/\n/g) || []).length; + return newlines >= 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 ( +
+

+ +

+ {showToggle ? ( + + ) : null} +
+ ); +} + +function ContentAction({ + label, + icon, + onClick, + disabled, + danger, + ok, +}: { + label: string; + icon: ReactNode; + onClick: () => void; + disabled?: boolean; + danger?: boolean; + ok?: boolean; +}) { + const tone = danger ? " is-danger" : ok ? " is-ok" : ""; + return ( + + ); } function isPendingRow(status: string, deleted: boolean) { @@ -104,16 +183,98 @@ function PendingActions({ ); } -function emptyCopy(kind: ContentKind, status: AdminContentStatus): { title: string; description?: string } { - if (status === "pending") { - return { - title: kind === "posts" ? "暂无待审帖子" : "暂无待审评论", - description: "新提交的内容会按时间顺序出现在这里,可通过或拒绝", - }; - } - return { title: kind === "posts" ? "暂无帖子" : "暂无评论" }; +/** 策略项:标题与控件同一行,供横向策略条复用 */ +function PolicyChip({ + title, + hint, + control, +}: { + title: string; + hint?: string; + control: ReactNode; +}) { + return ( +
+ {title} +
{control}
+
+ ); } +function ColumnShell({ + icon, + title, + count, + pending, + toolbar, + body, + pager, + className = "", +}: { + icon: ReactNode; + title: string; + count?: number; + pending?: number; + toolbar?: ReactNode; + body: ReactNode; + pager?: ReactNode; + className?: string; +}) { + return ( +
+
+ + {icon} + +

+ {title} +

+ {typeof count === "number" ? ( + {count} + ) : null} + {pending && pending > 0 ? ( + + {pending} + + ) : null} +
+ {toolbar} +
{body}
+ {pager} +
+ ); +} + +function statusCountFor( + kind: "posts" | "comments", + st: AdminContentStatus, + counts: AdminContentCounts +): number | null { + if (kind === "posts") { + if (st === "live") return counts.posts_live; + if (st === "pending") return counts.posts_pending; + if (st === "rejected") return counts.posts_rejected; + if (st === "deleted") return counts.posts_deleted; + if (st === "published") { + return Math.max(0, counts.posts_live - counts.posts_pending - counts.posts_rejected); + } + return null; + } + if (st === "live") return counts.comments_live; + if (st === "pending") return counts.comments_pending; + if (st === "rejected") return counts.comments_rejected; + if (st === "deleted") return counts.comments_deleted; + if (st === "published") { + return Math.max(0, counts.comments_live - counts.comments_pending - counts.comments_rejected); + } + return null; +} + +const pagerClass = "admin-content-col-pager px-3 sm:px-3.5"; + export default function ContentAdmin({ initialKind, initialStatus, @@ -137,27 +298,39 @@ export default function ContentAdmin({ canConfigureSettings: boolean; initialSettings: PublicSettings; }) { - const [kind, setKind] = useState(initialKind); - const [status, setStatus] = useState(initialStatus); + // 窄屏分段:lg 以下只显示一栏;PC 双栏同时可见 + const [mobileKind, setMobileKind] = useState(initialKind); const [counts, setCounts] = useState(initialCounts); const [posts, setPosts] = useState(initialPosts); const [postsTotal, setPostsTotal] = useState(initialPostsTotal); const [postsPage, setPostsPage] = useState(1); + const [postsStatus, setPostsStatus] = useState(initialStatus); + const [postsKeyword, setPostsKeyword] = useState(""); + const [postsQ, setPostsQ] = useState(""); + const [postsLoading, setPostsLoading] = useState(false); + const [postsError, setPostsError] = useState( + initError && initialKind === "posts" ? initError : "" + ); + const postsSeq = useRef(0); const [comments, setComments] = useState(initialComments); const [commentsTotal, setCommentsTotal] = useState(initialCommentsTotal); const [commentsPage, setCommentsPage] = useState(1); + const [commentsStatus, setCommentsStatus] = useState(initialStatus); + const [commentsKeyword, setCommentsKeyword] = useState(""); + const [commentsQ, setCommentsQ] = useState(""); + const [commentsLoading, setCommentsLoading] = useState(false); + const [commentsError, setCommentsError] = useState( + initError && initialKind === "comments" ? initError : "" + ); + const commentsSeq = useRef(0); - const [keyword, setKeyword] = useState(""); - const [appliedQ, setAppliedQ] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(initError); - const loadSeq = useRef(0); const [actingKey, setActingKey] = useState(null); const [trustOn, setTrustOn] = useState(initialSettings.trust_reviewed_publish); const [cooldown, setCooldown] = useState(initialSettings.post_cooldown_hours); + const [postLinkNewTab, setPostLinkNewTab] = useState(initialSettings.post_link_new_tab !== false); const [savingPolicy, setSavingPolicy] = useState(false); const [confirm, setConfirm] = useState<{ @@ -179,59 +352,45 @@ export default function ContentAdmin({ }, []); const loadPosts = useCallback(async (page: number, st: AdminContentStatus, q: string) => { - const seq = ++loadSeq.current; - setLoading(true); + const seq = ++postsSeq.current; + setPostsLoading(true); try { const res = await apiAdminContentPosts(page, st, q); - if (seq !== loadSeq.current) return; + if (seq !== postsSeq.current) return; setPosts(res.posts); setPostsTotal(res.total); setPostsPage(page); - setKind("posts"); - setStatus(st); - setAppliedQ(q); - setError(""); + setPostsStatus(st); + setPostsQ(q); + setPostsError(""); } catch (e) { - if (seq !== loadSeq.current) return; - setError(e instanceof Error ? e.message : "获取帖子失败"); + if (seq !== postsSeq.current) return; + setPostsError(e instanceof Error ? e.message : "获取帖子失败"); } finally { - if (seq === loadSeq.current) setLoading(false); + if (seq === postsSeq.current) setPostsLoading(false); } }, []); const loadComments = useCallback(async (page: number, st: AdminContentStatus, q: string) => { - const seq = ++loadSeq.current; - setLoading(true); + const seq = ++commentsSeq.current; + setCommentsLoading(true); try { const res = await apiAdminContentComments(page, st, q); - if (seq !== loadSeq.current) return; + if (seq !== commentsSeq.current) return; setComments(res.comments); setCommentsTotal(res.total); setCommentsPage(page); - setKind("comments"); - setStatus(st); - setAppliedQ(q); - setError(""); + setCommentsStatus(st); + setCommentsQ(q); + setCommentsError(""); } catch (e) { - if (seq !== loadSeq.current) return; - setError(e instanceof Error ? e.message : "获取评论失败"); + if (seq !== commentsSeq.current) return; + setCommentsError(e instanceof Error ? e.message : "获取评论失败"); } finally { - if (seq === loadSeq.current) setLoading(false); + if (seq === commentsSeq.current) setCommentsLoading(false); } }, []); - const reload = (page = 1) => { - if (kind === "posts") void loadPosts(page, status, appliedQ); - else void loadComments(page, status, appliedQ); - refreshCounts(); - }; - - const reloadAfterItemChange = () => { - const p = kind === "posts" ? postsPage : commentsPage; - const len = kind === "posts" ? posts.length : comments.length; - reload(len <= 1 && p > 1 ? p - 1 : p); - }; - useEffect(() => { refreshCounts(); }, [refreshCounts]); @@ -241,38 +400,30 @@ export default function ContentAdmin({ return off; }, [refreshCounts]); - const switchKind = (k: ContentKind) => { - if (k === kind) return; - setError(""); - if (k === "posts") void loadPosts(1, status, appliedQ); - else void loadComments(1, status, appliedQ); + const reloadPosts = (page?: number) => { + const p = page ?? postsPage; + const len = posts.length; + void loadPosts(len <= 1 && p > 1 && page === undefined ? p - 1 : p, postsStatus, postsQ); + refreshCounts(); }; - const switchStatus = (st: AdminContentStatus) => { - if (st === status) return; - setError(""); - if (kind === "posts") void loadPosts(1, st, appliedQ); - else void loadComments(1, st, appliedQ); + const reloadComments = (page?: number) => { + const p = page ?? commentsPage; + const len = comments.length; + void loadComments( + len <= 1 && p > 1 && page === undefined ? p - 1 : p, + commentsStatus, + commentsQ + ); + refreshCounts(); }; - const applySearch = () => { - const q = keyword.trim(); - if (kind === "posts") void loadPosts(1, status, q); - else void loadComments(1, status, q); - }; - - const clearSearch = () => { - setKeyword(""); - if (kind === "posts") void loadPosts(1, status, ""); - else void loadComments(1, status, ""); - }; - - const runAction = (key: string, fn: () => Promise, okMsg: string) => { + const runAction = (key: string, fn: () => Promise, okMsg: string, after: () => void) => { setActingKey(key); fn() .then(() => { toast(okMsg, "ok"); - reloadAfterItemChange(); + after(); }) .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .finally(() => setActingKey(null)); @@ -286,7 +437,7 @@ export default function ContentAdmin({ confirmLabel?: string ) => setConfirm({ title, message, run, danger, confirmLabel }); - const askReject = (kindNow: ContentKind, id: number) => { + const askReject = (kindNow: "posts" | "comments", id: number) => { askConfirm( kindNow === "posts" ? "拒绝这篇帖子?" : "拒绝这条评论?", "拒绝后作者将收到通知,内容不会公开。", @@ -294,7 +445,8 @@ export default function ContentAdmin({ if (kindNow === "posts") await apiAdminRejectPost(id); else await apiAdminRejectComment(id); toast("已拒绝,作者将收到通知", "ok"); - reloadAfterItemChange(); + if (kindNow === "posts") reloadPosts(); + else reloadComments(); }, true, "确认拒绝" @@ -307,7 +459,12 @@ export default function ContentAdmin({ try { const res = await apiUpdateSiteSettings({ trust_reviewed_publish: !trustOn }); setTrustOn(res.trust_reviewed_publish); - toast(res.trust_reviewed_publish ? "已开启过审免审" : "已关闭过审免审", "ok"); + toast( + res.trust_reviewed_publish + ? "已开启过审免审(帖子与评论)" + : "已关闭过审免审(帖子与评论将按规则待审)", + "ok" + ); } catch (e) { toast(e instanceof Error ? e.message : "保存失败"); } finally { @@ -325,7 +482,7 @@ export default function ContentAdmin({ toast( res.post_cooldown_hours === 0 ? "已关闭新用户发帖冷静期" - : `冷静期已设为 ${res.post_cooldown_hours} 小时`, + : `新用户发帖冷静期已设为 ${res.post_cooldown_hours} 小时(评论与聊天不受限)`, "ok" ); } catch (e) { @@ -335,228 +492,197 @@ export default function ContentAdmin({ } }; - const total = kind === "posts" ? postsTotal : commentsTotal; - const page = kind === "posts" ? postsPage : commentsPage; - - const statusCount = (st: AdminContentStatus) => { - if (kind === "posts") { - if (st === "live") return counts.posts_live; - if (st === "pending") return counts.posts_pending; - if (st === "rejected") return counts.posts_rejected; - if (st === "deleted") return counts.posts_deleted; - if (st === "published") { - return Math.max(0, counts.posts_live - counts.posts_pending - counts.posts_rejected); - } - return null; + const togglePostLinkNewTab = async () => { + if (!canConfigureSettings || savingPolicy) return; + const next = !postLinkNewTab; + setSavingPolicy(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 { + setSavingPolicy(false); } - if (st === "live") return counts.comments_live; - if (st === "pending") return counts.comments_pending; - if (st === "rejected") return counts.comments_rejected; - if (st === "deleted") return counts.comments_deleted; - if (st === "published") { - return Math.max(0, counts.comments_live - counts.comments_pending - counts.comments_rejected); - } - return null; }; - const goPage = (p: number) => { - if (kind === "posts") void loadPosts(p, status, appliedQ); - else void loadComments(p, status, appliedQ); - }; - - const empty = emptyCopy(kind, status); - - return ( -
- +
+ + ariaLabel="帖子状态筛选" + size="sm" + value={postsStatus} + onChange={(st) => { + if (st === postsStatus) return; + void loadPosts(1, st, postsQ); + }} + options={STATUS_TABS.map((t) => ({ + key: t.key, + label: t.label, + count: statusCountFor("posts", t.key, counts) ?? undefined, + }))} + /> +
+ void loadPosts(1, postsStatus, postsKeyword.trim())} + onClear={() => { + setPostsKeyword(""); + void loadPosts(1, postsStatus, ""); + }} + placeholder="搜索帖子标题…" + ariaLabel="搜索帖子" /> +
+ ); - {canConfigureSettings && ( -
- void toggleTrust()} - label="已过审用户后续免审" + const commentsToolbar = ( +
+
+ + ariaLabel="评论状态筛选" + size="sm" + value={commentsStatus} + onChange={(st) => { + if (st === commentsStatus) return; + void loadComments(1, st, commentsQ); + }} + options={STATUS_TABS.map((t) => ({ + key: t.key, + label: t.label, + count: statusCountFor("comments", t.key, counts) ?? undefined, + }))} + /> +
+ void loadComments(1, commentsStatus, commentsKeyword.trim())} + onClear={() => { + setCommentsKeyword(""); + void loadComments(1, commentsStatus, ""); + }} + placeholder="搜索评论正文…" + ariaLabel="搜索评论" + /> +
+ ); + + const postsBody = ( + <> + {postsError ?

{postsError}

: null} + {postsLoading && posts.length === 0 ? ( +
+ +
+ ) : posts.length === 0 ? ( + } + title={postsStatus === "pending" ? "暂无待审帖子" : "暂无帖子"} + description={ + postsStatus === "pending" ? "新提交的帖子会按时间出现在这里" : undefined + } + /> + ) : ( + posts.map((p, idx) => { + const key = `p-${p.id}`; + const busy = actingKey === key; + const chip = contentStatusChip(p.status, p.deleted); + const pending = isPendingRow(p.status, p.deleted); + return ( +
+ - } - /> - - - - -
- } - /> -
- )} - -
-
-
- - -
- -
- - ariaLabel="状态筛选" - size="sm" - value={status} - onChange={switchStatus} - options={STATUS_TABS.map((t) => ({ - key: t.key, - label: t.label, - count: statusCount(t.key) ?? undefined, - }))} - /> -
-
- -
-
- - {error ?

{error}

: null} - {kind === "posts" && posts.length === 0 ? ( - } title={empty.title} description={empty.description} /> - ) : kind === "comments" && comments.length === 0 ? ( - } title={empty.title} description={empty.description} /> - ) : kind === "posts" ? ( - posts.map((p, idx) => { - const key = `p-${p.id}`; - const busy = actingKey === key; - const chip = contentStatusChip(p.status, p.deleted); - const pending = isPendingRow(p.status, p.deleted); - return ( -
- -
-
- {chip.label} - {p.board?.name} - - {formatRelative(p.created_at)} - -
- {p.deleted ? ( -

- {p.title} -

- ) : ( - - {p.title} - - )} -

- {p.user?.nickname} · {p.comment_count} 评 -

+
+
+ + {p.user?.nickname || p.user?.username || "?"} + + {chip.label} + {p.board?.name} + + {formatRelative(p.created_at)} +
-
- {pending && ( + {p.deleted ? ( +

+ {p.title} +

+ ) : ( + + {p.title} + + )} +

{p.comment_count} 评

+
+ {pending ? ( - runAction(key, () => apiAdminApprovePost(p.id), "已通过审核,内容已公开") + runAction( + key, + () => apiAdminApprovePost(p.id), + "已通过审核,内容已公开", + () => reloadPosts() + ) } onReject={() => askReject("posts", p.id)} /> - )} + ) : null} {p.deleted ? ( <> - - runAction(key, () => apiAdminRestorePost(p.id), "帖子已恢复") + icon={ + busy ? ( + + ) : ( + + ) } - > - {busy ? ( - - ) : ( - - )} - - + runAction( + key, + () => apiAdminRestorePost(p.id), + "帖子已恢复", + () => reloadPosts() + ) + } + /> + } onClick={() => askConfirm( "彻底删除帖子", @@ -564,110 +690,139 @@ export default function ContentAdmin({ () => apiAdminPurgePost(p.id).then(() => { toast("帖子已彻底删除", "ok"); - reloadAfterItemChange(); + reloadPosts(); }), true ) } - > - - + /> ) : ( - } onClick={() => setStaffDeletePost(p)} - > - - + /> )}
- ); - }) - ) : ( - comments.map((c, idx) => { - const key = `c-${c.id}`; - const busy = actingKey === key; - const chip = contentStatusChip(c.status, c.deleted); - const pending = isPendingRow(c.status, c.deleted); - return ( -
- -
-
- {chip.label} - {c.board?.name} - - {formatRelative(c.created_at)} - -
-

- {excerpt(c.content)} -

-

- {c.user?.nickname} ·{" "} - {c.deleted ? ( - c.post_title - ) : ( - - {c.post_title || `帖子 #${c.post_id}`} - - )} -

+
+ ); + }) + )} + + ); + + const commentsBody = ( + <> + {commentsError ?

{commentsError}

: null} + {commentsLoading && comments.length === 0 ? ( +
+ +
+ ) : comments.length === 0 ? ( + } + title={commentsStatus === "pending" ? "暂无待审评论" : "暂无评论"} + description={ + commentsStatus === "pending" ? "新提交的评论会按时间出现在这里" : undefined + } + /> + ) : ( + comments.map((c, idx) => { + const key = `c-${c.id}`; + const busy = actingKey === key; + const chip = contentStatusChip(c.status, c.deleted); + const pending = isPendingRow(c.status, c.deleted); + const plain = commentPlain(c.content); + return ( +
+ +
+
+ + {c.user?.nickname || c.user?.username || "?"} + + {chip.label} + {c.board?.name} + + {formatRelative(c.created_at)} +
-
- {pending && ( + +

+ {c.deleted ? ( + c.post_title + ) : ( + + {c.post_title || `帖子 #${c.post_id}`} + + )} +

+
+ {pending ? ( - runAction(key, () => apiAdminApproveComment(c.id), "已通过审核,内容已公开") + runAction( + key, + () => apiAdminApproveComment(c.id), + "已通过审核,内容已公开", + () => reloadComments() + ) } onReject={() => askReject("comments", c.id)} /> - )} - } onClick={() => setHistoryCommentId(c.id)} - > - - + /> {c.deleted ? ( <> - - runAction(key, () => apiAdminRestoreComment(c.id), "评论已恢复") + icon={ + busy ? ( + + ) : ( + + ) } - > - {busy ? ( - - ) : ( - - )} - - + runAction( + key, + () => apiAdminRestoreComment(c.id), + "评论已恢复", + () => reloadComments() + ) + } + /> + } onClick={() => askConfirm( "彻底删除评论", @@ -675,38 +830,179 @@ export default function ContentAdmin({ () => apiAdminPurgeComment(c.id).then(() => { toast("评论已彻底删除", "ok"); - reloadAfterItemChange(); + reloadComments(); }), true ) } - > - - + /> ) : ( - } onClick={() => setStaffDeleteComment(c)} - > - - + /> )}
- ); - }) - )} +
+ ); + }) + )} + + ); + const postsCol = ( + } + title="帖子" + count={counts.posts_live} + pending={counts.posts_pending} + toolbar={postsToolbar} + body={postsBody} + pager={ void loadPosts(p, postsStatus, postsQ)} + className={pagerClass} /> + } + className={mobileKind === "posts" ? "max-lg:flex" : "max-lg:hidden"} + /> + ); + + const commentsCol = ( + } + title="评论" + count={counts.comments_live} + pending={counts.comments_pending} + toolbar={commentsToolbar} + body={commentsBody} + pager={ + void loadComments(p, commentsStatus, commentsQ)} + className={pagerClass} + /> + } + className={mobileKind === "comments" ? "max-lg:flex" : "max-lg:hidden"} + /> + ); + + return ( +
+ + + {canConfigureSettings && ( +
+ 共用策略 +
+ void toggleTrust()} + label="过审免审(帖子与评论)" + /> + } + /> + + + +
+ } + /> + void togglePostLinkNewTab()} + label="外链新开(帖子与评论)" + /> + } + /> +
+
+ )} + + {/* 窄屏:顶部分段切换双栏 */} +
+ + +
+ +
+ {postsCol} + {commentsCol}
{ toast("已移入回收站", "ok"); setStaffDeletePost(null); - reloadAfterItemChange(); + reloadPosts(); }) .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .finally(() => setStaffDeleteBusy(false)); @@ -761,7 +1057,7 @@ export default function ContentAdmin({ .then(() => { toast("已移入回收站", "ok"); setStaffDeleteComment(null); - reloadAfterItemChange(); + reloadComments(); }) .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) .finally(() => setStaffDeleteCommentBusy(false)); diff --git a/frontend/app/admin/content/page.tsx b/frontend/app/admin/content/page.tsx index 4ef6025..d16a92f 100644 --- a/frontend/app/admin/content/page.tsx +++ b/frontend/app/admin/content/page.tsx @@ -25,7 +25,8 @@ function parseStatus(v?: string): AdminContentStatus { } function parseKind(v?: string): ContentKind { - return v === "comments" ? "comments" : "posts"; + if (v === "comments") return "comments"; + return "posts"; } interface ContentPageProps { @@ -48,35 +49,37 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp let initialComments: AdminContentComment[] = []; let initialCommentsTotal = 0; let initError = ""; - const [listSettled, counts] = await Promise.all([ - initialKind === "comments" - ? fetchAdminContentComments(1, initialStatus, cookie || undefined) - .then((res) => ({ ok: true as const, kind: "comments" as const, res })) - .catch((e) => ({ - ok: false as const, - error: e instanceof Error ? e.message : "获取内容列表失败", - })) - : fetchAdminContentPosts(1, initialStatus, cookie || undefined) - .then((res) => ({ ok: true as const, kind: "posts" as const, res })) - .catch((e) => ({ - ok: false as const, - error: e instanceof Error ? e.message : "获取内容列表失败", - })), - fetchAdminContentCounts(cookie || undefined), - ]); - if (listSettled.ok) { - if (listSettled.kind === "posts") { - initialPosts = listSettled.res.posts; - initialPostsTotal = listSettled.res.total; - } else { - initialComments = listSettled.res.comments; - initialCommentsTotal = listSettled.res.total; - } - } else { - initError = listSettled.error; - } - const settings = await getPublicSettingsCached(); + // PC 双栏同时展示:SSR 并行预取帖子与评论 + const [postsSettled, commentsSettled, counts, settings] = await Promise.all([ + fetchAdminContentPosts(1, initialStatus, cookie || undefined) + .then((res) => ({ ok: true as const, res })) + .catch((e) => ({ + ok: false as const, + error: e instanceof Error ? e.message : "获取帖子失败", + })), + fetchAdminContentComments(1, initialStatus, cookie || undefined) + .then((res) => ({ ok: true as const, res })) + .catch((e) => ({ + ok: false as const, + error: e instanceof Error ? e.message : "获取评论失败", + })), + fetchAdminContentCounts(cookie || undefined), + getPublicSettingsCached(), + ]); + + if (postsSettled.ok) { + initialPosts = postsSettled.res.posts; + initialPostsTotal = postsSettled.res.total; + } else { + initError = postsSettled.error; + } + if (commentsSettled.ok) { + initialComments = commentsSettled.res.comments; + initialCommentsTotal = commentsSettled.res.total; + } else if (!initError) { + initError = commentsSettled.error; + } return ( 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 ( +
+ + +
+ } + /> + } + color="var(--clay)" + soft="var(--clay-soft)" + /> + } + color="var(--gold)" + soft="var(--gold-soft)" + /> + } + color={net >= 0 ? "var(--ok)" : "var(--danger)"} + soft={net >= 0 ? "var(--ok-soft)" : "var(--danger-soft)"} + /> +
+ +
+ + {checkinSeries.length === 0 ? ( +

暂无签到数据

+ ) : ( + + )} +
+ + + {ledgerByReason.length === 0 ? ( +

暂无流水

+ ) : ( + + )} +
+ + + {deltaSeries.length === 0 ? ( +

暂无数据

+ ) : ( + + )} +
+ + + {postsByType.length === 0 ? ( +

暂无数据

+ ) : ( + + )} +
+
+ +
+
+ 最近流水 +
+ {(stats.recent_ledger || []).length === 0 ? ( +

暂无流水

+ ) : ( +
    + {stats.recent_ledger.map((it, idx) => ( +
  • +
    +

    + {pointReasonLabel(it.reason)} + {it.note ? ` · ${it.note}` : ""} +

    +

    {formatRelative(it.created_at)}

    +
    + = 0 ? "var(--accent)" : "var(--danger)" }} + > + {it.delta >= 0 ? `+${it.delta}` : it.delta} + +
  • + ))} +
+ )} +
+ +

+ 用户可在{" "} + + /points + {" "} + 查看个人流水。 +

+
+ ); +} diff --git a/frontend/app/admin/economy/page.tsx b/frontend/app/admin/economy/page.tsx index 6262cd8..f60ebbd 100644 --- a/frontend/app/admin/economy/page.tsx +++ b/frontend/app/admin/economy/page.tsx @@ -1,23 +1,16 @@ import type { Metadata } from "next"; -import Link from "next/link"; import { cookies } from "next/headers"; -import { Coins, Flame, Gift, Layers } from "lucide-react"; import { fetchAdminEconomyStats } from "@/lib/api"; import { authCookieHeader } from "@/lib/cookies"; import { getMeCached } from "@/lib/serverData"; import { isAdminOrAbove } from "@/lib/roles"; -import { postTypeLabel } from "@/lib/postMeta"; -import { pointReasonLabel } from "@/lib/points"; -import { formatRelative } from "@/lib/format"; -import { AdminPageHeader, AdminStatCard } from "@/components/admin"; import Forbidden from "../Forbidden"; +import EconomyClient from "./EconomyClient"; export const metadata: Metadata = { title: "积分看板", }; -const fmt = (n: number) => n.toLocaleString("zh-CN"); - export default async function AdminEconomyPage() { const cookie = authCookieHeader(await cookies()); const me = await getMeCached(cookie || undefined); @@ -40,121 +33,5 @@ export default async function AdminEconomyPage() { ); } - const typeEntries = Object.entries(stats.posts_by_type || {}).sort( - (a, b) => b[1] - a[1] - ); - - return ( -
- - -
- } - /> - } - color="var(--clay)" - soft="var(--clay-soft)" - /> - } - color="var(--gold)" - soft="var(--gold-soft)" - /> - } - /> -
- -
-
-
- 帖子类型分布 -
- {typeEntries.length === 0 ? ( -

暂无数据

- ) : ( -
    - {typeEntries.map(([t, n], idx) => ( -
  • - - {postTypeLabel(t)} - - {fmt(n)} -
  • - ))} -
- )} -
- -
-
- 最近流水 -
- {(stats.recent_ledger || []).length === 0 ? ( -

暂无流水

- ) : ( -
    - {stats.recent_ledger.map((it, idx) => ( -
  • -
    -

    - {pointReasonLabel(it.reason)} - {it.note ? ` · ${it.note}` : ""} -

    -

    {formatRelative(it.created_at)}

    -
    - = 0 ? "var(--accent)" : "var(--danger)" }} - > - {it.delta >= 0 ? `+${it.delta}` : it.delta} - -
  • - ))} -
- )} -
-
- -

- 用户可在{" "} - - /points - {" "} - 查看个人流水。 -

-
- ); + return ; } diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx index 157bf49..b0d7e78 100644 --- a/frontend/app/admin/layout.tsx +++ b/frontend/app/admin/layout.tsx @@ -2,10 +2,10 @@ import type { Metadata } from "next"; import Link from "next/link"; import { cookies } from "next/headers"; import { ShieldAlert } from "lucide-react"; -import { authCookieHeader } from "@/lib/cookies"; -import { getMeCached } from "@/lib/serverData"; +import { authCookieHeader, TOKEN_COOKIE } from "@/lib/cookies"; +import { getMeCached, getPublicSettingsCached } from "@/lib/serverData"; import { isStaff } from "@/lib/roles"; -import { AdminSidebar, AdminNavMobile } from "./AdminNav"; +import AdminShell from "@/components/admin/AdminShell"; // 整个 /admin 树不进入索引(子页面无需重复声明) export const metadata: Metadata = { @@ -15,42 +15,51 @@ export const metadata: Metadata = { // 权限以 Go 端 RequireStaff 为唯一判定;这里仅决定管理界面是否直出, // 非管理团队访问任意 /admin/* 时 SSR 统一给出无权限提示(所有写接口仍由后端 403 兜底)。 export default async function AdminLayout({ children }: { children: React.ReactNode }) { - const cookie = authCookieHeader(await cookies()); + const cookieStore = await cookies(); + const cookie = authCookieHeader(cookieStore); const me = await getMeCached(cookie || undefined); + const settings = await getPublicSettingsCached(); + const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light"; if (!me.user || !isStaff(me.user.role)) { return ( -
- - - -

- 需要管理员权限 -

-

此区域仅供站点管理员访问,如有疑问请联系站长。

- - 返回首页 - +
+
+ + + +

+ 需要管理员权限 +

+

此区域仅供站点管理员访问,如有疑问请联系站长。

+ + 返回首页 + +
); } + // 未读仅在有 access cookie 时才可信;壳层铃铛用 SSR 直出避免挂载后才出红点 + const unread = cookieStore.get(TOKEN_COOKIE)?.value ? me.unread_count : 0; + return ( -
- -
- {/* sticky 必须钉在 aside 自身:内层与侧栏同高时等于没钉住,会跟着主栏一起滚 */} - -
{children}
-
-
+ + {children} + ); } diff --git a/frontend/app/admin/messages/MessagesAdmin.tsx b/frontend/app/admin/messages/MessagesAdmin.tsx new file mode 100644 index 0000000..eddfa92 --- /dev/null +++ b/frontend/app/admin/messages/MessagesAdmin.tsx @@ -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 ( +
+

+ +

+ {showToggle ? ( + + ) : null} +
+ ); +} + +function ColumnShell({ + icon, + title, + count, + toolbar, + body, + pager, + className = "", +}: { + icon: ReactNode; + title: string; + count?: number; + toolbar?: ReactNode; + body: ReactNode; + pager?: ReactNode; + className?: string; +}) { + return ( +
+
+ + {icon} + +

+ {title} +

+ {typeof count === "number" ? ( + {count} + ) : null} +
+ {toolbar} +
{body}
+ {pager} +
+ ); +} + +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 ( + + + + + {sender.name} + + + {peer ? ( + <> + + → + + + + + {peer.name} + + + + ) : null} + + ); +} + +function RecallButton({ + busy, + onClick, +}: { + busy: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function useMessageColumn(roomType: MsgKind) { + const [messages, setMessages] = useState([]); + 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("group"); + const group = useMessageColumn("group"); + const direct = useMessageColumn("direct"); + const [actingKey, setActingKey] = useState(null); + const [confirm, setConfirm] = useState<{ + title: string; + message: string; + run: () => Promise; + } | 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 = ( +
+

含全站大厅与普通群聊

+ void group.load(1, group.keyword.trim())} + onClear={() => { + group.setKeyword(""); + void group.load(1, ""); + }} + placeholder="搜索内容、群名或用户…" + ariaLabel="搜索群聊消息" + /> +
+ ); + + const directToolbar = ( +
+

一对一私信;按双方会话归档

+ void direct.load(1, direct.keyword.trim())} + onClear={() => { + direct.setKeyword(""); + void direct.load(1, ""); + }} + placeholder="搜索内容或参与者…" + ariaLabel="搜索私聊消息" + /> +
+ ); + + const groupBody = ( + <> + {group.error ?

{group.error}

: null} + {group.loading && group.messages.length === 0 ? ( +
+ +
+ ) : group.messages.length === 0 ? ( + } + title="暂无群聊消息" + description="大厅与群聊消息会按时间出现在这里" + /> + ) : ( + group.messages.map((m, idx) => { + const key = `m-${m.id}`; + const busy = actingKey === key; + const recalled = !!m.recalled_at; + return ( +
+ +
+
+ + {m.sender_name || "?"} + + {recalled ? 已撤回 : null} + {m.is_default ? ( + 大厅 + ) : ( + + {m.room_name} + + )} + + {formatRelative(m.created_at)} + +
+ + {!recalled ? ( +
+ askRecall(m, group.markRecalled)} /> +
+ ) : null} +
+
+ ); + }) + )} + + ); + + const directBody = ( + <> + {direct.error ?

{direct.error}

: null} + {direct.loading && direct.messages.length === 0 ? ( +
+ +
+ ) : direct.messages.length === 0 ? ( + } + 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 ( +
+
+
+
+ + {recalled ? 已撤回 : null} + + {formatRelative(m.created_at)} + +
+
+
+ + {!recalled ? ( +
+ askRecall(m, direct.markRecalled)} /> +
+ ) : null} +
+
+
+ ); + }) + )} + + ); + + const groupCol = ( + } + title="群聊" + count={!group.loading || group.total > 0 ? group.total : undefined} + toolbar={groupToolbar} + body={groupBody} + pager={ + void group.load(p, group.appliedQ)} + className={pagerClass} + /> + } + className={mobileKind === "group" ? "max-lg:flex" : "max-lg:hidden"} + /> + ); + + const directCol = ( + } + title="私聊" + count={!direct.loading || direct.total > 0 ? direct.total : undefined} + toolbar={directToolbar} + body={directBody} + pager={ + void direct.load(p, direct.appliedQ)} + className={pagerClass} + /> + } + className={mobileKind === "direct" ? "max-lg:flex" : "max-lg:hidden"} + /> + ); + + return ( +
+ + +
+ + +
+ +
+ {groupCol} + {directCol} +
+ + !confirmBusy && setConfirm(null)} + onConfirm={() => { + if (!confirm) return; + setConfirmBusy(true); + confirm + .run() + .catch((e) => toast(e instanceof Error ? e.message : "操作失败")) + .finally(() => { + setConfirmBusy(false); + setConfirm(null); + }); + }} + /> +
+ ); +} diff --git a/frontend/app/admin/messages/page.tsx b/frontend/app/admin/messages/page.tsx new file mode 100644 index 0000000..1c86d6f --- /dev/null +++ b/frontend/app/admin/messages/page.tsx @@ -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 ; + } + + return ; +} diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index 7c5c38e..722e168 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -2,27 +2,28 @@ import type { Metadata } from "next"; import Link from "next/link"; import { cookies } from "next/headers"; import { - UserPlus, - LayoutGrid, ClipboardCheck, ChevronRight, - MessageSquareText, + Eye, FileText, - Users, + MessageSquareText, LayoutDashboard, Coins, + Users, + Activity, } from "lucide-react"; import { fetchOverview, fetchAdminPendingCounts, + fetchAdminAnalytics, type PendingCounts, + type AnalyticsOverview, } from "@/lib/api"; import { authCookieHeader } from "@/lib/cookies"; import { getMeCached } from "@/lib/serverData"; import { canModerateAny, isSuperOrOwner, isAdminOrAbove } from "@/lib/roles"; -import Avatar from "@/components/Avatar"; import { AdminPageHeader, AdminStatCard } from "@/components/admin"; -import { formatRelative } from "@/lib/format"; +import DashboardClient from "./DashboardClient"; export const metadata: Metadata = { title: "管理仪表盘", @@ -38,93 +39,76 @@ function greeting(hour: number) { return "晚上好"; } -function PanelHead({ title, extra }: { title: string; extra?: string }) { - return ( -
- - {title} - - {extra && ( - - {extra} - - )} -
- ); -} - -/** 待办:待审帖子 / 评论,进入内容管理的待审筛选 */ -function PendingWorkPanel({ counts }: { counts: PendingCounts }) { +/** 紧凑待审条:跳转内容管理待审筛选 */ +function PendingStrip({ counts }: { counts: PendingCounts }) { const total = counts.posts + counts.comments; const idle = total === 0; + const href = + counts.posts > 0 || counts.comments === 0 + ? "/admin/content?status=pending" + : "/admin/content?status=pending&kind=comments"; return ( 0 || counts.comments === 0 - ? "/admin/content?status=pending" - : "/admin/content?status=pending&kind=comments" - } - className="panel hover-lift relative block p-4 sm:p-5 mb-4 group" + href={href} + className="panel hover-lift mb-4 px-4 sm:px-5 py-3 flex items-center gap-3 group" > -
- - + + + +
+ + 待审内容 + + {idle ? ( + 暂无待审 + ) : ( + {fmt(total)} 项 + )} + + + 帖子 + + {fmt(counts.posts)} + + + + + 评论 + + {fmt(counts.comments)} + -
-
-

- 待审内容 -

- {idle ? ( - - 暂无待审 - - ) : ( - - {fmt(total)} 项待处理 - - )} -
-
- - - 待审帖子 - - {fmt(counts.posts)} - - - - - 待审评论 - - {fmt(counts.comments)} - - -
-
-
+ ); } +const emptyAnalytics = (): AnalyticsOverview => ({ + range_days: 7, + today_pv: 0, + today_uv: 0, + visits: [], + posts_series: [], + comments_series: [], + registers_series: [], + boards: [], + post_types: [], + top_viewed: [], +}); + export default async function AdminDashboardPage() { const cookie = authCookieHeader(await cookies()); const me = await getMeCached(cookie || undefined); @@ -132,11 +116,12 @@ export default async function AdminDashboardPage() { const canUsers = isSuperOrOwner(me.user?.role); const canEconomy = isAdminOrAbove(me.user?.role); - const [overview, pendingCounts] = await Promise.all([ + const [overview, pendingCounts, analytics] = await Promise.all([ fetchOverview(cookie).catch(() => null), canMod ? fetchAdminPendingCounts(cookie).catch(() => ({ posts: 0, comments: 0 })) : Promise.resolve(null), + fetchAdminAnalytics(cookie, "7d").catch(() => emptyAnalytics()), ]); const now = new Date(); @@ -148,10 +133,7 @@ export default async function AdminDashboardPage() { }).format(now); const stats = overview?.stats; - const boards = overview?.boards ?? []; const newUsers = overview?.new_users ?? []; - const topBoards = [...boards].sort((a, b) => b.post_count - a.post_count).slice(0, 6); - const maxBoardCount = Math.max(1, ...topBoards.map((b) => b.post_count)); return (
@@ -159,138 +141,58 @@ export default async function AdminDashboardPage() { icon={LayoutDashboard} title={`${greeting(now.getHours())},${me.user?.nickname ?? ""}`} description={`${dateLabel} · 站点管理`} + actions={ + canEconomy ? ( + + + 积分看板 + + ) : undefined + } /> - {canMod && pendingCounts && } - - {canEconomy && ( -
- - } - color="var(--gold)" - soft="var(--gold-soft)" - /> - - {stats && ( - } - /> - )} -
- )} - -
-
- - {newUsers.length === 0 ? ( -

暂无成员数据

- ) : ( - newUsers.slice(0, 6).map((u) => ( - - - - - {u.nickname} - - - @{u.username} - - - - - {formatRelative(u.created_at)}加入 - - - )) - )} - {canUsers && ( - - - 前往用户权限 - - - )} -
- -
- - {topBoards.length === 0 ? ( -

暂无板块数据

- ) : ( - topBoards.map((b) => ( - - - - - - - - {b.name} - - - {fmt(b.post_count)} - - - - - - - - )) - )} -
+
+ } + /> + } + color="var(--ok)" + soft="var(--ok-soft)" + /> + } + color="var(--gold)" + soft="var(--gold-soft)" + /> + } + color="var(--clay)" + soft="var(--clay-soft)" + />
+ + {canMod && pendingCounts && } + +
); } diff --git a/frontend/app/admin/settings/SettingsAdmin.tsx b/frontend/app/admin/settings/SettingsAdmin.tsx index bf24217..4ae7a31 100644 --- a/frontend/app/admin/settings/SettingsAdmin.tsx +++ b/frontend/app/admin/settings/SettingsAdmin.tsx @@ -1,30 +1,149 @@ "use client"; -import { Globe, Loader2, MessageSquare, Save, UserPlus } from "lucide-react"; -import { useState } from "react"; +import { + Globe, + Loader2, + MessageSquare, + Save, + UserPlus, + ExternalLink, + Shield, + Mail, + HardDrive, + Ban, + Wrench, + Palette, + FileText, +} from "lucide-react"; +import { useEffect, useState, type ReactNode } from "react"; import { AdminField, AdminPageHeader, AdminSection, - AdminSettingRow, AdminSwitch, } from "@/components/admin"; import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api"; import { toast } from "@/lib/toast"; +type SectionId = + | "basic" + | "content" + | "brand" + | "footer" + | "security" + | "mail" + | "storage" + | "words" + | "maintenance"; + +const NAV: { id: SectionId; label: string; ready?: boolean }[] = [ + { id: "basic", label: "基本信息", ready: true }, + { id: "content", label: "内容与互动", ready: true }, + { id: "brand", label: "品牌与 SEO" }, + { id: "footer", label: "页脚与合规" }, + { id: "security", label: "安全与限流" }, + { id: "mail", label: "邮件" }, + { id: "storage", label: "对象存储" }, + { id: "words", label: "敏感词" }, + { id: "maintenance", label: "系统维护" }, +]; + +function SwitchRow({ + title, + description, + control, + bordered, +}: { + title: ReactNode; + description: string; + control: ReactNode; + bordered?: boolean; +}) { + return ( +
+
+

+ {title} +

+

{description}

+
+
{control}
+
+ ); +} + +function ComingSoonCard({ + title, + description, + icon, +}: { + title: string; + description: string; + icon: ReactNode; +}) { + return ( + +
+ + {icon} + +

+ 即将推出 +

+

此分区尚未开放配置

+
+
+ ); +} + export default function SettingsAdmin({ initial }: { initial: PublicSettings }) { const [siteName, setSiteName] = useState(initial.site_name); const [siteDescription, setSiteDescription] = useState(initial.site_description); const [allowRegister, setAllowRegister] = useState(initial.allow_register); const [allowComments, setAllowComments] = useState(initial.allow_comments !== false); + const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false); const [savedName, setSavedName] = useState(initial.site_name); const [savedDesc, setSavedDesc] = useState(initial.site_description); const [saving, setSaving] = useState(false); const [togglingReg, setTogglingReg] = useState(false); const [togglingComments, setTogglingComments] = useState(false); + const [togglingLink, setTogglingLink] = useState(false); + const [active, setActive] = useState("basic"); const dirty = siteName.trim() !== savedName || siteDescription.trim() !== savedDesc; + useEffect(() => { + const ids = NAV.map((n) => n.id); + const observer = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((e) => e.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); + if (visible[0]?.target?.id) { + const id = visible[0].target.id.replace(/^settings-/, "") as SectionId; + if (ids.includes(id)) setActive(id); + } + }, + { rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.25, 0.5] } + ); + ids.forEach((id) => { + const el = document.getElementById(`settings-${id}`); + if (el) observer.observe(el); + }); + return () => observer.disconnect(); + }, []); + + const scrollTo = (id: SectionId) => { + setActive(id); + document.getElementById(`settings-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" }); + }; + const saveIdentity = async () => { if (saving) return; const name = siteName.trim(); @@ -80,84 +199,196 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings }) } }; + const togglePostLinkNewTab = async () => { + if (togglingLink) return; + const next = !postLinkNewTab; + setTogglingLink(true); + try { + const res = await apiUpdateSiteSettings({ post_link_new_tab: next }); + setPostLinkNewTab(res.post_link_new_tab !== false); + toast(res.post_link_new_tab !== false ? "帖子与评论外链将在新标签打开" : "帖子与评论外链将在当前页打开", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败"); + } finally { + setTogglingLink(false); + } + }; + return ( -
+
-
- - +
+ - +
+
+ + -
- + + +
+ +
+
-
- - 开放注册 - - } - description="关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。" - control={ - void toggleRegister()} - label="开放注册" - /> - } - /> +
+ + + 开放注册 + + } + description="关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。" + control={ + void toggleRegister()} + label="开放注册" + /> + } + /> + + 开放评论 + + } + description="关闭后前台不展示评论入口、评论数量与已有评论,并提示本站不支持评论。" + control={ + void toggleComments()} + label="开放评论" + /> + } + /> + + 外链新标签打开 + + } + description="开启后,帖子与评论中的 Markdown 外链在新标签打开;关闭则当前页跳转。" + control={ + void togglePostLinkNewTab()} + label="外链新标签打开" + /> + } + /> + +
- - 开放评论 - - } - description="关闭后前台不展示评论入口、评论数量与已有评论,并提示本站不支持评论。适用于备案审核等合规需要。" - control={ - void toggleComments()} - label="开放评论" +
+ } /> - } - /> +
+ +
+ } + /> +
+
+ } /> +
+
+ } + /> +
+
+ } /> +
+
+ } + /> +
+
); diff --git a/frontend/app/admin/users/UsersAdmin.tsx b/frontend/app/admin/users/UsersAdmin.tsx index ac276e6..b84fc46 100644 --- a/frontend/app/admin/users/UsersAdmin.tsx +++ b/frontend/app/admin/users/UsersAdmin.tsx @@ -7,6 +7,7 @@ import { ChevronLeft, ChevronRight, Clock, + Columns3, ExternalLink, FolderSearch, History, @@ -47,6 +48,47 @@ import { export type UserFilter = "all" | "staff" | "banned"; const PAGE_SIZE = 20; +const COLS_STORAGE_KEY = "j13-admin-user-cols"; + +/** 默认始终显示的列 */ +const CORE_COLS = ["member", "role", "status", "points", "last_login", "actions"] as const; +/** 可选列(localStorage 开关) */ +const OPTIONAL_COLS = ["online", "email", "counts", "created_at"] as const; + +type CoreCol = (typeof CORE_COLS)[number]; +type OptionalCol = (typeof OPTIONAL_COLS)[number]; +type ColKey = CoreCol | OptionalCol; + +const OPTIONAL_LABELS: Record = { + online: "在线", + email: "邮箱", + counts: "帖/评数", + created_at: "注册时间", +}; + +const DEFAULT_OPTIONAL: Record = { + online: false, + email: false, + counts: false, + created_at: false, +}; + +function loadOptionalCols(): Record { + 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>; + return { + online: !!parsed.online, + email: !!parsed.email, + counts: !!parsed.counts, + created_at: !!parsed.created_at, + }; + } catch { + return { ...DEFAULT_OPTIONAL }; + } +} const FILTER_TABS: { key: UserFilter; label: string }[] = [ { key: "all", label: "全部" }, @@ -60,6 +102,90 @@ function filterToParams(filter: UserFilter): { role?: string; status?: string } return {}; } +function ColumnConfigPopover({ + cols, + onChange, +}: { + cols: Record; + onChange: (next: Record) => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(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 ( +
+ + {open && ( +
+

+ 可选列 +

+
    + {OPTIONAL_COLS.map((key) => ( +
  • + +
  • + ))} +
+

+ 成员 / 角色 / 状态 / 积分 / 上次登录 / 操作固定显示 +

+
+ )} +
+ ); +} + export default function UsersAdmin({ initial, initError, @@ -93,19 +219,29 @@ export default function UsersAdmin({ const [loading, setLoading] = useState(false); const [error, setError] = useState(initError); const [actingId, setActingId] = useState(null); - // 角色与账号弹窗 / 登录历史弹窗 const [roleTarget, setRoleTarget] = useState(null); const [logsTarget, setLogsTarget] = useState(null); - - // 板块字典(角色弹窗勾选 + 徽章板块名) const [boards, setBoards] = useState([]); + const [optCols, setOptCols] = useState>(DEFAULT_OPTIONAL); - // 防止快速翻页/输入时旧响应覆盖新结果 const seqRef = useRef(0); const debounceRef = useRef | null>(null); const urlFirstRun = useRef(true); const router = useRouter(); + useEffect(() => { + setOptCols(loadOptionalCols()); + }, []); + + const persistCols = (next: Record) => { + setOptCols(next); + try { + localStorage.setItem(COLS_STORAGE_KEY, JSON.stringify(next)); + } catch { + /* ignore */ + } + }; + const boardNameMap = useMemo(() => { const m = new Map(); boards.forEach((b) => m.set(b.id, b.name)); @@ -118,8 +254,6 @@ export default function UsersAdmin({ .catch(() => {}); }, []); - // 在线状态实时化:WS hello 全量快照校正当前页绿点,之后 presence:update 增量跳变; - // 后端在线判定(有活跃 WS 连接)比列表接口的 5 分钟 last_seen 启发式更精确 useEffect(() => { const reconcile = (ids: ReadonlySet) => { if (ids.size === 0) return; @@ -162,7 +296,6 @@ export default function UsersAdmin({ } }, []); - // 搜索框防抖(首次渲染因 keyword===appliedQ 不会触发请求) useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { @@ -177,7 +310,6 @@ export default function UsersAdmin({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [keyword]); - // 查询状态同步到 URL(首屏由 SSR 给出,跳过首次),刷新/分享可复现当前视图 useEffect(() => { if (urlFirstRun.current) { urlFirstRun.current = false; @@ -191,14 +323,12 @@ export default function UsersAdmin({ router.replace(`/admin/users${qs ? `?${qs}` : ""}`, { scroll: false }); }, [appliedQ, filter, page, router]); - // Enter 立即提交,跳过防抖 const submitSearch = () => { const q = keyword.trim(); if (q === appliedQ) return; load({ page: 1, filter, q }); }; - // 清除按钮:立即复位查询并重拉,不等待防抖,保证标题与列表同步 const clearSearch = () => { setKeyword(""); if (appliedQ !== "" || page !== 1) { @@ -221,12 +351,85 @@ export default function UsersAdmin({ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const tabCount = (f: UserFilter) => f === "all" ? summary.total : f === "staff" ? summary.admins : summary.banned; - // 有关键词或非默认筛选时,空态文案与"无成员"区分 const hasFilter = appliedQ !== "" || filter !== "all"; - - // 角色授权成功 / 封禁成功后的统一回刷 const reload = () => load({ page, filter, q: appliedQ }); + const visibleCols = useMemo(() => { + const list: ColKey[] = ["member", "role", "status", "points"]; + if (optCols.online) list.push("online"); + if (optCols.email) list.push("email"); + list.push("last_login"); + if (optCols.counts) list.push("counts"); + if (optCols.created_at) list.push("created_at"); + list.push("actions"); + return list; + }, [optCols]); + + // Tailwind 无法动态拼 grid-cols;用 style.gridTemplateColumns + const gridStyle = useMemo(() => { + const frags: string[] = []; + for (const c of visibleCols) { + switch (c) { + case "member": + frags.push("minmax(0,1.35fr)"); + break; + case "role": + frags.push("minmax(96px,0.9fr)"); + break; + case "status": + frags.push("72px"); + break; + case "points": + frags.push("72px"); + break; + case "online": + frags.push("64px"); + break; + case "email": + frags.push("minmax(120px,1fr)"); + break; + case "last_login": + frags.push("146px"); + break; + case "counts": + frags.push("100px"); + break; + case "created_at": + frags.push("100px"); + break; + case "actions": + frags.push("136px"); + break; + } + } + return { gridTemplateColumns: frags.join(" ") }; + }, [visibleCols]); + + const colHeader = (c: ColKey): string => { + switch (c) { + case "member": + return "成员"; + case "role": + return "角色"; + case "status": + return "状态"; + case "points": + return "积分"; + case "online": + return "在线"; + case "email": + return "邮箱"; + case "last_login": + return "上次登录"; + case "counts": + return "内容"; + case "created_at": + return "注册"; + case "actions": + return "操作"; + } + }; + const renderStatus = (u: AdminUser) => { if (u.banned) { return ( @@ -240,6 +443,19 @@ export default function UsersAdmin({ ); } + return ( + + + 正常 + + ); + }; + + const renderOnline = (u: AdminUser) => { if (u.online) { return ( ) : null; - // 站长账号:不可改角色/封禁,保留主页与(站长自己的)内容档案 if (u.role === ROLES.OWNER) { return (
@@ -350,11 +565,66 @@ export default function UsersAdmin({ ); }; - // 桌面端列模板(成员列收窄,新增在线状态与上次登录 IP;xl 起展示内容/注册列) - const rowGrid = - "hidden md:grid gap-3 items-center px-4 sm:px-5 py-3.5 " + - "md:grid-cols-[minmax(0,1fr)_78px_minmax(108px,0.95fr)_146px_136px] " + - "xl:grid-cols-[minmax(0,1.25fr)_78px_minmax(108px,0.95fr)_146px_116px_136px]"; + const renderCell = (c: ColKey, u: AdminUser, isSelf: boolean) => { + switch (c) { + case "member": + return ( +
+ +
+

+ {u.nickname} +

+

+ @{u.username} +

+
+
+ ); + case "role": + return ( +
+ +
+ ); + case "status": + return renderStatus(u); + case "points": + return ( + + {(u.points ?? 0).toLocaleString("zh-CN")} + + ); + case "online": + return renderOnline(u); + case "email": + return ( + + {u.email || "—"} + + ); + case "last_login": + return
{renderLastLogin(u)}
; + case "counts": + return ( +

+ 帖 {u.post_count} · 评 {u.comment_count} +

+ ); + case "created_at": + return ( + + {formatDate(u.created_at)} + + ); + case "actions": + return
{renderActions(u, isSelf)}
; + } + }; return (
@@ -364,7 +634,6 @@ export default function UsersAdmin({ description="检索全部成员;在「角色与账号」中配置管理角色、板块授权与封禁;站长账号受保护" /> - {/* 汇总:用户运营独有指标(与仪表盘站点总量区分) */}
- {/* 搜索 + 筛选 + 用户列表 */}
- - - role="group" - ariaLabel="用户筛选" - value={filter} - onChange={changeFilter} - options={FILTER_TABS.map((t) => ({ - key: t.key, - label: t.label, - count: tabCount(t.key), - }))} - /> +
+ +
+ + role="group" + ariaLabel="用户筛选" + value={filter} + onChange={changeFilter} + options={FILTER_TABS.map((t) => ({ + key: t.key, + label: t.label, + count: tabCount(t.key), + }))} + /> + +
+
- {/* 桌面表头 */} {users.length > 0 && (
- 成员 - 状态 - 角色 - 上次登录 IP - 内容 / 注册 - 操作 + {visibleCols.map((c) => ( + + {colHeader(c)} + + ))}
)} @@ -474,41 +750,14 @@ export default function UsersAdmin({ const isSelf = u.id === currentUserId; return (
- {/* 桌面行 */} -
-
- -
-

- {u.nickname} -

-

- @{u.username} - {u.email ? ` · ${u.email}` : ""} -

+
+ {visibleCols.map((c) => ( +
+ {renderCell(c, u, isSelf)}
-
-
{renderStatus(u)}
-
- -
-
{renderLastLogin(u)}
-
-

- 帖 {u.post_count} · 评 {u.comment_count} -

-

- {formatDate(u.created_at)} -

-
-
{renderActions(u, isSelf)}
+ ))}
- {/* 移动卡片:空间紧,保留 btn-line btn-sm */}
@@ -531,17 +780,18 @@ export default function UsersAdmin({

@{u.username} - {u.email ? ` · ${u.email}` : ""} + {optCols.email && u.email ? ` · ${u.email}` : ""}

{renderStatus(u)}
-

- 帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)} +

+ 积分 {(u.points ?? 0).toLocaleString("zh-CN")} + + 帖 {u.post_count} · 评 {u.comment_count} + + 注册 {formatDate(u.created_at)} + {optCols.online && {u.online ? "在线" : "离线"}}

@@ -620,8 +870,6 @@ export default function UsersAdmin({ ); } -// ===== 登录历史弹窗 ===== - function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClose: () => void }) { const [logs, setLogs] = useState([]); const [total, setTotal] = useState(0); @@ -675,7 +923,6 @@ function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClos panelClassName="p-6 max-h-[88vh] flex flex-col" ariaLabel="登录历史" footer={ - // Modal footer 不好塞 AdminPager,保留原紧凑按钮 total > pageSize ? ( <> diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 449e957..7271907 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1439,6 +1439,264 @@ body:has([data-chat-viewport]) #main { overflow: hidden; } +/* ── 管理后台独立壳:全高侧栏 + 顶栏 + 可滚主区(不依赖公开 Header/Footer) ── */ +body:has([data-admin-viewport]) { + height: 100dvh; + max-height: 100dvh; + overflow: hidden; + background: var(--bg); +} + +.admin-shell { + display: flex; + height: 100dvh; + max-height: 100dvh; + min-height: 0; + overflow: hidden; + background: var(--bg); +} + +.admin-rail { + width: 15.5rem; + flex-shrink: 0; + display: flex; + flex-direction: column; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 1rem 0.75rem 1.25rem; + background: var(--panel); + border-right: 1px solid var(--line); +} + +/* 桌面常驻侧栏;窄屏改用抽屉,避免与 .admin-rail 的 display 冲突 */ +.admin-rail-desktop { + display: none; +} + +@media (min-width: 1024px) { + .admin-rail-desktop { + display: flex; + } +} + +@media (min-width: 1280px) { + .admin-rail { + width: 16.5rem; + } +} + +.admin-drawer-panel { + position: relative; + z-index: 1; + display: flex; + width: min(18rem, 86vw); + height: 100%; + box-shadow: var(--shadow-lg); + animation: admin-drawer-in 0.18s ease-out; +} + +.admin-rail-brand { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.35rem 0.5rem 1rem; + margin-bottom: 0.35rem; + border-bottom: 1px solid var(--line); +} + +.admin-rail-brand-mark { + width: 2.25rem; + height: 2.25rem; + border-radius: 0.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background: var(--accent); + color: var(--accent-on); +} + +.admin-rail-nav { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 0.75rem 0 0.25rem; + flex: 1; +} + +.admin-rail-group-title { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ink-3); + padding: 0 0.65rem 0.4rem; + margin: 0; +} + +.admin-rail-link { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.55rem 0.75rem; + border-radius: 0.75rem; + font-size: 13.5px; + font-weight: 500; + color: var(--ink-2); + transition: background-color 0.15s ease, color 0.15s ease; +} + +.admin-rail-link:hover { + background: var(--panel-2); + color: var(--ink); +} + +.admin-rail-link.is-active { + background: var(--nav-active-bg); + color: var(--nav-active-ink); +} + +.admin-rail-link.is-active svg { + color: var(--nav-active-ink); +} + +.admin-rail-link:not(.is-active) svg { + color: var(--ink-3); +} + +.admin-main-col { + flex: 1 1 0; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.admin-topbar { + height: 3.5rem; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0 1rem; + background: color-mix(in srgb, var(--bg) 88%, transparent); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border-bottom: 1px solid var(--line); + z-index: 30; +} + +@media (min-width: 640px) { + .admin-topbar { + padding: 0 1.5rem; + } +} + +.admin-topbar-left, +.admin-topbar-right { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.admin-topbar-back { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.75rem; + border-radius: 999px; + font-size: 13px; + font-weight: 500; + color: var(--ink-2); + transition: background-color 0.15s ease, color 0.15s ease; +} + +.admin-topbar-back:hover { + background: var(--panel); + color: var(--ink); +} + +.admin-topbar-user { + display: inline-flex; + align-items: center; + gap: 0.5rem; + border-radius: 999px; + padding: 0.2rem 0.55rem 0.2rem 0.2rem; + border: 1px solid var(--line); + transition: border-color 0.15s ease, background-color 0.15s ease; +} + +.admin-canvas { + flex: 1 1 0; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 1.25rem 1rem 2rem; +} + +/* 内容管理:桌面禁整页滚动,三栏底部落在视口内 */ +@media (min-width: 1024px) { + .admin-canvas:has(.admin-content-page) { + display: flex; + flex-direction: column; + overflow: hidden; + padding-bottom: 1.25rem; + } +} + +@media (min-width: 640px) { + .admin-canvas { + padding: 1.5rem 1.5rem 2.5rem; + } +} + +@media (min-width: 1024px) { + .admin-canvas { + padding: 1.75rem 2rem 2.75rem; + } + .admin-canvas:has(.admin-content-page) { + padding-bottom: 1.25rem; + } +} + +/* 移动端导航抽屉 */ +.admin-drawer { + position: fixed; + inset: 0; + z-index: 60; + display: flex; +} + +.admin-drawer-backdrop { + position: absolute; + inset: 0; + border: 0; + padding: 0; + cursor: pointer; + background: color-mix(in srgb, var(--ink) 28%, transparent); +} + +@keyframes admin-drawer-in { + from { + transform: translateX(-100%); + opacity: 0.85; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .admin-drawer-panel { + animation: none; + } +} + ::selection { background: var(--accent); color: var(--accent-on); @@ -1913,20 +2171,244 @@ a.j13-mention-token:hover { } } - /* 内容管理工具条:类型 / 状态 / 搜索同一行,避免三行左对齐或等分顶栏留白 */ - .admin-content-toolbar { + /* 内容管理页:窄屏可整页滚;桌面高度由上方未分层的 canvas:has 规则锁定 */ + .admin-content-page { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 0; + } + @media (min-width: 1024px) { + .admin-content-page { + flex: 1 1 auto; + min-height: 0; + height: 100%; + } + } + .admin-content-page > .mb-5 { + margin-bottom: 0 !important; + flex-shrink: 0; + } + .admin-content-page > .mb-5 h1 { + font-size: 1.25rem; + } + @media (min-width: 640px) { + .admin-content-page > .mb-5 h1 { + font-size: 1.375rem; + } + } + .admin-content-page > .mb-5 .meta { + margin-top: 0.25rem; + } + + /* 策略条:横向排布,压缩垂直占用 */ + .admin-content-policy { display: flex; - flex-wrap: wrap; align-items: center; - gap: 0.75rem 1rem; - padding: 0.75rem 1rem; + gap: 0.65rem 0.85rem; + flex-wrap: wrap; + flex-shrink: 0; + padding: 0.5rem 0.75rem; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel); + } + .admin-content-policy-title { + font-size: 12px; + font-weight: 650; + color: var(--ink-3); + flex-shrink: 0; + } + .admin-content-policy-row { + display: flex; + align-items: center; + gap: 0.35rem 0.5rem; + flex: 1 1 auto; + min-width: 0; + flex-wrap: wrap; + } + .admin-content-policy-chip { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.28rem 0.55rem; + border-radius: 10px; + background: var(--panel-2); + border: 1px solid transparent; + min-height: 2.25rem; + } + .admin-content-policy-chip-label { + font-size: 12.5px; + font-weight: 600; + color: var(--ink); + white-space: nowrap; + } + + /* 内容管理:PC 左中右三栏等高;仅列表区滚动 */ + .admin-content-triptych { + display: grid; + gap: 0.85rem; + align-items: stretch; + min-height: 18rem; + } + @media (min-width: 1024px) { + .admin-content-triptych { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + flex: 1 1 auto; + min-height: 0; + height: auto; + } + } + .admin-messages-panel { + flex: 1 1 auto; + min-height: 0; + } + /* 私聊监管:方向条与正文同一卡片,无间隙 */ + .admin-dm-row { + min-width: 0; + } + .admin-dm-card { + display: flex; + flex-direction: column; + min-width: 0; + border-radius: 0.55rem; + background: var(--panel-2); + border: 1px solid var(--line); + overflow: hidden; + } + .admin-dm-card-head { + padding: 0.4rem 0.65rem; + border-bottom: 1px solid var(--line); + } + .admin-dm-card-body { + padding: 0.55rem 0.7rem 0.65rem; + } + .admin-dm-pair-meta { + display: flex; + align-items: center; + gap: 0.45rem; + flex: 1 1 auto; + min-width: 0; + } + .admin-dm-pair-names { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + flex: 1 1 auto; + } + .admin-dm-person { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + } + .admin-dm-person-name { + font-size: 12.5px; + font-weight: 600; + letter-spacing: 0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 7.5rem; + } + .admin-dm-arrow { + flex-shrink: 0; + font-weight: 500; + color: var(--ink-3); + font-size: 11px; + } + .admin-content-col { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + } + @media (min-width: 1024px) { + .admin-content-col { + height: 100%; + } + } + .admin-content-col-head, + .admin-content-col-toolbar, + .admin-content-col-pager { + flex-shrink: 0; + } + .admin-content-col-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + } + .admin-content-col-toolbar { + display: flex; + flex-direction: column; + gap: 0.55rem; + padding: 0.65rem 0.75rem; border-bottom: 1px solid var(--line); background: var(--panel); } - @media (min-width: 640px) { - .admin-content-toolbar { - padding: 0.75rem 1.25rem; - } + .admin-content-action { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.28rem 0.55rem; + border-radius: 8px; + border: 1px solid var(--line); + background: var(--panel); + color: var(--ink-2); + font-size: 12px; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; + } + .admin-content-action:hover:not(:disabled) { + background: var(--panel-2); + color: var(--ink); + border-color: var(--line-2); + } + .admin-content-action:disabled { + opacity: 0.55; + cursor: not-allowed; + } + .admin-content-action.is-danger { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 28%, var(--line)); + background: color-mix(in srgb, var(--danger) 6%, var(--panel)); + } + .admin-content-action.is-danger:hover:not(:disabled) { + background: color-mix(in srgb, var(--danger) 12%, var(--panel)); + } + .admin-content-action.is-ok { + color: var(--ok); + border-color: color-mix(in srgb, var(--ok) 28%, var(--line)); + background: color-mix(in srgb, var(--ok) 6%, var(--panel)); + } + .admin-content-clamp { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + word-break: break-word; + } + .admin-content-clamp.is-open { + display: block; + -webkit-line-clamp: unset; + } + /* 三栏宽度下状态条横向滚动,避免折行难看 */ + .admin-content-col-toolbar .admin-content-status-scroll { + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + } + .admin-content-col-toolbar .admin-content-status-scroll > div { + flex-wrap: nowrap !important; + } + .admin-content-col-toolbar .admin-content-status-scroll .admin-seg { + flex-shrink: 0; } .admin-content-kind { display: inline-flex; @@ -1979,35 +2461,6 @@ a.j13-mention-token:hover { background: var(--accent-soft); color: var(--accent); } - .admin-content-split { - display: none; - width: 1px; - height: 1.5rem; - background: var(--line); - flex-shrink: 0; - } - .admin-content-status { - flex: 1 1 14rem; - min-width: 0; - } - .admin-content-search { - flex: 1 1 12rem; - max-width: 18rem; - min-width: 11rem; - margin-left: auto; - } - @media (min-width: 768px) { - .admin-content-split { - display: block; - } - } - @media (max-width: 767px) { - .admin-content-search { - flex-basis: 100%; - max-width: none; - margin-left: 0; - } - } .admin-field-head { display: flex; @@ -2059,14 +2512,16 @@ a.j13-mention-token:hover { .admin-search-shell { display: flex; align-items: center; - gap: 0.5rem; - height: 2.5rem; - padding: 0 0.75rem; + gap: 0.625rem; + min-height: 2.875rem; + height: 2.875rem; + padding: 0 0.875rem; } .admin-search-shell .admin-field-input { padding: 0; height: 100%; - font-size: 13px; + font-size: 14px; + line-height: 1.4; } .admin-stepper { diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index e66270c..a42b81a 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,14 +1,15 @@ import type { Metadata } from "next"; -import { cookies } from "next/headers"; +import { cookies, headers } from "next/headers"; import { Plus_Jakarta_Sans, Noto_Sans_SC, JetBrains_Mono } from "next/font/google"; import "./globals.css"; -import Header from "@/components/Header"; -import Footer from "@/components/Footer"; +import SiteChrome from "@/components/SiteChrome"; import Toaster from "@/components/Toaster"; import TipHost from "@/components/TipHost"; import AccountGuard from "@/components/AccountGuard"; import ThemeSync from "@/components/ThemeSync"; import RealtimeProvider from "@/components/RealtimeProvider"; +import VisitBeacon from "@/components/VisitBeacon"; +import PostLinkProvider from "@/components/PostLinkProvider"; import CodeFoldProvider from "@/components/markdown/CodeFoldProvider"; import MotionProvider from "@/components/MotionProvider"; import CommentsPolicyProvider from "@/components/CommentsPolicyProvider"; @@ -65,6 +66,11 @@ export default async function RootLayout({ children: React.ReactNode; }) { const cookieStore = await cookies(); + const headerStore = await headers(); + // middleware 写入 x-pathname;供 SiteChrome SSR 初值(软导航靠客户端 pathname) + const pathname = headerStore.get("x-pathname") || ""; + const isAdmin = pathname === "/admin" || pathname.startsWith("/admin/"); + // 浅色优先的设计系统,仅在显式选择暗色时挂 .dark const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light"; @@ -113,35 +119,33 @@ export default async function RootLayout({ 跳到主要内容 -

-
- - + + + - {children} + + {children} + - - -
-
+ + + + diff --git a/frontend/components/Header.tsx b/frontend/components/Header.tsx index b320d34..6b7a0c2 100644 --- a/frontend/components/Header.tsx +++ b/frontend/components/Header.tsx @@ -6,8 +6,6 @@ import { useState, useEffect, useRef } from "react"; import { Menu, X, - Sun, - Moon, LogIn, UserPlus, LogOut, @@ -29,6 +27,7 @@ import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime"; import type { RtChatUnreadData } from "@/lib/realtime"; import { onDocumentVisible } from "@/lib/visibilityReconcile"; import NotificationBell from "./NotificationBell"; +import ThemeToggle from "./ThemeToggle"; import Avatar from "./Avatar"; export default function Header({ @@ -49,7 +48,6 @@ export default function Header({ const router = useRouter(); const pathname = usePathname(); const [menuOpen, setMenuOpen] = useState(false); - const [theme, setTheme] = useState<"light" | "dark">(initialTheme); // 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正 const [user, setUser] = useState(initialUser); const [unread, setUnread] = useState(initialUnread); @@ -148,11 +146,6 @@ export default function Header({ }; useEffect(() => { - // cookie 为单一真相源,客户端导航后校正(预取载荷可能回滚类名) - const isDark = document.cookie.match(/(?:^|;\s*)j13-theme=dark/); - const savedTheme: "light" | "dark" = isDark ? "dark" : "light"; - document.documentElement.classList.toggle("dark", savedTheme === "dark"); - setTheme(savedTheme); setSearchQuery(new URLSearchParams(window.location.search).get("q")?.trim() || ""); }, [pathname]); @@ -180,13 +173,6 @@ export default function Header({ } }; - const toggleTheme = () => { - const newTheme = theme === "dark" ? "light" : "dark"; - setTheme(newTheme); - document.documentElement.classList.toggle("dark", newTheme === "dark"); - document.cookie = `j13-theme=${newTheme}; path=/; max-age=31536000`; - }; - const handleLogout = async () => { try { await apiLogout(); @@ -280,15 +266,11 @@ export default function Header({
- + /> {user && ( 注册}
- + {user && ( + ); + } + + return ( + + ); +} diff --git a/frontend/components/VisitBeacon.tsx b/frontend/components/VisitBeacon.tsx new file mode 100644 index 0000000..571366e --- /dev/null +++ b/frontend/components/VisitBeacon.tsx @@ -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; +} diff --git a/frontend/components/admin/AdminSearchField.tsx b/frontend/components/admin/AdminSearchField.tsx index 6bf3b36..9d645f4 100644 --- a/frontend/components/admin/AdminSearchField.tsx +++ b/frontend/components/admin/AdminSearchField.tsx @@ -3,7 +3,7 @@ import { Search, X } from "lucide-react"; import type { FormEvent } from "react"; -/** 左图标、可清除、Enter 提交;壳层聚焦与聊天搜索一致 */ +/** 左图标、可清除、Enter 提交;加高壳层便于后台工具条点按 */ export default function AdminSearchField({ value, onChange, @@ -30,9 +30,13 @@ export default function AdminSearchField({ }; return ( -
+ - + ) : null} diff --git a/frontend/components/admin/AdminShell.tsx b/frontend/components/admin/AdminShell.tsx new file mode 100644 index 0000000..c063451 --- /dev/null +++ b/frontend/components/admin/AdminShell.tsx @@ -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 ( + + {count > 99 ? "99+" : count} + + ); +} + +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 ( +
+ + + + + + {siteName} + + + J13 ADMIN + + +
+ ); +} + +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 ( + + ); +} + +function AdminUserMenu({ user, onLogout }: { user: User; onLogout: () => void }) { + const [open, setOpen] = useState(false); + const menuRef = useRef(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 ( +
+ + + {open && ( +
+
+ +
+

+ {user.nickname} +

+

@{user.username}

+
+
+
+ setOpen(false)} className={itemCls} role="menuitem"> + 个人主页 + + setOpen(false)} className={itemCls} role="menuitem"> + 账号设置 + +
+
+ +
+
+ )} +
+ ); +} + +/** + * 后台独立壳:全高品牌侧栏 + 细顶栏 + 可滚动主区。 + * 不依赖公开站 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 = ( + <> + + setDrawerOpen(false)} /> + + ); + + return ( +
+ {/* 桌面侧栏:宽度断点由 CSS 控制,避免自定义 display 盖掉 Tailwind hidden */} + + + {/* 移动抽屉 */} + {drawerOpen && ( +
+ +
+ setDrawerOpen(false)} /> + +
+ )} + +
+
+
+ + + + 返回站点 + +
+
+ + + +
+
+ +
+ {children} +
+
+
+ ); +} diff --git a/frontend/components/admin/charts.tsx b/frontend/components/admin/charts.tsx new file mode 100644 index 0000000..4b6e97f --- /dev/null +++ b/frontend/components/admin/charts.tsx @@ -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 ( +
+
+
+

+ {title} +

+ {hint ? ( +

{hint}

+ ) : null} +
+ {actions} +
+
{children}
+
+ ); +} + +export function VisitTrendChart({ + data, +}: { + data: { date: string; pv: number; uv: number }[]; +}) { + const rows = data.map((d) => ({ ...d, label: shortDate(d.date) })); + return ( + + + + + + + + + + + + + + + + + + + + + + ); +} + +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 ( + + + + + + + + + + + + ); +} + +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 ( + + + + + + + + + + ); +} + +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 ( + + + + + + + + + + ); + } + return ( + + + + + + + + + + ); +} + +export function NamedPieChart({ data }: { data: { label: string; count: number }[] }) { + const total = data.reduce((s, d) => s + d.count, 0) || 1; + return ( + + + + {data.map((_, i) => ( + + ))} + + { + const n = typeof value === "number" ? value : Number(value) || 0; + return [`${n}(${Math.round((n / total) * 100)}%)`, String(name ?? "")]; + }} + /> + + + + ); +} + +export function DeltaBarChart({ + data, +}: { + data: { date: string; granted: number; spent: number }[]; +}) { + const rows = data.map((d) => ({ ...d, label: shortDate(d.date) })); + return ( + + + + + + + + + + + + ); +} diff --git a/frontend/components/admin/index.ts b/frontend/components/admin/index.ts index 01aa924..39a6988 100644 --- a/frontend/components/admin/index.ts +++ b/frontend/components/admin/index.ts @@ -13,3 +13,4 @@ export { default as AdminStatCard } from "./AdminStatCard"; export { default as AdminIconButton } from "./AdminIconButton"; export { default as AdminStatusChip, contentStatusChip } from "./AdminStatusChip"; export type { AdminStatusTone } from "./AdminStatusChip"; +export { default as AdminShell } from "./AdminShell"; diff --git a/frontend/components/markdown/MdLink.tsx b/frontend/components/markdown/MdLink.tsx index dc12c3f..59f5d1b 100644 --- a/frontend/components/markdown/MdLink.tsx +++ b/frontend/components/markdown/MdLink.tsx @@ -1,6 +1,7 @@ "use client"; import type { AnchorHTMLAttributes } from "react"; +import { usePostLinkNewTab } from "@/components/PostLinkProvider"; type MdLinkProps = AnchorHTMLAttributes; @@ -21,14 +22,15 @@ function isExternalHref(href?: string): boolean { return /^https?:\/\//i.test(href); } -/** Markdown 链接:外链新开页 */ +/** Markdown 链接:外链是否新开由站点设置 post_link_new_tab 控制 */ export default function MdLink({ href, children, ...rest }: MdLinkProps) { + const openInNewTab = usePostLinkNewTab(); const external = isExternalHref(href); return ( diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 2019742..ddb245c 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -609,6 +609,8 @@ export interface PublicSettings { anim_smooth_scroll: boolean; /** 装饰入场子开关;缺省 true */ anim_chrome: boolean; + /** 帖内 Markdown 外链是否新标签打开;缺省 true */ + post_link_new_tab: boolean; } const DEFAULT_PUBLIC_SETTINGS: PublicSettings = { @@ -625,6 +627,7 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = { anim_code_fold: true, anim_smooth_scroll: true, anim_chrome: true, + post_link_new_tab: true, }; function normalizePublicSettings(data: Partial | null | undefined): PublicSettings { @@ -653,6 +656,7 @@ function normalizePublicSettings(data: Partial | null | undefine anim_code_fold: data?.anim_code_fold !== false, anim_smooth_scroll: data?.anim_smooth_scroll !== false, anim_chrome: data?.anim_chrome !== false, + post_link_new_tab: data?.post_link_new_tab !== false, }; } @@ -869,6 +873,133 @@ export interface EconomyStats { escrowed_points: number; posts_by_type: Record; 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 { + 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 { + 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 { + 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 { @@ -1325,6 +1456,7 @@ export interface AdminUser { role: string; // user | board_admin | admin | super_admin | owner board_ids: number[]; // 板块管理员的授权板块 banned: boolean; + points?: number; post_count: number; comment_count: number; created_at: string; @@ -1619,6 +1751,7 @@ export type UpdateSiteSettingsBody = { anim_code_fold?: boolean; anim_smooth_scroll?: boolean; anim_chrome?: boolean; + post_link_new_tab?: boolean; }; // 更新站点设置(字段可选);accent 传空串恢复默认主题色 diff --git a/frontend/lib/realtime.ts b/frontend/lib/realtime.ts index 4b90613..b15b4a5 100644 --- a/frontend/lib/realtime.ts +++ b/frontend/lib/realtime.ts @@ -52,6 +52,7 @@ export interface RtSettingsData { anim_code_fold?: boolean; anim_smooth_scroll?: boolean; anim_chrome?: boolean; + post_link_new_tab?: boolean; } // 群聊新消息:data 为落库的 ChatMessage(含 sender) diff --git a/frontend/middleware.ts b/frontend/middleware.ts index 382ae06..5c2e24a 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -129,13 +129,20 @@ function parseCookiePair(setCookie: string): [string, string] | null { return [pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()]; } +/** 透传 pathname 给根布局,用于 /admin 与公开站 chrome 分叉 */ +function withPathname(req: NextRequest, init?: { request?: { headers: Headers } }) { + const headers = new Headers(init?.request?.headers ?? req.headers); + headers.set("x-pathname", req.nextUrl.pathname); + return NextResponse.next({ request: { headers } }); +} + export async function middleware(req: NextRequest) { const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value; const accessToken = req.cookies.get(TOKEN_COOKIE)?.value; // 游客、access 仍有效、或服务端地址未配置:直接放行 if (!refreshToken || !isAccessTokenExpired(accessToken) || !API_BASE) { - return NextResponse.next(); + return withPathname(req); } const csrf = req.cookies.get(CSRF_COOKIE)?.value ?? ""; @@ -154,7 +161,7 @@ export async function middleware(req: NextRequest) { const headers = new Headers(req.headers); headers.set("Cookie", [...jar].map(([k, v]) => `${k}=${v}`).join("; ")); - const res = NextResponse.next({ request: { headers } }); + const res = withPathname(req, { request: { headers } }); // 原样透传,HttpOnly/Path/SameSite/Secure 等属性全部以后端为准 for (const sc of setCookies) res.headers.append("Set-Cookie", sc); return res; @@ -162,7 +169,7 @@ export async function middleware(req: NextRequest) { if (setCookies.length > 0) { // refresh 已失效:透传后端的清 cookie 指令,避免之后每次请求都白轮转 - const res = NextResponse.next(); + const res = withPathname(req); for (const sc of setCookies) res.headers.append("Set-Cookie", sc); return res; } @@ -170,7 +177,7 @@ export async function middleware(req: NextRequest) { // 后端不可达:降级匿名渲染,客户端 fetchWithRefresh 仍可兜底 } - return NextResponse.next(); + return withPathname(req); } export const config = { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 15f2507..0c4fc36 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "react-dom": "^19.0.0", "react-easy-crop": "^6.2.3", "react-markdown": "^9.0.1", + "recharts": "^3.10.1", "rehype-pretty-code": "^0.14.5", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", @@ -800,6 +801,32 @@ "node": ">= 10" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@shikijs/core": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", @@ -900,6 +927,18 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -1192,6 +1231,69 @@ "tailwindcss": "4.3.3" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -1275,6 +1377,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@ungap/structured-clone": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", @@ -1379,6 +1487,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -1395,6 +1512,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1412,6 +1650,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -1483,6 +1727,18 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -1505,6 +1761,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -1682,12 +1944,31 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/immer": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz", + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -3149,6 +3430,13 @@ "react-dom": ">=16.4.0" } }, + "node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT", + "peer": true + }, "node_modules/react-markdown": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", @@ -3176,6 +3464,74 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", @@ -3301,6 +3657,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -3484,6 +3846,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -3618,6 +3986,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -3660,6 +4037,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1a6171d..846590c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "react-dom": "^19.0.0", "react-easy-crop": "^6.2.3", "react-markdown": "^9.0.1", + "recharts": "^3.10.1", "rehype-pretty-code": "^0.14.5", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0",