From 7b327bc8cc159841db92c87be9299a024c69eada Mon Sep 17 00:00:00 2001
From: freefire
Date: Thu, 24 Sep 2026 18:53:45 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AB=99=E7=82=B9=E8=AE=BF=E9=97=AE?=
=?UTF-8?q?=E7=BB=9F=E8=AE=A1=E4=B8=8E=E5=8F=AF=E9=85=8D=E7=BD=AE=E4=BE=A7?=
=?UTF-8?q?=E8=BE=B9=E6=A0=8F=E7=BB=84=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面
- 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件
- 新增 widget 嵌入代码生成(widgetCode)与 widgetApi
- 遥测逻辑迁移至 handler 层,删除 service/telemetry
- env 示例补充 Docker 部署可信代理 IP 说明
---
.env.example | 4 +-
backend/handler/handlers.go | 3 +-
backend/handler/sidebar.go | 123 +++
backend/handler/telemetry.go | 60 +-
backend/handler/visitstats.go | 166 ++++
backend/middleware/visit_capture.go | 62 ++
backend/model/db.go | 4 +-
backend/model/models.go | 38 +
backend/router/router.go | 44 +-
backend/service/ratelimit.go | 2 +
backend/service/setting.go | 52 ++
backend/service/sidebar.go | 453 ++++++++++
backend/service/sidebar_test.go | 224 +++++
backend/service/telemetry.go | 88 --
backend/service/visit_bot.go | 69 ++
backend/service/visit_bot_test.go | 31 +
backend/service/visit_ref.go | 63 ++
backend/service/visit_ref_test.go | 31 +
backend/service/visitstats.go | 295 +++++++
backend/service/visitstats_query.go | 616 +++++++++++++
backend/service/visitstats_test.go | 46 +
deploy/.env.example | 4 +-
docs/site-operations.md | 4 +
.../app/admin/analytics/AnalyticsShell.tsx | 74 ++
.../app/admin/analytics/OverviewClient.tsx | 137 +++
frontend/app/admin/analytics/details/page.tsx | 232 +++++
frontend/app/admin/analytics/layout.tsx | 15 +
frontend/app/admin/analytics/page.tsx | 17 +
frontend/app/admin/analytics/pages/page.tsx | 120 +++
.../app/admin/analytics/realtime/page.tsx | 118 +++
.../app/admin/analytics/security/page.tsx | 127 +++
.../app/admin/analytics/settings/page.tsx | 196 +++++
frontend/app/admin/analytics/sources/page.tsx | 111 +++
frontend/app/admin/analytics/ui.tsx | 166 ++++
.../app/admin/analytics/visitors/page.tsx | 111 +++
frontend/app/admin/sidebar/SidebarAdmin.tsx | 824 ++++++++++++++++++
frontend/app/admin/sidebar/page.tsx | 46 +
frontend/app/globals.css | 171 +++-
frontend/app/page.tsx | 171 ++--
frontend/app/post/[id]/page.tsx | 242 ++---
frontend/app/u/[id]/page.tsx | 8 +-
frontend/components/Avatar.tsx | 7 +-
frontend/components/PinnedPosts.tsx | 2 +-
frontend/components/PostRow.tsx | 2 +-
frontend/components/TimelineEditor.tsx | 232 +++--
frontend/components/VisitBeacon.tsx | 5 +-
frontend/components/admin/AdminShell.tsx | 9 +
frontend/components/home/BoardPanels.tsx | 10 +-
frontend/components/home/CommunityPanels.tsx | 18 +-
frontend/components/home/WorkspaceNav.tsx | 144 +--
frontend/components/post/PostRailBlocks.tsx | 170 ++++
.../components/sidebar/CustomWidgetBlock.tsx | 54 ++
.../components/sidebar/SidebarRenderer.tsx | 40 +
frontend/components/sidebar/registry.tsx | 155 ++++
frontend/lib/api.ts | 309 ++++++-
frontend/lib/sidebarMeta.ts | 179 ++++
frontend/lib/widgetApi.ts | 91 ++
frontend/lib/widgetCode.test.ts | 102 +++
frontend/lib/widgetCode.ts | 212 +++++
frontend/package.json | 2 +-
60 files changed, 6539 insertions(+), 572 deletions(-)
create mode 100644 backend/handler/sidebar.go
create mode 100644 backend/handler/visitstats.go
create mode 100644 backend/middleware/visit_capture.go
create mode 100644 backend/service/sidebar.go
create mode 100644 backend/service/sidebar_test.go
delete mode 100644 backend/service/telemetry.go
create mode 100644 backend/service/visit_bot.go
create mode 100644 backend/service/visit_bot_test.go
create mode 100644 backend/service/visit_ref.go
create mode 100644 backend/service/visit_ref_test.go
create mode 100644 backend/service/visitstats.go
create mode 100644 backend/service/visitstats_query.go
create mode 100644 backend/service/visitstats_test.go
create mode 100644 frontend/app/admin/analytics/AnalyticsShell.tsx
create mode 100644 frontend/app/admin/analytics/OverviewClient.tsx
create mode 100644 frontend/app/admin/analytics/details/page.tsx
create mode 100644 frontend/app/admin/analytics/layout.tsx
create mode 100644 frontend/app/admin/analytics/page.tsx
create mode 100644 frontend/app/admin/analytics/pages/page.tsx
create mode 100644 frontend/app/admin/analytics/realtime/page.tsx
create mode 100644 frontend/app/admin/analytics/security/page.tsx
create mode 100644 frontend/app/admin/analytics/settings/page.tsx
create mode 100644 frontend/app/admin/analytics/sources/page.tsx
create mode 100644 frontend/app/admin/analytics/ui.tsx
create mode 100644 frontend/app/admin/analytics/visitors/page.tsx
create mode 100644 frontend/app/admin/sidebar/SidebarAdmin.tsx
create mode 100644 frontend/app/admin/sidebar/page.tsx
create mode 100644 frontend/components/post/PostRailBlocks.tsx
create mode 100644 frontend/components/sidebar/CustomWidgetBlock.tsx
create mode 100644 frontend/components/sidebar/SidebarRenderer.tsx
create mode 100644 frontend/components/sidebar/registry.tsx
create mode 100644 frontend/lib/sidebarMeta.ts
create mode 100644 frontend/lib/widgetApi.ts
create mode 100644 frontend/lib/widgetCode.test.ts
create mode 100644 frontend/lib/widgetCode.ts
diff --git a/.env.example b/.env.example
index f393d62..ffb1bd9 100644
--- a/.env.example
+++ b/.env.example
@@ -19,7 +19,9 @@ SITE_URL=http://localhost:3000
SETTINGS_MASTER_KEY=
# 逗号分隔的精确内部服务主机名;默认拒绝回环、私网和保留网段
SERVICE_PRIVATE_HOSTS=
-# 仅填写实际可信反向代理 IP/CIDR;默认不信任转发头
+# 仅填写实际可信反向代理 IP/CIDR;默认不信任转发头。
+# Docker Compose 部署示例:TRUSTED_PROXIES=172.16.0.0/12(bridge 网段,可用 docker network inspect 查看);
+# 或精确填反代容器 IP;多值逗号分隔。经 Cloudflare 时改用 TRUSTED_PLATFORM=CF-Connecting-IP。
TRUSTED_PROXIES=
# 紧急恢复:1 强制正常运行,修复后台配置后撤销并重启 API
MAINTENANCE_RECOVERY=0
diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go
index 75c00b2..7a2fcdc 100644
--- a/backend/handler/handlers.go
+++ b/backend/handler/handlers.go
@@ -31,10 +31,11 @@ type Handlers struct {
LegacyImport *service.LegacyImportService
Moderation *service.ModerationService
Chat *service.ChatService
- Telemetry *service.TelemetryService
+ Visit *service.VisitStatsService
Analytics *service.AnalyticsService
HidePwd *service.HidePasswordCookie
Ads *service.AdService
+ Sidebar *service.SidebarService
}
// resolvePublishStatus 决定新帖/新评的初始状态:
diff --git a/backend/handler/sidebar.go b/backend/handler/sidebar.go
new file mode 100644
index 0000000..330e90d
--- /dev/null
+++ b/backend/handler/sidebar.go
@@ -0,0 +1,123 @@
+package handler
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+
+ "github.com/freefire/jiang13-bbs/model"
+ "github.com/freefire/jiang13-bbs/service"
+ "github.com/gin-gonic/gin"
+)
+
+// GET /api/sidebar 公开侧栏载荷(布局 + 启用中的自定义工具)
+func (h *Handlers) GetSidebar(c *gin.Context) {
+ payload, err := h.Sidebar.PublicPayload()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取侧栏配置失败"})
+ return
+ }
+ c.JSON(http.StatusOK, payload)
+}
+
+// GET /api/admin/sidebar 管理端全量(生效配置 + 全部工具)
+func (h *Handlers) AdminGetSidebar(c *gin.Context) {
+ cfg, err := h.Sidebar.GetConfig()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取侧栏配置失败"})
+ return
+ }
+ widgets, err := h.Sidebar.ListWidgets()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取侧栏工具失败"})
+ return
+ }
+ if widgets == nil {
+ widgets = []model.SidebarWidget{}
+ }
+ c.JSON(http.StatusOK, gin.H{"config": cfg, "widgets": widgets})
+}
+
+// PUT /api/admin/sidebar/config 保存布局配置
+func (h *Handlers) AdminSaveSidebarConfig(c *gin.Context) {
+ var req struct {
+ Config *service.SidebarConfig `json:"config"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil || req.Config == nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
+ return
+ }
+ saved, err := h.Sidebar.SaveConfig(*req.Config)
+ if err != nil {
+ if errors.Is(err, service.ErrSidebarInvalid) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "侧栏布局配置无效"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "保存侧栏配置失败"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"config": saved})
+}
+
+// POST /api/admin/sidebar/widgets 新建自定义工具
+func (h *Handlers) AdminCreateSidebarWidget(c *gin.Context) {
+ var w model.SidebarWidget
+ if err := c.ShouldBindJSON(&w); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
+ return
+ }
+ if err := h.Sidebar.CreateWidget(&w); err != nil {
+ if errors.Is(err, service.ErrSidebarInvalid) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "工具名称或代码长度无效"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "创建侧栏工具失败"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"widget": w})
+}
+
+// PUT /api/admin/sidebar/widgets/:id 更新工具(内容 + 启用态)
+func (h *Handlers) AdminUpdateSidebarWidget(c *gin.Context) {
+ id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil || id == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
+ return
+ }
+ var body model.SidebarWidget
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
+ return
+ }
+ if err := h.Sidebar.UpdateWidget(uint(id), &body); err != nil {
+ if errors.Is(err, service.ErrSidebarWidgetNotFound) {
+ c.JSON(http.StatusNotFound, gin.H{"error": "侧栏工具不存在"})
+ return
+ }
+ if errors.Is(err, service.ErrSidebarInvalid) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "工具名称或代码长度无效"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "更新侧栏工具失败"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
+// DELETE /api/admin/sidebar/widgets/:id 删除工具并清理布局引用
+func (h *Handlers) AdminDeleteSidebarWidget(c *gin.Context) {
+ id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil || id == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
+ return
+ }
+ if err := h.Sidebar.DeleteWidget(uint(id)); err != nil {
+ if errors.Is(err, service.ErrSidebarWidgetNotFound) {
+ c.JSON(http.StatusNotFound, gin.H{"error": "侧栏工具不存在"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "删除侧栏工具失败"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
diff --git a/backend/handler/telemetry.go b/backend/handler/telemetry.go
index 992d923..17c0b20 100644
--- a/backend/handler/telemetry.go
+++ b/backend/handler/telemetry.go
@@ -5,6 +5,7 @@ import (
"encoding/hex"
"fmt"
"net/http"
+ "strings"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
@@ -12,8 +13,6 @@ import (
"github.com/gin-gonic/gin"
)
-const visitorCookie = "j13_vid"
-
func newVisitorID() string {
var b [16]byte
_, _ = rand.Read(b[:])
@@ -27,21 +26,64 @@ func newVisitorID() string {
)
}
-// POST /api/telemetry/pageview — 轻量 PV/UV 埋点(公开,需 CSRF)
+// POST /api/telemetry/pageview — 轻量 PV/UV 埋点(公开,需 CSRF + 限流)。
+// 只组装事件并入队即返回,落库由后台协程攒批完成。
func (h *Handlers) TelemetryPageView(c *gin.Context) {
- vid, _ := c.Cookie(visitorCookie)
+ if !h.Visit.Enabled() {
+ c.Status(http.StatusNoContent)
+ return
+ }
+
+ var body struct {
+ Path string `json:"path"`
+ Ref string `json:"ref"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
+ return
+ }
+ path := strings.TrimSpace(body.Path)
+ if !strings.HasPrefix(path, "/") {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
+ return
+ }
+ if len(path) > 512 {
+ path = path[:512]
+ }
+
+ vid, _ := c.Cookie(service.VisitorCookieName)
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)
+ c.SetCookie(service.VisitorCookieName, 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
+ ua := c.Request.UserAgent()
+ ev := model.VisitEvent{Path: path}
+ if bot := service.ClassifyBot(ua); bot != "" {
+ // UA 命中爬虫:只进明细,不计 PV/UV
+ ev.Kind = model.VisitKindBot
+ ev.Bot = bot
+ } else {
+ ev.Kind = model.VisitKindPageview
}
+ host, kind := service.ClassifyReferrer(body.Ref, c.Request.Host)
+ ev.RefHost = host
+ ev.RefKind = kind
+ if service.ValidVisitorID(vid) {
+ ev.VidHash = service.HashVisitorID(vid)
+ }
+ if u := middleware.CurrentUser(c); u != nil {
+ ev.UserID = u.ID
+ }
+ ev.IP = service.CanonicalIP(c.ClientIP())
+ p := service.ParseUserAgent(ua)
+ ev.Device = p.Kind
+ ev.OS = p.OS
+ ev.Browser = p.Browser
+ h.Visit.Enqueue(ev)
+
c.JSON(http.StatusOK, gin.H{"ok": true})
}
diff --git a/backend/handler/visitstats.go b/backend/handler/visitstats.go
new file mode 100644
index 0000000..08beb6c
--- /dev/null
+++ b/backend/handler/visitstats.go
@@ -0,0 +1,166 @@
+package handler
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/freefire/jiang13-bbs/service"
+ "github.com/gin-gonic/gin"
+)
+
+// ============ 访问统计管理端 API(全部 RequirePerm(PermSettings)) ============
+
+func statsRange(c *gin.Context) string { return c.DefaultQuery("range", "7d") }
+
+// GET /api/admin/stats/overview
+func (h *Handlers) StatsOverview(c *gin.Context) {
+ data, err := h.Visit.Overview(statsRange(c))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取概览失败", "detail": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, data)
+}
+
+// GET /api/admin/stats/realtime
+func (h *Handlers) StatsRealtime(c *gin.Context) {
+ data, err := h.Visit.Realtime()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取实时数据失败", "detail": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, data)
+}
+
+// GET /api/admin/stats/sources
+func (h *Handlers) StatsSources(c *gin.Context) {
+ data, err := h.Visit.Sources(statsRange(c))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取来源分析失败"})
+ return
+ }
+ c.JSON(http.StatusOK, data)
+}
+
+// GET /api/admin/stats/pages?q=&page=&size=
+func (h *Handlers) StatsPages(c *gin.Context) {
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
+ data, err := h.Visit.Pages(statsRange(c), strings.TrimSpace(c.Query("q")), page, size)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取受访页面失败"})
+ return
+ }
+ c.JSON(http.StatusOK, data)
+}
+
+// GET /api/admin/stats/visitors
+func (h *Handlers) StatsVisitors(c *gin.Context) {
+ data, err := h.Visit.Visitors(statsRange(c))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取访客画像失败"})
+ return
+ }
+ c.JSON(http.StatusOK, data)
+}
+
+// GET /api/admin/stats/security
+func (h *Handlers) StatsSecurity(c *gin.Context) {
+ data, err := h.Visit.Security(statsRange(c))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取安全数据失败"})
+ return
+ }
+ c.JSON(http.StatusOK, data)
+}
+
+// GET /api/admin/stats/events?kind=&bot=&path=&ip=&start=&end=&page=&size=
+func (h *Handlers) StatsEvents(c *gin.Context) {
+ f := service.VisitEventFilter{
+ Kind: strings.TrimSpace(c.Query("kind")),
+ Bot: strings.TrimSpace(c.Query("bot")),
+ Path: strings.TrimSpace(c.Query("path")),
+ IP: strings.TrimSpace(c.Query("ip")),
+ }
+ if v := c.Query("start"); v != "" {
+ if t, err := time.Parse(time.RFC3339, v); err == nil {
+ f.Start = t
+ }
+ }
+ if v := c.Query("end"); v != "" {
+ if t, err := time.Parse(time.RFC3339, v); err == nil {
+ f.End = t
+ }
+ }
+ f.Page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
+ f.Size, _ = strconv.Atoi(c.DefaultQuery("size", "50"))
+ data, err := h.Visit.Events(f)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取访问明细失败"})
+ return
+ }
+ c.JSON(http.StatusOK, data)
+}
+
+// GET /api/admin/stats/settings — 开关/保留天数 + 容量信息
+func (h *Handlers) StatsGetSettings(c *gin.Context) {
+ data, err := h.Visit.SettingsPayload()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取设置失败"})
+ return
+ }
+ c.JSON(http.StatusOK, data)
+}
+
+// PUT /api/admin/stats/settings
+func (h *Handlers) StatsSaveSettings(c *gin.Context) {
+ var body struct {
+ Enabled *bool `json:"enabled"`
+ RetentionDays *int `json:"retention_days"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
+ return
+ }
+ cur, err := h.Visit.SettingsPayload()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "读取当前设置失败"})
+ return
+ }
+ enabled := cur.Enabled
+ if body.Enabled != nil {
+ enabled = *body.Enabled
+ }
+ days := cur.RetentionDays
+ if body.RetentionDays != nil {
+ days = *body.RetentionDays
+ }
+ if err := h.Visit.SaveSettings(enabled, days); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "保存失败,保留天数需在 7–365 之间"})
+ return
+ }
+ data, err := h.Visit.SettingsPayload()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取设置失败"})
+ return
+ }
+ c.JSON(http.StatusOK, data)
+}
+
+// POST /api/admin/stats/purge — 立即清理 N 天前明细(异步分批执行,受理即返回)
+func (h *Handlers) StatsPurge(c *gin.Context) {
+ var body struct {
+ Days int `json:"days"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
+ return
+ }
+ if !h.Visit.PurgeBefore(body.Days) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "天数需在 7–365 之间"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
diff --git a/backend/middleware/visit_capture.go b/backend/middleware/visit_capture.go
new file mode 100644
index 0000000..29787d9
--- /dev/null
+++ b/backend/middleware/visit_capture.go
@@ -0,0 +1,62 @@
+package middleware
+
+import (
+ "strings"
+
+ "github.com/freefire/jiang13-bbs/model"
+ "github.com/freefire/jiang13-bbs/service"
+ "github.com/gin-gonic/gin"
+)
+
+// VisitCapture 服务端访问捕获:只记「UA 命中爬虫库」与「响应状态 ≥ 400」两类请求,
+// 普通人类浏览由客户端埋点上报(互补、不重复)。事件非阻塞入队,不影响响应。
+func VisitCapture(svc *service.VisitStatsService) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Next()
+
+ if svc == nil || !svc.Enabled() {
+ return
+ }
+ // 静态资源与健康检查/WS 无统计意义
+ path := c.Request.URL.Path
+ if strings.HasPrefix(path, "/uploads") || path == "/health" || path == "/api/ws" {
+ return
+ }
+
+ ua := c.Request.UserAgent()
+ bot := service.ClassifyBot(ua)
+ status := c.Writer.Status()
+ if bot == "" && status < 400 {
+ return // 正常人类请求:不记
+ }
+
+ ev := model.VisitEvent{}
+ if bot != "" {
+ ev.Kind = model.VisitKindBot
+ ev.Bot = bot
+ } else {
+ ev.Kind = model.VisitKindProbe
+ ev.Status = status
+ }
+ if q := c.Request.URL.RawQuery; q != "" {
+ ev.Path = path + "?" + q
+ } else {
+ ev.Path = path
+ }
+ if len(ev.Path) > 512 {
+ ev.Path = ev.Path[:512]
+ }
+ ev.IP = service.CanonicalIP(c.ClientIP())
+ if vid, err := c.Cookie(service.VisitorCookieName); err == nil && service.ValidVisitorID(vid) {
+ ev.VidHash = service.HashVisitorID(vid)
+ }
+ if u := CurrentUser(c); u != nil {
+ ev.UserID = u.ID
+ }
+ p := service.ParseUserAgent(ua)
+ ev.Device = p.Kind
+ ev.OS = p.OS
+ ev.Browser = p.Browser
+ svc.Enqueue(ev)
+ }
+}
diff --git a/backend/model/db.go b/backend/model/db.go
index bd929b7..20e1cdf 100644
--- a/backend/model/db.go
+++ b/backend/model/db.go
@@ -58,10 +58,10 @@ func InitDB(dsn string) error {
if err := db.AutoMigrate(
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{},
&User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
- &Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
+ &Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
- &PostPollVote{}, &PostLotteryEntry{}, &Ad{},
+ &PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{},
&ImportRecord{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
diff --git a/backend/model/models.go b/backend/model/models.go
index 766489b..951f7ca 100644
--- a/backend/model/models.go
+++ b/backend/model/models.go
@@ -455,6 +455,19 @@ type SiteSetting struct {
UpdatedAt time.Time `json:"updated_at"`
}
+// SidebarWidget 自定义侧栏工具(站长 HTML+CSS+JS 代码块,前台原样渲染执行)
+// Enabled 不加 gorm default 标签:规避布尔零值省略陷阱,创建时由代码显式赋值
+type SidebarWidget struct {
+ ID uint `gorm:"primarykey" json:"id"`
+ Name string `gorm:"size:64;not null" json:"name"`
+ Html string `gorm:"type:text" json:"html"`
+ Css string `gorm:"type:text" json:"css"`
+ Js string `gorm:"type:text" json:"js"`
+ Enabled bool `json:"enabled"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
// SiteDailyStats 站点日维度访问量(轻量 PV/UV)
type SiteDailyStats struct {
Date time.Time `gorm:"type:date;primaryKey" json:"date"`
@@ -470,6 +483,31 @@ type SiteDailyVisitor struct {
VidHash string `gorm:"size:64;primaryKey" json:"-"`
}
+// 访问事件类型:pageview 人类浏览(计 PV/UV);bot 爬虫;probe 异常/错误响应
+const (
+ VisitKindPageview = "pageview"
+ VisitKindBot = "bot"
+ VisitKindProbe = "probe"
+)
+
+// VisitEvent 单次访问事件(内存攒批落库,保留期滚动清理)
+type VisitEvent struct {
+ ID uint64 `gorm:"primaryKey" json:"id"`
+ CreatedAt time.Time `gorm:"index:idx_visit_created" json:"created_at"`
+ Kind string `gorm:"size:16;index:idx_visit_kind_time,priority:1;not null;default:'pageview'" json:"kind"`
+ Path string `gorm:"size:512;not null;default:''" json:"path"`
+ RefHost string `gorm:"size:128;not null;default:''" json:"ref_host"`
+ RefKind string `gorm:"size:16;not null;default:'direct'" json:"ref_kind"` // direct|internal|search|social|external
+ VidHash string `gorm:"size:64;not null;default:''" json:"-"`
+ UserID uint `gorm:"not null;default:0" json:"user_id"`
+ IP string `gorm:"size:45;not null;default:''" json:"ip"`
+ Device string `gorm:"size:16;not null;default:''" json:"device"` // computer|mobile|tablet
+ OS string `gorm:"size:32;not null;default:''" json:"os"`
+ Browser string `gorm:"size:32;not null;default:''" json:"browser"`
+ Bot string `gorm:"size:64;not null;default:''" json:"bot"` // 非空 = 爬虫名
+ Status int `gorm:"not null;default:0" json:"status"` // probe 的响应状态码
+}
+
// 附件类型
const (
AttachmentKindAvatar = "avatar" // 用户头像(正方形 WebP)
diff --git a/backend/router/router.go b/backend/router/router.go
index f49d6ff..95891cb 100644
--- a/backend/router/router.go
+++ b/backend/router/router.go
@@ -2,6 +2,7 @@ package router
import (
"context"
+ "log"
"os"
"path/filepath"
"strings"
@@ -29,6 +30,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
var proxies []string
if v := os.Getenv("TRUSTED_PROXIES"); v != "" {
proxies = strings.Split(v, ",")
+ } else {
+ // 未配置信任代理时 ClientIP 退化为 TCP 直连对端:反代/容器部署会记录内网地址
+ log.Println("警告: TRUSTED_PROXIES 未配置,ClientIP 将记录反代/容器内网地址(如 172.19.0.4),请设置为实际反代网段,如 172.16.0.0/12")
}
if err := r.SetTrustedProxies(proxies); err != nil {
return nil, err
@@ -73,7 +77,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
moderationSvc := service.NewModerationService(model.DB, notifSvc)
chatSvc := service.NewChatService(model.DB, notifSvc)
- telemetrySvc := service.NewTelemetryService(model.DB)
+ visitSvc := service.NewVisitStatsService(model.DB, settingSvc)
analyticsSvc := service.NewAnalyticsService(model.DB)
adSvc := service.NewAdService(model.DB, service.NewCaptchaStore())
overviewSvc.WithAds(adSvc)
@@ -85,6 +89,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
}
limiter := service.DefaultRateLimiter()
+ // 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期)
+ pipeCtx, pipeCancel := context.WithCancel(context.Background())
+ defer pipeCancel()
+ go visitSvc.StartFlusher(pipeCtx)
+ go visitSvc.RunRetention(pipeCtx)
+
h := &handler.Handlers{
Ops: ops,
Cfg: cfg,
@@ -107,10 +117,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
LegacyImport: legacyImportSvc,
Moderation: moderationSvc,
Chat: chatSvc,
- Telemetry: telemetrySvc,
+ Visit: visitSvc,
Analytics: analyticsSvc,
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
Ads: adSvc,
+ Sidebar: service.NewSidebarService(model.DB),
}
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
notifSvc.OnNotifyNew = func(userID uint) {
@@ -127,6 +138,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
authMW := middleware.NewAuthMiddleware(authSvc)
r.Use(authMW.OptionalAuth(), h.RuntimeGuard, h.BusinessQuota)
+ // 访问捕获:仅记爬虫 UA 与 ≥400 响应(人类浏览走客户端埋点);
+ // 挂在 OptionalAuth 之后可顺带拿到登录用户
+ r.Use(middleware.VisitCapture(visitSvc))
+
// 上传文件静态服务(data/uploads → /uploads)
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
@@ -143,6 +158,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/boards", h.Boards)
pubAPI.GET("/boards/:id/sidebar", h.BoardSidebar)
pubAPI.GET("/overview", h.Overview)
+ pubAPI.GET("/sidebar", h.GetSidebar)
pubAPI.GET("/posts", h.Posts)
pubAPI.GET("/posts/:id", h.PostDetail)
pubAPI.POST("/posts/:id/unlock-password", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateHidePassword), h.UnlockPostPassword)
@@ -159,7 +175,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/media/:object", h.PublicObject)
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword)
- pubAPI.POST("/telemetry/pageview", middleware.CSRFMiddleware(), h.TelemetryPageView)
+ pubAPI.POST("/telemetry/pageview", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateTelemetry), h.TelemetryPageView)
pubAPI.POST("/register", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
pubAPI.POST("/login", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
// refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护)
@@ -277,6 +293,21 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 站点分析概览(任意 staff;板管按授权板过滤)
staffAPI.GET("/analytics/overview", h.AdminAnalyticsOverview)
+ // 访问统计(仅超管/站长):8 个 Tab 的数据源
+ statsAPI := staffAPI.Group("/stats", authMW.RequirePerm(service.PermSettings))
+ {
+ statsAPI.GET("/overview", h.StatsOverview)
+ statsAPI.GET("/realtime", h.StatsRealtime)
+ statsAPI.GET("/sources", h.StatsSources)
+ statsAPI.GET("/pages", h.StatsPages)
+ statsAPI.GET("/visitors", h.StatsVisitors)
+ statsAPI.GET("/security", h.StatsSecurity)
+ statsAPI.GET("/events", h.StatsEvents)
+ statsAPI.GET("/settings", h.StatsGetSettings)
+ statsAPI.PUT("/settings", h.StatsSaveSettings)
+ statsAPI.POST("/purge", h.StatsPurge)
+ }
+
// 群聊监管:移出后挂到 RequireAuth 组,允许「仅群管/消息 flag」用户访问
// (见下方 msgAPI)
@@ -305,6 +336,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
adsAPI.GET("/sponsors/config", h.AdminGetSponsorsConfig)
adsAPI.PUT("/sponsors/config", h.AdminSaveSponsorsConfig)
+ // 侧栏管理(超级管理员/站长):布局配置 + 自定义 HTML 工具
+ staffAPI.GET("/sidebar", authMW.RequirePerm(service.PermSettings), h.AdminGetSidebar)
+ staffAPI.PUT("/sidebar/config", authMW.RequirePerm(service.PermSettings), h.AdminSaveSidebarConfig)
+ staffAPI.POST("/sidebar/widgets", authMW.RequirePerm(service.PermSettings), h.AdminCreateSidebarWidget)
+ staffAPI.PUT("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminUpdateSidebarWidget)
+ staffAPI.DELETE("/sidebar/widgets/:id", authMW.RequirePerm(service.PermSettings), h.AdminDeleteSidebarWidget)
+
// 站点设置(超级管理员/站长)
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings))
diff --git a/backend/service/ratelimit.go b/backend/service/ratelimit.go
index 9632904..dbdbdb5 100644
--- a/backend/service/ratelimit.go
+++ b/backend/service/ratelimit.go
@@ -84,6 +84,7 @@ const (
RateTimelineGit = "timeline_git" // Git 提交导入 10/分钟
RateAdSubmit = "ad_submit" // 广告申购 5/分钟(按 IP)
RateCaptcha = "captcha" // 验证码刷新 30/分钟
+ RateTelemetry = "telemetry" // 页面浏览埋点 120/分钟(按 IP)
)
// DefaultRateLimiter 创建默认速率限制器
@@ -100,5 +101,6 @@ func DefaultRateLimiter() *RateLimiter {
rl.SetLimit(RateTimelineGit, 10) // 时间线 Git 导入 10/分钟
rl.SetLimit(RateAdSubmit, 5) // 广告申购 5/分钟
rl.SetLimit(RateCaptcha, 30) // 验证码 30/分钟
+ rl.SetLimit(RateTelemetry, 120) // 埋点 120/分钟
return rl
}
diff --git a/backend/service/setting.go b/backend/service/setting.go
index 810ec11..9735aa1 100644
--- a/backend/service/setting.go
+++ b/backend/service/setting.go
@@ -53,6 +53,10 @@ const (
SettingKeyAttachmentMaxCount = "attachment_max_count"
// SettingKeyImageMaxMB 正文插图上限(MB);缺行=5
SettingKeyImageMaxMB = "image_max_mb"
+ // SettingKeyAnalyticsEnabled 访问统计采集开关;缺行视为开启(默认开)
+ SettingKeyAnalyticsEnabled = "analytics_enabled"
+ // SettingKeyAnalyticsRetentionDays 访问明细保留天数;缺行=90
+ SettingKeyAnalyticsRetentionDays = "analytics_retention_days"
)
const (
@@ -74,6 +78,10 @@ const (
MinAttachmentMaxCount = 1
MaxAttachmentMaxCount = 20
MaxAttachmentExtCount = 80
+
+ DefaultAnalyticsRetentionDays = 90
+ MinAnalyticsRetentionDays = 7
+ MaxAnalyticsRetentionDays = 365
)
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
@@ -880,3 +888,47 @@ func (s *SettingService) ImageMaxBytes() (int64, error) {
}
return int64(mb) << 20, nil
}
+
+// AnalyticsEnabled 访问统计采集开关。缺行视为开启。
+func (s *SettingService) AnalyticsEnabled() (bool, error) {
+ v, found, err := s.getValue(SettingKeyAnalyticsEnabled)
+ if err != nil {
+ return true, err
+ }
+ return parseBoolDefaultTrue(v, found), nil
+}
+
+// SetAnalyticsEnabled 持久化采集开关;开启时删键保持「缺行=默认开」。
+func (s *SettingService) SetAnalyticsEnabled(on bool) error {
+ return s.setBoolDefaultTrue(SettingKeyAnalyticsEnabled, on)
+}
+
+// AnalyticsRetentionDays 访问明细保留天数。缺行=90;钳制 7–365。
+func (s *SettingService) AnalyticsRetentionDays() (int, error) {
+ v, found, err := s.getValue(SettingKeyAnalyticsRetentionDays)
+ if err != nil {
+ return DefaultAnalyticsRetentionDays, err
+ }
+ if !found {
+ return DefaultAnalyticsRetentionDays, nil
+ }
+ n, convErr := strconv.Atoi(strings.TrimSpace(v))
+ if convErr != nil || n < MinAnalyticsRetentionDays {
+ return DefaultAnalyticsRetentionDays, nil
+ }
+ if n > MaxAnalyticsRetentionDays {
+ return MaxAnalyticsRetentionDays, nil
+ }
+ return n, nil
+}
+
+// SetAnalyticsRetentionDays 持久化保留天数;等于默认值时删键。
+func (s *SettingService) SetAnalyticsRetentionDays(days int) error {
+ if days < MinAnalyticsRetentionDays || days > MaxAnalyticsRetentionDays {
+ return ErrInvalidSiteSetting
+ }
+ if days == DefaultAnalyticsRetentionDays {
+ return s.deleteKey(SettingKeyAnalyticsRetentionDays)
+ }
+ return s.putValue(SettingKeyAnalyticsRetentionDays, strconv.Itoa(days))
+}
diff --git a/backend/service/sidebar.go b/backend/service/sidebar.go
new file mode 100644
index 0000000..7c567ec
--- /dev/null
+++ b/backend/service/sidebar.go
@@ -0,0 +1,453 @@
+package service
+
+import (
+ "encoding/json"
+ "errors"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/freefire/jiang13-bbs/model"
+ "gorm.io/gorm"
+)
+
+// SettingKeySidebarConfig 侧栏布局配置在 site_settings 中的键(沿 ads_config JSON 先例)
+const SettingKeySidebarConfig = "sidebar_config"
+
+const (
+ // SidebarConfigVersion 配置结构版本;升级结构时递增并做迁移
+ SidebarConfigVersion = 1
+ // SidebarMaxBlocksPerSide 每侧最多放置的块数
+ SidebarMaxBlocksPerSide = 24
+ // SidebarWidgetMaxNameRunes 自定义工具备注名上限(字符数)
+ SidebarWidgetMaxNameRunes = 40
+ // SidebarWidgetMaxCodeBytes 单段代码(HTML/CSS/JS)字节上限
+ SidebarWidgetMaxCodeBytes = 64 << 10
+)
+
+var (
+ // ErrSidebarInvalid 侧栏配置/工具参数校验失败
+ ErrSidebarInvalid = errors.New("侧栏配置参数无效")
+ // ErrSidebarWidgetNotFound 自定义工具不存在
+ ErrSidebarWidgetNotFound = errors.New("侧栏工具不存在")
+)
+
+// 内置块 key 白名单(与前端 lib/sidebarMeta.ts 保持一致)
+var sidebarBuiltinKeys = map[string]map[string]struct{}{
+ "home": setOf("board_nav", "pulse", "announcements", "ads", "sponsors",
+ "hot", "new_users", "active_users", "friend_links", "about_site"),
+ // board scope 仅右栏可配;board_* 不允许放进 home/post
+ "board": setOf("board_moderators", "board_intro", "board_pulse", "board_hot", "board_active"),
+ "post": setOf("post_board", "post_author", "post_author_posts", "post_toc"),
+}
+
+// sidebarScopes 合法页面范围(board 布局只存 right;board_* 块仅限 board scope)
+var sidebarScopes = setOf("home", "board", "post")
+
+func setOf(keys ...string) map[string]struct{} {
+ m := make(map[string]struct{}, len(keys))
+ for _, k := range keys {
+ m[k] = struct{}{}
+ }
+ return m
+}
+
+// SidebarBlockRef 侧栏块引用:内置块用 Key,自定义工具用 WidgetID(二选一)
+type SidebarBlockRef struct {
+ Key string `json:"key,omitempty"`
+ WidgetID uint `json:"widget,omitempty"`
+}
+
+// SidebarScopeConfig 单个页面范围的侧栏布局;指针切片区分「未配置(回落默认)」与「显式清空」
+type SidebarScopeConfig struct {
+ Left *[]SidebarBlockRef `json:"left,omitempty"`
+ Right *[]SidebarBlockRef `json:"right,omitempty"`
+}
+
+// SidebarConfig 全站侧栏布局配置
+type SidebarConfig struct {
+ Version int `json:"version"`
+ Scopes map[string]SidebarScopeConfig `json:"scopes"`
+}
+
+// SidebarPublicWidget 前台可见的自定义工具内容(仅启用项)
+type SidebarPublicWidget struct {
+ ID uint `json:"id"`
+ Name string `json:"name"`
+ Html string `json:"html"`
+ Css string `json:"css"`
+ Js string `json:"js"`
+}
+
+// SidebarPublicPayload 公开侧栏载荷:生效布局 + 启用中的自定义工具
+type SidebarPublicPayload struct {
+ Config SidebarConfig `json:"config"`
+ Widgets []SidebarPublicWidget `json:"widgets"`
+}
+
+func refKey(r SidebarBlockRef) string {
+ if r.WidgetID > 0 {
+ return "widget:" + itoa(int(r.WidgetID))
+ }
+ return "key:" + r.Key
+}
+
+func refsEqualRefs(a, b []SidebarBlockRef) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func defaultScopeLeft(scope string) *[]SidebarBlockRef {
+ var refs []SidebarBlockRef
+ switch scope {
+ case "home":
+ refs = []SidebarBlockRef{{Key: "board_nav"}}
+ case "post":
+ refs = []SidebarBlockRef{{Key: "post_board"}, {Key: "post_author"}, {Key: "post_author_posts"}}
+ default: // board:左栏不可配,恒为空
+ refs = []SidebarBlockRef{}
+ }
+ return &refs
+}
+
+func defaultScopeRight(scope string) *[]SidebarBlockRef {
+ var refs []SidebarBlockRef
+ switch scope {
+ case "home":
+ refs = []SidebarBlockRef{
+ {Key: "pulse"}, {Key: "announcements"}, {Key: "ads"}, {Key: "sponsors"},
+ {Key: "hot"}, {Key: "new_users"}, {Key: "active_users"},
+ {Key: "friend_links"}, {Key: "about_site"},
+ }
+ case "board":
+ refs = []SidebarBlockRef{
+ {Key: "board_moderators"}, {Key: "board_intro"}, {Key: "board_pulse"},
+ {Key: "board_hot"}, {Key: "board_active"},
+ }
+ default: // post
+ refs = []SidebarBlockRef{{Key: "post_toc"}}
+ }
+ return &refs
+}
+
+// DefaultSidebarConfig 当前站点的默认侧栏布局(与历史硬编码渲染一致,零迁移)
+func DefaultSidebarConfig() SidebarConfig {
+ return SidebarConfig{
+ Version: SidebarConfigVersion,
+ Scopes: map[string]SidebarScopeConfig{
+ "home": {Left: defaultScopeLeft("home"), Right: defaultScopeRight("home")},
+ "board": {Left: defaultScopeLeft("board"), Right: defaultScopeRight("board")},
+ "post": {Left: defaultScopeLeft("post"), Right: defaultScopeRight("post")},
+ },
+ }
+}
+
+// SidebarService 侧栏布局配置 + 自定义工具
+type SidebarService struct {
+ db *gorm.DB
+}
+
+func NewSidebarService(db *gorm.DB) *SidebarService {
+ return &SidebarService{db: db}
+}
+
+// fillScopeDefaults 补齐缺失的 scope 与侧栏(nil 指针 = 未配置 → 默认布局)
+func fillScopeDefaults(cfg SidebarConfig) SidebarConfig {
+ out := DefaultSidebarConfig()
+ for scope, sc := range cfg.Scopes {
+ dst, ok := out.Scopes[scope]
+ if !ok {
+ continue
+ }
+ if sc.Left != nil {
+ dst.Left = sc.Left
+ }
+ if sc.Right != nil {
+ dst.Right = sc.Right
+ }
+ out.Scopes[scope] = dst
+ }
+ return out
+}
+
+// GetConfig 读取生效配置(缺键/坏 JSON 回落默认布局)
+func (s *SidebarService) GetConfig() (SidebarConfig, error) {
+ var row model.SiteSetting
+ err := s.db.Where("key = ?", SettingKeySidebarConfig).First(&row).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return DefaultSidebarConfig(), nil
+ }
+ if err != nil {
+ return DefaultSidebarConfig(), err
+ }
+ if strings.TrimSpace(row.Value) == "" {
+ return DefaultSidebarConfig(), nil
+ }
+ var cfg SidebarConfig
+ if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil {
+ return DefaultSidebarConfig(), nil
+ }
+ return fillScopeDefaults(cfg), nil
+}
+
+// validateConfig 校验并归一化;widgetExists 用于校验工具引用
+func (s *SidebarService) validateConfig(in SidebarConfig, widgetExists func(uint) (bool, error)) (SidebarConfig, error) {
+ if len(in.Scopes) == 0 {
+ return SidebarConfig{}, ErrSidebarInvalid
+ }
+ out := SidebarConfig{Version: SidebarConfigVersion, Scopes: map[string]SidebarScopeConfig{}}
+ for scope, sc := range in.Scopes {
+ if _, ok := sidebarScopes[scope]; !ok {
+ return SidebarConfig{}, ErrSidebarInvalid
+ }
+ keys := sidebarBuiltinKeys[scope]
+ seen := map[string]struct{}{}
+ left, err := s.validateSide(scope, "left", sc.Left, keys, seen, widgetExists)
+ if err != nil {
+ return SidebarConfig{}, err
+ }
+ right, err := s.validateSide(scope, "right", sc.Right, keys, seen, widgetExists)
+ if err != nil {
+ return SidebarConfig{}, err
+ }
+ // board 布局只存 right(左栏沿用 home 配置)
+ if scope == "board" {
+ left = &[]SidebarBlockRef{}
+ }
+ out.Scopes[scope] = SidebarScopeConfig{Left: left, Right: right}
+ }
+ return out, nil
+}
+
+func (s *SidebarService) validateSide(scope, side string, list *[]SidebarBlockRef, builtinKeys map[string]struct{}, seen map[string]struct{}, widgetExists func(uint) (bool, error)) (*[]SidebarBlockRef, error) {
+ if list == nil {
+ return defaultScopeSide(scope, side), nil
+ }
+ if len(*list) > SidebarMaxBlocksPerSide {
+ return nil, ErrSidebarInvalid
+ }
+ out := make([]SidebarBlockRef, 0, len(*list))
+ for _, r := range *list {
+ hasKey := r.Key != ""
+ hasWidget := r.WidgetID > 0
+ if hasKey == hasWidget { // 二选一,且必填其一
+ return nil, ErrSidebarInvalid
+ }
+ if hasKey {
+ if _, ok := builtinKeys[r.Key]; !ok {
+ return nil, ErrSidebarInvalid
+ }
+ } else {
+ ok, err := widgetExists(r.WidgetID)
+ if err != nil {
+ return nil, err
+ }
+ if !ok {
+ return nil, ErrSidebarInvalid
+ }
+ }
+ k := refKey(r)
+ if _, dup := seen[k]; dup {
+ return nil, ErrSidebarInvalid
+ }
+ seen[k] = struct{}{}
+ out = append(out, r)
+ }
+ return &out, nil
+}
+
+func defaultScopeSide(scope, side string) *[]SidebarBlockRef {
+ if side == "left" {
+ return defaultScopeLeft(scope)
+ }
+ return defaultScopeRight(scope)
+}
+
+// SaveConfig 保存布局配置(board.left 丢弃)
+func (s *SidebarService) SaveConfig(in SidebarConfig) (SidebarConfig, error) {
+ out, err := s.validateConfig(in, s.widgetExists)
+ if err != nil {
+ return SidebarConfig{}, err
+ }
+ b, err := json.Marshal(out)
+ if err != nil {
+ return SidebarConfig{}, err
+ }
+ row := model.SiteSetting{Key: SettingKeySidebarConfig, Value: string(b), UpdatedAt: time.Now()}
+ if err := s.db.Save(&row).Error; err != nil {
+ return SidebarConfig{}, err
+ }
+ return out, nil
+}
+
+// widgetExists 批量存在性检查走缓存式的单查即可(引用量 ≤48)
+func (s *SidebarService) widgetExists(id uint) (bool, error) {
+ var n int64
+ if err := s.db.Model(&model.SidebarWidget{}).Where("id = ?", id).Count(&n).Error; err != nil {
+ return false, err
+ }
+ return n > 0, nil
+}
+
+// ListWidgets 管理端工具全量列表
+func (s *SidebarService) ListWidgets() ([]model.SidebarWidget, error) {
+ var list []model.SidebarWidget
+ err := s.db.Order("id ASC").Find(&list).Error
+ return list, err
+}
+
+func validateWidgetInput(name, html, css, js string) (string, error) {
+ name = strings.TrimSpace(name)
+ if name == "" || utf8.RuneCountInString(name) > SidebarWidgetMaxNameRunes {
+ return "", ErrSidebarInvalid
+ }
+ for _, code := range []string{html, css, js} {
+ if len(code) > SidebarWidgetMaxCodeBytes {
+ return "", ErrSidebarInvalid
+ }
+ }
+ return name, nil
+}
+
+// CreateWidget 新建自定义工具
+func (s *SidebarService) CreateWidget(w *model.SidebarWidget) error {
+ name, err := validateWidgetInput(w.Name, w.Html, w.Css, w.Js)
+ if err != nil {
+ return err
+ }
+ w.Name = name
+ w.Enabled = true
+ now := time.Now()
+ w.CreatedAt = now
+ w.UpdatedAt = now
+ return s.db.Create(w).Error
+}
+
+// UpdateWidget 全量更新工具内容
+func (s *SidebarService) UpdateWidget(id uint, in *model.SidebarWidget) error {
+ name, err := validateWidgetInput(in.Name, in.Html, in.Css, in.Js)
+ if err != nil {
+ return err
+ }
+ var w model.SidebarWidget
+ if err := s.db.First(&w, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ErrSidebarWidgetNotFound
+ }
+ return err
+ }
+ // 用 Select 强制写全部列,规避布尔零值省略
+ return s.db.Model(&w).Select("name", "html", "css", "js", "enabled", "updated_at").
+ Updates(model.SidebarWidget{Name: name, Html: in.Html, Css: in.Css, Js: in.Js,
+ Enabled: in.Enabled, UpdatedAt: time.Now()}).Error
+}
+
+// SetWidgetEnabled 仅切换启用状态
+func (s *SidebarService) SetWidgetEnabled(id uint, enabled bool) error {
+ var w model.SidebarWidget
+ if err := s.db.First(&w, id).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ErrSidebarWidgetNotFound
+ }
+ return err
+ }
+ return s.db.Model(&w).Select("enabled", "updated_at").
+ Updates(model.SidebarWidget{Enabled: enabled, UpdatedAt: time.Now()}).Error
+}
+
+// DeleteWidget 删除工具并同事务清理配置中的引用
+func (s *SidebarService) DeleteWidget(id uint) error {
+ return s.db.Transaction(func(tx *gorm.DB) error {
+ res := tx.Delete(&model.SidebarWidget{}, id)
+ if res.Error != nil {
+ return res.Error
+ }
+ if res.RowsAffected == 0 {
+ return ErrSidebarWidgetNotFound
+ }
+ cfg, err := loadConfigTx(tx)
+ if err != nil {
+ return err
+ }
+ changed := false
+ for scope, sc := range cfg.Scopes {
+ if sc.Left != nil {
+ filtered := filterOutWidget(*sc.Left, id)
+ if !refsEqualRefs(filtered, *sc.Left) {
+ sc.Left = &filtered
+ changed = true
+ }
+ }
+ if sc.Right != nil {
+ filtered := filterOutWidget(*sc.Right, id)
+ if !refsEqualRefs(filtered, *sc.Right) {
+ sc.Right = &filtered
+ changed = true
+ }
+ }
+ cfg.Scopes[scope] = sc
+ }
+ if !changed {
+ return nil
+ }
+ b, err := json.Marshal(cfg)
+ if err != nil {
+ return err
+ }
+ return tx.Model(&model.SiteSetting{}).Where("key = ?", SettingKeySidebarConfig).
+ Update("value", string(b)).Error
+ })
+}
+
+func filterOutWidget(refs []SidebarBlockRef, id uint) []SidebarBlockRef {
+ out := make([]SidebarBlockRef, 0, len(refs))
+ for _, r := range refs {
+ if r.WidgetID == id {
+ continue
+ }
+ out = append(out, r)
+ }
+ return out
+}
+
+func loadConfigTx(tx *gorm.DB) (SidebarConfig, error) {
+ var row model.SiteSetting
+ err := tx.Where("key = ?", SettingKeySidebarConfig).First(&row).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return DefaultSidebarConfig(), nil
+ }
+ if err != nil {
+ return SidebarConfig{}, err
+ }
+ var cfg SidebarConfig
+ if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil {
+ return DefaultSidebarConfig(), nil
+ }
+ return cfg, nil
+}
+
+// PublicPayload 前台载荷:生效布局 + 启用中的工具(按 id 升序)
+func (s *SidebarService) PublicPayload() (*SidebarPublicPayload, error) {
+ cfg, err := s.GetConfig()
+ if err != nil {
+ return nil, err
+ }
+ var rows []model.SidebarWidget
+ if err := s.db.Where("enabled = ?", true).Order("id ASC").Find(&rows).Error; err != nil {
+ return nil, err
+ }
+ widgets := make([]SidebarPublicWidget, 0, len(rows))
+ for _, w := range rows {
+ widgets = append(widgets, SidebarPublicWidget{
+ ID: w.ID, Name: w.Name, Html: w.Html, Css: w.Css, Js: w.Js,
+ })
+ }
+ return &SidebarPublicPayload{Config: cfg, Widgets: widgets}, nil
+}
diff --git a/backend/service/sidebar_test.go b/backend/service/sidebar_test.go
new file mode 100644
index 0000000..47864bc
--- /dev/null
+++ b/backend/service/sidebar_test.go
@@ -0,0 +1,224 @@
+package service
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/freefire/jiang13-bbs/model"
+ "github.com/glebarez/sqlite"
+ "gorm.io/gorm"
+)
+
+func newSidebarTestDB(t *testing.T) *gorm.DB {
+ t.Helper()
+ db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("open sqlite: %v", err)
+ }
+ if err := db.AutoMigrate(&model.SiteSetting{}, &model.SidebarWidget{}); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ return db
+}
+
+func TestSidebarGetConfigDefaults(t *testing.T) {
+ db := newSidebarTestDB(t)
+ s := NewSidebarService(db)
+
+ // 无键 → 默认布局
+ cfg, err := s.GetConfig()
+ if err != nil {
+ t.Fatalf("GetConfig: %v", err)
+ }
+ def := DefaultSidebarConfig()
+ home := cfg.Scopes["home"]
+ if home.Left == nil || (*home.Left)[0].Key != "board_nav" {
+ t.Fatal("home.left 默认应为 board_nav")
+ }
+ if len(*home.Right) != len(*def.Scopes["home"].Right) {
+ t.Fatalf("home.right 默认长度不符: %d", len(*home.Right))
+ }
+ // board.left 恒为空
+ if bl := cfg.Scopes["board"].Left; bl == nil || len(*bl) != 0 {
+ t.Fatal("board.left 默认应为空")
+ }
+
+ // 坏 JSON → 回落默认
+ if err := db.Create(&model.SiteSetting{Key: SettingKeySidebarConfig, Value: "{oops"}).Error; err != nil {
+ t.Fatalf("seed bad json: %v", err)
+ }
+ cfg2, err := s.GetConfig()
+ if err != nil {
+ t.Fatalf("GetConfig bad json: %v", err)
+ }
+ if (*cfg2.Scopes["home"].Left)[0].Key != "board_nav" {
+ t.Fatal("坏 JSON 应回落默认布局")
+ }
+
+ // 显式清空的侧栏要保持为空(非默认回填)
+ empty := []SidebarBlockRef{}
+ saved, err := s.SaveConfig(SidebarConfig{
+ Version: SidebarConfigVersion,
+ Scopes: map[string]SidebarScopeConfig{
+ "home": {Right: &empty},
+ },
+ })
+ if err != nil {
+ t.Fatalf("SaveConfig: %v", err)
+ }
+ if saved.Scopes["home"].Right == nil || len(*saved.Scopes["home"].Right) != 0 {
+ t.Fatal("显式清空的 home.right 应保持为空")
+ }
+ cfg3, _ := s.GetConfig()
+ if len(*cfg3.Scopes["home"].Right) != 0 {
+ t.Fatal("读取时显式清空不应被默认回填")
+ }
+ if len(*cfg3.Scopes["post"].Left) == 0 {
+ t.Fatal("未配置的 post.left 应回落默认")
+ }
+}
+
+func TestSidebarSaveConfigValidation(t *testing.T) {
+ db := newSidebarTestDB(t)
+ s := NewSidebarService(db)
+
+ mk := func(scopes map[string]SidebarScopeConfig) SidebarConfig {
+ return SidebarConfig{Version: SidebarConfigVersion, Scopes: scopes}
+ }
+ side := func(refs []SidebarBlockRef) *[]SidebarBlockRef { return &refs }
+
+ // 非法 scope
+ if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
+ "unknown": {Right: side([]SidebarBlockRef{{Key: "pulse"}})},
+ })); !errors.Is(err, ErrSidebarInvalid) {
+ t.Fatal("非法 scope 应被拒绝")
+ }
+ // 非法 key(board_* 不允许放进 home)
+ if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
+ "home": {Right: side([]SidebarBlockRef{{Key: "board_hot"}})},
+ })); !errors.Is(err, ErrSidebarInvalid) {
+ t.Fatal("跨 scope 的内置 key 应被拒绝")
+ }
+ // 引用不存在的自定义工具
+ if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
+ "home": {Right: side([]SidebarBlockRef{{WidgetID: 999}})},
+ })); !errors.Is(err, ErrSidebarInvalid) {
+ t.Fatal("不存在的 widget 引用应被拒绝")
+ }
+ // 同 scope 重复引用
+ if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
+ "home": {Right: side([]SidebarBlockRef{{Key: "pulse"}, {Key: "pulse"}})},
+ })); !errors.Is(err, ErrSidebarInvalid) {
+ t.Fatal("同 scope 重复引用应被拒绝")
+ }
+ // 超 24 块
+ many := make([]SidebarBlockRef, SidebarMaxBlocksPerSide+1)
+ for i := range many {
+ many[i] = SidebarBlockRef{Key: "pulse"}
+ }
+ if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
+ "home": {Right: &many},
+ })); !errors.Is(err, ErrSidebarInvalid) {
+ t.Fatal("超上限应被拒绝")
+ }
+ // key 与 widget 同时出现
+ if _, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
+ "home": {Right: side([]SidebarBlockRef{{Key: "pulse", WidgetID: 1}})},
+ })); !errors.Is(err, ErrSidebarInvalid) {
+ t.Fatal("key 与 widget 二选一应被强制")
+ }
+ // 空 scopes
+ if _, err := s.SaveConfig(SidebarConfig{Version: 1}); !errors.Is(err, ErrSidebarInvalid) {
+ t.Fatal("空 scopes 应被拒绝")
+ }
+ // board.left 保存后应被丢弃(即使引用合法的 board 块)
+ saved, err := s.SaveConfig(mk(map[string]SidebarScopeConfig{
+ "board": {Left: side([]SidebarBlockRef{{Key: "board_intro"}}), Right: side([]SidebarBlockRef{{Key: "board_hot"}})},
+ }))
+ if err != nil {
+ t.Fatalf("board 保存失败: %v", err)
+ }
+ if len(*saved.Scopes["board"].Left) != 0 {
+ t.Fatal("board.left 应被丢弃为空")
+ }
+}
+
+func TestSidebarWidgetCRUDAndRefCleanup(t *testing.T) {
+ db := newSidebarTestDB(t)
+ s := NewSidebarService(db)
+
+ // 名称校验
+ w := &model.SidebarWidget{Name: " ", Html: "x"}
+ if err := s.CreateWidget(w); !errors.Is(err, ErrSidebarInvalid) {
+ t.Fatal("空名称应被拒绝")
+ }
+ w = &model.SidebarWidget{Name: strings.Repeat("字", SidebarWidgetMaxNameRunes+1)}
+ if err := s.CreateWidget(w); !errors.Is(err, ErrSidebarInvalid) {
+ t.Fatal("超长名称应被拒绝")
+ }
+ big := strings.Repeat("a", SidebarWidgetMaxCodeBytes+1)
+ if err := s.CreateWidget(&model.SidebarWidget{Name: "t", Html: big}); !errors.Is(err, ErrSidebarInvalid) {
+ t.Fatal("超 64KB 代码应被拒绝")
+ }
+
+ // 正常创建 + 放入配置
+ w = &model.SidebarWidget{Name: "跑马灯", Html: "hi
", Enabled: false}
+ if err := s.CreateWidget(w); err != nil {
+ t.Fatalf("CreateWidget: %v", err)
+ }
+ if w.ID == 0 || !w.Enabled {
+ t.Fatalf("创建后应启用且有 ID: %+v", w)
+ }
+ if _, err := s.SaveConfig(SidebarConfig{Version: SidebarConfigVersion, Scopes: map[string]SidebarScopeConfig{
+ "home": {Right: func() *[]SidebarBlockRef {
+ r := []SidebarBlockRef{{Key: "pulse"}, {WidgetID: w.ID}}
+ return &r
+ }()},
+ }}); err != nil {
+ t.Fatalf("SaveConfig with widget: %v", err)
+ }
+
+ // 更新:显式禁用(布尔零值不能被省略)
+ if err := s.UpdateWidget(w.ID, &model.SidebarWidget{Name: "跑马灯2", Html: "hi2
", Js: "console.log(1)", Enabled: false}); err != nil {
+ t.Fatalf("UpdateWidget: %v", err)
+ }
+ var after model.SidebarWidget
+ if err := db.First(&after, w.ID).Error; err != nil {
+ t.Fatalf("reload: %v", err)
+ }
+ if after.Enabled || after.Name != "跑马灯2" || after.Js == "" {
+ t.Fatalf("禁用/字段更新未生效: %+v", after)
+ }
+
+ // 禁用后不出现在公开载荷
+ payload, err := s.PublicPayload()
+ if err != nil {
+ t.Fatalf("PublicPayload: %v", err)
+ }
+ if len(payload.Widgets) != 0 {
+ t.Fatal("禁用的工具不应出现在公开载荷")
+ }
+ if err := s.SetWidgetEnabled(w.ID, true); err != nil {
+ t.Fatalf("SetWidgetEnabled: %v", err)
+ }
+ payload, _ = s.PublicPayload()
+ if len(payload.Widgets) != 1 || payload.Widgets[0].Html != "hi2
" {
+ t.Fatalf("启用后应出现在公开载荷: %+v", payload.Widgets)
+ }
+
+ // 删除工具 → 配置中的引用被清理
+ if err := s.DeleteWidget(w.ID); err != nil {
+ t.Fatalf("DeleteWidget: %v", err)
+ }
+ cfg, _ := s.GetConfig()
+ for _, r := range *cfg.Scopes["home"].Right {
+ if r.WidgetID == w.ID {
+ t.Fatal("删除后配置中不应残留引用")
+ }
+ }
+ // 重复删除 → NotFound
+ if err := s.DeleteWidget(w.ID); !errors.Is(err, ErrSidebarWidgetNotFound) {
+ t.Fatal("重复删除应返回 NotFound")
+ }
+}
diff --git a/backend/service/telemetry.go b/backend/service/telemetry.go
deleted file mode 100644
index b32935c..0000000
--- a/backend/service/telemetry.go
+++ /dev/null
@@ -1,88 +0,0 @@
-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/backend/service/visit_bot.go b/backend/service/visit_bot.go
new file mode 100644
index 0000000..a994bf9
--- /dev/null
+++ b/backend/service/visit_bot.go
@@ -0,0 +1,69 @@
+package service
+
+import "strings"
+
+// botSignatures 爬虫 UA 特征(小写子串 → 展示名),覆盖搜索引擎 / SEO 工具 / AI 爬虫 / 命令行客户端
+var botSignatures = []struct {
+ token string
+ name string
+}{
+ {"googlebot", "Googlebot"},
+ {"google-inspectiontool", "Google Inspection Tool"},
+ {"apis-google", "Googlebot"},
+ {"bingbot", "bingbot"},
+ {"baiduspider", "Baiduspider"},
+ {"sogou", "Sogou spider"},
+ {"yisou", "YisouSpider"},
+ {"360spider", "360Spider"},
+ {"haosouspider", "HaosouSpider"},
+ {"bytespider", "Bytespider"},
+ {"duckduckbot", "DuckDuckBot"},
+ {"applebot", "Applebot"},
+ {"petalbot", "PetalBot"},
+ {"yandexbot", "YandexBot"},
+ {"semrushbot", "SemrushBot"},
+ {"ahrefsbot", "AhrefsBot"},
+ {"mj12bot", "MJ12bot"},
+ {"dotbot", "DotBot"},
+ {"gptbot", "GPTBot"},
+ {"claudebot", "ClaudeBot"},
+ {"claude-web", "ClaudeBot"},
+ {"ccbot", "CCBot"},
+ {"perplexitybot", "PerplexityBot"},
+ {"amazonbot", "Amazonbot"},
+ {"facebookexternalhit", "facebookexternalhit"},
+ {"twitterbot", "Twitterbot"},
+ {"slackbot", "Slackbot"},
+ {"discordbot", "Discordbot"},
+ {"linkedinbot", "LinkedInBot"},
+ {"curl/", "curl"},
+ {"wget", "Wget"},
+ {"python-requests", "python-requests"},
+ {"python-urllib", "python-urllib"},
+ {"httpclient", "HTTP Client"},
+ {"go-http-client", "Go-http-client"},
+ {"java/", "Java Client"},
+ {"okhttp", "OkHttp"},
+ {"scrapy", "Scrapy"},
+ {"headlesschrome", "HeadlessChrome"},
+ {"puppeteer", "Puppeteer"},
+ {"phantomjs", "PhantomJS"},
+ {"bot;", "Bot"},
+ {"spider", "Spider"},
+ {"crawler", "Crawler"},
+}
+
+// ClassifyBot 根据 User-Agent 判定爬虫;命中返回爬虫展示名,否则返回空串。
+// 纯小写包含匹配,便于单测。
+func ClassifyBot(ua string) string {
+ low := strings.ToLower(strings.TrimSpace(ua))
+ if low == "" {
+ return ""
+ }
+ for _, sig := range botSignatures {
+ if strings.Contains(low, sig.token) {
+ return sig.name
+ }
+ }
+ return ""
+}
diff --git a/backend/service/visit_bot_test.go b/backend/service/visit_bot_test.go
new file mode 100644
index 0000000..01780ff
--- /dev/null
+++ b/backend/service/visit_bot_test.go
@@ -0,0 +1,31 @@
+package service
+
+import "testing"
+
+func TestClassifyBot(t *testing.T) {
+ cases := []struct {
+ ua string
+ want string
+ }{
+ {"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", "Googlebot"},
+ {"Mozilla/5.0 (compatible; bingbot/2.0)", "bingbot"},
+ {"Mozilla/5.0 (compatible; Baiduspider/2.0)", "Baiduspider"},
+ {"Mozilla/5.0 (compatible; Bytespider; https://zhanzhang.toutiao.com/)", "Bytespider"},
+ {"curl/8.4.0", "curl"},
+ {"Wget/1.21", "Wget"},
+ {"python-requests/2.31", "python-requests"},
+ {"Go-http-client/2.0", "Go-http-client"},
+ {"GPTBot/1.0", "GPTBot"},
+ {"ClaudeBot/1.0", "ClaudeBot"},
+ {"HeadlessChrome/120", "HeadlessChrome"},
+ // 人类浏览器不命中
+ {"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36", ""},
+ {"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1", ""},
+ {"", ""},
+ }
+ for _, c := range cases {
+ if got := ClassifyBot(c.ua); got != c.want {
+ t.Errorf("ClassifyBot(%q) = %q, want %q", c.ua, got, c.want)
+ }
+ }
+}
diff --git a/backend/service/visit_ref.go b/backend/service/visit_ref.go
new file mode 100644
index 0000000..82313e4
--- /dev/null
+++ b/backend/service/visit_ref.go
@@ -0,0 +1,63 @@
+package service
+
+import (
+ "net/url"
+ "strings"
+)
+
+// 来源分类常量
+const (
+ RefKindDirect = "direct"
+ RefKindInternal = "internal"
+ RefKindSearch = "search"
+ RefKindSocial = "social"
+ RefKindExternal = "external"
+)
+
+// searchHosts 搜索引擎域名(按「等于或以 .子域结尾」匹配)
+var searchHosts = []string{"google.com", "bing.com", "baidu.com", "sogou.com", "so.com", "sm.cn", "duckduckgo.com", "yandex.com", "yandex.ru", "yahoo.com"}
+
+// socialHosts 社交/社区域名特征
+var socialHosts = []string{
+ "weibo.com", "weibo.cn", "zhihu.com", "zhuanlan.zhihu.com", "twitter.com", "x.com",
+ "t.me", "telegram.me", "douyin.com", "xiaohongshu.com", "bilibili.com", "b23.tv",
+ "qq.com", "tieba.baidu.com", "v2ex.com", "juejin.cn", "csdn.net", "segmentfault.com",
+ "github.com", "gitee.com", "reddit.com", "facebook.com", "instagram.com", "discord.com",
+}
+
+// hostMatches 按「等于或以 .子域结尾」匹配 host 与域名列表
+func hostMatches(host string, list []string) bool {
+ for _, d := range list {
+ if host == d || strings.HasSuffix(host, "."+d) {
+ return true
+ }
+ }
+ return false
+}
+
+// ClassifyReferrer 解析并归类 Referer:返回 (来源 host, 来源类别)。
+// 空 → direct;本站 → internal;搜索引擎 → search;社交 → social;其余 → external;解析失败按 direct。
+func ClassifyReferrer(raw, siteHost string) (string, string) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", RefKindDirect
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.Host == "" {
+ return "", RefKindDirect
+ }
+ host := strings.ToLower(u.Host)
+ if i := strings.LastIndex(host, ":"); i >= 0 && !strings.Contains(host, "]") {
+ host = host[:i] // 去端口
+ }
+ if siteHost != "" && (host == siteHost || siteHost == host) {
+ return host, RefKindInternal
+ }
+ if hostMatches(host, searchHosts) {
+ return host, RefKindSearch
+ }
+ if hostMatches(host, socialHosts) {
+ return host, RefKindSocial
+ }
+ return host, RefKindExternal
+}
diff --git a/backend/service/visit_ref_test.go b/backend/service/visit_ref_test.go
new file mode 100644
index 0000000..75afafd
--- /dev/null
+++ b/backend/service/visit_ref_test.go
@@ -0,0 +1,31 @@
+package service
+
+import "testing"
+
+func TestClassifyReferrer(t *testing.T) {
+ cases := []struct {
+ raw, siteHost string
+ wantHost string
+ wantKind string
+ }{
+ {"", "example.com", "", RefKindDirect},
+ {"not a url", "example.com", "", RefKindDirect},
+ {"https://example.com/posts/1", "example.com", "example.com", RefKindInternal},
+ {"https://www.google.com/search?q=x", "example.com", "www.google.com", RefKindSearch},
+ {"https://www.baidu.com/s?wd=x", "example.com", "www.baidu.com", RefKindSearch},
+ {"https://cn.bing.com/search?q=x", "example.com", "cn.bing.com", RefKindSearch},
+ {"https://weibo.com/abc", "example.com", "weibo.com", RefKindSocial},
+ {"https://x.com/user/status/1", "example.com", "x.com", RefKindSocial},
+ {"https://v2ex.com/t/123", "example.com", "v2ex.com", RefKindSocial},
+ {"https://blog.other.org/post", "example.com", "blog.other.org", RefKindExternal},
+ {"https://google.com:443/search?q=x", "example.com", "google.com", RefKindSearch},
+ {"https://evil.com/?u=https://google.com", "example.com", "evil.com", RefKindExternal},
+ }
+ for _, c := range cases {
+ host, kind := ClassifyReferrer(c.raw, c.siteHost)
+ if host != c.wantHost || kind != c.wantKind {
+ t.Errorf("ClassifyReferrer(%q, %q) = (%q, %q), want (%q, %q)",
+ c.raw, c.siteHost, host, kind, c.wantHost, c.wantKind)
+ }
+ }
+}
diff --git a/backend/service/visitstats.go b/backend/service/visitstats.go
new file mode 100644
index 0000000..2ad86c1
--- /dev/null
+++ b/backend/service/visitstats.go
@@ -0,0 +1,295 @@
+package service
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "log"
+ "regexp"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/freefire/jiang13-bbs/model"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// 访问统计管道参数
+const (
+ visitChannelSize = 8192 // 事件缓冲:满则丢弃(统计场景可接受)
+ visitFlushBatch = 500 // 攒批上限:满批立即落库
+ visitFlushInterval = 3 * time.Second // 或每 3 秒落库一次
+ visitDeleteBatch = 5000 // 保留期清理分批大小,避免长事务
+)
+
+// VisitorCookieName 浏览器访客 ID cookie 名(handler 签发、捕获中间件读取)
+const VisitorCookieName = "j13_vid"
+
+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}$`)
+
+// todayDate 当天零点(本地时区)
+func todayDate() time.Time {
+ now := time.Now()
+ return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+}
+
+// HashVisitorID 访客 ID 仅存 SHA-256 哈希(明细不含原始 vid)
+func HashVisitorID(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))
+}
+
+// VisitStatsService 访问统计:内存攒批落库 + 保留期滚动清理 + 管理端聚合查询
+type VisitStatsService struct {
+ db *gorm.DB
+ setting *SettingService
+ ch chan model.VisitEvent
+ dropped atomic.Uint64
+ flushOnce sync.Once
+}
+
+func NewVisitStatsService(db *gorm.DB, settingSvc *SettingService) *VisitStatsService {
+ return &VisitStatsService{
+ db: db,
+ setting: settingSvc,
+ ch: make(chan model.VisitEvent, visitChannelSize),
+ }
+}
+
+// Enqueue 非阻塞投递事件;缓冲满时丢弃并计数
+func (s *VisitStatsService) Enqueue(ev model.VisitEvent) {
+ if ev.CreatedAt.IsZero() {
+ ev.CreatedAt = time.Now()
+ }
+ select {
+ case s.ch <- ev:
+ default:
+ s.dropped.Add(1)
+ }
+}
+
+// DroppedCount 已因缓冲满而丢弃的事件数(诊断用)
+func (s *VisitStatsService) DroppedCount() uint64 { return s.dropped.Load() }
+
+// pvDelta 一天内某批 pageview 事件的 PV/UV 增量
+type pvDelta struct {
+ PV int64
+ LoggedInPV int64
+ VidHashes map[string]struct{}
+}
+
+// summarizePageviews 批内按 (日期, vid) 归并 PV/UV 增量;bot/probe 不计。纯函数,便于单测。
+func summarizePageviews(events []model.VisitEvent) map[time.Time]*pvDelta {
+ out := make(map[time.Time]*pvDelta, 2)
+ for _, ev := range events {
+ if ev.Kind != model.VisitKindPageview {
+ continue
+ }
+ // 取事件本地日期(不能用 Truncate:它按 UTC 绝对时间截断,非零时区会错位)
+ t := ev.CreatedAt.In(time.Local)
+ day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
+ d, ok := out[day]
+ if !ok {
+ d = &pvDelta{VidHashes: make(map[string]struct{})}
+ out[day] = d
+ }
+ d.PV++
+ if ev.UserID > 0 {
+ d.LoggedInPV++
+ }
+ if ev.VidHash != "" {
+ d.VidHashes[ev.VidHash] = struct{}{}
+ }
+ }
+ return out
+}
+
+// flush 落库一批事件:明细批量 INSERT + 同一事务合并日统计(PV 必增、当日 vid 首次 UV+1)。
+// bot/probe 只写明细。失败记日志不重试(统计允许少量丢失)。
+func (s *VisitStatsService) flush(events []model.VisitEvent) {
+ if len(events) == 0 {
+ return
+ }
+ if err := s.db.CreateInBatches(&events, visitFlushBatch).Error; err != nil {
+ log.Printf("[visitstats] 明细落库失败(%d 条丢弃): %v", len(events), err)
+ return
+ }
+
+ deltas := summarizePageviews(events)
+ if len(deltas) == 0 {
+ return
+ }
+ err := s.db.Transaction(func(tx *gorm.DB) error {
+ for day, d := range deltas {
+ // 当日访客去重:插入成功(此前未出现)才计 UV
+ uv := int64(0)
+ for vid := range d.VidHashes {
+ vis := model.SiteDailyVisitor{Date: day, VidHash: vid}
+ res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&vis)
+ if res.Error != nil {
+ return res.Error
+ }
+ uv += res.RowsAffected
+ }
+ // 日统计 upsert:行不存在自动创建,存在则累加
+ if err := tx.Exec(`
+INSERT INTO site_daily_stats (date, pv, uv, logged_in_pv, updated_at)
+VALUES (?, ?, ?, ?, ?)
+ON CONFLICT (date) DO UPDATE SET
+ pv = site_daily_stats.pv + EXCLUDED.pv,
+ uv = site_daily_stats.uv + EXCLUDED.uv,
+ logged_in_pv = site_daily_stats.logged_in_pv + EXCLUDED.logged_in_pv,
+ updated_at = EXCLUDED.updated_at
+`, day, d.PV, uv, d.LoggedInPV, time.Now()).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ log.Printf("[visitstats] 日统计合并失败: %v", err)
+ }
+}
+
+// StartFlusher 攒批落库协程:满 500 条或每 3 秒触发;ctx 取消时冲刷余量后退出
+func (s *VisitStatsService) StartFlusher(ctx context.Context) {
+ s.flushOnce.Do(func() {
+ ticker := time.NewTicker(visitFlushInterval)
+ defer ticker.Stop()
+ buf := make([]model.VisitEvent, 0, visitFlushBatch)
+ for {
+ select {
+ case <-ctx.Done():
+ s.drain(buf)
+ return
+ case ev := <-s.ch:
+ buf = append(buf, ev)
+ if len(buf) >= visitFlushBatch {
+ s.flush(buf)
+ buf = make([]model.VisitEvent, 0, visitFlushBatch)
+ }
+ case <-ticker.C:
+ if len(buf) > 0 {
+ s.flush(buf)
+ buf = make([]model.VisitEvent, 0, visitFlushBatch)
+ }
+ }
+ }
+ })
+}
+
+// drain 退出前尽量冲刷缓冲中的余量(非阻塞)
+func (s *VisitStatsService) drain(buf []model.VisitEvent) {
+ for {
+ select {
+ case ev := <-s.ch:
+ buf = append(buf, ev)
+ if len(buf) >= visitFlushBatch {
+ s.flush(buf)
+ buf = make([]model.VisitEvent, 0, visitFlushBatch)
+ }
+ default:
+ s.flush(buf)
+ return
+ }
+ }
+}
+
+// retentionDays 当前保留天数(缺行/非法回退默认 90)
+func (s *VisitStatsService) retentionDays() int {
+ days, err := s.setting.AnalyticsRetentionDays()
+ if err != nil || days <= 0 {
+ return DefaultAnalyticsRetentionDays
+ }
+ return days
+}
+
+// deleteOlderThan 分批删除截止时间前的明细,返回删除总数
+func (s *VisitStatsService) deleteOlderThan(cutoff time.Time) int64 {
+ var total int64
+ for {
+ res := s.db.Exec(
+ `DELETE FROM visit_events WHERE id IN (
+ SELECT id FROM visit_events WHERE created_at < ? LIMIT ?
+ )`, cutoff, visitDeleteBatch)
+ if res.Error != nil {
+ log.Printf("[visitstats] 保留期清理失败: %v", res.Error)
+ return total
+ }
+ total += res.RowsAffected
+ if res.RowsAffected < visitDeleteBatch {
+ return total
+ }
+ }
+}
+
+// RunRetention 保留期滚动清理:每天 04:30 低峰触发
+func (s *VisitStatsService) RunRetention(ctx context.Context) {
+ for {
+ now := time.Now()
+ next := time.Date(now.Year(), now.Month(), now.Day(), 4, 30, 0, 0, now.Location())
+ if !next.After(now) {
+ next = next.AddDate(0, 0, 1)
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(next.Sub(now)):
+ }
+ cutoff := time.Now().AddDate(0, 0, -s.retentionDays())
+ if n := s.deleteOlderThan(cutoff); n > 0 {
+ log.Printf("[visitstats] 保留期清理 %d 条(> %s)", n, cutoff.Format("2006-01-02"))
+ }
+ }
+}
+
+// PurgeBefore 手动清理 N 天前明细:goroutine 异步分批执行,受理即返回(前端稍后刷新容量)。
+func (s *VisitStatsService) PurgeBefore(days int) bool {
+ if days < MinAnalyticsRetentionDays {
+ return false
+ }
+ if days > MaxAnalyticsRetentionDays {
+ days = MaxAnalyticsRetentionDays
+ }
+ go func() {
+ cutoff := time.Now().AddDate(0, 0, -days)
+ if n := s.deleteOlderThan(cutoff); n > 0 {
+ log.Printf("[visitstats] 手动清理 %d 条(> %s)", n, cutoff.Format("2006-01-02"))
+ }
+ }()
+ return true
+}
+
+// VisitUsage 明细表容量(行数 + 磁盘占用字节)
+type VisitUsage struct {
+ Rows int64 `json:"rows"`
+ Bytes int64 `json:"bytes"`
+}
+
+// Usage 统计明细行数与磁盘占用(含索引/TOAST)
+func (s *VisitStatsService) Usage() (VisitUsage, error) {
+ var u VisitUsage
+ if err := s.db.Model(&model.VisitEvent{}).Count(&u.Rows).Error; err != nil {
+ return u, err
+ }
+ if err := s.db.Raw(`SELECT pg_total_relation_size('visit_events')`).Scan(&u.Bytes).Error; err != nil {
+ return u, err
+ }
+ return u, nil
+}
+
+// Enabled 采集开关(缺行视为开启)
+func (s *VisitStatsService) Enabled() bool {
+ on, err := s.setting.AnalyticsEnabled()
+ return err == nil && on
+}
+
+// CanonicalIP 归一化客户端 IP(导出供 handler/middleware 使用)
+func CanonicalIP(ip string) string { return canonicalIP(ip) }
diff --git a/backend/service/visitstats_query.go b/backend/service/visitstats_query.go
new file mode 100644
index 0000000..ca12e98
--- /dev/null
+++ b/backend/service/visitstats_query.go
@@ -0,0 +1,616 @@
+package service
+
+import (
+ "strconv"
+ "time"
+
+ "github.com/freefire/jiang13-bbs/model"
+ "gorm.io/gorm"
+)
+
+// ============ 管理端聚合查询(visit_events SQL 聚合;长趋势用 site_daily_stats) ============
+
+// VisitDayPoint 日趋势点
+type VisitDayPoint struct {
+ Date string `json:"date"`
+ PV int64 `json:"pv"`
+ UV int64 `json:"uv"`
+}
+
+// VisitNamedCount 命名计数(设备/来源/爬虫等)
+type VisitNamedCount struct {
+ Key string `json:"key"`
+ Label string `json:"label"`
+ Count int64 `json:"count"`
+}
+
+// VisitPageRow 受访页面行
+type VisitPageRow struct {
+ Path string `json:"path"`
+ PV int64 `json:"pv"`
+ UV int64 `json:"uv"`
+ Share float64 `json:"share"` // 占区间总 PV 比例(0–100)
+}
+
+// VisitOverview 概览 Tab
+type VisitOverview struct {
+ RangeDays int `json:"range_days"`
+ Trend []VisitDayPoint `json:"trend"`
+ TodayPV int64 `json:"today_pv"`
+ TodayUV int64 `json:"today_uv"`
+ YesterdayPV int64 `json:"yesterday_pv"`
+ YesterdayUV int64 `json:"yesterday_uv"`
+ LoggedInPV int64 `json:"logged_in_pv"` // 今日登录 PV
+ Devices []VisitNamedCount `json:"devices"`
+ Sources []VisitNamedCount `json:"sources"`
+ TopPages []VisitPageRow `json:"top_pages"`
+}
+
+// VisitMinutePoint 分钟级 PV 点
+type VisitMinutePoint struct {
+ Minute string `json:"minute"` // YYYY-MM-DD HH24:MI
+ PV int64 `json:"pv"`
+}
+
+// VisitEventItem 明细列表行
+type VisitEventItem struct {
+ ID uint64 `json:"id"`
+ CreatedAt time.Time `json:"created_at"`
+ Kind string `json:"kind"`
+ Path string `json:"path"`
+ RefHost string `json:"ref_host"`
+ RefKind string `json:"ref_kind"`
+ UserID uint `json:"user_id"`
+ Username string `json:"username"`
+ IP string `json:"ip"`
+ Device string `json:"device"`
+ OS string `json:"os"`
+ Browser string `json:"browser"`
+ Bot string `json:"bot"`
+ Status int `json:"status"`
+}
+
+// VisitRealtime 实时 Tab
+type VisitRealtime struct {
+ Minutes []VisitMinutePoint `json:"minutes"`
+ PV5min int64 `json:"pv_5min"`
+ PV30min int64 `json:"pv_30min"`
+ Online int64 `json:"online"`
+ TopPages []VisitNamedCount `json:"top_pages"`
+ Recent []VisitEventItem `json:"recent"`
+}
+
+// VisitSourcesKindDay 来源按日(kind 分列行)
+type VisitSourcesKindDay struct {
+ Date string `json:"date"`
+ Items []VisitNamedCount `json:"items"`
+}
+
+// VisitSources 来源分析 Tab
+type VisitSources struct {
+ RangeDays int `json:"range_days"`
+ Kinds []VisitNamedCount `json:"kinds"`
+ SearchHost []VisitNamedCount `json:"search_hosts"`
+ External []VisitNamedCount `json:"external"`
+ Trend []VisitSourcesKindDay `json:"trend"`
+}
+
+// VisitPages 受访页面 Tab
+type VisitPages struct {
+ RangeDays int `json:"range_days"`
+ Total int64 `json:"total"` // 命中的 path 数
+ Items []VisitPageRow `json:"items"`
+}
+
+// VisitVisitors 访客画像 Tab
+type VisitVisitors struct {
+ RangeDays int `json:"range_days"`
+ Devices []VisitNamedCount `json:"devices"`
+ OSes []VisitNamedCount `json:"oses"`
+ Browsers []VisitNamedCount `json:"browsers"`
+ LoggedInPV int64 `json:"logged_in_pv"`
+ GuestPV int64 `json:"guest_pv"`
+ NewVisitors int64 `json:"new_visitors"`
+ Returning int64 `json:"returning"`
+ Hourly []VisitNamedCount `json:"hourly"` // 0–23 时段 PV
+}
+
+// VisitSecurity 爬虫与安全 Tab
+type VisitSecurity struct {
+ RangeDays int `json:"range_days"`
+ Bots []VisitNamedCount `json:"bots"`
+ BotTrend []VisitDayPoint `json:"bot_trend"`
+ ProbeStatus []VisitNamedCount `json:"probe_status"`
+ ProbeIPs []VisitNamedCount `json:"probe_ips"`
+ LoginFailed int64 `json:"login_failed"` // 近 N 日登录失败次数
+ FailedUsers int64 `json:"failed_users"` // 涉及用户名数
+}
+
+// VisitEventFilter 明细筛选
+type VisitEventFilter struct {
+ Kind string
+ Bot string
+ Path string
+ IP string
+ Start time.Time
+ End time.Time
+ Page int
+ Size int
+}
+
+// VisitEventsResult 明细分页结果
+type VisitEventsResult struct {
+ Total int64 `json:"total"`
+ Items []VisitEventItem `json:"items"`
+}
+
+// VisitSettingsPayload 设置 Tab(开关 + 保留天数 + 容量)
+type VisitSettingsPayload struct {
+ Enabled bool `json:"enabled"`
+ RetentionDays int `json:"retention_days"`
+ Usage VisitUsage `json:"usage"`
+ Dropped uint64 `json:"dropped"`
+}
+
+type pvUVRow struct {
+ PV int64 `gorm:"column:pv"`
+ UV int64 `gorm:"column:uv"`
+}
+
+func visitRangeStart(days int) time.Time {
+ return todayDate().AddDate(0, 0, -(days - 1))
+}
+
+// groupCounts 通用 GROUP BY 计数(expr 为聚合表达式),按计数降序取前 limit 条。
+// extraSQL 追加到 WHERE(如 " AND ref_kind = 'search'")。
+func (s *VisitStatsService) groupCounts(start time.Time, expr, kind, extraSQL string, limit int) ([]VisitNamedCount, error) {
+ var rows []struct {
+ Key string
+ Count int64
+ }
+ q := s.db.Model(&model.VisitEvent{}).
+ Select(expr+" AS key, COUNT(*) AS count").
+ Where("created_at >= ?", start)
+ if kind != "" {
+ q = q.Where("kind = ?", kind)
+ }
+ if extraSQL != "" {
+ q = q.Where(extraSQL)
+ }
+ if err := q.Group("key").Order("count DESC").Limit(limit).Scan(&rows).Error; err != nil {
+ return nil, err
+ }
+ out := make([]VisitNamedCount, 0, len(rows))
+ for _, r := range rows {
+ out = append(out, VisitNamedCount{Key: r.Key, Label: r.Key, Count: r.Count})
+ }
+ return out, nil
+}
+
+// Overview 概览:趋势(site_daily_stats)+ 今日/昨日 + 设备/来源/热门页面
+func (s *VisitStatsService) Overview(rangeQ string) (*VisitOverview, error) {
+ days := parseRangeDays(rangeQ)
+ start := visitRangeStart(days)
+ out := &VisitOverview{RangeDays: days}
+
+ // 日趋势:日聚合表(每天一行,不随明细清理丢失)
+ if err := s.db.Model(&model.SiteDailyStats{}).
+ Select("to_char(date, 'YYYY-MM-DD') AS date, pv, uv").
+ Where("date >= ?", start).
+ Order("date ASC").Scan(&out.Trend).Error; err != nil {
+ return nil, err
+ }
+ // 空结果时 GORM 不赋值,nil 切片会序列化成 JSON null(前端期望 [])
+ if out.Trend == nil {
+ out.Trend = []VisitDayPoint{}
+ }
+
+ var today pvUVRow
+ if err := s.db.Model(&model.SiteDailyStats{}).
+ Select("COALESCE(SUM(pv),0) AS pv, COALESCE(SUM(uv),0) AS uv").
+ Where("date = ?", todayDate()).Scan(&today).Error; err != nil {
+ return nil, err
+ }
+ out.TodayPV, out.TodayUV = today.PV, today.UV
+
+ var yesterday pvUVRow
+ if err := s.db.Model(&model.SiteDailyStats{}).
+ Select("COALESCE(SUM(pv),0) AS pv, COALESCE(SUM(uv),0) AS uv").
+ Where("date = ?", todayDate().AddDate(0, 0, -1)).Scan(&yesterday).Error; err != nil {
+ return nil, err
+ }
+ out.YesterdayPV, out.YesterdayUV = yesterday.PV, yesterday.UV
+
+ if err := s.db.Model(&model.SiteDailyStats{}).
+ Select("COALESCE(SUM(logged_in_pv),0) AS logged_in_pv").
+ Where("date = ?", todayDate()).Scan(&out.LoggedInPV).Error; err != nil {
+ return nil, err
+ }
+
+ var err error
+ if out.Devices, err = s.groupCounts(start, "device", model.VisitKindPageview, "", 8); err != nil {
+ return nil, err
+ }
+ if out.Sources, err = s.groupCounts(start, "ref_kind", model.VisitKindPageview, "", 8); err != nil {
+ return nil, err
+ }
+ if out.TopPages, err = s.pageAgg(start, "", 1, 10); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// pageAgg path 聚合 PV/UV/占比(q 为 path 模糊匹配;page 从 1 起)
+func (s *VisitStatsService) pageAgg(start time.Time, q string, page, size int) ([]VisitPageRow, error) {
+ var totalPV int64
+ if err := s.db.Model(&model.VisitEvent{}).
+ Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
+ Scopes(pathFilter(q)).
+ Count(&totalPV).Error; err != nil {
+ return nil, err
+ }
+ if totalPV == 0 {
+ return []VisitPageRow{}, nil
+ }
+
+ var rows []VisitPageRow
+ if err := s.db.Model(&model.VisitEvent{}).
+ Select("path, COUNT(*) AS pv, COUNT(DISTINCT vid_hash) AS uv").
+ Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
+ Scopes(pathFilter(q)).
+ Group("path").Order("pv DESC").
+ Offset((page - 1) * size).Limit(size).Scan(&rows).Error; err != nil {
+ return nil, err
+ }
+ for i := range rows {
+ rows[i].Share = float64(rows[i].PV) * 100 / float64(totalPV)
+ }
+ return rows, nil
+}
+
+func pathFilter(q string) func(*gorm.DB) *gorm.DB {
+ if q == "" {
+ return func(db *gorm.DB) *gorm.DB { return db }
+ }
+ return func(db *gorm.DB) *gorm.DB { return db.Where("path ILIKE ?", "%"+q+"%") }
+}
+
+// Realtime 实时:近 60 分钟分钟级 PV + 在线 + 近 5 分钟热门路径 + 最新 20 条
+func (s *VisitStatsService) Realtime() (*VisitRealtime, error) {
+ out := &VisitRealtime{}
+ now := time.Now()
+
+ if err := s.db.Model(&model.VisitEvent{}).
+ Select("to_char(created_at, 'YYYY-MM-DD HH24:MI') AS minute, COUNT(*) AS pv").
+ Where("created_at >= ? AND kind = ?", now.Add(-time.Hour), model.VisitKindPageview).
+ Group("minute").Order("minute ASC").Scan(&out.Minutes).Error; err != nil {
+ return nil, err
+ }
+ if out.Minutes == nil {
+ out.Minutes = []VisitMinutePoint{}
+ }
+ if err := s.db.Model(&model.VisitEvent{}).
+ Where("created_at >= ? AND kind = ?", now.Add(-5*time.Minute), model.VisitKindPageview).
+ Count(&out.PV5min).Error; err != nil {
+ return nil, err
+ }
+ if err := s.db.Model(&model.VisitEvent{}).
+ Where("created_at >= ? AND kind = ?", now.Add(-30*time.Minute), model.VisitKindPageview).
+ Count(&out.PV30min).Error; err != nil {
+ return nil, err
+ }
+ // 在线口径与首页概览一致:users.last_seen_at 近 5 分钟且未封禁
+ if err := s.db.Model(&model.User{}).
+ Where("last_seen_at IS NOT NULL AND last_seen_at >= ? AND banned = ?", now.Add(-5*time.Minute), false).
+ Count(&out.Online).Error; err != nil {
+ return nil, err
+ }
+ if err := s.db.Model(&model.VisitEvent{}).
+ Select("path AS key, COUNT(*) AS count").
+ Where("created_at >= ? AND kind = ?", now.Add(-5*time.Minute), model.VisitKindPageview).
+ Group("path").Order("count DESC").Limit(10).
+ Scan(&out.TopPages).Error; err != nil {
+ return nil, err
+ }
+ for i := range out.TopPages {
+ out.TopPages[i].Label = out.TopPages[i].Key
+ }
+ if out.TopPages == nil {
+ out.TopPages = []VisitNamedCount{}
+ }
+
+ res, err := s.Events(VisitEventFilter{Kind: model.VisitKindPageview, Page: 1, Size: 20})
+ if err != nil {
+ return nil, err
+ }
+ out.Recent = res.Items
+ return out, nil
+}
+
+// Sources 来源分析
+func (s *VisitStatsService) Sources(rangeQ string) (*VisitSources, error) {
+ days := parseRangeDays(rangeQ)
+ start := visitRangeStart(days)
+ out := &VisitSources{RangeDays: days}
+ var err error
+ if out.Kinds, err = s.groupCounts(start, "ref_kind", model.VisitKindPageview, "", 8); err != nil {
+ return nil, err
+ }
+ if out.SearchHost, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
+ "ref_kind = '"+RefKindSearch+"'", 20); err != nil {
+ return nil, err
+ }
+ if out.External, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
+ "ref_kind = '"+RefKindExternal+"'", 20); err != nil {
+ return nil, err
+ }
+ if out.Trend, err = s.SourcesTrend(rangeQ); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// SourcesTrend 来源按日明细(kind 分列行)
+func (s *VisitStatsService) SourcesTrend(rangeQ string) ([]VisitSourcesKindDay, error) {
+ days := parseRangeDays(rangeQ)
+ start := visitRangeStart(days)
+ var rows []struct {
+ Date string
+ RefKind string
+ Count int64
+ }
+ if err := s.db.Model(&model.VisitEvent{}).
+ Select("to_char(created_at, 'YYYY-MM-DD') AS date, ref_kind, COUNT(*) AS count").
+ Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
+ Group("date, ref_kind").Order("date ASC").Scan(&rows).Error; err != nil {
+ return nil, err
+ }
+ byDay := make(map[string][]VisitNamedCount)
+ var dates []string
+ seen := make(map[string]struct{})
+ for _, r := range rows {
+ if _, ok := seen[r.Date]; !ok {
+ seen[r.Date] = struct{}{}
+ dates = append(dates, r.Date)
+ }
+ byDay[r.Date] = append(byDay[r.Date], VisitNamedCount{Key: r.RefKind, Label: r.RefKind, Count: r.Count})
+ }
+ out := make([]VisitSourcesKindDay, 0, len(dates))
+ for _, d := range dates {
+ out = append(out, VisitSourcesKindDay{Date: d, Items: byDay[d]})
+ }
+ return out, nil
+}
+
+// Pages 受访页面(分页 + 模糊)
+func (s *VisitStatsService) Pages(rangeQ, q string, page, size int) (*VisitPages, error) {
+ days := parseRangeDays(rangeQ)
+ start := visitRangeStart(days)
+ if page < 1 {
+ page = 1
+ }
+ if size < 1 || size > 100 {
+ size = 20
+ }
+ out := &VisitPages{RangeDays: days}
+ if err := s.db.Model(&model.VisitEvent{}).
+ Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
+ Scopes(pathFilter(q)).
+ Distinct("path").Count(&out.Total).Error; err != nil {
+ return nil, err
+ }
+ items, err := s.pageAgg(start, q, page, size)
+ if err != nil {
+ return nil, err
+ }
+ if items == nil {
+ items = []VisitPageRow{}
+ }
+ out.Items = items
+ return out, nil
+}
+
+// Visitors 访客画像
+func (s *VisitStatsService) Visitors(rangeQ string) (*VisitVisitors, error) {
+ days := parseRangeDays(rangeQ)
+ start := visitRangeStart(days)
+ out := &VisitVisitors{RangeDays: days}
+ var err error
+ if out.Devices, err = s.groupCounts(start, "device", model.VisitKindPageview, "", 8); err != nil {
+ return nil, err
+ }
+ if out.OSes, err = s.groupCounts(start, "os", model.VisitKindPageview, "", 10); err != nil {
+ return nil, err
+ }
+ if out.Browsers, err = s.groupCounts(start, "browser", model.VisitKindPageview, "", 10); err != nil {
+ return nil, err
+ }
+
+ var lg pvUVRow
+ if err := s.db.Model(&model.VisitEvent{}).
+ Select("COALESCE(SUM(CASE WHEN user_id > 0 THEN 1 ELSE 0 END),0) AS pv, COALESCE(SUM(CASE WHEN user_id = 0 THEN 1 ELSE 0 END),0) AS uv").
+ Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
+ Scan(&lg).Error; err != nil {
+ return nil, err
+ }
+ out.LoggedInPV, out.GuestPV = lg.PV, lg.UV
+
+ // 新老访客:区间内 vid 首次出现时间晚于区间起点视为新访客
+ var uvRows []struct {
+ VidHash string
+ FirstAt time.Time `gorm:"column:first_at"`
+ }
+ if err := s.db.Model(&model.VisitEvent{}).
+ Select("vid_hash, MIN(created_at) AS first_at").
+ Where("created_at >= ? AND kind = ? AND vid_hash <> ''", start, model.VisitKindPageview).
+ Group("vid_hash").Scan(&uvRows).Error; err != nil {
+ return nil, err
+ }
+ for _, r := range uvRows {
+ if r.FirstAt.After(start) {
+ out.NewVisitors++
+ } else {
+ out.Returning++
+ }
+ }
+
+ // 24 小时时段 PV(区间合计)
+ var hours []struct {
+ Hour int
+ Count int64
+ }
+ if err := s.db.Model(&model.VisitEvent{}).
+ Select("EXTRACT(HOUR FROM created_at)::int AS hour, COUNT(*) AS count").
+ Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
+ Group("hour").Order("hour ASC").Scan(&hours).Error; err != nil {
+ return nil, err
+ }
+ out.Hourly = make([]VisitNamedCount, 24)
+ for i := range out.Hourly {
+ label := strconv.Itoa(i)
+ if i < 10 {
+ label = "0" + label
+ }
+ out.Hourly[i].Key = label
+ out.Hourly[i].Label = label + " 时"
+ }
+ for _, h := range hours {
+ if h.Hour >= 0 && h.Hour < 24 {
+ out.Hourly[h.Hour].Count = h.Count
+ }
+ }
+ return out, nil
+}
+
+// Security 爬虫与安全
+func (s *VisitStatsService) Security(rangeQ string) (*VisitSecurity, error) {
+ days := parseRangeDays(rangeQ)
+ start := visitRangeStart(days)
+ out := &VisitSecurity{RangeDays: days}
+ var err error
+ if out.Bots, err = s.groupCounts(start, "bot", model.VisitKindBot, "", 20); err != nil {
+ return nil, err
+ }
+ if err := s.db.Model(&model.VisitEvent{}).
+ Select("to_char(created_at, 'YYYY-MM-DD') AS date, COUNT(*) AS pv").
+ Where("created_at >= ? AND kind = ?", start, model.VisitKindBot).
+ Group("date").Order("date ASC").Scan(&out.BotTrend).Error; err != nil {
+ return nil, err
+ }
+ if out.BotTrend == nil {
+ out.BotTrend = []VisitDayPoint{}
+ }
+ if out.ProbeStatus, err = s.groupCounts(start, "status::text", model.VisitKindProbe, "", 15); err != nil {
+ return nil, err
+ }
+ if out.ProbeIPs, err = s.groupCounts(start, "ip", model.VisitKindProbe, "", 20); err != nil {
+ return nil, err
+ }
+
+ if err := s.db.Model(&model.LoginLog{}).
+ Where("success = ? AND created_at >= ?", false, start).
+ Count(&out.LoginFailed).Error; err != nil {
+ return nil, err
+ }
+ if err := s.db.Raw(`SELECT COUNT(DISTINCT username) FROM login_logs WHERE success = ? AND created_at >= ?`,
+ false, start).Scan(&out.FailedUsers).Error; err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// Events 访问明细(筛选 + 分页 + 补用户昵称)
+func (s *VisitStatsService) Events(f VisitEventFilter) (*VisitEventsResult, error) {
+ if f.Page < 1 {
+ f.Page = 1
+ }
+ if f.Size < 1 || f.Size > 200 {
+ f.Size = 50
+ }
+ apply := func(db *gorm.DB) *gorm.DB {
+ if f.Kind != "" {
+ db = db.Where("kind = ?", f.Kind)
+ }
+ if f.Bot != "" {
+ db = db.Where("bot = ?", f.Bot)
+ }
+ if f.Path != "" {
+ db = db.Where("path ILIKE ?", "%"+f.Path+"%")
+ }
+ if f.IP != "" {
+ db = db.Where("ip = ?", f.IP)
+ }
+ if !f.Start.IsZero() {
+ db = db.Where("created_at >= ?", f.Start)
+ }
+ if !f.End.IsZero() {
+ db = db.Where("created_at < ?", f.End)
+ }
+ return db
+ }
+
+ out := &VisitEventsResult{}
+ if err := apply(s.db.Model(&model.VisitEvent{})).Count(&out.Total).Error; err != nil {
+ return nil, err
+ }
+ var events []model.VisitEvent
+ if err := apply(s.db.Model(&model.VisitEvent{})).
+ Order("id DESC").
+ Offset((f.Page - 1) * f.Size).Limit(f.Size).
+ Find(&events).Error; err != nil {
+ return nil, err
+ }
+
+ // 批量补昵称
+ userIDs := make([]uint, 0, len(events))
+ for _, e := range events {
+ if e.UserID > 0 {
+ userIDs = append(userIDs, e.UserID)
+ }
+ }
+ nameMap := make(map[uint]string, len(userIDs))
+ if len(userIDs) > 0 {
+ var users []model.User
+ if err := s.db.Select("id, username, nickname").
+ Where("id IN ?", userIDs).Find(&users).Error; err != nil {
+ return nil, err
+ }
+ for _, u := range users {
+ nameMap[u.ID] = u.Nickname
+ }
+ }
+ out.Items = make([]VisitEventItem, 0, len(events))
+ for _, e := range events {
+ out.Items = append(out.Items, VisitEventItem{
+ ID: e.ID, CreatedAt: e.CreatedAt, Kind: e.Kind, Path: e.Path,
+ RefHost: e.RefHost, RefKind: e.RefKind,
+ UserID: e.UserID, Username: nameMap[e.UserID],
+ IP: e.IP, Device: e.Device, OS: e.OS, Browser: e.Browser,
+ Bot: e.Bot, Status: e.Status,
+ })
+ }
+ return out, nil
+}
+
+// SettingsPayload 设置 Tab 数据
+func (s *VisitStatsService) SettingsPayload() (*VisitSettingsPayload, error) {
+ out := &VisitSettingsPayload{
+ Enabled: s.Enabled(),
+ RetentionDays: s.retentionDays(),
+ Dropped: s.DroppedCount(),
+ }
+ u, err := s.Usage()
+ if err != nil {
+ return nil, err
+ }
+ out.Usage = u
+ return out, nil
+}
+
+// SaveSettings 保存采集开关与保留天数
+func (s *VisitStatsService) SaveSettings(enabled bool, retentionDays int) error {
+ if err := s.setting.SetAnalyticsEnabled(enabled); err != nil {
+ return err
+ }
+ return s.setting.SetAnalyticsRetentionDays(retentionDays)
+}
diff --git a/backend/service/visitstats_test.go b/backend/service/visitstats_test.go
new file mode 100644
index 0000000..6c3d15d
--- /dev/null
+++ b/backend/service/visitstats_test.go
@@ -0,0 +1,46 @@
+package service
+
+import (
+ "testing"
+ "time"
+
+ "github.com/freefire/jiang13-bbs/model"
+)
+
+func TestSummarizePageviews(t *testing.T) {
+ day := time.Date(2026, 9, 24, 0, 0, 0, 0, time.Local)
+ at := func(hour int) time.Time { return day.Add(time.Duration(hour) * time.Hour) }
+
+ events := []model.VisitEvent{
+ {Kind: model.VisitKindPageview, CreatedAt: at(9), UserID: 1, VidHash: "aaa"},
+ {Kind: model.VisitKindPageview, CreatedAt: at(9), VidHash: "aaa"}, // 同 vid 同日:去重
+ {Kind: model.VisitKindPageview, CreatedAt: at(10), VidHash: "bbb"},
+ {Kind: model.VisitKindBot, CreatedAt: at(9), VidHash: "aaa"}, // bot 不计
+ {Kind: model.VisitKindProbe, CreatedAt: at(9), VidHash: "bbb"}, // probe 不计
+ }
+ out := summarizePageviews(events)
+ d, ok := out[day]
+ if !ok {
+ t.Fatal("缺少当日聚合")
+ }
+ if d.PV != 3 {
+ t.Errorf("PV = %d, want 3", d.PV)
+ }
+ if d.LoggedInPV != 1 {
+ t.Errorf("LoggedInPV = %d, want 1", d.LoggedInPV)
+ }
+ if len(d.VidHashes) != 2 {
+ t.Errorf("UV 候选 = %d, want 2", len(d.VidHashes))
+ }
+}
+
+func TestHashVisitorID(t *testing.T) {
+ a := HashVisitorID(" ABcd1234-0000-0000-0000-000000000000 ")
+ b := HashVisitorID("abcd1234-0000-0000-0000-000000000000")
+ if a != b {
+ t.Error("大小写/空白应归一后哈希一致")
+ }
+ if len(a) != 64 {
+ t.Errorf("哈希长度 = %d, want 64", len(a))
+ }
+}
diff --git a/deploy/.env.example b/deploy/.env.example
index dd17aab..a735886 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -31,7 +31,9 @@ JWT_SECRET=
SETTINGS_MASTER_KEY=
# 逗号分隔的精确内部服务主机名;默认拒绝回环、私网和保留网段
SERVICE_PRIVATE_HOSTS=
-# 仅填写实际可信反向代理 IP/CIDR;默认不信任转发头
+# 仅填写实际可信反向代理 IP/CIDR;默认不信任转发头。
+# Compose 默认 bridge 网段在 172.16.0.0/12 内(docker network inspect 可查),
+# 填 TRUSTED_PROXIES=172.16.0.0/12 即可让登录日志/访问统计记录真实访客 IP。
TRUSTED_PROXIES=
# 紧急恢复:1 强制正常运行,修复后台配置后撤销并重启 API
MAINTENANCE_RECOVERY=0
diff --git a/docs/site-operations.md b/docs/site-operations.md
index b9b41ff..4e6e3ea 100644
--- a/docs/site-operations.md
+++ b/docs/site-operations.md
@@ -104,6 +104,10 @@
4. Go 与 Next 的 SITE_URL 必须一致且为正式外部 HTTPS origin;后台基本信息只读显示。Next 的 BACKEND_URL 指向可达 API,不使用请求 Host 生成邮件地址。
5. SERVICE_PRIVATE_HOSTS 为逗号分隔的精确主机名。默认拒绝私网、回环、链路本地及特殊地址,DNS 解析后连接已经检查的 IP;确有自建 SMTP/S3 时只放行对应主机。虚拟主机 S3 寻址时要按实际连接的 bucket.endpoint 主机配置。
6. TRUSTED_PROXIES 仅列出真正的代理 IP/CIDR。默认不信任转发头;不要直接填任意全网,避免登录/IP额度绕过。
+ Compose 部署下不配置时 ClientIP 会记录为反代容器内网地址(如 172.19.0.4),登录日志与访问统计全部失真。
+ 取真实 IP:先 `docker network inspect <网络名>` 查看子网(常见 172.18.0.0/16–172.31.0.0/16),再把该网段填入
+ `TRUSTED_PROXIES`(如 `172.16.0.0/12` 覆盖全部 Docker bridge),反代需设置 `X-Forwarded-For`/`X-Real-IP`。
+ 未配置时后端启动会输出警告日志便于排查。经 Cloudflare 等平台代理时改用 `TRUSTED_PLATFORM=CF-Connecting-IP`。
7. MAINTENANCE_RECOVERY=1 是部署级紧急恢复:重启 API 后强制正常访问;修复后台模式,再撤销该变量并重启。不会悄悄覆写数据库保存模式。
8. Compose 已透传四个新变量并保留原服务/端口/数据卷。参见 deploy/CHANGELOG.md。JWT、DB_DSN、Cookie安全属性仍保持部署级管理。
9. 反代/CDN不得缓存 HTML、/api/site-state、鉴权接口、503;停站和恢复后验证外部入口的真实状态码及缓存头。外部 CDN 未在本地测试范围内。
diff --git a/frontend/app/admin/analytics/AnalyticsShell.tsx b/frontend/app/admin/analytics/AnalyticsShell.tsx
new file mode 100644
index 0000000..48afc34
--- /dev/null
+++ b/frontend/app/admin/analytics/AnalyticsShell.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname, useRouter } from "next/navigation";
+import { useRef, type KeyboardEvent, type ReactNode } from "react";
+import { BarChart3 } from "lucide-react";
+import { AdminPageHeader } from "@/components/admin";
+
+export const ANALYTICS_TABS: { href: string; label: string }[] = [
+ { href: "/admin/analytics", label: "概览" },
+ { href: "/admin/analytics/realtime", label: "实时" },
+ { href: "/admin/analytics/sources", label: "来源分析" },
+ { href: "/admin/analytics/pages", label: "受访页面" },
+ { href: "/admin/analytics/visitors", label: "访客画像" },
+ { href: "/admin/analytics/security", label: "爬虫与安全" },
+ { href: "/admin/analytics/details", label: "访问明细" },
+ { href: "/admin/analytics/settings", label: "设置" },
+];
+
+export default function AnalyticsShell({ children }: { children: ReactNode }) {
+ const pathname = usePathname();
+ const router = useRouter();
+ const navRefs = useRef<(HTMLAnchorElement | null)[]>([]);
+
+ // ←→/Home/End 键盘导航,范式同 SettingsShell(纵向改横向)
+ const onNavKeyDown = (e: KeyboardEvent, i: number) => {
+ if (
+ e.key !== "ArrowRight" &&
+ e.key !== "ArrowLeft" &&
+ e.key !== "Home" &&
+ e.key !== "End"
+ ) {
+ return;
+ }
+ e.preventDefault();
+ let next = i;
+ if (e.key === "ArrowRight") next = (i + 1) % ANALYTICS_TABS.length;
+ else if (e.key === "ArrowLeft") next = (i + ANALYTICS_TABS.length - 1) % ANALYTICS_TABS.length;
+ else if (e.key === "Home") next = 0;
+ else next = ANALYTICS_TABS.length - 1;
+ navRefs.current[next]?.focus();
+ router.push(ANALYTICS_TABS[next].href);
+ };
+
+ return (
+
+
+
+ {children}
+
+ );
+}
diff --git a/frontend/app/admin/analytics/OverviewClient.tsx b/frontend/app/admin/analytics/OverviewClient.tsx
new file mode 100644
index 0000000..93c0213
--- /dev/null
+++ b/frontend/app/admin/analytics/OverviewClient.tsx
@@ -0,0 +1,137 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Eye, Users, LogIn, CalendarDays } from "lucide-react";
+import { AdminStatCard } from "@/components/admin";
+import { AdminChartCard, NamedBarChart, NamedPieChart, VisitTrendChart } from "@/components/admin/charts";
+import { apiStatsOverview, type VisitOverview, type VisitPageRow } from "@/lib/api";
+import { formatInt, RangeSegmented, StatsState, TableEmpty, deviceLabel, refKindLabel } from "./ui";
+
+export default function OverviewClient({ initial }: { initial: VisitOverview | null }) {
+ const [range, setRange] = useState<"7d" | "30d">("7d");
+ const [data, setData] = useState(initial);
+ const [loading, setLoading] = useState(!initial);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ let alive = true;
+ setLoading(true);
+ apiStatsOverview(range)
+ .then((d) => {
+ if (alive) {
+ setData(d);
+ setError("");
+ }
+ })
+ .catch(() => alive && setError("加载失败,请稍后重试"))
+ .finally(() => alive && setLoading(false));
+ return () => {
+ alive = false;
+ };
+ }, [range]);
+
+ const delta = (today: number, yesterday: number) => {
+ if (yesterday <= 0) return today > 0 ? "+100%" : "—";
+ const pct = Math.round(((today - yesterday) / yesterday) * 100);
+ return `${pct >= 0 ? "+" : ""}${pct}%`;
+ };
+
+ if (!data) return ;
+
+ const devices = data.devices.map((d) => ({ label: deviceLabel(d.key), count: d.count }));
+ const sources = data.sources.map((d) => ({ label: refKindLabel(d.key), count: d.count }));
+
+ return (
+
+
+
+ 今日 PV {formatInt(data.today_pv)} · UV {formatInt(data.today_uv)}
+
+
+
+
+ {error ?
: null}
+
+
+
}
+ />
+
}
+ color="var(--ok)"
+ soft="var(--ok-soft, var(--panel-2))"
+ />
+
0
+ ? `占今日 PV ${Math.round((data.logged_in_pv / data.today_pv) * 100)}%`
+ : undefined
+ }
+ icon={}
+ color="var(--gold)"
+ soft="var(--gold-soft)"
+ />
+ }
+ color="var(--ink-2)"
+ soft="var(--panel-2)"
+ />
+
+
+
+
+
+
+
+
+ {devices.length ? : }
+
+
+ {sources.length ? : }
+
+
+
+
+ {data.top_pages.length ? (
+
+ {data.top_pages.map((p: VisitPageRow, i) => (
+
+
+ {i + 1}
+
+
+ {p.path || "/"}
+
+
+
+
+
+ {formatInt(p.pv)}
+
+
+ UV {formatInt(p.uv)}
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/admin/analytics/details/page.tsx b/frontend/app/admin/analytics/details/page.tsx
new file mode 100644
index 0000000..1d1023f
--- /dev/null
+++ b/frontend/app/admin/analytics/details/page.tsx
@@ -0,0 +1,232 @@
+"use client";
+
+import { Suspense, useCallback, useEffect, useState } from "react";
+import { useSearchParams } from "next/navigation";
+import { AdminPager, AdminSegmented } from "@/components/admin";
+import { AdminChartCard } from "@/components/admin/charts";
+import AdminSearchField from "@/components/admin/AdminSearchField";
+import { apiStatsEvents, type VisitEventItem, type VisitEventsResult } from "@/lib/api";
+import { StatsState, TableEmpty, deviceLabel, kindLabel, refKindLabel } from "../ui";
+
+const PAGE_SIZE = 50;
+
+const KIND_OPTIONS = [
+ { key: "", label: "全部" },
+ { key: "pageview", label: "浏览" },
+ { key: "bot", label: "爬虫" },
+ { key: "probe", label: "探测" },
+];
+
+/** 本地日期(yyyy-mm-dd)→ RFC3339,覆盖全天 */
+function dayToRFC3339(date: string, endOfDay = false): string {
+ if (!date) return "";
+ return new Date(`${date}T${endOfDay ? "23:59:59" : "00:00:00"}`).toISOString();
+}
+
+function kindBadge(kind: string) {
+ const map: Record = {
+ pageview: { color: "var(--accent)", soft: "var(--accent-soft)" },
+ bot: { color: "var(--gold)", soft: "var(--gold-soft)" },
+ probe: { color: "var(--danger)", soft: "var(--danger-soft, var(--panel-2))" },
+ };
+ const s = map[kind] ?? { color: "var(--ink-3)", soft: "var(--panel-2)" };
+ return (
+
+ {kindLabel(kind)}
+
+ );
+}
+
+function DetailsInner() {
+ const searchParams = useSearchParams();
+ // 初始筛选只读一次(如从「爬虫与安全」跳转 ?ip=x.x.x.x)
+ const [initialIp] = useState(() => searchParams.get("ip") ?? "");
+
+ const [kind, setKind] = useState("");
+ const [path, setPath] = useState("");
+ const [ip, setIp] = useState(initialIp);
+ const [bot, setBot] = useState("");
+ const [start, setStart] = useState("");
+ const [end, setEnd] = useState("");
+ const [page, setPage] = useState(1);
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ const load = useCallback(
+ (f: { kind: string; path: string; ip: string; bot: string; start: string; end: string }, p: number) => {
+ setLoading(true);
+ apiStatsEvents({
+ kind: f.kind || undefined,
+ path: f.path || undefined,
+ ip: f.ip || undefined,
+ bot: f.bot || undefined,
+ start: dayToRFC3339(f.start) || undefined,
+ end: dayToRFC3339(f.end, true) || undefined,
+ page: p,
+ size: PAGE_SIZE,
+ })
+ .then((d) => {
+ setData(d);
+ setError("");
+ })
+ .catch(() => setError("加载失败,请稍后重试"))
+ .finally(() => setLoading(false));
+ },
+ []
+ );
+
+ useEffect(() => {
+ load({ kind, path, ip, bot, start, end }, page);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [load, kind, path, ip, bot, start, end, page]);
+
+ const resetPage = () => setPage(1);
+
+ return (
+
+
+ 记录含浏览、爬虫与异常请求;访客标识仅存哈希,IP 与登录日志口径一致,仅超管可见。
+
+
+
+
+
{ setKind(k); resetPage(); }} ariaLabel="事件类型筛选" role="group" size="sm" />
+
+
+
{ setPath(v); }}
+ onSubmit={resetPage}
+ onClear={() => { setPath(""); resetPage(); }}
+ placeholder="路径模糊匹配,回车应用"
+ ariaLabel="按路径筛选"
+ />
+
+
+
{ setIp(""); resetPage(); }}
+ placeholder="按 IP 筛选,回车应用"
+ ariaLabel="按 IP 筛选"
+ />
+
+
+
{ setBot(""); resetPage(); }}
+ placeholder="按爬虫名筛选"
+ ariaLabel="按爬虫名筛选"
+ />
+
+
+
+
+
+
+ {error ? : null}
+
+ {data && data.items.length ? (
+ <>
+
+
+
+
+ | 时间 |
+ 类型 |
+ 路径 |
+ 来源 |
+ 设备 / 系统 / 浏览器 |
+ 用户 |
+ IP |
+ 状态 |
+
+
+
+ {data.items.map((e: VisitEventItem) => (
+
+ |
+ {new Date(e.created_at).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })}
+ |
+
+ {kindBadge(e.kind)}
+ {e.bot ? (
+
+ {e.bot}
+
+ ) : null}
+ |
+
+ {e.path || "/"}
+ |
+
+ {e.ref_host ? (
+ <>
+ {e.ref_host}
+ {refKindLabel(e.ref_kind)}
+ >
+ ) : (
+ refKindLabel(e.ref_kind)
+ )}
+ |
+
+ {deviceLabel(e.device)} · {e.os || "?"} · {e.browser || "?"}
+ |
+
+ {e.username || 游客}
+ |
+
+ {e.ip || "—"}
+ |
+ = 400 ? "var(--danger)" : "var(--ink-3)" }}>
+ {e.status > 0 ? e.status : "—"}
+ |
+
+ ))}
+
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+ );
+}
+
+export default function DetailsPage() {
+ return (
+ }>
+
+
+ );
+}
diff --git a/frontend/app/admin/analytics/layout.tsx b/frontend/app/admin/analytics/layout.tsx
new file mode 100644
index 0000000..3a896e4
--- /dev/null
+++ b/frontend/app/admin/analytics/layout.tsx
@@ -0,0 +1,15 @@
+import { cookies } from "next/headers";
+import { authCookieHeader } from "@/lib/cookies";
+import { getMeCached } from "@/lib/serverData";
+import { isSuperOrOwner } from "@/lib/roles";
+import Forbidden from "../Forbidden";
+import AnalyticsShell from "./AnalyticsShell";
+
+export default async function AnalyticsLayout({ children }: { children: React.ReactNode }) {
+ const cookie = authCookieHeader(await cookies());
+ const me = await getMeCached(cookie || undefined);
+ if (!me.user || !isSuperOrOwner(me.user.role)) {
+ return ;
+ }
+ return {children};
+}
diff --git a/frontend/app/admin/analytics/page.tsx b/frontend/app/admin/analytics/page.tsx
new file mode 100644
index 0000000..64c07ec
--- /dev/null
+++ b/frontend/app/admin/analytics/page.tsx
@@ -0,0 +1,17 @@
+import { cookies } from "next/headers";
+import { authCookieHeader } from "@/lib/cookies";
+import { fetchStatsOverview, type VisitOverview } from "@/lib/api";
+import OverviewClient from "./OverviewClient";
+
+export const dynamic = "force-dynamic";
+
+export default async function AnalyticsOverviewPage() {
+ const cookie = authCookieHeader(await cookies());
+ let initial: VisitOverview | null = null;
+ try {
+ initial = await fetchStatsOverview(cookie || undefined, "7d");
+ } catch {
+ initial = null;
+ }
+ return ;
+}
diff --git a/frontend/app/admin/analytics/pages/page.tsx b/frontend/app/admin/analytics/pages/page.tsx
new file mode 100644
index 0000000..c277eb3
--- /dev/null
+++ b/frontend/app/admin/analytics/pages/page.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { AdminPager } from "@/components/admin";
+import { AdminChartCard } from "@/components/admin/charts";
+import AdminSearchField from "@/components/admin/AdminSearchField";
+import { apiStatsPages, type VisitPages } from "@/lib/api";
+import { RangeSegmented, StatsState, TableEmpty, formatInt } from "../ui";
+
+const PAGE_SIZE = 20;
+
+export default function PagesPage() {
+ const [range, setRange] = useState<"7d" | "30d">("7d");
+ const [q, setQ] = useState("");
+ const [applied, setApplied] = useState("");
+ const [page, setPage] = useState(1);
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ const load = useCallback(
+ (r: "7d" | "30d", query: string, p: number) => {
+ setLoading(true);
+ apiStatsPages(r, query, p, PAGE_SIZE)
+ .then((d) => {
+ setData(d);
+ setError("");
+ })
+ .catch(() => setError("加载失败,请稍后重试"))
+ .finally(() => setLoading(false));
+ },
+ []
+ );
+
+ useEffect(() => {
+ load(range, applied, page);
+ }, [load, range, applied, page]);
+
+ const switchRange = (r: "7d" | "30d") => {
+ setRange(r);
+ setPage(1);
+ };
+
+ const submitSearch = () => {
+ setApplied(q.trim());
+ setPage(1);
+ };
+
+ const clearSearch = () => {
+ setQ("");
+ setApplied("");
+ setPage(1);
+ };
+
+ if (!data) return ;
+
+ const maxPv = data.items[0]?.pv ?? 1;
+
+ return (
+
+
+
+ 共 {formatInt(data.total)} 个不同路径被访问(近 {data.range_days} 日)
+
+
+
+
+ {error ?
: null}
+
+
+
+
+ }
+ >
+ {data.items.length ? (
+ <>
+
+ {data.items.map((p) => (
+
+
+ {p.path || "/"}
+
+
+
+
+
+ {formatInt(p.pv)}
+
+
+ UV {formatInt(p.uv)}
+
+
+ {p.share.toFixed(1)}%
+
+
+ ))}
+
+
+ >
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/admin/analytics/realtime/page.tsx b/frontend/app/admin/analytics/realtime/page.tsx
new file mode 100644
index 0000000..2b0b98f
--- /dev/null
+++ b/frontend/app/admin/analytics/realtime/page.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import { Activity, Zap, Users, Clock } from "lucide-react";
+import { AdminStatCard } from "@/components/admin";
+import { AdminChartCard, SingleAreaChart } from "@/components/admin/charts";
+import { apiStatsRealtime, type VisitRealtime } from "@/lib/api";
+import { CardShell, StatsState, TableEmpty, deviceLabel, formatInt, kindLabel, usePolling } from "../ui";
+
+export default function RealtimePage() {
+ const [data, setData] = useState(null);
+ const [error, setError] = useState("");
+
+ const load = useCallback(() => {
+ apiStatsRealtime()
+ .then((d) => {
+ setData(d);
+ setError("");
+ })
+ .catch(() => setError("加载失败,请稍后重试"));
+ }, []);
+ usePolling(load, 15_000);
+
+ if (!data) return ;
+
+ // 兜底:旧后端可能返回 null(Go nil 切片)
+ const minutes = (data.minutes ?? []).map((m) => ({
+ // SingleAreaChart 取 date 字段画 X 轴:只留 HH:MM
+ date: m.minute.length >= 16 ? m.minute.slice(11) : m.minute,
+ pv: m.pv,
+ }));
+ const topPages = data.top_pages ?? [];
+ const recent = data.recent ?? [];
+
+ return (
+
+
每 15 秒自动刷新;切回前台标签页时立即刷新。
+ {error ?
: null}
+
+
+
} />
+
}
+ color="var(--ok)"
+ soft="var(--ok-soft, var(--panel-2))"
+ />
+
}
+ color="var(--gold)"
+ soft="var(--gold-soft)"
+ />
+
}
+ color="var(--ink-2)"
+ soft="var(--panel-2)"
+ />
+
+
+
+ {minutes.length ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {topPages.length ? (
+
+ {topPages.map((p) => (
+ -
+
+ {p.key || "/"}
+
+
+ {formatInt(p.count)}
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+
+ {recent.length ? (
+
+ {recent.map((e) => (
+ -
+
+ {new Date(e.created_at).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
+
+
+ {e.path || "/"}
+
+
+ {deviceLabel(e.device)} · {kindLabel(e.kind)}
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/app/admin/analytics/security/page.tsx b/frontend/app/admin/analytics/security/page.tsx
new file mode 100644
index 0000000..6ea6a4b
--- /dev/null
+++ b/frontend/app/admin/analytics/security/page.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { Bot, ShieldAlert, KeyRound, Radar } from "lucide-react";
+import { AdminStatCard } from "@/components/admin";
+import { AdminChartCard, NamedBarChart, SingleAreaChart } from "@/components/admin/charts";
+import { apiStatsSecurity, type VisitSecurity } from "@/lib/api";
+import { RangeSegmented, StatsState, TableEmpty, formatInt } from "../ui";
+
+export default function SecurityPage() {
+ const [range, setRange] = useState<"7d" | "30d">("7d");
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ let alive = true;
+ setLoading(true);
+ apiStatsSecurity(range)
+ .then((d) => {
+ if (alive) {
+ setData(d);
+ setError("");
+ }
+ })
+ .catch(() => alive && setError("加载失败,请稍后重试"))
+ .finally(() => alive && setLoading(false));
+ return () => {
+ alive = false;
+ };
+ }, [range]);
+
+ if (!data) return ;
+
+ const botTrend = data.bot_trend.map((d) => ({ date: d.date, pv: d.pv }));
+
+ return (
+
+
+
爬虫与异常探测请求仅记录明细,不计入 PV/UV 统计。
+
+
+
+ {error ?
: null}
+
+
+
s + b.count, 0))} icon={} />
+ s + b.count, 0))}
+ hint="状态码 ≥400 的请求"
+ icon={}
+ color="var(--gold)"
+ soft="var(--gold-soft)"
+ />
+ }
+ color="var(--ink-2)"
+ soft="var(--panel-2)"
+ />
+ }
+ color="var(--danger)"
+ soft="var(--danger-soft, var(--panel-2))"
+ />
+
+
+
+
+ {data.bots.length ? (
+ ({ label: b.key, count: b.count }))} />
+ ) : (
+
+ )}
+
+
+ {botTrend.length ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {data.probe_status.length ? (
+ ({ label: s.key, count: s.count }))} />
+ ) : (
+
+ )}
+
+
+ {data.probe_ips.length ? (
+
+ {data.probe_ips.map((ip) => (
+ -
+
+ {ip.key}
+
+
+ {formatInt(ip.count)}
+
+
+ 查明细
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/app/admin/analytics/settings/page.tsx b/frontend/app/admin/analytics/settings/page.tsx
new file mode 100644
index 0000000..3c2cfe6
--- /dev/null
+++ b/frontend/app/admin/analytics/settings/page.tsx
@@ -0,0 +1,196 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { Database, HardDrive, Trash2, Activity } from "lucide-react";
+import { AdminSettingRow, AdminStatCard, AdminSwitch } from "@/components/admin";
+import ConfirmDialog from "@/components/ConfirmDialog";
+import { apiStatsPurge, apiStatsSaveSettings, apiStatsSettings, type VisitSettingsPayload } from "@/lib/api";
+import { StatsState, formatBytes, formatInt } from "../ui";
+
+/** 保留天数输入边界(与后端 service 一致) */
+const RETENTION_MIN = 7;
+const RETENTION_MAX = 365;
+
+export default function SettingsPage() {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [enabled, setEnabled] = useState(true);
+ const [retention, setRetention] = useState(90);
+ const [saving, setSaving] = useState(false);
+ const [savedMsg, setSavedMsg] = useState("");
+ // 立即清理:天数输入 + 确认弹窗 + 受理提示
+ const [purgeDays, setPurgeDays] = useState(90);
+ const [purgeOpen, setPurgeOpen] = useState(false);
+ const [purging, setPurging] = useState(false);
+ const [purgeMsg, setPurgeMsg] = useState("");
+
+ const load = useCallback(() => {
+ apiStatsSettings()
+ .then((d) => {
+ setData(d);
+ setEnabled(d.enabled);
+ setRetention(d.retention_days);
+ setPurgeDays(d.retention_days);
+ setError("");
+ })
+ .catch(() => setError("加载失败,请稍后重试"))
+ .finally(() => setLoading(false));
+ }, []);
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const save = (nextEnabled: boolean, nextRetention: number) => {
+ setSaving(true);
+ setSavedMsg("");
+ apiStatsSaveSettings(nextEnabled, nextRetention)
+ .then((d) => {
+ setData(d);
+ setEnabled(d.enabled);
+ setRetention(d.retention_days);
+ setSavedMsg("已保存");
+ setTimeout(() => setSavedMsg(""), 2000);
+ })
+ .catch(() => setError("保存失败,请稍后重试"))
+ .finally(() => setSaving(false));
+ };
+
+ const toggleEnabled = () => {
+ if (saving) return;
+ save(!enabled, retention);
+ };
+
+ const submitRetention = () => {
+ if (saving) return;
+ const v = Math.min(RETENTION_MAX, Math.max(RETENTION_MIN, Math.round(Number(retention) || 90)));
+ setRetention(v);
+ save(enabled, v);
+ };
+
+ const doPurge = () => {
+ setPurging(true);
+ apiStatsPurge(purgeDays)
+ .then(() => {
+ setPurgeMsg(`清理任务已受理(删除 ${purgeDays} 天前的明细),稍后刷新本页查看容量变化。`);
+ setTimeout(load, 3000);
+ })
+ .catch(() => setPurgeMsg("清理请求失败,请稍后重试。"))
+ .finally(() => {
+ setPurging(false);
+ setPurgeOpen(false);
+ });
+ };
+
+ if (!data) return ;
+
+ const clampPurge = (v: number) =>
+ Math.min(RETENTION_MAX, Math.max(1, Math.round(Number(v) || 1)));
+
+ return (
+
+ {error ?
: null}
+ {purgeMsg ? (
+
+ {purgeMsg}
+
+ ) : null}
+
+
+
} />
+
}
+ color="var(--ok)"
+ soft="var(--ok-soft, var(--panel-2))"
+ />
+
} color="var(--gold)" soft="var(--gold-soft)" />
+
}
+ color="var(--ink-2)"
+ soft="var(--panel-2)"
+ />
+
+
+
}
+ />
+
+
+
+ setRetention(Number(e.target.value))}
+ onBlur={submitRetention}
+ onKeyDown={(e) => e.key === "Enter" && submitRetention()}
+ className="admin-field-input"
+ style={{ width: 120 }}
+ aria-label="明细保留天数"
+ />
+
+ 天
+
+ {saving ? 保存中… : null}
+ {savedMsg ? (
+
+ {savedMsg}
+
+ ) : null}
+
+
+
+
+
+ setPurgeDays(clampPurge(Number(e.target.value)))}
+ className="admin-field-input"
+ style={{ width: 120 }}
+ aria-label="清理多少天前的明细"
+ />
+
+ 天前的明细
+
+
+
+
+
+
setPurgeOpen(false)}
+ />
+
+ );
+}
diff --git a/frontend/app/admin/analytics/sources/page.tsx b/frontend/app/admin/analytics/sources/page.tsx
new file mode 100644
index 0000000..50d1640
--- /dev/null
+++ b/frontend/app/admin/analytics/sources/page.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { AdminChartCard, NamedBarChart, NamedPieChart } from "@/components/admin/charts";
+import { apiStatsSources, type VisitSources } from "@/lib/api";
+import { RangeSegmented, StatsState, TableEmpty, refKindLabel } from "../ui";
+
+/** 来源按日表:固定展示四类主要来源列 */
+const TREND_KINDS = ["direct", "search", "social", "external"] as const;
+
+export default function SourcesPage() {
+ const [range, setRange] = useState<"7d" | "30d">("7d");
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ let alive = true;
+ setLoading(true);
+ apiStatsSources(range)
+ .then((d) => {
+ if (alive) {
+ setData(d);
+ setError("");
+ }
+ })
+ .catch(() => alive && setError("加载失败,请稍后重试"))
+ .finally(() => alive && setLoading(false));
+ return () => {
+ alive = false;
+ };
+ }, [range]);
+
+ if (!data) return ;
+
+ const kinds = data.kinds.map((k) => ({ label: refKindLabel(k.key), count: k.count }));
+
+ return (
+
+
+
按浏览来源(Referer)自动分类;爬虫与探测请求不计入。
+
+
+
+ {error ?
: null}
+
+
+ {kinds.length ? : }
+
+
+
+
+ {data.search_hosts.length ? (
+ ({ label: h.key, count: h.count }))} />
+ ) : (
+
+ )}
+
+
+ {data.external.length ? (
+ ({ label: h.key, count: h.count }))} />
+ ) : (
+
+ )}
+
+
+
+
+ {data.trend.length ? (
+
+
+
+
+ | 日期 |
+ {TREND_KINDS.map((k) => (
+
+ {refKindLabel(k)}
+ |
+ ))}
+
+
+
+ {data.trend.map((row) => {
+ const byKey = new Map(row.items.map((i) => [i.key, i.count]));
+ return (
+
+ |
+ {row.date.slice(0, 10)}
+ |
+ {TREND_KINDS.map((k) => (
+
+ {(byKey.get(k) ?? 0) === 0 ? (
+ —
+ ) : (
+ (byKey.get(k) ?? 0).toLocaleString("zh-CN")
+ )}
+ |
+ ))}
+
+ );
+ })}
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/admin/analytics/ui.tsx b/frontend/app/admin/analytics/ui.tsx
new file mode 100644
index 0000000..195786e
--- /dev/null
+++ b/frontend/app/admin/analytics/ui.tsx
@@ -0,0 +1,166 @@
+"use client";
+
+import { useEffect, useState, type ReactNode } from "react";
+import { onDocumentVisible } from "@/lib/visibilityReconcile";
+
+/** 事件类型中文标签 */
+export function kindLabel(kind: string): string {
+ switch (kind) {
+ case "pageview":
+ return "浏览";
+ case "bot":
+ return "爬虫";
+ case "probe":
+ return "探测";
+ default:
+ return kind || "-";
+ }
+}
+
+/** 来源类别中文标签 */
+export function refKindLabel(kind: string): string {
+ switch (kind) {
+ case "direct":
+ return "直接访问";
+ case "internal":
+ return "站内";
+ case "search":
+ return "搜索引擎";
+ case "social":
+ return "社交";
+ case "external":
+ return "外部链接";
+ default:
+ return kind || "-";
+ }
+}
+
+export function deviceLabel(d: string): string {
+ switch (d) {
+ case "computer":
+ return "电脑";
+ case "mobile":
+ return "手机";
+ case "tablet":
+ return "平板";
+ default:
+ return d || "未知";
+ }
+}
+
+export function formatBytes(n: number): string {
+ if (!Number.isFinite(n) || n <= 0) return "0 B";
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ let v = n;
+ let i = 0;
+ while (v >= 1024 && i < units.length - 1) {
+ v /= 1024;
+ i++;
+ }
+ return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
+}
+
+export function formatInt(n: number): string {
+ return new Intl.NumberFormat("zh-CN").format(n);
+}
+
+/** 简单加载/错误占位(居中卡片内文本) */
+export function StatsState({ loading, error }: { loading?: boolean; error?: string }) {
+ if (loading) {
+ return (
+
+ 正在加载…
+
+ );
+ }
+ if (error) {
+ return (
+
+ {error}
+
+ );
+ }
+ return null;
+}
+
+export function TableEmpty({ text = "暂无数据" }: { text?: string }) {
+ return (
+
+ {text}
+
+ );
+}
+
+/** 15 秒轮询 + 回前台即拉;返回手动 reload */
+export function usePolling(load: () => void, intervalMs = 15_000) {
+ const [, setTick] = useState(0);
+ useEffect(() => {
+ load();
+ const timer = setInterval(load, intervalMs);
+ const off = onDocumentVisible(load);
+ return () => {
+ clearInterval(timer);
+ off();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [intervalMs]);
+ return () => setTick((t) => t + 1);
+}
+
+/** 7/30 日区间切换段控件(各 Tab 复用) */
+export function RangeSegmented({
+ value,
+ onChange,
+}: {
+ value: "7d" | "30d";
+ onChange: (v: "7d" | "30d") => void;
+}) {
+ return (
+
+ {(
+ [
+ { key: "7d", label: "近 7 日" },
+ { key: "30d", label: "近 30 日" },
+ ] as const
+ ).map((o) => (
+
+ ))}
+
+ );
+}
+
+export function CardShell({
+ title,
+ hint,
+ actions,
+ children,
+}: {
+ title: string;
+ hint?: string;
+ actions?: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+
+ {title}
+
+ {hint ?
{hint}
: null}
+
+ {actions}
+
+ {children}
+
+ );
+}
diff --git a/frontend/app/admin/analytics/visitors/page.tsx b/frontend/app/admin/analytics/visitors/page.tsx
new file mode 100644
index 0000000..2e04fc6
--- /dev/null
+++ b/frontend/app/admin/analytics/visitors/page.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Users, UserPlus, Repeat, LogIn } from "lucide-react";
+import { AdminStatCard } from "@/components/admin";
+import { AdminChartCard, NamedBarChart, NamedPieChart } from "@/components/admin/charts";
+import { apiStatsVisitors, type VisitVisitors } from "@/lib/api";
+import { RangeSegmented, StatsState, TableEmpty, formatInt } from "../ui";
+
+export default function VisitorsPage() {
+ const [range, setRange] = useState<"7d" | "30d">("7d");
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ let alive = true;
+ setLoading(true);
+ apiStatsVisitors(range)
+ .then((d) => {
+ if (alive) {
+ setData(d);
+ setError("");
+ }
+ })
+ .catch(() => alive && setError("加载失败,请稍后重试"))
+ .finally(() => alive && setLoading(false));
+ return () => {
+ alive = false;
+ };
+ }, [range]);
+
+ if (!data) return ;
+
+ const devices = data.devices.map((d) => ({ label: d.label || "未知", count: d.count }));
+ const oses = data.oses.map((d) => ({ label: d.label || "未知", count: d.count }));
+ const browsers = data.browsers.map((d) => ({ label: d.label || "未知", count: d.count }));
+ const hourly = data.hourly.map((h) => ({ label: `${h.key}时`, count: h.count }));
+ const totalPv = data.logged_in_pv + data.guest_pv;
+ const totalVisitors = data.new_visitors + data.returning;
+
+ return (
+
+
+
基于匿名访客标识(仅存哈希)与登录用户统计。
+
+
+
+ {error ?
: null}
+
+
+
} />
+
}
+ color="var(--ok)"
+ soft="var(--ok-soft, var(--panel-2))"
+ />
+
0 ? `${Math.round((data.new_visitors / totalVisitors) * 100)}%` : undefined}
+ hint="统计期内首次出现"
+ icon={}
+ color="var(--gold)"
+ soft="var(--gold-soft)"
+ />
+ 0 ? `${Math.round((data.returning / totalVisitors) * 100)}%` : undefined}
+ icon={}
+ color="var(--ink-2)"
+ soft="var(--panel-2)"
+ />
+
+
+ {totalPv > 0 ? (
+
+
+
+ ) : null}
+
+
+
+ {devices.length ? : }
+
+
+ {oses.length ? : }
+
+
+ {browsers.length ? : }
+
+
+
+
+ {hourly.length ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/admin/sidebar/SidebarAdmin.tsx b/frontend/app/admin/sidebar/SidebarAdmin.tsx
new file mode 100644
index 0000000..cd4e000
--- /dev/null
+++ b/frontend/app/admin/sidebar/SidebarAdmin.tsx
@@ -0,0 +1,824 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ ArrowDown,
+ ArrowLeft,
+ ArrowRight,
+ ArrowUp,
+ BarChart3,
+ Blocks,
+ ChevronDown,
+ CircleDollarSign,
+ Code2,
+ FileText,
+ Flame,
+ Handshake,
+ Home,
+ Info,
+ Layers,
+ LayoutGrid,
+ Link2,
+ List,
+ Megaphone,
+ PanelLeft,
+ Pencil,
+ PenLine,
+ Plus,
+ RotateCcw,
+ ShieldCheck,
+ Trash2,
+ User,
+ UserPlus,
+ Users,
+ type LucideIcon,
+} from "lucide-react";
+import {
+ apiAdminCreateSidebarWidget,
+ apiAdminDeleteSidebarWidget,
+ apiAdminGetSidebar,
+ apiAdminSaveSidebarConfig,
+ apiAdminUpdateSidebarWidget,
+ type SidebarBlockRef,
+ type SidebarConfig,
+ type SidebarScope,
+ type SidebarWidgetRecord,
+} from "@/lib/api";
+import {
+ SIDEBAR_BLOCK_ICON,
+ SIDEBAR_MAX_BLOCKS_PER_SIDE,
+ SIDEBAR_SCOPE_LABELS,
+ SIDEBAR_WIDGET_META,
+ defaultSidebarConfig,
+ sidebarBlockLabel,
+} from "@/lib/sidebarMeta";
+import {
+ AdminEmpty,
+ AdminPageHeader,
+ AdminSettingsPage,
+ AdminSettingsWork,
+ AdminSwitch,
+} from "@/components/admin";
+import ConfirmDialog from "@/components/ConfirmDialog";
+import Modal from "@/components/Modal";
+import useUnsavedGuard from "@/hooks/useUnsavedGuard";
+import { toast } from "@/lib/toast";
+import { mergeWidgetCode, parseMixedWidgetCode } from "@/lib/widgetCode";
+
+type Side = "left" | "right";
+type SectionId = SidebarScope | "widgets";
+
+const refKey = (r: SidebarBlockRef) => (r.widget ? `w:${r.widget}` : `k:${r.key}`);
+
+// lucide icon 映射(SIDEBAR_BLOCK_ICON 是字符串 key,这里解析成组件)
+const ICON_MAP: Record = {
+ "layout-grid": LayoutGrid,
+ "bar-chart-3": BarChart3,
+ megaphone: Megaphone,
+ "circle-dollar-sign": CircleDollarSign,
+ handshake: Handshake,
+ flame: Flame,
+ "user-plus": UserPlus,
+ users: Users,
+ link2: Link2,
+ info: Info,
+ "shield-check": ShieldCheck,
+ "file-text": FileText,
+ layers: Layers,
+ user: User,
+ "pen-line": PenLine,
+ list: List,
+};
+function keyIcon(key: string): LucideIcon | undefined {
+ return ICON_MAP[SIDEBAR_BLOCK_ICON[key] ?? ""];
+}
+
+const SIDES: { scope: SidebarScope; icon: LucideIcon; desc: string; sides: Side[] }[] = [
+ { scope: "home", icon: Home, desc: "首页全站态(含板块页左栏):左右两栏均可增删排序", sides: ["left", "right"] },
+ { scope: "board", icon: Layers, desc: "板块页右栏;板块页左栏跟随「首页侧栏」配置", sides: ["right"] },
+ { scope: "post", icon: FileText, desc: "帖子详情页:左右两栏(窄屏不渲染侧栏)", sides: ["left", "right"] },
+];
+
+const SIDE_LABEL: Record = { left: "左栏", right: "右栏" };
+const WIDGET_CODE_MAX_BYTES = 64 * 1024;
+
+export default function SidebarAdmin({
+ initialConfig,
+ initialWidgets,
+ initError,
+}: {
+ initialConfig: SidebarConfig | null;
+ initialWidgets: SidebarWidgetRecord[];
+ initError: string;
+}) {
+ const [config, setConfig] = useState(() => initialConfig || defaultSidebarConfig());
+ const [widgets, setWidgets] = useState(initialWidgets);
+ const [busy, setBusy] = useState(false);
+ const [activeSection, setActiveSection] = useState("home");
+ const savedJsonRef = useRef(JSON.stringify(initialConfig || defaultSidebarConfig()));
+ const dirty = JSON.stringify(config) !== savedJsonRef.current;
+ const { leaveOpen, confirmLeave, cancelLeave } = useUnsavedGuard(dirty && !busy);
+
+ // 工具编辑弹层
+ const [editorOpen, setEditorOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [draftName, setDraftName] = useState("");
+ const [mixed, setMixed] = useState("");
+ const [deleteId, setDeleteId] = useState(null);
+
+ const draftBytes = useMemo(() => new TextEncoder().encode(mixed).length, [mixed]);
+ const draftOk = draftName.trim().length > 0 && draftBytes <= WIDGET_CODE_MAX_BYTES;
+
+ const reloadAll = async () => {
+ const res = await apiAdminGetSidebar();
+ setConfig(res.config);
+ setWidgets(res.widgets);
+ savedJsonRef.current = JSON.stringify(res.config);
+ };
+
+ /* ---------- 布局配置 ---------- */
+
+ const setSide = (scope: SidebarScope, side: Side, next: SidebarBlockRef[]) => {
+ setConfig((c) => {
+ const cur = c.scopes[scope] ?? {};
+ return { ...c, scopes: { ...c.scopes, [scope]: { ...cur, [side]: next } } };
+ });
+ };
+
+ const moveAcross = (scope: SidebarScope, from: Side, index: number) => {
+ const to: Side = from === "left" ? "right" : "left";
+ setConfig((c) => {
+ const cur = c.scopes[scope] ?? {};
+ const fromList = [...(cur[from] ?? [])];
+ const [item] = fromList.splice(index, 1);
+ const toList = cur[to] ?? [];
+ if (!item || toList.length >= SIDEBAR_MAX_BLOCKS_PER_SIDE) return c;
+ return { ...c, scopes: { ...c.scopes, [scope]: { ...cur, [from]: fromList, [to]: [...toList, item] } } };
+ });
+ };
+
+ const resetScope = (scope: SidebarScope) => {
+ const def = defaultSidebarConfig().scopes[scope] ?? {};
+ setConfig((c) => ({
+ ...c,
+ scopes: { ...c.scopes, [scope]: { left: def.left ?? [], right: def.right ?? [] } },
+ }));
+ toast("已重置为默认布局,记得保存", "ok");
+ };
+
+ const saveConfig = async () => {
+ setBusy(true);
+ try {
+ const res = await apiAdminSaveSidebarConfig(config);
+ setConfig(res.config);
+ savedJsonRef.current = JSON.stringify(res.config);
+ toast("侧栏布局已保存", "ok");
+ } catch (e) {
+ toast(e instanceof Error ? e.message : "保存失败", "error");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ /* ---------- 自定义工具 ---------- */
+
+ const openCreate = () => {
+ setEditingId(null);
+ setDraftName("");
+ setMixed("");
+ setEditorOpen(true);
+ };
+
+ const openEdit = (w: SidebarWidgetRecord) => {
+ setEditingId(w.id);
+ setDraftName(w.name);
+ setMixed(mergeWidgetCode({ html: w.html, css: w.css, js: w.js }));
+ setEditorOpen(true);
+ };
+
+ const submitWidget = async () => {
+ if (!draftOk) return;
+ setBusy(true);
+ try {
+ const code = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) };
+ if (editingId == null) {
+ await apiAdminCreateSidebarWidget(code);
+ toast("工具已创建", "ok");
+ } else {
+ const w = widgets.find((x) => x.id === editingId);
+ await apiAdminUpdateSidebarWidget(editingId, { ...code, enabled: w?.enabled ?? true });
+ toast("工具已保存", "ok");
+ }
+ setEditorOpen(false);
+ await reloadAll();
+ } catch (e) {
+ toast(e instanceof Error ? e.message : "保存失败", "error");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const toggleWidget = async (w: SidebarWidgetRecord) => {
+ setBusy(true);
+ try {
+ await apiAdminUpdateSidebarWidget(w.id, { name: w.name, html: w.html, css: w.css, js: w.js, enabled: !w.enabled });
+ toast(w.enabled ? "已停用" : "已启用", "ok");
+ await reloadAll();
+ } catch (e) {
+ toast(e instanceof Error ? e.message : "操作失败", "error");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const deleteWidget = async () => {
+ if (deleteId == null) return;
+ setBusy(true);
+ try {
+ await apiAdminDeleteSidebarWidget(deleteId);
+ toast("工具已删除,侧栏引用已同步清理", "ok");
+ setDeleteId(null);
+ await reloadAll();
+ } catch (e) {
+ toast(e instanceof Error ? e.message : "删除失败", "error");
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ /* ---------- 分区滚动导航 ---------- */
+
+ const sectionRefs = useRef>({ home: null, board: null, post: null, widgets: null });
+ useEffect(() => {
+ const els = Object.values(sectionRefs.current).filter((el): el is HTMLElement => !!el);
+ if (els.length === 0) return;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const visible = entries.filter((e) => e.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio);
+ if (visible[0]) setActiveSection((visible[0].target as HTMLElement).dataset.section as SectionId);
+ },
+ { rootMargin: "-30% 0px -55% 0px", threshold: [0, 0.25, 0.5, 1] }
+ );
+ els.forEach((el) => observer.observe(el));
+ return () => observer.disconnect();
+ }, []);
+ const scrollTo = (id: SectionId) => sectionRefs.current[id]?.scrollIntoView({ behavior: "smooth", block: "start" });
+
+ if (initError && !initialConfig) return {initError}
;
+
+ // 各范围块数(含左右两侧合计,用于导航徽章)
+ const scopeCount = (s: SidebarScope) => {
+ const sc = config.scopes[s] ?? {};
+ return (sc.left ?? []).length + (sc.right ?? []).length;
+ };
+ const builtinCount = widgets.filter((w) => w.enabled).length;
+
+ const navItems: { id: SectionId; label: string; icon: LucideIcon; count: number }[] = [
+ ...SIDES.map((s) => ({ id: s.scope as SectionId, label: `${SIDEBAR_SCOPE_LABELS[s.scope]}侧栏`, icon: s.icon, count: scopeCount(s.scope) })),
+ { id: "widgets", label: "自定义工具", icon: Code2, count: builtinCount },
+ ];
+
+ const leftNav = (
+
+ );
+
+ return (
+
+
+
+
+
+ {SIDES.map(({ scope, icon, desc, sides }) => {
+ const sc = config.scopes[scope] ?? {};
+ const left = sc.left ?? [];
+ const right = sc.right ?? [];
+ const usedKeys = new Set([...left, ...right].map(refKey));
+ const Icon = icon;
+ return (
+
{ sectionRefs.current[scope] = el; }}
+ className="panel"
+ >
+ }
+ title={`${SIDEBAR_SCOPE_LABELS[scope]}侧栏`}
+ desc={desc}
+ extra={
+
+ }
+ />
+
+ {sides.map((side) => (
+ = SIDEBAR_MAX_BLOCKS_PER_SIDE}
+ widgets={widgets}
+ onChange={(next) => setSide(scope, side, next)}
+ onSendAcross={(i) => moveAcross(scope, side, i)}
+ />
+ ))}
+
+
+ );
+ })}
+
+ {/* 自定义工具 */}
+
+
+
+
+ {/* 吸底保存栏 */}
+ {dirty && (
+
+
+
+ 布局有未保存的修改
+
+
+
+ )}
+
+ {/* 工具编辑弹层 */}
+ { if (!busy) setEditorOpen(false); }}
+ busy={busy}
+ maxWidthClass="max-w-2xl"
+ footer={
+ <>
+
+
+ >
+ }
+ >
+
+
+
+
+ 开放能力速查:window.J13 / 主题变量
+
+
J13.user:当前登录用户(未登录为 null)
+
J13.theme.get() 返回 "light" | "dark"; J13.theme.onChange(fn) 监听明暗切换
+
J13.getOverview():站点公开统计(Promise,users / posts / online 等)
+
CSS 变量自动跟随明暗主题与后台自定义主题色:var(--ink) var(--accent) var(--panel)…
+
+
+
+ CSS 自动限定在本板块内、不污染其他区域;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
+
+
+
+
+ {/* 删除工具确认 */}
+ void deleteWidget()}
+ onCancel={() => setDeleteId(null)}
+ />
+
+ {/* 未保存离开确认 */}
+
+
+ );
+}
+
+/* ─── 分区卡片头 ─── */
+function SectionHead({
+ icon,
+ title,
+ desc,
+ extra,
+}: {
+ icon: React.ReactNode;
+ title: string;
+ desc?: string;
+ extra?: React.ReactNode;
+}) {
+ return (
+
+
+ {icon}
+
+ {title}
+ {desc ? {desc} : null}
+
+
+ {extra ?
{extra}
: null}
+
+ );
+}
+
+/* ─── 单侧栏块列表编辑器 ─── */
+function RailEditor({
+ scope,
+ side,
+ list,
+ usedKeys,
+ targetFull,
+ widgets,
+ onChange,
+ onSendAcross,
+}: {
+ scope: SidebarScope;
+ side: Side;
+ list: SidebarBlockRef[];
+ usedKeys: Set;
+ targetFull: boolean;
+ widgets: SidebarWidgetRecord[];
+ onChange: (next: SidebarBlockRef[]) => void;
+ onSendAcross: (index: number) => void;
+}) {
+ const candidates = useMemo(() => {
+ const meta = SIDEBAR_WIDGET_META[scope];
+ const builtin: { ref: SidebarBlockRef; label: string; custom: boolean; iconKey?: string; widget?: SidebarWidgetRecord }[] = [];
+ for (const key of Object.keys(meta)) {
+ if (!usedKeys.has(`k:${key}`)) builtin.push({ ref: { key }, label: meta[key], custom: false, iconKey: SIDEBAR_BLOCK_ICON[key] });
+ }
+ const customWidgets: typeof builtin = [];
+ for (const w of widgets) {
+ if (!usedKeys.has(`w:${w.id}`)) customWidgets.push({ ref: { widget: w.id }, label: w.name, custom: true, widget: w });
+ }
+ return { builtin, customWidgets };
+ }, [scope, usedKeys, widgets]);
+
+ const full = list.length >= SIDEBAR_MAX_BLOCKS_PER_SIDE;
+
+ const move = (index: number, delta: number) => {
+ const next = [...list];
+ const target = index + delta;
+ if (target < 0 || target >= next.length) return;
+ [next[index], next[target]] = [next[target], next[index]];
+ onChange(next);
+ };
+
+ return (
+
+
+
+ {SIDE_LABEL[side]}
+ {list.length} / {SIDEBAR_MAX_BLOCKS_PER_SIDE}
+
+
+
+ {list.length === 0 ? (
+
+
+
该侧栏为空——前台不渲染这一列
+
从下方选择块添加
+
+ ) : (
+
+ {list.map((ref, i) => {
+ const info = sidebarBlockLabel(ref, widgets.map((w) => ({ id: w.id, name: w.name })));
+ const w = ref.widget ? widgets.find((x) => x.id === ref.widget) : null;
+ const disabled = !!(w && !w.enabled);
+ const Icon = ref.key ? keyIcon(ref.key) : undefined;
+ return (
+ -
+ {/* 类型图标圆点:内置蓝 / 自定义金 */}
+
+ {Icon ? : info?.custom ? : }
+
+ {/* 序号 */}
+ {i + 1}
+ {/* 块信息 */}
+
+
+ {info ? info.label : "未知模块"}
+
+
+ {info?.custom ? "自定义工具" : "内置模块"}
+ {disabled ? " · 已停用(前台不显示)" : ""}
+
+
+ {/* 操作按钮组 */}
+
+
+
+ {scope !== "board" && (
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+ {/* 添加块:自定义下拉浮层(不依赖原生 select/option,完全跟随主题变量) */}
+
onChange([...list, ref])}
+ />
+
+ );
+}
+
+/* ─── 添加块下拉浮层 ─── */
+type CandidateItem = { ref: SidebarBlockRef; label: string; custom: boolean; iconKey?: string; widget?: SidebarWidgetRecord };
+
+function AddBlockPicker({
+ candidates,
+ full,
+ onPick,
+}: {
+ candidates: { builtin: CandidateItem[]; customWidgets: CandidateItem[] };
+ full: boolean;
+ onPick: (ref: SidebarBlockRef) => 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 onEsc = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setOpen(false);
+ };
+ document.addEventListener("mousedown", onDown);
+ document.addEventListener("keydown", onEsc);
+ return () => {
+ document.removeEventListener("mousedown", onDown);
+ document.removeEventListener("keydown", onEsc);
+ };
+ }, [open]);
+
+ const hasAny = candidates.builtin.length + candidates.customWidgets.length > 0;
+
+ return (
+
+
+
+ {open && !full && hasAny && (
+
+ {candidates.builtin.length > 0 && (
+
{ onPick(r); setOpen(false); }} />
+ )}
+ {candidates.customWidgets.length > 0 && (
+ { onPick(r); setOpen(false); }} />
+ )}
+
+ )}
+
+ );
+}
+
+function AddGroup({
+ title,
+ items,
+ onPick,
+}: {
+ title: string;
+ items: CandidateItem[];
+ onPick: (ref: SidebarBlockRef) => void;
+}) {
+ return (
+
+
+ {title}
+
+
+ {items.map((c) => {
+ const Icon = c.iconKey ? ICON_MAP[c.iconKey] : undefined;
+ const disabled = c.widget ? !c.widget.enabled : false;
+ return (
+ -
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/app/admin/sidebar/page.tsx b/frontend/app/admin/sidebar/page.tsx
new file mode 100644
index 0000000..67bf5ac
--- /dev/null
+++ b/frontend/app/admin/sidebar/page.tsx
@@ -0,0 +1,46 @@
+import type { Metadata } from "next";
+import { Suspense } from "react";
+import { cookies } from "next/headers";
+import SidebarAdmin from "./SidebarAdmin";
+import { authCookieHeader } from "@/lib/cookies";
+import {
+ fetchAdminSidebar,
+ type SidebarConfig,
+ type SidebarWidgetRecord,
+} from "@/lib/api";
+import { getMeCached } from "@/lib/serverData";
+import { isSuperOrOwner } from "@/lib/roles";
+import Forbidden from "../Forbidden";
+
+export const metadata: Metadata = {
+ title: "侧栏管理",
+};
+
+export default async function AdminSidebarPage() {
+ const cookie = authCookieHeader(await cookies());
+ const me = await getMeCached(cookie || undefined);
+ if (!me.user || !isSuperOrOwner(me.user.role)) {
+ return ;
+ }
+
+ let config: SidebarConfig | null = null;
+ let widgets: SidebarWidgetRecord[] = [];
+ let initError = "";
+ try {
+ const res = await fetchAdminSidebar(cookie || undefined);
+ config = res.config;
+ widgets = res.widgets ?? [];
+ } catch (e) {
+ initError = e instanceof Error ? e.message : "加载失败";
+ }
+
+ return (
+ 加载中…
}>
+
+
+ );
+}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 18cdbc1..3010788 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -99,7 +99,7 @@
栏在 scrollY=0 时的视口 top,否则第一下滚动会先空走一截再吸住 */
--header-bar: 68px;
--header-h: calc(var(--header-bar) + 1px);
- --main-pad-y: 2.25rem;
+ --main-pad-y: 1.25rem;
--rail-top: calc(var(--header-h) + var(--main-pad-y));
--rail-max-h: calc(100vh - var(--rail-top) - 1rem);
@@ -6329,29 +6329,29 @@ a.j13-mention-token:hover {
/* ----- 正文时间线:日志行样式(非标题墙);日徽章为唯一强层级 ----- */
.j13-timeline {
+ position: relative;
margin: 1rem 0 1.25rem;
padding: 0.1rem 0 0.1rem 0.1rem;
display: flex;
flex-direction: column;
gap: 1.15rem;
}
+ /* 连续主干:贯穿所有日分组,避免按日分段导致竖线"断树" */
+ .j13-timeline::before {
+ content: "";
+ position: absolute;
+ left: 0.38rem;
+ top: 0.55rem;
+ bottom: 0.55rem;
+ width: 1.5px;
+ border-radius: 1px;
+ background: color-mix(in srgb, var(--ok) 45%, var(--line));
+ z-index: 0;
+ }
.j13-timeline-day {
position: relative;
padding-left: 1.15rem;
}
- .j13-timeline-day::before {
- content: "";
- position: absolute;
- left: 0.28rem;
- top: 1.7rem;
- bottom: 0.2rem;
- width: 1.5px;
- border-radius: 1px;
- background: color-mix(in srgb, var(--ok) 32%, var(--line));
- }
- .j13-timeline-day:last-child::before {
- bottom: 0.85rem;
- }
.j13-timeline-day-label {
display: inline-flex;
align-items: center;
@@ -6401,12 +6401,12 @@ a.j13-mention-token:hover {
position: absolute;
left: -1.15rem;
top: 0.85rem;
- width: 0.42rem;
- height: 0.42rem;
+ width: 0.5rem;
+ height: 0.5rem;
margin-left: 0.28rem;
border-radius: 999px;
background: var(--ok);
- border: 2px solid var(--panel);
+ border: 1.5px solid var(--panel);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ok) 40%, var(--line));
transform: translateX(-50%);
z-index: 1;
@@ -6532,6 +6532,77 @@ a.j13-mention-token:hover {
border-radius: 10px;
border: 1px solid var(--line);
background: var(--panel);
+ transition: border-color 0.15s ease, background 0.15s ease, opacity 0.15s ease;
+ }
+ .j13-tl-edit-card:hover {
+ border-color: var(--control-border-hover);
+ }
+ /* 已排除条目:降饱和度 + 虚线边,提示不会写入正文 */
+ .j13-tl-edit-card.is-excluded {
+ opacity: 0.55;
+ background: var(--tint);
+ border-style: dashed;
+ }
+ .j13-tl-tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.05rem 0.4rem;
+ border-radius: 4px;
+ font-size: 10.5px;
+ font-weight: 600;
+ line-height: 1.4;
+ letter-spacing: 0.01em;
+ background: var(--ink-3);
+ color: var(--panel);
+ }
+ .j13-tl-count {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.05rem 0.45rem;
+ border-radius: 999px;
+ font-size: 11.5px;
+ font-weight: 600;
+ line-height: 1.4;
+ font-variant-numeric: tabular-nums;
+ background: var(--accent-soft);
+ color: var(--accent);
+ }
+ .j13-tl-count.is-muted {
+ background: var(--tint);
+ color: var(--ink-3);
+ }
+ .j13-tl-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 1.6rem 1rem;
+ border: 1px dashed var(--line-2);
+ border-radius: 10px;
+ background: var(--tint);
+ color: var(--ink-3);
+ font-size: 13px;
+ text-align: center;
+ }
+ .j13-tl-hint {
+ margin-top: 0.35rem;
+ font-size: 11.5px;
+ color: var(--ink-3);
+ font-variant-numeric: tabular-nums;
+ }
+ .j13-tl-banner {
+ margin-top: 0.5rem;
+ padding: 0.45rem 0.65rem;
+ border-radius: 8px;
+ font-size: 12.5px;
+ line-height: 1.5;
+ border: 1px solid transparent;
+ }
+ .j13-tl-banner.is-warn,
+ .j13-tl-banner.is-err {
+ background: var(--danger-soft);
+ color: var(--danger);
+ border-color: color-mix(in srgb, var(--danger) 25%, transparent);
}
/* ----- 分页 ----- */
@@ -9018,3 +9089,69 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
display: block;
}
.settings-nav-item small { margin-left:8px; }
+
+/* ===== 访问统计:横向 Tab 导航 ===== */
+.admin-analytics-page {
+ width: 100%;
+ max-width: 1120px;
+}
+.admin-analytics-tabs {
+ display: flex;
+ align-items: stretch;
+ gap: 0.15rem;
+ overflow-x: auto;
+ scrollbar-width: thin;
+ border-bottom: 1px solid var(--line);
+ margin-bottom: 1.25rem;
+ padding: 0 0.25rem;
+}
+.admin-analytics-tab {
+ position: relative;
+ white-space: nowrap;
+ padding: 0.55rem 0.9rem;
+ font-size: 13.5px;
+ font-weight: 500;
+ line-height: 1.35;
+ color: var(--ink-2);
+ text-decoration: none;
+ border-radius: 0.6rem 0.6rem 0 0;
+ transition: background 0.15s ease, color 0.15s ease;
+}
+.admin-analytics-tab:hover {
+ background: var(--panel-2);
+ color: var(--ink);
+}
+.admin-analytics-tab:visited {
+ color: var(--ink-2);
+}
+.admin-analytics-tab[aria-current="page"],
+.admin-analytics-tab[aria-current="page"]:visited {
+ color: var(--nav-active-ink);
+ background: var(--nav-active-bg);
+ font-weight: 600;
+}
+.admin-analytics-tab[aria-current="page"]::after {
+ content: "";
+ position: absolute;
+ left: 0.5rem;
+ right: 0.5rem;
+ bottom: 0;
+ height: 2px;
+ background: var(--accent);
+ border-radius: 2px 2px 0 0;
+}
+.admin-analytics-tab:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+
+/* ── 侧栏管理:站长自定义工具容器 ──
+ 不附加面板底色/边框,视觉完全交给工具自带的 HTML/CSS;
+ 仅保证在侧栏列内不横向溢出。 */
+.j13-widget-body {
+ min-width: 0;
+}
+.j13-widget-body > .j13-widget-html {
+ min-width: 0;
+}
+
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index d2e52ae..ef615c4 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -6,9 +6,13 @@ import {
fetchPosts,
fetchOverview,
fetchBoardSidebar,
+ fetchSidebar,
type OverviewResponse,
type BoardSidebarResponse,
+ type SidebarPublicPayload,
} from "@/lib/api";
+import { defaultSidebarConfig, sidebarScopeLists } from "@/lib/sidebarMeta";
+import SidebarRenderer, { type SidebarBlockProps } from "@/components/sidebar/SidebarRenderer";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { siteDocumentTitle } from "@/lib/brand";
@@ -17,14 +21,9 @@ import Pagination from "@/components/Pagination";
import PostRow from "@/components/PostRow";
import FeedTabs from "@/components/home/FeedTabs";
import FeedNewTip from "@/components/home/FeedNewTip";
-import { WorkspaceNav, WorkspaceNavMobile } from "@/components/home/WorkspaceNav";
+import { WorkspaceNavMobile } from "@/components/home/WorkspaceNav";
import { SiteDocBreadcrumb } from "@/components/site-doc";
-import { CommunityPanels, CommunityPanelsMobile } from "@/components/home/CommunityPanels";
-import {
- BoardPanels,
- BoardPanelsMobile,
- BoardMissingPanel,
-} from "@/components/home/BoardPanels";
+import { BoardMissingPanel } from "@/components/home/BoardPanels";
import { requestCanonical } from "@/lib/canonicalMeta";
import { defaultFeedSort, homeCanonicalQuery, normalizeFeedSort } from "@/lib/siteUrl";
import { listingJsonLd } from "@/lib/jsonLd";
@@ -107,13 +106,19 @@ export default async function HomePage({ searchParams }: HomePageProps) {
boardId > 0 && !isSearch
? fetchBoardSidebar(boardId, cookie).catch(() => null)
: Promise.resolve(null);
+ // 侧栏布局配置失败不阻塞页面,回落默认布局
+ const sidebarPromise: Promise = isSearch
+ ? Promise.resolve(null)
+ : fetchSidebar(cookie).catch(() => null);
- const [me, postsData, overview, boardSidebar] = await Promise.all([
+ const [me, postsData, overview, boardSidebar, sidebarData] = await Promise.all([
mePromise,
postsPromise,
overviewPromise,
boardSidebarPromise,
+ sidebarPromise,
]);
+ const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] };
const user = me.user;
const boards = overview?.boards ?? [];
const canManageAnnouncements = !!user && isAdminOrAbove(user.role);
@@ -124,6 +129,48 @@ export default async function HomePage({ searchParams }: HomePageProps) {
// boardId 有效但接口失败 → 显示简卡;成功则板级右栏;否则全站右栏
const boardPanelsMissing = showBoardPanels && !boardSidebar;
+ // 侧栏布局:板块态右栏走 board 配置(左栏跟随首页),其余走 home 配置
+ const scopeLists = sidebarScopeLists(sidebar.config, showBoardPanels ? "board" : "home");
+ const sidebarProps: SidebarBlockProps = {
+ allowComments,
+ user,
+ boards,
+ checkin: (boardSidebar ?? overview)?.checkin ?? undefined,
+ ...(overview
+ ? {
+ stats: overview.stats,
+ announcements: overview.announcements,
+ canManageAnnouncements,
+ ads: overview.ads ?? [],
+ adsPanelTitle: overview.ads_panel_title || "自助推广",
+ adsEnabled: overview.ads_enabled !== false,
+ sponsors: overview.sponsors ?? [],
+ sponsorsPanelTitle: overview.sponsors_panel_title || "赞助商",
+ sponsorsEnabled: overview.sponsors_enabled !== false,
+ hot: overview.hot,
+ newUsers: overview.new_users,
+ activeUsers: overview.active_users,
+ }
+ : {}),
+ canManagePages,
+ sidebarPages,
+ siteDescription: publicSettings.site_description,
+ ...(boardSidebar
+ ? {
+ board: boardSidebar.board,
+ moderators: boardSidebar.moderators,
+ boardStats: boardSidebar.stats,
+ boardHot: boardSidebar.hot,
+ boardActiveUsers: boardSidebar.active_users,
+ }
+ : {}),
+ };
+ // 窄屏堆叠:右栏列表 + 左栏列表(板块目录已在顶部有横向板块条,跳过)
+ const mobileBlocks = [
+ ...scopeLists.right,
+ ...scopeLists.left.filter((r) => r.key !== "board_nav"),
+ ];
+
const pageName = isSearch
? `搜索“${keyword}”`
: activeBoard
@@ -189,7 +236,12 @@ export default async function HomePage({ searchParams }: HomePageProps) {
-
+ {/* 左栏:按配置渲染(缺省 = 板块目录) */}
+ {scopeLists.left.length > 0 && (
+
+ )}
{/* 中栏:tab 栏与帖子流为同一个整体卡片(左栏切板块只替换这一列) */}
@@ -247,85 +299,44 @@ export default async function HomePage({ searchParams }: HomePageProps) {
/>
- {/* 右栏:选中板块时切板级面板,否则全站社区面板 */}
+ {/* 右栏:选中板块时切板级面板,否则全站社区面板;均按配置渲染 */}
{showBoardPanels ? (
boardSidebar ? (
-
+ scopeLists.right.length > 0 ? (
+
+ ) : null
) : boardPanelsMissing ? (
) : null
- ) : (
- overview && (
-
0 ? (
+
+ ) : null}
- {/* 窄屏:面板下沉到帖子流之后 */}
- {showBoardPanels ? (
- boardSidebar ? (
-
-
-
- ) : null
- ) : (
- overview && (
-
-
-
- )
+ {/* 窄屏:面板下沉到帖子流之后(右栏列表 + 左栏列表,板块目录除外) */}
+ {mobileBlocks.length > 0 && (
+
+
+
)}
);
diff --git a/frontend/app/post/[id]/page.tsx b/frontend/app/post/[id]/page.tsx
index c0f222c..aeacfff 100644
--- a/frontend/app/post/[id]/page.tsx
+++ b/frontend/app/post/[id]/page.tsx
@@ -7,10 +7,6 @@ import {
Heart,
MessageCircle,
Flame,
- Layers,
- User,
- PenLine,
- ArrowRight,
ChevronRight,
} from "lucide-react";
import {
@@ -18,19 +14,20 @@ import {
fetchComments,
fetchPosts,
fetchUserProfile,
+ fetchSidebar,
postDeleteTypeLabel,
type UserProfile,
type PostListItem,
- type Board,
type CommentsResponse,
+ type SidebarPublicPayload,
} from "@/lib/api";
-import BoardIcon, { boardColor } from "@/components/BoardIcon";
+import { defaultSidebarConfig, sidebarScopeLists } from "@/lib/sidebarMeta";
+import SidebarRenderer, { type SidebarBlockProps } from "@/components/sidebar/SidebarRenderer";
import { postViewerCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import CommentSection from "@/components/CommentSection";
import PostActions from "@/components/PostActions";
import LikeButton from "@/components/LikeButton";
-import PostToc from "@/components/PostToc";
import { SiteDocBreadcrumb } from "@/components/site-doc";
import MobilePostBar from "@/components/MobilePostBar";
import Avatar from "@/components/Avatar";
@@ -88,13 +85,6 @@ export async function generateMetadata({ params, searchParams }: PageProps): Pro
}
}
-function formatYMD(dateStr: string) {
- const d = new Date(dateStr);
- return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(
- d.getDate()
- ).padStart(2, "0")}`;
-}
-
function formatDateTime(dateStr: string) {
return new Date(dateStr).toLocaleString("zh-CN", {
year: "numeric",
@@ -105,162 +95,6 @@ function formatDateTime(dateStr: string) {
});
}
-/* ============================================================
- 左栏模块(仅 xl 三栏时展示)
- ============================================================ */
-
-function BoardCard({ board }: { board: Board }) {
- const colorIdx = boardColor(board);
- return (
-
-
- 所属板块
-
-
-
-
-
-
- {board.name}
-
-
- {board.description && (
-
- {board.description}
-
- )}
-
- 查看该板讨论
-
-
- );
-}
-
-function AuthorRailCard({
- profile,
- allowComments,
-}: {
- profile: UserProfile;
- allowComments: boolean;
-}) {
- const u = profile.user;
- const signature = (u.signature || "").trim();
- const { post_count, comment_count, points, streak } = profile.stats;
- const stats = [
- { label: "发帖", value: post_count },
- ...(allowComments ? [{ label: "评论", value: comment_count }] : []),
- { label: "积分", value: points },
- ];
- return (
-
-
- 作者
-
-
-
-
-
-
-
- {u.nickname}
-
-
@{u.username}
- {u.role !== "user" && (
-
-
-
- )}
-
-
- {/* 有签名则展示;无则留空,不放占位文案 */}
- {signature ? (
-
- {signature}
-
- ) : null}
-
- {stats.map((s, i) => (
-
0 ? { borderLeft: "1px solid var(--line)" } : undefined}
- >
-
- {s.value}
-
-
- {s.label}
-
-
- ))}
-
- {streak > 0 && (
-
- 连签 {streak} 天
-
- )}
-
- 访问主页
-
-
- );
-}
-
-function AuthorPosts({ posts }: { posts: PostListItem[] }) {
- if (posts.length === 0) return null;
- return (
-
-
- TA 的其他帖子
-
-
- {posts.map((p) => (
- -
-
-
- {p.title}
-
- {formatYMD(p.created_at)}
-
-
- ))}
-
-
- );
-}
-
/* ============================================================
右栏模块(lg 起展示;移动端下沉到评论之后)
============================================================ */
@@ -466,11 +300,14 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
};
const meFirst = await getMeCached(cookie || undefined);
const canFetchComments = allowComments && (!commentsRequireLogin || !!meFirst.user);
- const [commentsData, profile, relatedData] = await Promise.all([
+ const [commentsData, profile, relatedData, sidebarData] = await Promise.all([
canFetchComments ? fetchComments(id, commentPage, cookie) : Promise.resolve(emptyComments),
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
fetchPosts(1, 8, post.board_id, "recommended", "", false, cookie).catch(() => null),
+ // 侧栏布局配置失败不阻塞页面,回落默认布局
+ fetchSidebar(cookie).catch(() => null),
]);
+ const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] };
const me = meFirst;
const comments = commentsData.comments;
const related = (relatedData?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
@@ -483,6 +320,26 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
const edited =
new Date(post.updated_at).getTime() - new Date(post.created_at).getTime() > 60_000;
+ // 侧栏布局:post 范围左右两列(toc 需在下方已就绪)
+ const sidebarLists = sidebarScopeLists(sidebar.config, "post");
+ const hasLeftRail = sidebarLists.left.length > 0;
+ const hasRightRail = sidebarLists.right.length > 0;
+ const sidebarProps: SidebarBlockProps = {
+ allowComments,
+ postBoard: post.board,
+ authorProfile: profile ?? undefined,
+ authorPosts,
+ toc,
+ };
+ // 三档网格退化:双栏 / 仅左 / 仅右 / 单栏
+ const railGridClass = !hasLeftRail && !hasRightRail
+ ? ""
+ : hasLeftRail && hasRightRail
+ ? "lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px]"
+ : hasLeftRail
+ ? "lg:grid-cols-[224px_minmax(0,1fr)] xl:grid-cols-[240px_minmax(0,1fr)]"
+ : "lg:grid-cols-[minmax(0,1fr)_280px] xl:grid-cols-[minmax(0,1fr)_300px]";
+
// 非公开帖(待审/未通过)仅作者本人与管理团队可看,顶部给出醒目状态
const isPublished = post.status === "published";
const postType = normalizePostType(post.post_type);
@@ -602,19 +459,22 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
allowComments={allowComments}
/>
- {/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏 */}
-
- {/* 左栏:板块 / 作者 / 作者其他帖 */}
+ {/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏
+ 侧栏两列均可被站长清空,网格随之退化为两栏/单栏 */}
+
+ {/* 左栏:按配置渲染(缺省 = 板块 / 作者 / 作者其他帖) */}
{/* sticky 必须直接挂在 grid 子项上:aside 作为 grid item,其可粘行程
等于行高(正文高度);内层包裹会被 items-start 缩成内容高而失去行程 */}
-
+ {hasLeftRail && (
+
+ )}
{/* 中栏:正文主体 */}
@@ -845,14 +705,16 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
- {/* 右栏:目录 / 同板块热议(阅读数据已并入标题下元信息条) */}
-
+ {/* 右栏:按配置渲染(缺省 = 文章目录)+ 固定的同板块热议 */}
+ {hasRightRail && (
+
+ )}
{/* 给移动端浮动操作条让位,避免遮住页尾内容 */}
diff --git a/frontend/app/u/[id]/page.tsx b/frontend/app/u/[id]/page.tsx
index bb16383..2fe45a9 100644
--- a/frontend/app/u/[id]/page.tsx
+++ b/frontend/app/u/[id]/page.tsx
@@ -2,7 +2,7 @@ import Link from "next/link";
import { cookies, headers } from "next/headers";
import { Metadata } from "next";
import {
- ChevronRight, FileText, MessageCircle, Flame, Calendar, Hash, Shield,
+ FileText, MessageCircle, Flame, Calendar, Hash, Shield,
Settings, KeyRound, Info, PenLine,
} from "lucide-react";
import {
@@ -178,12 +178,6 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
return (
-
-
{/* ═══ Hero:头像 + 昵称 + 简介 + 行内统计,约 120px 高 ═══ */}
diff --git a/frontend/components/PinnedPosts.tsx b/frontend/components/PinnedPosts.tsx
index f0dd142..5e833e9 100644
--- a/frontend/components/PinnedPosts.tsx
+++ b/frontend/components/PinnedPosts.tsx
@@ -50,7 +50,7 @@ export default function PinnedPosts({
animationDelay: `${i * 35}ms`,
}}
>
-
+
-
+
diff --git a/frontend/components/TimelineEditor.tsx b/frontend/components/TimelineEditor.tsx
index e87b5d9..bac3f35 100644
--- a/frontend/components/TimelineEditor.tsx
+++ b/frontend/components/TimelineEditor.tsx
@@ -165,6 +165,16 @@ export default function TimelineEditor({
onClose();
};
+ const keptCount = items.filter((it) => it.selected !== false).length;
+ const urlCount = urlsText
+ .split(/\n/)
+ .map((l) => l.trim())
+ .filter(Boolean).length;
+ const hasExistingContent = items.some(
+ (it) => it.title.trim() || it.body.trim()
+ );
+ const showReplaceWarning = importMode === "replace" && hasExistingContent;
+
return (
<>
-
-
- >
+
+
0 ? "var(--ink-3)" : "var(--danger)" }}
+ >
+ {keptCount > 0
+ ? `将${initialBody.trim() ? "更新" : "插入"} ${keptCount} 条`
+ : "没有可保留的条目"}
+
+
+
+
+
+
}
>
-
+
{/* 导入区 */}
@@ -195,16 +222,22 @@ export default function TimelineEditor({
- 粘贴 Gitea / GitHub 的 commits 列表页(一行一个)。默认自动拉取后续页;也可只贴某几页。
+ 粘贴 Gitea / GitHub 的 commits 列表页 URL,每行一条。默认自动拉取后续页,也可只贴某几页。
{/* 条目列表 */}
-
- 时间线条目
-
+
+
+ 时间线条目
+
+
{items.length} 项
+
-
- {items.map((it, idx) => (
- -
-
-
-
-
+ {items.length === 0 ? (
+
+
还没有时间线条目
+
+
+ ) : (
+
+ {items.map((it, idx) => (
+ -
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- ))}
-
+
+ ))}
+
+ )}
diff --git a/frontend/components/VisitBeacon.tsx b/frontend/components/VisitBeacon.tsx
index 571366e..9664ed6 100644
--- a/frontend/components/VisitBeacon.tsx
+++ b/frontend/components/VisitBeacon.tsx
@@ -16,7 +16,10 @@ export default function VisitBeacon() {
const now = Date.now();
if (lastRef.current.path === pathname && now - lastRef.current.at < 30_000) return;
lastRef.current = { path: pathname, at: now };
- void apiTelemetryPageView();
+ // 带 query 完整路径 + 来源页,供后台来源分析
+ const full = pathname + (typeof window !== "undefined" ? window.location.search : "");
+ const ref = typeof document !== "undefined" ? document.referrer : "";
+ void apiTelemetryPageView(full, ref);
}, [pathname]);
return null;
diff --git a/frontend/components/admin/AdminShell.tsx b/frontend/components/admin/AdminShell.tsx
index 841b5d7..f7e7b6c 100644
--- a/frontend/components/admin/AdminShell.tsx
+++ b/frontend/components/admin/AdminShell.tsx
@@ -24,6 +24,8 @@ import {
FileText,
CircleDollarSign,
Columns3,
+ PanelLeft,
+ BarChart3,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
@@ -64,6 +66,7 @@ const NAV_GROUPS: NavGroup[] = [
title: "概览",
items: [
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
+ { href: "/admin/analytics", label: "访问统计", icon: BarChart3, visible: (u) => isSuperOrOwner(u.role) },
],
},
{
@@ -145,6 +148,12 @@ const NAV_GROUPS: NavGroup[] = [
icon: Palette,
visible: (u) => isSuperOrOwner(u.role),
},
+ {
+ href: "/admin/sidebar",
+ label: "侧栏管理",
+ icon: PanelLeft,
+ visible: (u) => isSuperOrOwner(u.role),
+ },
],
},
];
diff --git a/frontend/components/home/BoardPanels.tsx b/frontend/components/home/BoardPanels.tsx
index a22de81..df17105 100644
--- a/frontend/components/home/BoardPanels.tsx
+++ b/frontend/components/home/BoardPanels.tsx
@@ -25,7 +25,7 @@ import { formatCompact } from "@/lib/format";
/* ---------- 1. 板块管理员 ---------- */
-function Moderators({ items }: { items: BoardModerator[] }) {
+export function Moderators({ items }: { items: BoardModerator[] }) {
return (
@@ -56,7 +56,7 @@ function Moderators({ items }: { items: BoardModerator[] }) {
/* ---------- 2. 板块概况 ---------- */
-function BoardIntro({ board }: { board: BoardWithCount }) {
+export function BoardIntro({ board }: { board: BoardWithCount }) {
return (
@@ -82,7 +82,7 @@ function BoardIntro({ board }: { board: BoardWithCount }) {
/* ---------- 3. 板块数据 + 签到 ---------- */
-function BoardPulse({
+export function BoardPulse({
stats,
checkin,
allowComments,
@@ -156,7 +156,7 @@ function BoardPulse({
/* ---------- 4. 本板热门 ---------- */
-function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
+export function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
return (
@@ -196,7 +196,7 @@ function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowCommen
/* ---------- 5. 本板活跃 ---------- */
-function BoardActive({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
+export function BoardActive({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
return (
diff --git a/frontend/components/home/CommunityPanels.tsx b/frontend/components/home/CommunityPanels.tsx
index 65dc2ac..237fda7 100644
--- a/frontend/components/home/CommunityPanels.tsx
+++ b/frontend/components/home/CommunityPanels.tsx
@@ -38,7 +38,7 @@ import { AD_IMAGE_HEIGHT, AD_IMAGE_SIZE_LABEL, AD_IMAGE_WIDTH } from "@/lib/ads"
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
-function PulseCard({
+export function PulseCard({
stats,
checkin,
allowComments,
@@ -130,7 +130,7 @@ function PulseCard({
/* ---------- 关于本站(侧栏页目录,置于右栏最底) ---------- */
-function AboutSite({
+export function AboutSite({
pages,
siteDescription,
canManagePages,
@@ -175,7 +175,7 @@ function AboutSite({
/* ---------- 站点公告 ---------- */
-function Announcements({
+export function Announcements({
items,
canManageAnnouncements,
}: {
@@ -248,7 +248,7 @@ function Announcements({
/* ---------- 热门榜 ---------- */
-function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
+export function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
return (
@@ -288,7 +288,7 @@ function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComm
/* ---------- 最新注册 ---------- */
-function NewUsers({ users }: { users: NewUser[] }) {
+export function NewUsers({ users }: { users: NewUser[] }) {
if (users.length === 0) return null;
return (
@@ -323,7 +323,7 @@ function NewUsers({ users }: { users: NewUser[] }) {
/* ---------- 活跃成员(本周发帖 / 回复) ---------- */
-function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
+export function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
return (
@@ -383,7 +383,7 @@ function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComme
/* ---------- 友情链接 ---------- */
-function FriendLinks() {
+export function FriendLinks() {
if (FRIEND_LINKS.length === 0) return null;
return (
@@ -441,7 +441,7 @@ type PanelsProps = {
sponsorsEnabled?: boolean;
};
-function AdsPanel({
+export function AdsPanel({
items,
title,
enabled,
@@ -529,7 +529,7 @@ function AdsPanel({
);
}
-function SponsorsPanel({
+export function SponsorsPanel({
items,
title,
enabled,
diff --git a/frontend/components/home/WorkspaceNav.tsx b/frontend/components/home/WorkspaceNav.tsx
index 1908d0a..0887517 100644
--- a/frontend/components/home/WorkspaceNav.tsx
+++ b/frontend/components/home/WorkspaceNav.tsx
@@ -12,81 +12,95 @@ function boardHref(id: number) {
return `/?board=${id}`;
}
-// 桌面左栏:整块模块,与右栏 j13-module 对齐
-export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?: User | null }) {
+// 板块目录裸面板:只有 j13-module 本体,不含外层 nav / 宽度 / sticky 类,
+// 供侧栏注册表按管理端配置在任意侧栏渲染。
+export function WorkspaceNavPanel({
+ boards,
+ user,
+}: {
+ boards: BoardWithCount[];
+ user?: User | null;
+}) {
const params = useSearchParams();
const activeBoard = Number(params.get("board")) || 0;
const showAdmin = !!user && isStaff(user.role);
+ return (
+
+
+
+ 板块目录
+ {boards.length > 0 ? (
+
+ {boards.length} 个
+
+ ) : null}
+
+
+
+
+
+
+
+
全部讨论
+
+
+ {boards.length > 0 ?
: null}
+
+ {boards.map((b) => (
+
+
+
+
+
+ {b.name}
+ {b.post_count} 篇
+
+
+ ))}
+
+
+ {showAdmin ? (
+
+
+
+
+
+ 管理面板
+
+
+ ) : null}
+
+ );
+}
+
+// 桌面左栏:整块模块,与右栏 j13-module 对齐
+export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?: User | null }) {
return (
// sticky 必须挂在 flex 子项上:父级 items-start 会把内层缩成内容高,失去粘滞行程
);
}
diff --git a/frontend/components/post/PostRailBlocks.tsx b/frontend/components/post/PostRailBlocks.tsx
new file mode 100644
index 0000000..506bb84
--- /dev/null
+++ b/frontend/components/post/PostRailBlocks.tsx
@@ -0,0 +1,170 @@
+import Link from "next/link";
+import { Layers, ArrowRight, User, Flame, PenLine } from "lucide-react";
+import type { UserProfile, PostListItem, Board } from "@/lib/api";
+import BoardIcon, { boardColor } from "@/components/BoardIcon";
+import Avatar from "@/components/Avatar";
+import RoleBadge from "@/components/RoleBadge";
+
+// formatYMD 帖子详情页左栏「作者其他帖」日期
+function formatYMD(dateStr: string) {
+ const d = new Date(dateStr);
+ return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}.${String(
+ d.getDate()
+ ).padStart(2, "0")}`;
+}
+
+/* ============================================================
+ 帖子详情页侧栏块(自 page.tsx 原样迁出,供侧栏注册表按配置渲染)
+ ============================================================ */
+
+export function BoardCard({ board }: { board: Board }) {
+ const colorIdx = boardColor(board);
+ return (
+
+
+ 所属板块
+
+
+
+
+
+
+ {board.name}
+
+
+ {board.description && (
+
+ {board.description}
+
+ )}
+
+ 查看该板讨论
+
+
+ );
+}
+
+export function AuthorRailCard({
+ profile,
+ allowComments,
+}: {
+ profile: UserProfile;
+ allowComments: boolean;
+}) {
+ const u = profile.user;
+ const signature = (u.signature || "").trim();
+ const { post_count, comment_count, points, streak } = profile.stats;
+ const stats = [
+ { label: "发帖", value: post_count },
+ ...(allowComments ? [{ label: "评论", value: comment_count }] : []),
+ { label: "积分", value: points },
+ ];
+ return (
+
+
+ 作者
+
+
+
+
+
+
+
+ {u.nickname}
+
+
@{u.username}
+ {u.role !== "user" && (
+
+
+
+ )}
+
+
+ {/* 有签名则展示;无则留空,不放占位文案 */}
+ {signature ? (
+
+ {signature}
+
+ ) : null}
+
+ {stats.map((s, i) => (
+
0 ? { borderLeft: "1px solid var(--line)" } : undefined}
+ >
+
+ {s.value}
+
+
+ {s.label}
+
+
+ ))}
+
+ {streak > 0 && (
+
+ 连签 {streak} 天
+
+ )}
+
+ 访问主页
+
+
+ );
+}
+
+export function AuthorPosts({ posts }: { posts: PostListItem[] }) {
+ if (posts.length === 0) return null;
+ return (
+
+
+ TA 的其他帖子
+
+
+ {posts.map((p) => (
+ -
+
+
+ {p.title}
+
+ {formatYMD(p.created_at)}
+
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/components/sidebar/CustomWidgetBlock.tsx b/frontend/components/sidebar/CustomWidgetBlock.tsx
new file mode 100644
index 0000000..6493801
--- /dev/null
+++ b/frontend/components/sidebar/CustomWidgetBlock.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import type { SidebarWidgetPublic, User } from "@/lib/api";
+import { scopeWidgetCss } from "@/lib/widgetCode";
+import { ensureWidgetApi } from "@/lib/widgetApi";
+
+// 站长自定义工具块:HTML/CSS 随 SSR 直出(dangerouslySetInnerHTML),
+// JS 在客户端挂载时注入执行、卸载时移除。站长可信内容,不做消毒/沙箱。
+// CSS 经 scopeWidgetCss 限定在 `.j13-widget-{id}` 容器内,不污染页面其他区域;
+// 工具内可直接用站点主题变量(var(--accent) 等)与 window.J13 接口。
+export default function CustomWidgetBlock({
+ widget,
+ viewer,
+}: {
+ widget: SidebarWidgetPublic;
+ viewer?: User | null;
+}) {
+ const hostRef = useRef(null);
+
+ // 先于站长 JS 建立站点接口(声明顺序保证本 effect 先执行)
+ useEffect(() => {
+ ensureWidgetApi(viewer);
+ }, [viewer]);
+
+ useEffect(() => {
+ const host = hostRef.current;
+ if (!host || !widget.js) return;
+ const script = document.createElement("script");
+ script.dataset.widgetScript = String(widget.id);
+ script.textContent = widget.js;
+ host.appendChild(script);
+ return () => {
+ script.remove();
+ };
+ }, [widget.id, widget.js]);
+
+ return (
+
+ {widget.css ? (
+
+ ) : null}
+ {widget.html ? (
+
+ ) : null}
+ {/* 自定义 JS 的宿主节点:脚本由客户端创建后挂到此处执行 */}
+
+
+ );
+}
diff --git a/frontend/components/sidebar/SidebarRenderer.tsx b/frontend/components/sidebar/SidebarRenderer.tsx
new file mode 100644
index 0000000..d2d6163
--- /dev/null
+++ b/frontend/components/sidebar/SidebarRenderer.tsx
@@ -0,0 +1,40 @@
+import { Fragment } from "react";
+import type { SidebarBlockRef, SidebarWidgetPublic } from "@/lib/sidebarMeta";
+import { renderSidebarBlock, type SidebarBlockProps } from "./registry";
+
+// 页面从本文件导入该类型(registry 中定义)
+export type { SidebarBlockProps };
+import CustomWidgetBlock from "./CustomWidgetBlock";
+
+// 按管理端配置渲染一列侧栏块:
+// - 内置块经注册表解析,未知 key / 数据缺失静默跳过;
+// - 自定义工具引用已删除或停用(公开载荷不含)时静默跳过;
+// - className 缺省时输出 Fragment,布局容器(gap/space-y、sticky、限高)由页面自行决定。
+export default function SidebarRenderer({
+ blocks,
+ widgets,
+ props,
+ className,
+}: {
+ blocks: SidebarBlockRef[];
+ widgets: SidebarWidgetPublic[];
+ props: SidebarBlockProps;
+ className?: string;
+}) {
+ const items = blocks
+ .map((ref, i) => {
+ if (ref.widget) {
+ const widget = widgets.find((x) => x.id === ref.widget);
+ if (!widget) return null;
+ return ;
+ }
+ if (!ref.key) return null;
+ const node = renderSidebarBlock(ref.key, props);
+ if (node === null) return null;
+ return {node};
+ })
+ .filter((x) => x !== null);
+
+ if (items.length === 0) return null;
+ return className ? {items}
: <>{items}>;
+}
diff --git a/frontend/components/sidebar/registry.tsx b/frontend/components/sidebar/registry.tsx
new file mode 100644
index 0000000..813393f
--- /dev/null
+++ b/frontend/components/sidebar/registry.tsx
@@ -0,0 +1,155 @@
+import type {
+ ActiveUser,
+ AnnouncementSummary,
+ Board,
+ BoardModerator,
+ BoardSidebarStats,
+ BoardWithCount,
+ CheckinStatus,
+ NewUser,
+ OverviewStats,
+ PostListItem,
+ PublicAdItem,
+ PublicSponsorItem,
+ SidebarPageItem,
+ User,
+ UserProfile,
+} from "@/lib/api";
+import PostToc, { type TocItem } from "@/components/PostToc";
+import { WorkspaceNavPanel } from "@/components/home/WorkspaceNav";
+import {
+ PulseCard,
+ Announcements,
+ AdsPanel,
+ SponsorsPanel,
+ HotRanking,
+ NewUsers,
+ ActiveUsers,
+ FriendLinks,
+ AboutSite,
+} from "@/components/home/CommunityPanels";
+import {
+ Moderators,
+ BoardIntro,
+ BoardPulse,
+ BoardHot,
+ BoardActive,
+} from "@/components/home/BoardPanels";
+import {
+ BoardCard as PostBoardCard,
+ AuthorRailCard as PostAuthorCard,
+ AuthorPosts as PostAuthorPosts,
+} from "@/components/post/PostRailBlocks";
+
+// 侧栏块 props 汇总:页面把已有数据整包传入,块按需取用;
+// 某块所需数据缺失(如板块态没传 stats)时对应块渲染为空,安全跳过。
+export interface SidebarBlockProps {
+ allowComments: boolean;
+ user?: User | null;
+ checkin?: CheckinStatus | null;
+
+ // home 范围
+ boards?: BoardWithCount[];
+ stats?: OverviewStats;
+ announcements?: AnnouncementSummary[];
+ canManageAnnouncements?: boolean;
+ ads?: PublicAdItem[];
+ adsPanelTitle?: string;
+ adsEnabled?: boolean;
+ sponsors?: PublicSponsorItem[];
+ sponsorsPanelTitle?: string;
+ sponsorsEnabled?: boolean;
+ hot?: PostListItem[];
+ newUsers?: NewUser[];
+ activeUsers?: ActiveUser[];
+ sidebarPages?: SidebarPageItem[];
+ siteDescription?: string;
+ canManagePages?: boolean;
+
+ // board 范围
+ board?: BoardWithCount;
+ moderators?: BoardModerator[];
+ boardStats?: BoardSidebarStats;
+ boardHot?: PostListItem[];
+ boardActiveUsers?: ActiveUser[];
+
+ // post 范围
+ postBoard?: Board;
+ authorProfile?: UserProfile;
+ authorPosts?: PostListItem[];
+ toc?: TocItem[];
+}
+
+// 内置块注册表:key → 组件。数据未就绪或未知 key 返回 null(渲染层静默跳过)。
+export function renderSidebarBlock(key: string, p: SidebarBlockProps) {
+ switch (key) {
+ case "board_nav":
+ return p.boards ? : null;
+ case "pulse":
+ return p.stats ? (
+
+ ) : null;
+ case "announcements":
+ return p.announcements ? (
+
+ ) : null;
+ case "ads":
+ return p.ads && p.adsEnabled !== false ? (
+
+ ) : null;
+ case "sponsors":
+ return p.sponsors && p.sponsorsEnabled !== false ? (
+
+ ) : null;
+ case "hot":
+ return p.hot ? : null;
+ case "new_users":
+ return p.newUsers ? : null;
+ case "active_users":
+ return p.activeUsers ? (
+
+ ) : null;
+ case "friend_links":
+ return ;
+ case "about_site":
+ return p.sidebarPages || p.canManagePages ? (
+
+ ) : null;
+ case "board_moderators":
+ return p.moderators ? : null;
+ case "board_intro":
+ return p.board ? : null;
+ case "board_pulse":
+ return p.boardStats ? (
+
+ ) : null;
+ case "board_hot":
+ return p.boardHot ? (
+
+ ) : null;
+ case "board_active":
+ return p.boardActiveUsers ? (
+
+ ) : null;
+ case "post_board":
+ return p.postBoard ? : null;
+ case "post_author":
+ return p.authorProfile ? (
+
+ ) : null;
+ case "post_author_posts":
+ return p.authorPosts ? : null;
+ case "post_toc":
+ return p.toc && p.toc.length > 0 ? : null;
+ default:
+ return null;
+ }
+}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index f6f538f..0cb4aae 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -1269,17 +1269,257 @@ export async function apiAdminAnalytics(range: "7d" | "30d" = "7d"): Promise {
+export async function apiTelemetryPageView(path: string, ref: string): Promise {
try {
await fetchWithRefresh("/api/telemetry/pageview", {
method: "POST",
- headers: clientHeaders(),
+ headers: clientHeaders({ "Content-Type": "application/json" }),
+ body: JSON.stringify({ path, ref }),
});
} catch {
/* 埋点失败不影响浏览 */
}
}
+// ===== 访问统计(管理端,仅超管/站长) =====
+
+export interface VisitDayPoint {
+ date: string;
+ pv: number;
+ uv: number;
+}
+
+export interface VisitNamedCount {
+ key: string;
+ label: string;
+ count: number;
+}
+
+export interface VisitPageRow {
+ path: string;
+ pv: number;
+ uv: number;
+ share: number;
+}
+
+export interface VisitOverview {
+ range_days: number;
+ trend: VisitDayPoint[];
+ today_pv: number;
+ today_uv: number;
+ yesterday_pv: number;
+ yesterday_uv: number;
+ logged_in_pv: number;
+ devices: VisitNamedCount[];
+ sources: VisitNamedCount[];
+ top_pages: VisitPageRow[];
+}
+
+export interface VisitMinutePoint {
+ minute: string;
+ pv: number;
+}
+
+export interface VisitEventItem {
+ id: number;
+ created_at: string;
+ kind: string;
+ path: string;
+ ref_host: string;
+ ref_kind: string;
+ user_id: number;
+ username: string;
+ ip: string;
+ device: string;
+ os: string;
+ browser: string;
+ bot: string;
+ status: number;
+}
+
+export interface VisitRealtime {
+ minutes: VisitMinutePoint[];
+ pv_5min: number;
+ pv_30min: number;
+ online: number;
+ top_pages: VisitNamedCount[];
+ recent: VisitEventItem[];
+}
+
+export interface VisitSourcesKindDay {
+ date: string;
+ items: VisitNamedCount[];
+}
+
+export interface VisitSources {
+ range_days: number;
+ kinds: VisitNamedCount[];
+ search_hosts: VisitNamedCount[];
+ external: VisitNamedCount[];
+ trend: VisitSourcesKindDay[];
+}
+
+export interface VisitPages {
+ range_days: number;
+ total: number;
+ items: VisitPageRow[];
+}
+
+export interface VisitVisitors {
+ range_days: number;
+ devices: VisitNamedCount[];
+ oses: VisitNamedCount[];
+ browsers: VisitNamedCount[];
+ logged_in_pv: number;
+ guest_pv: number;
+ new_visitors: number;
+ returning: number;
+ hourly: VisitNamedCount[];
+}
+
+export interface VisitSecurity {
+ range_days: number;
+ bots: VisitNamedCount[];
+ bot_trend: VisitDayPoint[];
+ probe_status: VisitNamedCount[];
+ probe_ips: VisitNamedCount[];
+ login_failed: number;
+ failed_users: number;
+}
+
+export interface VisitEventsQuery {
+ kind?: string;
+ bot?: string;
+ path?: string;
+ ip?: string;
+ start?: string;
+ end?: string;
+ page?: number;
+ size?: number;
+}
+
+export interface VisitEventsResult {
+ total: number;
+ items: VisitEventItem[];
+}
+
+export interface VisitUsage {
+ rows: number;
+ bytes: number;
+}
+
+export interface VisitSettingsPayload {
+ enabled: boolean;
+ retention_days: number;
+ usage: VisitUsage;
+ dropped: number;
+}
+
+// SSR 首拉:透传 cookie
+async function fetchVisitStats(path: string, cookieHeader?: string): Promise {
+ const res = await fetch(`${API_BASE}${path}`, ssrInit(cookieHeaders(cookieHeader)));
+ if (!res.ok) throw new Error("failed");
+ return res.json();
+}
+
+// 客户端拉取:带 CSRF + 自动续期
+async function apiVisitStats(path: string, init?: RequestInit): Promise {
+ const headers = init?.body
+ ? clientHeaders({ "Content-Type": "application/json" })
+ : clientHeaders();
+ const res = await fetchWithRefresh(path, {
+ method: init?.method ?? "GET",
+ ...init,
+ headers,
+ });
+ if (!res.ok) throw new Error("failed");
+ return res.json();
+}
+
+export function fetchStatsOverview(cookieHeader?: string, range = "7d") {
+ return fetchVisitStats(`/api/admin/stats/overview?range=${range}`, cookieHeader);
+}
+export function apiStatsOverview(range = "7d") {
+ return apiVisitStats(`/api/admin/stats/overview?range=${range}`);
+}
+export function fetchStatsRealtime(cookieHeader?: string) {
+ return fetchVisitStats("/api/admin/stats/realtime", cookieHeader);
+}
+export function apiStatsRealtime() {
+ return apiVisitStats("/api/admin/stats/realtime");
+}
+export function fetchStatsSources(cookieHeader?: string, range = "7d") {
+ return fetchVisitStats(`/api/admin/stats/sources?range=${range}`, cookieHeader);
+}
+export function apiStatsSources(range = "7d") {
+ return apiVisitStats(`/api/admin/stats/sources?range=${range}`);
+}
+export function fetchStatsPages(cookieHeader?: string, range = "7d", q = "", page = 1, size = 20) {
+ return fetchVisitStats(
+ `/api/admin/stats/pages?range=${range}&q=${encodeURIComponent(q)}&page=${page}&size=${size}`,
+ cookieHeader
+ );
+}
+export function apiStatsPages(range = "7d", q = "", page = 1, size = 20) {
+ return apiVisitStats(
+ `/api/admin/stats/pages?range=${range}&q=${encodeURIComponent(q)}&page=${page}&size=${size}`
+ );
+}
+export function fetchStatsVisitors(cookieHeader?: string, range = "7d") {
+ return fetchVisitStats(`/api/admin/stats/visitors?range=${range}`, cookieHeader);
+}
+export function apiStatsVisitors(range = "7d") {
+ return apiVisitStats(`/api/admin/stats/visitors?range=${range}`);
+}
+export function fetchStatsSecurity(cookieHeader?: string, range = "7d") {
+ return fetchVisitStats(`/api/admin/stats/security?range=${range}`, cookieHeader);
+}
+export function apiStatsSecurity(range = "7d") {
+ return apiVisitStats(`/api/admin/stats/security?range=${range}`);
+}
+export function fetchStatsEvents(cookieHeader?: string, query: VisitEventsQuery = {}) {
+ const p = new URLSearchParams();
+ if (query.kind) p.set("kind", query.kind);
+ if (query.bot) p.set("bot", query.bot);
+ if (query.path) p.set("path", query.path);
+ if (query.ip) p.set("ip", query.ip);
+ if (query.start) p.set("start", query.start);
+ if (query.end) p.set("end", query.end);
+ p.set("page", String(query.page ?? 1));
+ p.set("size", String(query.size ?? 50));
+ return fetchVisitStats(`/api/admin/stats/events?${p.toString()}`, cookieHeader);
+}
+export function apiStatsEvents(query: VisitEventsQuery = {}) {
+ const p = new URLSearchParams();
+ if (query.kind) p.set("kind", query.kind);
+ if (query.bot) p.set("bot", query.bot);
+ if (query.path) p.set("path", query.path);
+ if (query.ip) p.set("ip", query.ip);
+ if (query.start) p.set("start", query.start);
+ if (query.end) p.set("end", query.end);
+ p.set("page", String(query.page ?? 1));
+ p.set("size", String(query.size ?? 50));
+ return apiVisitStats(`/api/admin/stats/events?${p.toString()}`);
+}
+export function fetchStatsSettings(cookieHeader?: string) {
+ return fetchVisitStats("/api/admin/stats/settings", cookieHeader);
+}
+export function apiStatsSettings() {
+ return apiVisitStats("/api/admin/stats/settings");
+}
+export function apiStatsSaveSettings(enabled: boolean, retentionDays: number) {
+ return apiVisitStats("/api/admin/stats/settings", {
+ method: "PUT",
+ body: JSON.stringify({ enabled, retention_days: retentionDays }),
+ });
+}
+export function apiStatsPurge(days: number) {
+ return apiVisitStats<{ ok: boolean }>("/api/admin/stats/purge", {
+ method: "POST",
+ body: JSON.stringify({ days }),
+ });
+}
+
export type AdminChatRoomType = "group" | "direct";
export interface AdminChatParticipant {
@@ -3451,3 +3691,68 @@ export async function fetchAdminSponsorsConfig(cookie?: string): Promise {
+ const res = await fetch(`${API_BASE}/api/sidebar`, ssrInit(cookieHeaders(cookieHeader)));
+ if (!res.ok) throw new Error("获取侧栏配置失败");
+ return res.json();
+}
+
+/** SSR 管理端侧栏配置(config + 全量工具含停用);失败由调用方提示 */
+export async function fetchAdminSidebar(
+ cookie?: string
+): Promise<{ config: SidebarConfig; widgets: SidebarWidgetRecord[] }> {
+ const res = await fetch(`${API_BASE}/api/admin/sidebar`, {
+ ...ssrInit(cookie ? { Cookie: cookie } : undefined),
+ credentials: "include",
+ });
+ if (!res.ok) throw new Error("加载侧栏配置失败");
+ return res.json();
+}
+
+export async function apiAdminGetSidebar(): Promise<{
+ config: SidebarConfig;
+ widgets: SidebarWidgetRecord[];
+}> {
+ return apiOperations("/api/admin/sidebar");
+}
+
+export async function apiAdminSaveSidebarConfig(config: SidebarConfig): Promise<{ config: SidebarConfig }> {
+ return apiOperations("/api/admin/sidebar/config", "PUT", { config });
+}
+
+export async function apiAdminCreateSidebarWidget(
+ body: Pick
+): Promise<{ widget: SidebarWidgetRecord }> {
+ return apiOperations("/api/admin/sidebar/widgets", "POST", body);
+}
+
+export async function apiAdminUpdateSidebarWidget(
+ id: number,
+ body: Pick
+): Promise<{ ok: boolean }> {
+ return apiOperations(`/api/admin/sidebar/widgets/${id}`, "PUT", body);
+}
+
+export async function apiAdminDeleteSidebarWidget(id: number): Promise<{ ok: boolean }> {
+ return apiOperations(`/api/admin/sidebar/widgets/${id}`, "DELETE");
+}
diff --git a/frontend/lib/sidebarMeta.ts b/frontend/lib/sidebarMeta.ts
new file mode 100644
index 0000000..3e939af
--- /dev/null
+++ b/frontend/lib/sidebarMeta.ts
@@ -0,0 +1,179 @@
+// 侧栏管理:内置块元数据与默认布局(管理页与前台注册表共用,勿在此引入组件)
+
+export type SidebarScope = "home" | "board" | "post";
+
+export interface SidebarBlockRef {
+ key?: string;
+ widget?: number;
+}
+
+export interface SidebarScopeConfig {
+ left?: SidebarBlockRef[] | null;
+ right?: SidebarBlockRef[] | null;
+}
+
+export interface SidebarConfig {
+ version: number;
+ scopes: Partial>;
+}
+
+export interface SidebarWidgetPublic {
+ id: number;
+ name: string;
+ html: string;
+ css: string;
+ js: string;
+}
+
+export interface SidebarPublicPayload {
+ config: SidebarConfig;
+ widgets: SidebarWidgetPublic[];
+}
+
+export interface SidebarWidgetRecord {
+ id: number;
+ name: string;
+ html: string;
+ css: string;
+ js: string;
+ enabled: boolean;
+ created_at: string;
+ updated_at: string;
+}
+
+// 每侧最多放置块数(与后端 SidebarMaxBlocksPerSide 一致)
+export const SIDEBAR_MAX_BLOCKS_PER_SIDE = 24;
+
+export const SIDEBAR_SCOPE_LABELS: Record = {
+ home: "首页",
+ board: "板块页",
+ post: "帖子详情",
+};
+
+export const SIDEBAR_SIDE_LABELS: Record<"left" | "right", string> = {
+ left: "左栏",
+ right: "右栏",
+};
+
+// 各页面范围可用的内置块(key → 展示名),与后端 sidebarBuiltinKeys 保持一致
+export const SIDEBAR_WIDGET_META: Record> = {
+ home: {
+ board_nav: "板块目录",
+ pulse: "站点数据",
+ announcements: "站点公告",
+ ads: "自助推广",
+ sponsors: "赞助商",
+ hot: "热门讨论",
+ new_users: "最新注册",
+ active_users: "活跃成员",
+ friend_links: "友情链接",
+ about_site: "关于本站",
+ },
+ board: {
+ board_moderators: "板块管理员",
+ board_intro: "板块概况",
+ board_pulse: "板块数据",
+ board_hot: "本板热门",
+ board_active: "活跃成员",
+ },
+ post: {
+ post_board: "所属板块",
+ post_author: "作者名片",
+ post_author_posts: "作者其他帖",
+ post_toc: "文章目录",
+ },
+};
+
+// 内置块 icon(字符串引用,渲染方 import lucide-react 时映射)
+// 保持轻量:不在 sidebarMeta 里引入组件
+export const SIDEBAR_BLOCK_ICON: Record = {
+ board_nav: "layout-grid",
+ pulse: "bar-chart-3",
+ announcements: "megaphone",
+ ads: "circle-dollar-sign",
+ sponsors: "handshake",
+ hot: "flame",
+ new_users: "user-plus",
+ active_users: "users",
+ friend_links: "link2",
+ about_site: "info",
+ board_moderators: "shield-check",
+ board_intro: "file-text",
+ board_pulse: "bar-chart-3",
+ board_hot: "flame",
+ board_active: "users",
+ post_board: "layers",
+ post_author: "user",
+ post_author_posts: "pen-line",
+ post_toc: "list",
+};
+
+// 默认布局(= 历史硬编码渲染;后端 DefaultSidebarConfig 的前端镜像)
+export function defaultSidebarConfig(): SidebarConfig {
+ const refs = (keys: string[]): SidebarBlockRef[] => keys.map((key) => ({ key }));
+ return {
+ version: 1,
+ scopes: {
+ home: {
+ left: refs(["board_nav"]),
+ right: refs([
+ "pulse",
+ "announcements",
+ "ads",
+ "sponsors",
+ "hot",
+ "new_users",
+ "active_users",
+ "friend_links",
+ "about_site",
+ ]),
+ },
+ board: {
+ left: [],
+ right: refs(["board_moderators", "board_intro", "board_pulse", "board_hot", "board_active"]),
+ },
+ post: {
+ left: refs(["post_board", "post_author", "post_author_posts"]),
+ right: refs(["post_toc"]),
+ },
+ },
+ };
+}
+
+// 按页面范围解析左右两列的块列表:
+// - 未配置(nil)的侧回落默认布局,显式空数组 = 站长清空该侧;
+// - board 范围左栏跟随 home.left(板块态仅管理右栏)。
+export function sidebarScopeLists(
+ config: SidebarConfig,
+ scope: SidebarScope
+): { left: SidebarBlockRef[]; right: SidebarBlockRef[] } {
+ const def = defaultSidebarConfig().scopes;
+ if (scope === "board") {
+ return {
+ left: config.scopes.home?.left ?? def.home?.left ?? [],
+ right: config.scopes.board?.right ?? def.board?.right ?? [],
+ };
+ }
+ return {
+ left: config.scopes[scope]?.left ?? def[scope]?.left ?? [],
+ right: config.scopes[scope]?.right ?? def[scope]?.right ?? [],
+ };
+}
+
+// 块的展示名:内置取 META,自定义取备注名;未知返回 null(渲染时跳过)
+export function sidebarBlockLabel(
+ ref: SidebarBlockRef,
+ widgets: { id: number; name: string }[]
+): { label: string; custom: boolean } | null {
+ if (ref.widget) {
+ const w = widgets.find((x) => x.id === ref.widget);
+ return w ? { label: w.name, custom: true } : null;
+ }
+ if (ref.key) {
+ for (const scope of Object.keys(SIDEBAR_WIDGET_META) as SidebarScope[]) {
+ const label = SIDEBAR_WIDGET_META[scope][ref.key];
+ if (label) return { label, custom: false };
+ }
+ }
+ return null;
+}
diff --git a/frontend/lib/widgetApi.ts b/frontend/lib/widgetApi.ts
new file mode 100644
index 0000000..247eec1
--- /dev/null
+++ b/frontend/lib/widgetApi.ts
@@ -0,0 +1,91 @@
+// 自定义侧栏工具的站点开放接口:
+// - CSS:工具内直接使用站点主题变量(var(--accent) 等),随明暗主题与
+// 后台自定义主题色自动变化(CSS 变量沿 DOM 继承,无需任何 JS);
+// - JS:window.J13 提供当前用户、主题读取/监听、站点概览懒加载。
+// 本模块只在浏览器端(CustomWidgetBlock 的 effect 内)执行。
+import type { OverviewResponse, User } from "@/lib/api";
+
+export interface WidgetViewer {
+ id: number;
+ username: string;
+ nickname: string;
+ role: string;
+ avatar: string | null;
+}
+
+export interface WidgetApi {
+ version: 1;
+ /** 当前登录用户(未登录为 null);每次工具挂载时刷新 */
+ user: WidgetViewer | null;
+ theme: {
+ get: () => "light" | "dark";
+ /** 监听明暗切换,返回取消监听函数 */
+ onChange: (cb: (theme: "light" | "dark") => void) => () => void;
+ };
+ /** 站点公开概览统计(结果模块级缓存,多个工具共享一次请求) */
+ getOverview: () => Promise;
+}
+
+declare global {
+ interface Window {
+ J13?: WidgetApi;
+ }
+}
+
+function toViewer(user: User | null | undefined): WidgetViewer | null {
+ if (!user) return null;
+ return {
+ id: user.id,
+ username: user.username,
+ nickname: user.nickname,
+ role: user.role,
+ avatar: user.avatar || null,
+ };
+}
+
+// 概览请求全站只发一次(无论多少个工具、多少次挂载)
+let overviewPromise: Promise | null = null;
+
+function loadOverview(): Promise {
+ if (!overviewPromise) {
+ overviewPromise = fetch("/api/overview", { credentials: "include" })
+ .then((res) => (res.ok ? res.json() : null))
+ .catch(() => null);
+ }
+ return overviewPromise;
+}
+
+/**
+ * 幂等初始化 window.J13 并刷新当前用户信息。
+ * 必须在注入站长 JS 之前调用;多个工具共享同一个实例。
+ */
+export function ensureWidgetApi(viewer: User | null | undefined): WidgetApi {
+ const root = document.documentElement;
+ const readTheme = (): "light" | "dark" =>
+ root.classList.contains("dark") ? "dark" : "light";
+
+ if (!window.J13) {
+ const listeners = new Set<(theme: "light" | "dark") => void>();
+ // 主题切换 = html 元素 .dark 类的增删,观察其变化广播给工具
+ const observer = new MutationObserver(() => {
+ const theme = readTheme();
+ listeners.forEach((cb) => cb(theme));
+ });
+ observer.observe(root, { attributes: true, attributeFilter: ["class"] });
+
+ window.J13 = {
+ version: 1,
+ user: null,
+ theme: {
+ get: readTheme,
+ onChange(cb) {
+ listeners.add(cb);
+ return () => listeners.delete(cb);
+ },
+ },
+ getOverview: loadOverview,
+ };
+ }
+ window.J13.user = toViewer(viewer);
+ return window.J13;
+}
diff --git a/frontend/lib/widgetCode.test.ts b/frontend/lib/widgetCode.test.ts
new file mode 100644
index 0000000..a744693
--- /dev/null
+++ b/frontend/lib/widgetCode.test.ts
@@ -0,0 +1,102 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { mergeWidgetCode, parseMixedWidgetCode, scopeWidgetCss } from "./widgetCode.ts";
+
+describe("parseMixedWidgetCode", () => {
+ it("拆分混写的 style/script 段", () => {
+ const r = parseMixedWidgetCode(
+ `hi
\n\n`
+ );
+ assert.equal(r.html, `hi
`);
+ assert.equal(r.css, `.a { color: red; }`);
+ assert.equal(r.js, `console.log(1);`);
+ });
+
+ it("多段 style/script 按序拼接", () => {
+ const r = parseMixedWidgetCode(`x
`);
+ assert.equal(r.css, ".a{}\n\n.b{}");
+ assert.equal(r.js, "a();\n\nb();");
+ assert.equal(r.html, "x
");
+ });
+
+ it("纯 HTML 不误伤", () => {
+ const r = parseMixedWidgetCode(`hello
`);
+ assert.deepEqual(r, { html: "hello
", css: "", js: "" });
+ });
+
+ it("外链与非 JS type 的 script 留在 html", () => {
+ const code = ``;
+ const r = parseMixedWidgetCode(code);
+ assert.equal(r.js, "z();");
+ assert.equal(
+ r.html,
+ ``
+ );
+ });
+});
+
+describe("mergeWidgetCode", () => {
+ it("合并后再解析可还原三字段", () => {
+ const parts = { html: "go", css: "b { font-size: 18px; }", js: "x();" };
+ assert.deepEqual(parseMixedWidgetCode(mergeWidgetCode(parts)), parts);
+ });
+
+ it("空段不产生标签", () => {
+ assert.equal(mergeWidgetCode({ html: "hi", css: "", js: "" }), "hi");
+ });
+});
+
+describe("scopeWidgetCss", () => {
+ const p = ".j13-widget-7";
+
+ it("普通规则加前缀", () => {
+ assert.equal(scopeWidgetCss(".a { color: red; }", 7), `${p} .a { color: red; }`);
+ });
+
+ it("选择器列表逐个前缀,括号内逗号不分割", () => {
+ assert.equal(
+ scopeWidgetCss(".a, .b {}", 7),
+ `${p} .a, ${p} .b {}`
+ );
+ assert.equal(
+ scopeWidgetCss(':is(.a, .b) { color: red }', 7),
+ `${p} :is(.a, .b) { color: red }`
+ );
+ });
+
+ it("root/body 选择器替换为容器本身", () => {
+ assert.equal(scopeWidgetCss(":root { --x: 1; }", 7), `${p} { --x: 1; }`);
+ assert.equal(scopeWidgetCss("body { margin: 0 }", 7), `${p} { margin: 0 }`);
+ assert.equal(scopeWidgetCss("body .card {}", 7), `${p} .card {}`);
+ });
+
+ it("容器自身伪类紧贴前缀", () => {
+ assert.equal(scopeWidgetCss(":hover { opacity: .8 }", 7), `${p}:hover { opacity: .8 }`);
+ });
+
+ it("@media 内层前缀化,头部原样", () => {
+ assert.equal(
+ scopeWidgetCss("@media (min-width: 10px) { .a {} }", 7),
+ `@media (min-width: 10px) { ${p} .a {} }`
+ );
+ });
+
+ it("@keyframes 原样保留", () => {
+ const css = "@keyframes spin { from { opacity: 0 } to { opacity: 1 } }";
+ assert.equal(scopeWidgetCss(css, 7), css);
+ });
+
+ it("@import 语句原样保留", () => {
+ assert.equal(
+ scopeWidgetCss('@import url("a.css"); .a {}', 7),
+ '@import url("a.css"); .j13-widget-7 .a {}'
+ );
+ });
+
+ it("注释与字符串内的花括号不干扰配对", () => {
+ assert.equal(
+ scopeWidgetCss("/* { nope } */ .a::after { content: \"{\" }", 7),
+ `/* { nope } */ ${p} .a::after { content: "{" }`
+ );
+ });
+});
diff --git a/frontend/lib/widgetCode.ts b/frontend/lib/widgetCode.ts
new file mode 100644
index 0000000..a991afd
--- /dev/null
+++ b/frontend/lib/widgetCode.ts
@@ -0,0 +1,212 @@
+// 自定义侧栏工具代码处理:
+// - parseMixedWidgetCode / mergeWidgetCode:「混写一框」(内联 `);
+ if (parts.js.trim()) out.push(``);
+ return out.join("\n\n");
+}
+
+/* ─────────────────────────── CSS 作用域 ─────────────────────────── */
+
+// 按 , 分割选择器列表,忽略 () / [] 与引号内的逗号(:is(a,b)、[attr="a,b"])
+function splitSelectorList(sel: string): string[] {
+ const out: string[] = [];
+ let depth = 0;
+ let quote = "";
+ let start = 0;
+ for (let i = 0; i < sel.length; i++) {
+ const ch = sel[i];
+ if (quote) {
+ if (ch === "\\") i++;
+ else if (ch === quote) quote = "";
+ continue;
+ }
+ if (ch === '"' || ch === "'") {
+ quote = ch;
+ continue;
+ }
+ if (ch === "(" || ch === "[") depth++;
+ else if (ch === ")" || ch === "]") depth = Math.max(0, depth - 1);
+ else if (ch === "," && depth === 0) {
+ out.push(sel.slice(start, i));
+ start = i + 1;
+ }
+ }
+ out.push(sel.slice(start));
+ return out;
+}
+
+// 单个选择器加前缀:
+// - :root / :scope / html / body 开头 → 视作容器本身(变量定义在容器上,板块内可用);
+// - 以伪类/伪元素(:)开头 → 紧贴容器(容器自身状态,如 .j13-widget-1:hover);
+// - 其余 → 后代选择器。
+function scopeOneSelector(sel: string, prefix: string): string | null {
+ const s = sel.trim();
+ if (!s) return null;
+ const rootLike = s.match(/^(?::root\b|:scope\b|html\b|body\b)([\s\S]*)$/);
+ if (rootLike) {
+ const rest = rootLike[1].replace(/^\s+/, "");
+ return rest ? `${prefix} ${rest}` : prefix;
+ }
+ if (s.startsWith(":")) {
+ // 功能型伪类开头(:is( / :where( / :has( / :not( )→ 按容器后代处理;
+ // 简单伪类/伪元素(:hover、::before…)→ 紧贴容器(容器自身状态)。
+ return /^::?[a-zA-Z-]+\(/.test(s) ? `${prefix} ${s}` : prefix + s;
+ }
+ return `${prefix} ${s}`;
+}
+
+/**
+ * 把 CSS 限定到容器内:
+ * - 普通规则:选择器逐个加 `.j13-widget-{id}` 前缀;
+ * - @media/@supports/@container/@layer 块:头部原样,内部规则递归前缀化;
+ * - @keyframes/@font-face/@property 等:整块原样(内部是帧/描述符,不是选择器);
+ * - 注释、引号内容(如 content:"{")不影响括号配对。
+ * 已知取舍:同名 @keyframes 在多个工具间仍是全局的,命名请自带区分。
+ */
+export function scopeWidgetCss(css: string, id: number): string {
+ const prefix = `.j13-widget-${id}`;
+ const n = css.length;
+ let out = "";
+ let i = 0;
+
+ // 从 from 起读一个平衡的 {...} 块(引号与转义内的括号不计入深度)
+ const readBlock = (from: number): { body: string; end: number } => {
+ let depth = 0;
+ let quote = "";
+ for (let j = from; j < n; j++) {
+ const ch = css[j];
+ if (quote) {
+ if (ch === "\\") j++;
+ else if (ch === quote) quote = "";
+ continue;
+ }
+ if (ch === '"' || ch === "'") quote = ch;
+ else if (ch === "{") depth++;
+ else if (ch === "}") {
+ depth--;
+ if (depth === 0) return { body: css.slice(from, j + 1), end: j + 1 };
+ }
+ }
+ return { body: css.slice(from), end: n };
+ };
+
+ while (i < n) {
+ if (css.startsWith("/*", i)) {
+ const close = css.indexOf("*/", i + 2);
+ const stop = close === -1 ? n : close + 2;
+ out += css.slice(i, stop);
+ i = stop;
+ continue;
+ }
+ if (css[i] === "}") {
+ // 多余闭括号容错,原样透传
+ out += css[i];
+ i++;
+ continue;
+ }
+ // 读取选择器/语句头直到 { ; } 或结尾(引号内不截断)
+ let j = i;
+ let quote = "";
+ for (; j < n; j++) {
+ const c = css[j];
+ if (quote) {
+ if (c === "\\") j++;
+ else if (c === quote) quote = "";
+ continue;
+ }
+ if (c === '"' || c === "'") {
+ quote = c;
+ continue;
+ }
+ if (c === "{" || c === ";" || c === "}") break;
+ }
+ const head = css.slice(i, j);
+ if (j >= n) {
+ out += head;
+ break;
+ }
+ if (css[j] === ";") {
+ // @import / @charset / @layer a, b; 等语句原样保留
+ out += head + ";";
+ i = j + 1;
+ continue;
+ }
+ if (css[j] === "}") {
+ out += head;
+ i = j + 1;
+ continue;
+ }
+ const { body, end } = readBlock(j);
+ const trimmedHead = head.trim();
+ if (trimmedHead.startsWith("@")) {
+ const at = trimmedHead.split(/[\s(]/)[0].toLowerCase();
+ if (at === "@media" || at === "@supports" || at === "@container" || at === "@layer") {
+ const inner = body.slice(1, -1);
+ out += head + "{" + scopeWidgetCss(inner, id) + "}";
+ } else {
+ out += head + body;
+ }
+ } else {
+ const scoped = splitSelectorList(head)
+ .map((s) => scopeOneSelector(s, prefix))
+ .filter((s): s is string => s !== null);
+ if (scoped.length) {
+ // 保留选择器前后的原始空白,尽量不改变站长代码的排版
+ const lead = head.match(/^\s*/)?.[0] ?? "";
+ const trail = head.match(/\s*$/)?.[0] ?? "";
+ out += lead + scoped.join(", ") + trail + body;
+ } else {
+ out += head + body;
+ }
+ }
+ i = end;
+ }
+ return out;
+}
diff --git a/frontend/package.json b/frontend/package.json
index ee938bd..62d98f9 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -7,7 +7,7 @@
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint",
- "test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts"
+ "test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/widgetCode.test.ts"
},
"dependencies": {
"hast-util-to-jsx-runtime": "^2.3.6",