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 ? (
- <>
-