feat: 站点访问统计与可配置侧边栏组件
- 新增访问统计:visitstats 服务与查询、visit_capture 中间件、爬虫/来源识别、管理端 analytics 页面 - 新增侧边栏:sidebar 服务与 handler、管理端 sidebar 配置页、前端侧边栏组件 - 新增 widget 嵌入代码生成(widgetCode)与 widgetApi - 遥测逻辑迁移至 handler 层,删除 service/telemetry - env 示例补充 Docker 部署可信代理 IP 说明
This commit is contained in:
@@ -19,7 +19,9 @@ SITE_URL=http://localhost:3000
|
|||||||
SETTINGS_MASTER_KEY=
|
SETTINGS_MASTER_KEY=
|
||||||
# 逗号分隔的精确内部服务主机名;默认拒绝回环、私网和保留网段
|
# 逗号分隔的精确内部服务主机名;默认拒绝回环、私网和保留网段
|
||||||
SERVICE_PRIVATE_HOSTS=
|
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=
|
TRUSTED_PROXIES=
|
||||||
# 紧急恢复:1 强制正常运行,修复后台配置后撤销并重启 API
|
# 紧急恢复:1 强制正常运行,修复后台配置后撤销并重启 API
|
||||||
MAINTENANCE_RECOVERY=0
|
MAINTENANCE_RECOVERY=0
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ type Handlers struct {
|
|||||||
LegacyImport *service.LegacyImportService
|
LegacyImport *service.LegacyImportService
|
||||||
Moderation *service.ModerationService
|
Moderation *service.ModerationService
|
||||||
Chat *service.ChatService
|
Chat *service.ChatService
|
||||||
Telemetry *service.TelemetryService
|
Visit *service.VisitStatsService
|
||||||
Analytics *service.AnalyticsService
|
Analytics *service.AnalyticsService
|
||||||
HidePwd *service.HidePasswordCookie
|
HidePwd *service.HidePasswordCookie
|
||||||
Ads *service.AdService
|
Ads *service.AdService
|
||||||
|
Sidebar *service.SidebarService
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePublishStatus 决定新帖/新评的初始状态:
|
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||||
|
|||||||
123
backend/handler/sidebar.go
Normal file
123
backend/handler/sidebar.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
@@ -12,8 +13,6 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
const visitorCookie = "j13_vid"
|
|
||||||
|
|
||||||
func newVisitorID() string {
|
func newVisitorID() string {
|
||||||
var b [16]byte
|
var b [16]byte
|
||||||
_, _ = rand.Read(b[:])
|
_, _ = 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) {
|
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) {
|
if !service.ValidVisitorID(vid) {
|
||||||
vid = newVisitorID()
|
vid = newVisitorID()
|
||||||
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
|
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
|
||||||
// HttpOnly:仅服务端读 UV,前端埋点不依赖可读 cookie
|
// 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
|
ua := c.Request.UserAgent()
|
||||||
if err := h.Telemetry.RecordPageView(vid, loggedIn); err != nil {
|
ev := model.VisitEvent{Path: path}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "记录失败"})
|
if bot := service.ClassifyBot(ua); bot != "" {
|
||||||
return
|
// 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})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
166
backend/handler/visitstats.go
Normal file
166
backend/handler/visitstats.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
62
backend/middleware/visit_capture.go
Normal file
62
backend/middleware/visit_capture.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,10 +58,10 @@ func InitDB(dsn string) error {
|
|||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{},
|
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{},
|
||||||
&User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
|
&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{},
|
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||||
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
||||||
&PostPollVote{}, &PostLotteryEntry{}, &Ad{},
|
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{},
|
||||||
&ImportRecord{},
|
&ImportRecord{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("自动迁移失败: %w", err)
|
return fmt.Errorf("自动迁移失败: %w", err)
|
||||||
|
|||||||
@@ -455,6 +455,19 @@ type SiteSetting struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
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)
|
// SiteDailyStats 站点日维度访问量(轻量 PV/UV)
|
||||||
type SiteDailyStats struct {
|
type SiteDailyStats struct {
|
||||||
Date time.Time `gorm:"type:date;primaryKey" json:"date"`
|
Date time.Time `gorm:"type:date;primaryKey" json:"date"`
|
||||||
@@ -470,6 +483,31 @@ type SiteDailyVisitor struct {
|
|||||||
VidHash string `gorm:"size:64;primaryKey" json:"-"`
|
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 (
|
const (
|
||||||
AttachmentKindAvatar = "avatar" // 用户头像(正方形 WebP)
|
AttachmentKindAvatar = "avatar" // 用户头像(正方形 WebP)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -29,6 +30,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
var proxies []string
|
var proxies []string
|
||||||
if v := os.Getenv("TRUSTED_PROXIES"); v != "" {
|
if v := os.Getenv("TRUSTED_PROXIES"); v != "" {
|
||||||
proxies = strings.Split(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 {
|
if err := r.SetTrustedProxies(proxies); err != nil {
|
||||||
return nil, err
|
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"))
|
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
|
||||||
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
||||||
chatSvc := service.NewChatService(model.DB, notifSvc)
|
chatSvc := service.NewChatService(model.DB, notifSvc)
|
||||||
telemetrySvc := service.NewTelemetryService(model.DB)
|
visitSvc := service.NewVisitStatsService(model.DB, settingSvc)
|
||||||
analyticsSvc := service.NewAnalyticsService(model.DB)
|
analyticsSvc := service.NewAnalyticsService(model.DB)
|
||||||
adSvc := service.NewAdService(model.DB, service.NewCaptchaStore())
|
adSvc := service.NewAdService(model.DB, service.NewCaptchaStore())
|
||||||
overviewSvc.WithAds(adSvc)
|
overviewSvc.WithAds(adSvc)
|
||||||
@@ -85,6 +89,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
}
|
}
|
||||||
limiter := service.DefaultRateLimiter()
|
limiter := service.DefaultRateLimiter()
|
||||||
|
|
||||||
|
// 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期)
|
||||||
|
pipeCtx, pipeCancel := context.WithCancel(context.Background())
|
||||||
|
defer pipeCancel()
|
||||||
|
go visitSvc.StartFlusher(pipeCtx)
|
||||||
|
go visitSvc.RunRetention(pipeCtx)
|
||||||
|
|
||||||
h := &handler.Handlers{
|
h := &handler.Handlers{
|
||||||
Ops: ops,
|
Ops: ops,
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
@@ -107,10 +117,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
LegacyImport: legacyImportSvc,
|
LegacyImport: legacyImportSvc,
|
||||||
Moderation: moderationSvc,
|
Moderation: moderationSvc,
|
||||||
Chat: chatSvc,
|
Chat: chatSvc,
|
||||||
Telemetry: telemetrySvc,
|
Visit: visitSvc,
|
||||||
Analytics: analyticsSvc,
|
Analytics: analyticsSvc,
|
||||||
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
|
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
|
||||||
Ads: adSvc,
|
Ads: adSvc,
|
||||||
|
Sidebar: service.NewSidebarService(model.DB),
|
||||||
}
|
}
|
||||||
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
||||||
notifSvc.OnNotifyNew = func(userID uint) {
|
notifSvc.OnNotifyNew = func(userID uint) {
|
||||||
@@ -127,6 +138,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
authMW := middleware.NewAuthMiddleware(authSvc)
|
authMW := middleware.NewAuthMiddleware(authSvc)
|
||||||
r.Use(authMW.OptionalAuth(), h.RuntimeGuard, h.BusinessQuota)
|
r.Use(authMW.OptionalAuth(), h.RuntimeGuard, h.BusinessQuota)
|
||||||
|
|
||||||
|
// 访问捕获:仅记爬虫 UA 与 ≥400 响应(人类浏览走客户端埋点);
|
||||||
|
// 挂在 OptionalAuth 之后可顺带拿到登录用户
|
||||||
|
r.Use(middleware.VisitCapture(visitSvc))
|
||||||
|
|
||||||
// 上传文件静态服务(data/uploads → /uploads)
|
// 上传文件静态服务(data/uploads → /uploads)
|
||||||
r.Static("/uploads", filepath.Join(cfg.DataDir, "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", h.Boards)
|
||||||
pubAPI.GET("/boards/:id/sidebar", h.BoardSidebar)
|
pubAPI.GET("/boards/:id/sidebar", h.BoardSidebar)
|
||||||
pubAPI.GET("/overview", h.Overview)
|
pubAPI.GET("/overview", h.Overview)
|
||||||
|
pubAPI.GET("/sidebar", h.GetSidebar)
|
||||||
pubAPI.GET("/posts", h.Posts)
|
pubAPI.GET("/posts", h.Posts)
|
||||||
pubAPI.GET("/posts/:id", h.PostDetail)
|
pubAPI.GET("/posts/:id", h.PostDetail)
|
||||||
pubAPI.POST("/posts/:id/unlock-password", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateHidePassword), h.UnlockPostPassword)
|
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.GET("/media/:object", h.PublicObject)
|
||||||
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
|
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
|
||||||
pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword)
|
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("/register", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateRegister), h.Register)
|
||||||
pubAPI.POST("/login", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
|
pubAPI.POST("/login", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateLogin), h.Login)
|
||||||
// refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护)
|
// refresh token 端点:access 过期后用 refresh 换新 token(需 CSRF 防护)
|
||||||
@@ -277,6 +293,21 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
// 站点分析概览(任意 staff;板管按授权板过滤)
|
// 站点分析概览(任意 staff;板管按授权板过滤)
|
||||||
staffAPI.GET("/analytics/overview", h.AdminAnalyticsOverview)
|
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」用户访问
|
// 群聊监管:移出后挂到 RequireAuth 组,允许「仅群管/消息 flag」用户访问
|
||||||
// (见下方 msgAPI)
|
// (见下方 msgAPI)
|
||||||
|
|
||||||
@@ -305,6 +336,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
adsAPI.GET("/sponsors/config", h.AdminGetSponsorsConfig)
|
adsAPI.GET("/sponsors/config", h.AdminGetSponsorsConfig)
|
||||||
adsAPI.PUT("/sponsors/config", h.AdminSaveSponsorsConfig)
|
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)
|
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
|
||||||
opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings))
|
opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings))
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const (
|
|||||||
RateTimelineGit = "timeline_git" // Git 提交导入 10/分钟
|
RateTimelineGit = "timeline_git" // Git 提交导入 10/分钟
|
||||||
RateAdSubmit = "ad_submit" // 广告申购 5/分钟(按 IP)
|
RateAdSubmit = "ad_submit" // 广告申购 5/分钟(按 IP)
|
||||||
RateCaptcha = "captcha" // 验证码刷新 30/分钟
|
RateCaptcha = "captcha" // 验证码刷新 30/分钟
|
||||||
|
RateTelemetry = "telemetry" // 页面浏览埋点 120/分钟(按 IP)
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultRateLimiter 创建默认速率限制器
|
// DefaultRateLimiter 创建默认速率限制器
|
||||||
@@ -100,5 +101,6 @@ func DefaultRateLimiter() *RateLimiter {
|
|||||||
rl.SetLimit(RateTimelineGit, 10) // 时间线 Git 导入 10/分钟
|
rl.SetLimit(RateTimelineGit, 10) // 时间线 Git 导入 10/分钟
|
||||||
rl.SetLimit(RateAdSubmit, 5) // 广告申购 5/分钟
|
rl.SetLimit(RateAdSubmit, 5) // 广告申购 5/分钟
|
||||||
rl.SetLimit(RateCaptcha, 30) // 验证码 30/分钟
|
rl.SetLimit(RateCaptcha, 30) // 验证码 30/分钟
|
||||||
|
rl.SetLimit(RateTelemetry, 120) // 埋点 120/分钟
|
||||||
return rl
|
return rl
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ const (
|
|||||||
SettingKeyAttachmentMaxCount = "attachment_max_count"
|
SettingKeyAttachmentMaxCount = "attachment_max_count"
|
||||||
// SettingKeyImageMaxMB 正文插图上限(MB);缺行=5
|
// SettingKeyImageMaxMB 正文插图上限(MB);缺行=5
|
||||||
SettingKeyImageMaxMB = "image_max_mb"
|
SettingKeyImageMaxMB = "image_max_mb"
|
||||||
|
// SettingKeyAnalyticsEnabled 访问统计采集开关;缺行视为开启(默认开)
|
||||||
|
SettingKeyAnalyticsEnabled = "analytics_enabled"
|
||||||
|
// SettingKeyAnalyticsRetentionDays 访问明细保留天数;缺行=90
|
||||||
|
SettingKeyAnalyticsRetentionDays = "analytics_retention_days"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -74,6 +78,10 @@ const (
|
|||||||
MinAttachmentMaxCount = 1
|
MinAttachmentMaxCount = 1
|
||||||
MaxAttachmentMaxCount = 20
|
MaxAttachmentMaxCount = 20
|
||||||
MaxAttachmentExtCount = 80
|
MaxAttachmentExtCount = 80
|
||||||
|
|
||||||
|
DefaultAnalyticsRetentionDays = 90
|
||||||
|
MinAnalyticsRetentionDays = 7
|
||||||
|
MaxAnalyticsRetentionDays = 365
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
|
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
|
||||||
@@ -880,3 +888,47 @@ func (s *SettingService) ImageMaxBytes() (int64, error) {
|
|||||||
}
|
}
|
||||||
return int64(mb) << 20, nil
|
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))
|
||||||
|
}
|
||||||
|
|||||||
453
backend/service/sidebar.go
Normal file
453
backend/service/sidebar.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
224
backend/service/sidebar_test.go
Normal file
224
backend/service/sidebar_test.go
Normal file
@@ -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: "<b>x</b>"}
|
||||||
|
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: "<div>hi</div>", 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: "<div>hi2</div>", 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 != "<div>hi2</div>" {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
69
backend/service/visit_bot.go
Normal file
69
backend/service/visit_bot.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
31
backend/service/visit_bot_test.go
Normal file
31
backend/service/visit_bot_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
backend/service/visit_ref.go
Normal file
63
backend/service/visit_ref.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
31
backend/service/visit_ref_test.go
Normal file
31
backend/service/visit_ref_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
295
backend/service/visitstats.go
Normal file
295
backend/service/visitstats.go
Normal file
@@ -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) }
|
||||||
616
backend/service/visitstats_query.go
Normal file
616
backend/service/visitstats_query.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
46
backend/service/visitstats_test.go
Normal file
46
backend/service/visitstats_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,9 @@ JWT_SECRET=
|
|||||||
SETTINGS_MASTER_KEY=
|
SETTINGS_MASTER_KEY=
|
||||||
# 逗号分隔的精确内部服务主机名;默认拒绝回环、私网和保留网段
|
# 逗号分隔的精确内部服务主机名;默认拒绝回环、私网和保留网段
|
||||||
SERVICE_PRIVATE_HOSTS=
|
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=
|
TRUSTED_PROXIES=
|
||||||
# 紧急恢复:1 强制正常运行,修复后台配置后撤销并重启 API
|
# 紧急恢复:1 强制正常运行,修复后台配置后撤销并重启 API
|
||||||
MAINTENANCE_RECOVERY=0
|
MAINTENANCE_RECOVERY=0
|
||||||
|
|||||||
@@ -104,6 +104,10 @@
|
|||||||
4. Go 与 Next 的 SITE_URL 必须一致且为正式外部 HTTPS origin;后台基本信息只读显示。Next 的 BACKEND_URL 指向可达 API,不使用请求 Host 生成邮件地址。
|
4. Go 与 Next 的 SITE_URL 必须一致且为正式外部 HTTPS origin;后台基本信息只读显示。Next 的 BACKEND_URL 指向可达 API,不使用请求 Host 生成邮件地址。
|
||||||
5. SERVICE_PRIVATE_HOSTS 为逗号分隔的精确主机名。默认拒绝私网、回环、链路本地及特殊地址,DNS 解析后连接已经检查的 IP;确有自建 SMTP/S3 时只放行对应主机。虚拟主机 S3 寻址时要按实际连接的 bucket.endpoint 主机配置。
|
5. SERVICE_PRIVATE_HOSTS 为逗号分隔的精确主机名。默认拒绝私网、回环、链路本地及特殊地址,DNS 解析后连接已经检查的 IP;确有自建 SMTP/S3 时只放行对应主机。虚拟主机 S3 寻址时要按实际连接的 bucket.endpoint 主机配置。
|
||||||
6. TRUSTED_PROXIES 仅列出真正的代理 IP/CIDR。默认不信任转发头;不要直接填任意全网,避免登录/IP额度绕过。
|
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 后强制正常访问;修复后台模式,再撤销该变量并重启。不会悄悄覆写数据库保存模式。
|
7. MAINTENANCE_RECOVERY=1 是部署级紧急恢复:重启 API 后强制正常访问;修复后台模式,再撤销该变量并重启。不会悄悄覆写数据库保存模式。
|
||||||
8. Compose 已透传四个新变量并保留原服务/端口/数据卷。参见 deploy/CHANGELOG.md。JWT、DB_DSN、Cookie安全属性仍保持部署级管理。
|
8. Compose 已透传四个新变量并保留原服务/端口/数据卷。参见 deploy/CHANGELOG.md。JWT、DB_DSN、Cookie安全属性仍保持部署级管理。
|
||||||
9. 反代/CDN不得缓存 HTML、/api/site-state、鉴权接口、503;停站和恢复后验证外部入口的真实状态码及缓存头。外部 CDN 未在本地测试范围内。
|
9. 反代/CDN不得缓存 HTML、/api/site-state、鉴权接口、503;停站和恢复后验证外部入口的真实状态码及缓存头。外部 CDN 未在本地测试范围内。
|
||||||
|
|||||||
74
frontend/app/admin/analytics/AnalyticsShell.tsx
Normal file
74
frontend/app/admin/analytics/AnalyticsShell.tsx
Normal file
@@ -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<HTMLAnchorElement>, 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 (
|
||||||
|
<div className="admin-analytics-page">
|
||||||
|
<AdminPageHeader
|
||||||
|
icon={BarChart3}
|
||||||
|
title="访问统计"
|
||||||
|
description="站点流量、来源、访客与安全事件的采集与分析"
|
||||||
|
/>
|
||||||
|
<nav className="admin-analytics-tabs" aria-label="访问统计分类">
|
||||||
|
{ANALYTICS_TABS.map((item, i) => {
|
||||||
|
const active = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
ref={(el) => {
|
||||||
|
navRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
href={item.href}
|
||||||
|
className="admin-analytics-tab"
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
onKeyDown={(e) => onNavKeyDown(e, i)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
frontend/app/admin/analytics/OverviewClient.tsx
Normal file
137
frontend/app/admin/analytics/OverviewClient.tsx
Normal file
@@ -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<VisitOverview | null>(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 <StatsState loading={loading} error={error || undefined} />;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<p className="meta text-[12.5px]">
|
||||||
|
今日 PV {formatInt(data.today_pv)} · UV {formatInt(data.today_uv)}
|
||||||
|
</p>
|
||||||
|
<RangeSegmented value={range} onChange={setRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <StatsState error={error} /> : null}
|
||||||
|
|
||||||
|
<div className="grid gap-3 grid-cols-2 xl:grid-cols-4">
|
||||||
|
<AdminStatCard
|
||||||
|
label="今日 PV"
|
||||||
|
value={formatInt(data.today_pv)}
|
||||||
|
sub={`${delta(data.today_pv, data.yesterday_pv)} vs 昨日`}
|
||||||
|
icon={<Eye size={17} />}
|
||||||
|
/>
|
||||||
|
<AdminStatCard
|
||||||
|
label="今日 UV"
|
||||||
|
value={formatInt(data.today_uv)}
|
||||||
|
sub={`${delta(data.today_uv, data.yesterday_uv)} vs 昨日`}
|
||||||
|
icon={<Users size={17} />}
|
||||||
|
color="var(--ok)"
|
||||||
|
soft="var(--ok-soft, var(--panel-2))"
|
||||||
|
/>
|
||||||
|
<AdminStatCard
|
||||||
|
label="今日登录浏览"
|
||||||
|
value={formatInt(data.logged_in_pv)}
|
||||||
|
hint={
|
||||||
|
data.today_pv > 0
|
||||||
|
? `占今日 PV ${Math.round((data.logged_in_pv / data.today_pv) * 100)}%`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
icon={<LogIn size={17} />}
|
||||||
|
color="var(--gold)"
|
||||||
|
soft="var(--gold-soft)"
|
||||||
|
/>
|
||||||
|
<AdminStatCard
|
||||||
|
label="昨日 PV"
|
||||||
|
value={formatInt(data.yesterday_pv)}
|
||||||
|
hint={`昨日 UV ${formatInt(data.yesterday_uv)}`}
|
||||||
|
icon={<CalendarDays size={17} />}
|
||||||
|
color="var(--ink-2)"
|
||||||
|
soft="var(--panel-2)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminChartCard title="PV / UV 趋势" hint="来自日聚合表,历史数据不随明细清理丢失">
|
||||||
|
<VisitTrendChart data={data.trend} />
|
||||||
|
</AdminChartCard>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<AdminChartCard title="设备分布" hint={`近 ${data.range_days} 日浏览`}>
|
||||||
|
{devices.length ? <NamedPieChart data={devices} /> : <TableEmpty />}
|
||||||
|
</AdminChartCard>
|
||||||
|
<AdminChartCard title="来源占比" hint={`近 ${data.range_days} 日浏览`}>
|
||||||
|
{sources.length ? <NamedBarChart data={sources} /> : <TableEmpty />}
|
||||||
|
</AdminChartCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminChartCard title="热门页面 Top 10" hint={`近 ${data.range_days} 日`}>
|
||||||
|
{data.top_pages.length ? (
|
||||||
|
<div className="flex flex-col divide-y" style={{ borderColor: "var(--line)" }}>
|
||||||
|
{data.top_pages.map((p: VisitPageRow, i) => (
|
||||||
|
<div key={p.path} className="py-2 flex items-center gap-3 text-[13px]">
|
||||||
|
<span className="w-5 text-right tabular-nums shrink-0" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0 truncate" style={{ color: "var(--ink)" }} title={p.path}>
|
||||||
|
{p.path || "/"}
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:block w-32 h-1.5 rounded-full overflow-hidden shrink-0" style={{ background: "var(--panel-2)" }}>
|
||||||
|
<span
|
||||||
|
className="block h-full rounded-full"
|
||||||
|
style={{ width: `${Math.min(100, p.share).toFixed(1)}%`, background: "var(--accent)" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="w-16 text-right tabular-nums" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{formatInt(p.pv)}
|
||||||
|
</span>
|
||||||
|
<span className="w-14 text-right tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||||
|
UV {formatInt(p.uv)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TableEmpty text="暂无浏览数据" />
|
||||||
|
)}
|
||||||
|
</AdminChartCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
frontend/app/admin/analytics/details/page.tsx
Normal file
232
frontend/app/admin/analytics/details/page.tsx
Normal file
@@ -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<string, { color: string; soft: string }> = {
|
||||||
|
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 (
|
||||||
|
<span
|
||||||
|
className="inline-block px-2 py-0.5 rounded-full text-[11px] font-medium shrink-0"
|
||||||
|
style={{ color: s.color, background: s.soft }}
|
||||||
|
>
|
||||||
|
{kindLabel(kind)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<VisitEventsResult | null>(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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="meta text-[12.5px]">
|
||||||
|
记录含浏览、爬虫与异常请求;访客标识仅存哈希,IP 与登录日志口径一致,仅超管可见。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<AdminChartCard title="访问明细" hint={`共 ${data ? data.total.toLocaleString("zh-CN") : "…"} 条记录`}>
|
||||||
|
<div className="flex flex-col gap-2.5 mb-4">
|
||||||
|
<AdminSegmented options={KIND_OPTIONS} value={kind} onChange={(k) => { setKind(k); resetPage(); }} ariaLabel="事件类型筛选" role="group" size="sm" />
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<div className="w-full sm:w-56">
|
||||||
|
<AdminSearchField
|
||||||
|
value={path}
|
||||||
|
onChange={(v) => { setPath(v); }}
|
||||||
|
onSubmit={resetPage}
|
||||||
|
onClear={() => { setPath(""); resetPage(); }}
|
||||||
|
placeholder="路径模糊匹配,回车应用"
|
||||||
|
ariaLabel="按路径筛选"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-44">
|
||||||
|
<AdminSearchField
|
||||||
|
value={ip}
|
||||||
|
onChange={setIp}
|
||||||
|
onSubmit={resetPage}
|
||||||
|
onClear={() => { setIp(""); resetPage(); }}
|
||||||
|
placeholder="按 IP 筛选,回车应用"
|
||||||
|
ariaLabel="按 IP 筛选"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-40">
|
||||||
|
<AdminSearchField
|
||||||
|
value={bot}
|
||||||
|
onChange={setBot}
|
||||||
|
onSubmit={resetPage}
|
||||||
|
onClear={() => { setBot(""); resetPage(); }}
|
||||||
|
placeholder="按爬虫名筛选"
|
||||||
|
ariaLabel="按爬虫名筛选"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
从
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={start}
|
||||||
|
onChange={(e) => { setStart(e.target.value); resetPage(); }}
|
||||||
|
className="admin-field-input"
|
||||||
|
style={{ width: 150 }}
|
||||||
|
aria-label="开始日期"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
至
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={end}
|
||||||
|
onChange={(e) => { setEnd(e.target.value); resetPage(); }}
|
||||||
|
className="admin-field-input"
|
||||||
|
style={{ width: 150 }}
|
||||||
|
aria-label="结束日期"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <StatsState error={error} /> : null}
|
||||||
|
|
||||||
|
{data && data.items.length ? (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[12.5px]" style={{ minWidth: 860 }}>
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left" style={{ color: "var(--ink-3)" }}>
|
||||||
|
<th className="py-2 pr-3 font-medium whitespace-nowrap">时间</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">类型</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">路径</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">来源</th>
|
||||||
|
<th className="py-2 pr-3 font-medium whitespace-nowrap">设备 / 系统 / 浏览器</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">用户</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">IP</th>
|
||||||
|
<th className="py-2 font-medium text-right">状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.map((e: VisitEventItem) => (
|
||||||
|
<tr key={e.id} style={{ borderTop: "1px solid var(--line)" }}>
|
||||||
|
<td className="py-2 pr-3 whitespace-nowrap tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{new Date(e.created_at).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
{kindBadge(e.kind)}
|
||||||
|
{e.bot ? (
|
||||||
|
<span className="ml-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{e.bot}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 max-w-[220px] truncate" style={{ color: "var(--ink)" }} title={e.path}>
|
||||||
|
{e.path || "/"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 whitespace-nowrap" style={{ color: "var(--ink-3)" }} title={e.ref_host}>
|
||||||
|
{e.ref_host ? (
|
||||||
|
<>
|
||||||
|
{e.ref_host}
|
||||||
|
<span className="ml-1 text-[11px]">{refKindLabel(e.ref_kind)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
refKindLabel(e.ref_kind)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 whitespace-nowrap" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{deviceLabel(e.device)} · {e.os || "?"} · {e.browser || "?"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 whitespace-nowrap" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{e.username || <span style={{ color: "var(--ink-3)" }}>游客</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 tabular-nums" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{e.ip || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right tabular-nums" style={{ color: e.status >= 400 ? "var(--danger)" : "var(--ink-3)" }}>
|
||||||
|
{e.status > 0 ? e.status : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<AdminPager page={page} total={data.total} pageSize={PAGE_SIZE} onChange={setPage} loading={loading} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<TableEmpty text={loading ? "正在加载…" : "没有匹配的记录"} />
|
||||||
|
)}
|
||||||
|
</AdminChartCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<StatsState loading />}>
|
||||||
|
<DetailsInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/app/admin/analytics/layout.tsx
Normal file
15
frontend/app/admin/analytics/layout.tsx
Normal file
@@ -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 <Forbidden text="访问统计仅超级管理员和站长可查看。" />;
|
||||||
|
}
|
||||||
|
return <AnalyticsShell>{children}</AnalyticsShell>;
|
||||||
|
}
|
||||||
17
frontend/app/admin/analytics/page.tsx
Normal file
17
frontend/app/admin/analytics/page.tsx
Normal file
@@ -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 <OverviewClient initial={initial} />;
|
||||||
|
}
|
||||||
120
frontend/app/admin/analytics/pages/page.tsx
Normal file
120
frontend/app/admin/analytics/pages/page.tsx
Normal file
@@ -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<VisitPages | null>(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 <StatsState loading={loading} error={error || undefined} />;
|
||||||
|
|
||||||
|
const maxPv = data.items[0]?.pv ?? 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<p className="meta text-[12.5px]">
|
||||||
|
共 {formatInt(data.total)} 个不同路径被访问(近 {data.range_days} 日)
|
||||||
|
</p>
|
||||||
|
<RangeSegmented value={range} onChange={switchRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <StatsState error={error} /> : null}
|
||||||
|
|
||||||
|
<AdminChartCard
|
||||||
|
title="受访页面"
|
||||||
|
hint="按 PV 降序;搜索支持路径模糊匹配"
|
||||||
|
actions={
|
||||||
|
<div className="w-full sm:w-64">
|
||||||
|
<AdminSearchField
|
||||||
|
value={q}
|
||||||
|
onChange={setQ}
|
||||||
|
onSubmit={submitSearch}
|
||||||
|
onClear={clearSearch}
|
||||||
|
placeholder="搜索路径…"
|
||||||
|
ariaLabel="搜索受访路径"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{data.items.length ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col divide-y" style={{ borderColor: "var(--line)" }}>
|
||||||
|
{data.items.map((p) => (
|
||||||
|
<div key={p.path} className="py-2.5 flex items-center gap-3 text-[13px]">
|
||||||
|
<span className="flex-1 min-w-0 truncate font-medium" style={{ color: "var(--ink)" }} title={p.path}>
|
||||||
|
{p.path || "/"}
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:block w-36 h-1.5 rounded-full overflow-hidden shrink-0" style={{ background: "var(--panel-2)" }}>
|
||||||
|
<span
|
||||||
|
className="block h-full rounded-full"
|
||||||
|
style={{ width: `${Math.max(2, (p.pv / maxPv) * 100).toFixed(1)}%`, background: "var(--accent)" }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="w-16 text-right tabular-nums" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{formatInt(p.pv)}
|
||||||
|
</span>
|
||||||
|
<span className="w-16 text-right tabular-nums" style={{ color: "var(--ink-3)" }}>
|
||||||
|
UV {formatInt(p.uv)}
|
||||||
|
</span>
|
||||||
|
<span className="w-12 text-right tabular-nums text-[11.5px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{p.share.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<AdminPager page={page} total={data.total} pageSize={PAGE_SIZE} onChange={setPage} loading={loading} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<TableEmpty text={applied ? "没有匹配的页面" : "暂无浏览数据"} />
|
||||||
|
)}
|
||||||
|
</AdminChartCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
frontend/app/admin/analytics/realtime/page.tsx
Normal file
118
frontend/app/admin/analytics/realtime/page.tsx
Normal file
@@ -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<VisitRealtime | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
apiStatsRealtime()
|
||||||
|
.then((d) => {
|
||||||
|
setData(d);
|
||||||
|
setError("");
|
||||||
|
})
|
||||||
|
.catch(() => setError("加载失败,请稍后重试"));
|
||||||
|
}, []);
|
||||||
|
usePolling(load, 15_000);
|
||||||
|
|
||||||
|
if (!data) return <StatsState loading={!error} error={error || undefined} />;
|
||||||
|
|
||||||
|
// 兜底:旧后端可能返回 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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="meta text-[12.5px]">每 15 秒自动刷新;切回前台标签页时立即刷新。</p>
|
||||||
|
{error ? <StatsState error={error} /> : null}
|
||||||
|
|
||||||
|
<div className="grid gap-3 grid-cols-2 xl:grid-cols-4">
|
||||||
|
<AdminStatCard label="当前在线" value={formatInt(data.online)} hint="近 5 分钟活跃用户" icon={<Users size={17} />} />
|
||||||
|
<AdminStatCard
|
||||||
|
label="近 5 分钟 PV"
|
||||||
|
value={formatInt(data.pv_5min)}
|
||||||
|
icon={<Zap size={17} />}
|
||||||
|
color="var(--ok)"
|
||||||
|
soft="var(--ok-soft, var(--panel-2))"
|
||||||
|
/>
|
||||||
|
<AdminStatCard
|
||||||
|
label="近 30 分钟 PV"
|
||||||
|
value={formatInt(data.pv_30min)}
|
||||||
|
icon={<Activity size={17} />}
|
||||||
|
color="var(--gold)"
|
||||||
|
soft="var(--gold-soft)"
|
||||||
|
/>
|
||||||
|
<AdminStatCard
|
||||||
|
label="数据延迟"
|
||||||
|
value="< 3s"
|
||||||
|
hint="事件攒批落库周期"
|
||||||
|
icon={<Clock size={17} />}
|
||||||
|
color="var(--ink-2)"
|
||||||
|
soft="var(--panel-2)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminChartCard title="近 60 分钟浏览" hint="分钟级 PV">
|
||||||
|
{minutes.length ? (
|
||||||
|
<SingleAreaChart data={minutes} dataKey="pv" name="PV" />
|
||||||
|
) : (
|
||||||
|
<TableEmpty text="暂无浏览数据" />
|
||||||
|
)}
|
||||||
|
</AdminChartCard>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<CardShell title="近 5 分钟热门路径">
|
||||||
|
{topPages.length ? (
|
||||||
|
<ul className="flex flex-col divide-y" style={{ borderColor: "var(--line)" }}>
|
||||||
|
{topPages.map((p) => (
|
||||||
|
<li key={p.key} className="py-2 flex items-center gap-3 text-[13px]">
|
||||||
|
<span className="flex-1 min-w-0 truncate" style={{ color: "var(--ink)" }}>
|
||||||
|
{p.key || "/"}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums shrink-0" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{formatInt(p.count)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<TableEmpty />
|
||||||
|
)}
|
||||||
|
</CardShell>
|
||||||
|
|
||||||
|
<CardShell title="最新浏览" hint="最多 20 条">
|
||||||
|
{recent.length ? (
|
||||||
|
<ul className="flex flex-col divide-y max-h-[420px] overflow-y-auto" style={{ borderColor: "var(--line)" }}>
|
||||||
|
{recent.map((e) => (
|
||||||
|
<li key={e.id} className="py-2 flex items-center gap-2.5 text-[13px]">
|
||||||
|
<span className="w-[4.5rem] shrink-0 tabular-nums text-[11.5px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{new Date(e.created_at).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0 truncate" style={{ color: "var(--ink)" }} title={e.path}>
|
||||||
|
{e.path || "/"}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[11.5px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{deviceLabel(e.device)} · {kindLabel(e.kind)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<TableEmpty />
|
||||||
|
)}
|
||||||
|
</CardShell>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
frontend/app/admin/analytics/security/page.tsx
Normal file
127
frontend/app/admin/analytics/security/page.tsx
Normal file
@@ -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<VisitSecurity | null>(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 <StatsState loading={loading} error={error || undefined} />;
|
||||||
|
|
||||||
|
const botTrend = data.bot_trend.map((d) => ({ date: d.date, pv: d.pv }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<p className="meta text-[12.5px]">爬虫与异常探测请求仅记录明细,不计入 PV/UV 统计。</p>
|
||||||
|
<RangeSegmented value={range} onChange={setRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <StatsState error={error} /> : null}
|
||||||
|
|
||||||
|
<div className="grid gap-3 grid-cols-2 xl:grid-cols-4">
|
||||||
|
<AdminStatCard label="爬虫请求" value={formatInt(data.bots.reduce((s, b) => s + b.count, 0))} icon={<Bot size={17} />} />
|
||||||
|
<AdminStatCard
|
||||||
|
label="异常探测"
|
||||||
|
value={formatInt(data.probe_status.reduce((s, b) => s + b.count, 0))}
|
||||||
|
hint="状态码 ≥400 的请求"
|
||||||
|
icon={<Radar size={17} />}
|
||||||
|
color="var(--gold)"
|
||||||
|
soft="var(--gold-soft)"
|
||||||
|
/>
|
||||||
|
<AdminStatCard
|
||||||
|
label="登录失败"
|
||||||
|
value={formatInt(data.login_failed)}
|
||||||
|
sub={`${formatInt(data.failed_users)} 个账号`}
|
||||||
|
icon={<KeyRound size={17} />}
|
||||||
|
color="var(--ink-2)"
|
||||||
|
soft="var(--panel-2)"
|
||||||
|
/>
|
||||||
|
<AdminStatCard
|
||||||
|
label="可疑 IP"
|
||||||
|
value={formatInt(data.probe_ips.length)}
|
||||||
|
hint="按探测次数排序"
|
||||||
|
icon={<ShieldAlert size={17} />}
|
||||||
|
color="var(--danger)"
|
||||||
|
soft="var(--danger-soft, var(--panel-2))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<AdminChartCard title="爬虫分布" hint={`近 ${data.range_days} 日各爬虫请求量`}>
|
||||||
|
{data.bots.length ? (
|
||||||
|
<NamedBarChart data={data.bots.map((b) => ({ label: b.key, count: b.count }))} />
|
||||||
|
) : (
|
||||||
|
<TableEmpty text="暂无爬虫记录" />
|
||||||
|
)}
|
||||||
|
</AdminChartCard>
|
||||||
|
<AdminChartCard title="爬虫趋势" hint="每日爬虫请求量">
|
||||||
|
{botTrend.length ? (
|
||||||
|
<SingleAreaChart data={botTrend} dataKey="pv" name="爬虫请求" color="var(--gold)" />
|
||||||
|
) : (
|
||||||
|
<TableEmpty />
|
||||||
|
)}
|
||||||
|
</AdminChartCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<AdminChartCard title="探测状态码分布" hint="状态码 ≥400 的请求">
|
||||||
|
{data.probe_status.length ? (
|
||||||
|
<NamedBarChart data={data.probe_status.map((s) => ({ label: s.key, count: s.count }))} />
|
||||||
|
) : (
|
||||||
|
<TableEmpty text="暂无异常探测" />
|
||||||
|
)}
|
||||||
|
</AdminChartCard>
|
||||||
|
<AdminChartCard title="可疑 IP Top 20" hint="点击「查明细」查看该 IP 全部访问记录">
|
||||||
|
{data.probe_ips.length ? (
|
||||||
|
<ul className="flex flex-col divide-y max-h-[360px] overflow-y-auto" style={{ borderColor: "var(--line)" }}>
|
||||||
|
{data.probe_ips.map((ip) => (
|
||||||
|
<li key={ip.key} className="py-2 flex items-center gap-3 text-[13px]">
|
||||||
|
<span className="flex-1 min-w-0 truncate tabular-nums" style={{ color: "var(--ink)" }}>
|
||||||
|
{ip.key}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums shrink-0" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{formatInt(ip.count)}
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href={`/admin/analytics/details?ip=${encodeURIComponent(ip.key)}`}
|
||||||
|
className="btn btn-line btn-sm shrink-0"
|
||||||
|
>
|
||||||
|
查明细
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<TableEmpty text="暂无可疑 IP" />
|
||||||
|
)}
|
||||||
|
</AdminChartCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
frontend/app/admin/analytics/settings/page.tsx
Normal file
196
frontend/app/admin/analytics/settings/page.tsx
Normal file
@@ -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<VisitSettingsPayload | null>(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 <StatsState loading={loading} error={error || undefined} />;
|
||||||
|
|
||||||
|
const clampPurge = (v: number) =>
|
||||||
|
Math.min(RETENTION_MAX, Math.max(1, Math.round(Number(v) || 1)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{error ? <StatsState error={error} /> : null}
|
||||||
|
{purgeMsg ? (
|
||||||
|
<div className="panel p-4 text-[13px]" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{purgeMsg}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-3 grid-cols-2 xl:grid-cols-4">
|
||||||
|
<AdminStatCard label="明细行数" value={formatInt(data.usage.rows)} icon={<Database size={17} />} />
|
||||||
|
<AdminStatCard
|
||||||
|
label="磁盘占用"
|
||||||
|
value={formatBytes(data.usage.bytes)}
|
||||||
|
hint="含索引(pg_total_relation_size)"
|
||||||
|
icon={<HardDrive size={17} />}
|
||||||
|
color="var(--ok)"
|
||||||
|
soft="var(--ok-soft, var(--panel-2))"
|
||||||
|
/>
|
||||||
|
<AdminStatCard label="保留天数" value={`${data.retention_days} 天`} hint="超期明细每日 04:30 自动清理" icon={<Activity size={17} />} color="var(--gold)" soft="var(--gold-soft)" />
|
||||||
|
<AdminStatCard
|
||||||
|
label="采集丢弃"
|
||||||
|
value={formatInt(data.dropped)}
|
||||||
|
hint="写入队列满时丢弃的事件数(进程启动以来)"
|
||||||
|
icon={<Trash2 size={17} />}
|
||||||
|
color="var(--ink-2)"
|
||||||
|
soft="var(--panel-2)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminSettingRow
|
||||||
|
title="访问统计采集"
|
||||||
|
description="关闭后前端埋点与后端捕获中间件立即停止写入(返回 204),已有数据保留。"
|
||||||
|
control={<AdminSwitch checked={enabled} disabled={saving} onChange={toggleEnabled} label="访问统计采集开关" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminSettingRow
|
||||||
|
title="明细保留期"
|
||||||
|
description={`超过保留期的访问明细每日 04:30 自动分批清理;日聚合趋势不受影响。范围 ${RETENTION_MIN}–${RETENTION_MAX} 天。`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={RETENTION_MIN}
|
||||||
|
max={RETENTION_MAX}
|
||||||
|
value={retention}
|
||||||
|
onChange={(e) => setRetention(Number(e.target.value))}
|
||||||
|
onBlur={submitRetention}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && submitRetention()}
|
||||||
|
className="admin-field-input"
|
||||||
|
style={{ width: 120 }}
|
||||||
|
aria-label="明细保留天数"
|
||||||
|
/>
|
||||||
|
<span className="text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
天
|
||||||
|
</span>
|
||||||
|
{saving ? <span className="meta text-[12px]">保存中…</span> : null}
|
||||||
|
{savedMsg ? (
|
||||||
|
<span className="text-[12px]" style={{ color: "var(--ok)" }}>
|
||||||
|
{savedMsg}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</AdminSettingRow>
|
||||||
|
|
||||||
|
<AdminSettingRow
|
||||||
|
title="立即清理"
|
||||||
|
description="手动删除指定天数之前的明细记录,异步执行,不影响正常访问。"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={RETENTION_MAX}
|
||||||
|
value={purgeDays}
|
||||||
|
onChange={(e) => setPurgeDays(clampPurge(Number(e.target.value)))}
|
||||||
|
className="admin-field-input"
|
||||||
|
style={{ width: 120 }}
|
||||||
|
aria-label="清理多少天前的明细"
|
||||||
|
/>
|
||||||
|
<span className="text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
天前的明细
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
onClick={() => setPurgeOpen(true)}
|
||||||
|
disabled={purging}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> 立即清理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</AdminSettingRow>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={purgeOpen}
|
||||||
|
title="确认立即清理"
|
||||||
|
message={`将删除 ${purgeDays} 天前的全部访问明细(当前共 ${formatInt(data.usage.rows)} 行)。该操作不可恢复,日聚合趋势不受影响。确认执行?`}
|
||||||
|
confirmLabel="确认清理"
|
||||||
|
danger
|
||||||
|
busy={purging}
|
||||||
|
onConfirm={doPurge}
|
||||||
|
onCancel={() => setPurgeOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
frontend/app/admin/analytics/sources/page.tsx
Normal file
111
frontend/app/admin/analytics/sources/page.tsx
Normal file
@@ -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<VisitSources | null>(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 <StatsState loading={loading} error={error || undefined} />;
|
||||||
|
|
||||||
|
const kinds = data.kinds.map((k) => ({ label: refKindLabel(k.key), count: k.count }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<p className="meta text-[12.5px]">按浏览来源(Referer)自动分类;爬虫与探测请求不计入。</p>
|
||||||
|
<RangeSegmented value={range} onChange={setRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <StatsState error={error} /> : null}
|
||||||
|
|
||||||
|
<AdminChartCard title="来源类别占比" hint={`近 ${data.range_days} 日浏览`}>
|
||||||
|
{kinds.length ? <NamedPieChart data={kinds} /> : <TableEmpty />}
|
||||||
|
</AdminChartCard>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<AdminChartCard title="搜索引擎明细" hint="来自搜索结果页的浏览 Top 10">
|
||||||
|
{data.search_hosts.length ? (
|
||||||
|
<NamedBarChart data={data.search_hosts.map((h) => ({ label: h.key, count: h.count }))} />
|
||||||
|
) : (
|
||||||
|
<TableEmpty text="暂无搜索引擎来源" />
|
||||||
|
)}
|
||||||
|
</AdminChartCard>
|
||||||
|
<AdminChartCard title="外部链接明细" hint="其他站点跳转来源 Top 10">
|
||||||
|
{data.external.length ? (
|
||||||
|
<NamedBarChart data={data.external.map((h) => ({ label: h.key, count: h.count }))} />
|
||||||
|
) : (
|
||||||
|
<TableEmpty text="暂无外链来源" />
|
||||||
|
)}
|
||||||
|
</AdminChartCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminChartCard title="来源按日分布" hint="各来源类别每日浏览量">
|
||||||
|
{data.trend.length ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[13px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left" style={{ color: "var(--ink-3)" }}>
|
||||||
|
<th className="py-2 pr-3 font-medium">日期</th>
|
||||||
|
{TREND_KINDS.map((k) => (
|
||||||
|
<th key={k} className="py-2 pr-3 font-medium text-right">
|
||||||
|
{refKindLabel(k)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.trend.map((row) => {
|
||||||
|
const byKey = new Map(row.items.map((i) => [i.key, i.count]));
|
||||||
|
return (
|
||||||
|
<tr key={row.date} style={{ borderTop: "1px solid var(--line)" }}>
|
||||||
|
<td className="py-2 pr-3 tabular-nums" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{row.date.slice(0, 10)}
|
||||||
|
</td>
|
||||||
|
{TREND_KINDS.map((k) => (
|
||||||
|
<td key={k} className="py-2 pr-3 text-right tabular-nums" style={{ color: "var(--ink)" }}>
|
||||||
|
{(byKey.get(k) ?? 0) === 0 ? (
|
||||||
|
<span style={{ color: "var(--ink-3)" }}>—</span>
|
||||||
|
) : (
|
||||||
|
(byKey.get(k) ?? 0).toLocaleString("zh-CN")
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TableEmpty />
|
||||||
|
)}
|
||||||
|
</AdminChartCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
frontend/app/admin/analytics/ui.tsx
Normal file
166
frontend/app/admin/analytics/ui.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="panel p-10 text-center text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
正在加载…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="panel p-10 text-center text-[13px]" style={{ color: "var(--danger)" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableEmpty({ text = "暂无数据" }: { text?: string }) {
|
||||||
|
return (
|
||||||
|
<p className="py-8 text-center text-[13px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div className="flex items-center gap-1.5" role="tablist" aria-label="统计区间">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ key: "7d", label: "近 7 日" },
|
||||||
|
{ key: "30d", label: "近 30 日" },
|
||||||
|
] as const
|
||||||
|
).map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={value === o.key}
|
||||||
|
onClick={() => value !== o.key && onChange(o.key)}
|
||||||
|
className="admin-seg"
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardShell({
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
hint?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="panel p-4 sm:p-5">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{hint ? <p className="meta text-[11.5px] mt-0.5">{hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
frontend/app/admin/analytics/visitors/page.tsx
Normal file
111
frontend/app/admin/analytics/visitors/page.tsx
Normal file
@@ -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<VisitVisitors | null>(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 <StatsState loading={loading} error={error || undefined} />;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<p className="meta text-[12.5px]">基于匿名访客标识(仅存哈希)与登录用户统计。</p>
|
||||||
|
<RangeSegmented value={range} onChange={setRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <StatsState error={error} /> : null}
|
||||||
|
|
||||||
|
<div className="grid gap-3 grid-cols-2 xl:grid-cols-4">
|
||||||
|
<AdminStatCard label="登录用户浏览" value={formatInt(data.logged_in_pv)} icon={<LogIn size={17} />} />
|
||||||
|
<AdminStatCard
|
||||||
|
label="游客浏览"
|
||||||
|
value={formatInt(data.guest_pv)}
|
||||||
|
icon={<Users size={17} />}
|
||||||
|
color="var(--ok)"
|
||||||
|
soft="var(--ok-soft, var(--panel-2))"
|
||||||
|
/>
|
||||||
|
<AdminStatCard
|
||||||
|
label="新访客"
|
||||||
|
value={formatInt(data.new_visitors)}
|
||||||
|
sub={totalVisitors > 0 ? `${Math.round((data.new_visitors / totalVisitors) * 100)}%` : undefined}
|
||||||
|
hint="统计期内首次出现"
|
||||||
|
icon={<UserPlus size={17} />}
|
||||||
|
color="var(--gold)"
|
||||||
|
soft="var(--gold-soft)"
|
||||||
|
/>
|
||||||
|
<AdminStatCard
|
||||||
|
label="回访"
|
||||||
|
value={formatInt(data.returning)}
|
||||||
|
sub={totalVisitors > 0 ? `${Math.round((data.returning / totalVisitors) * 100)}%` : undefined}
|
||||||
|
icon={<Repeat size={17} />}
|
||||||
|
color="var(--ink-2)"
|
||||||
|
soft="var(--panel-2)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPv > 0 ? (
|
||||||
|
<AdminChartCard title="登录 vs 游客" hint={`近 ${data.range_days} 日浏览占比`}>
|
||||||
|
<NamedPieChart
|
||||||
|
data={[
|
||||||
|
{ label: "登录用户", count: data.logged_in_pv },
|
||||||
|
{ label: "游客", count: data.guest_pv },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</AdminChartCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
<AdminChartCard title="设备分布">
|
||||||
|
{devices.length ? <NamedPieChart data={devices} /> : <TableEmpty />}
|
||||||
|
</AdminChartCard>
|
||||||
|
<AdminChartCard title="操作系统 Top 8">
|
||||||
|
{oses.length ? <NamedPieChart data={oses} /> : <TableEmpty />}
|
||||||
|
</AdminChartCard>
|
||||||
|
<AdminChartCard title="浏览器 Top 8">
|
||||||
|
{browsers.length ? <NamedPieChart data={browsers} /> : <TableEmpty />}
|
||||||
|
</AdminChartCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminChartCard title="24 小时活跃分布" hint="各小时浏览量(本地时区)">
|
||||||
|
{hourly.length ? (
|
||||||
|
<NamedBarChart data={hourly} />
|
||||||
|
) : (
|
||||||
|
<TableEmpty />
|
||||||
|
)}
|
||||||
|
</AdminChartCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
824
frontend/app/admin/sidebar/SidebarAdmin.tsx
Normal file
824
frontend/app/admin/sidebar/SidebarAdmin.tsx
Normal file
@@ -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<string, LucideIcon> = {
|
||||||
|
"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<Side, string> = { 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<SidebarConfig>(() => initialConfig || defaultSidebarConfig());
|
||||||
|
const [widgets, setWidgets] = useState<SidebarWidgetRecord[]>(initialWidgets);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [activeSection, setActiveSection] = useState<SectionId>("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<number | null>(null);
|
||||||
|
const [draftName, setDraftName] = useState("");
|
||||||
|
const [mixed, setMixed] = useState("");
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(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<Record<SectionId, HTMLElement | null>>({ 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 <p className="meta px-1 py-6">{initError}</p>;
|
||||||
|
|
||||||
|
// 各范围块数(含左右两侧合计,用于导航徽章)
|
||||||
|
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 = (
|
||||||
|
<nav className="admin-settings-nav" aria-label="侧栏管理分区">
|
||||||
|
{navItems.map((s) => {
|
||||||
|
const Icon = s.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => scrollTo(s.id)}
|
||||||
|
aria-current={activeSection === s.id ? "page" : undefined}
|
||||||
|
className="admin-settings-nav-link gap-2"
|
||||||
|
>
|
||||||
|
<Icon size={15} aria-hidden />
|
||||||
|
<span className="flex-1 truncate text-left">{s.label}</span>
|
||||||
|
{s.count > 0 && (
|
||||||
|
<span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10.5px] font-semibold tabular-nums"
|
||||||
|
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}>
|
||||||
|
{s.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminSettingsPage>
|
||||||
|
<AdminPageHeader
|
||||||
|
icon={PanelLeft}
|
||||||
|
title="侧栏管理"
|
||||||
|
description="配置各页面左右侧栏的显示、顺序与位置,管理自定义 HTML 工具"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminSettingsWork nav={leftNav}>
|
||||||
|
<div className="space-y-5 pb-24">
|
||||||
|
{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 (
|
||||||
|
<section
|
||||||
|
key={scope}
|
||||||
|
id={`sec-${scope}`}
|
||||||
|
data-section={scope}
|
||||||
|
ref={(el) => { sectionRefs.current[scope] = el; }}
|
||||||
|
className="panel"
|
||||||
|
>
|
||||||
|
<SectionHead
|
||||||
|
icon={<Icon size={16} />}
|
||||||
|
title={`${SIDEBAR_SCOPE_LABELS[scope]}侧栏`}
|
||||||
|
desc={desc}
|
||||||
|
extra={
|
||||||
|
<button type="button" className="btn btn-line btn-sm" onClick={() => resetScope(scope)}>
|
||||||
|
<RotateCcw size={13} /> 恢复默认
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className={`grid grid-cols-1 gap-5 p-4 sm:p-5 ${sides.length === 2 ? "lg:grid-cols-2" : ""}`}>
|
||||||
|
{sides.map((side) => (
|
||||||
|
<RailEditor
|
||||||
|
key={side}
|
||||||
|
scope={scope}
|
||||||
|
side={side}
|
||||||
|
list={side === "left" ? left : right}
|
||||||
|
usedKeys={usedKeys}
|
||||||
|
targetFull={(side === "left" ? right : left).length >= SIDEBAR_MAX_BLOCKS_PER_SIDE}
|
||||||
|
widgets={widgets}
|
||||||
|
onChange={(next) => setSide(scope, side, next)}
|
||||||
|
onSendAcross={(i) => moveAcross(scope, side, i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 自定义工具 */}
|
||||||
|
<section
|
||||||
|
id="sec-widgets"
|
||||||
|
data-section="widgets"
|
||||||
|
ref={(el) => { sectionRefs.current.widgets = el; }}
|
||||||
|
className="panel overflow-hidden"
|
||||||
|
>
|
||||||
|
<SectionHead
|
||||||
|
icon={<Code2 size={16} />}
|
||||||
|
title="自定义工具"
|
||||||
|
desc="HTML + CSS + JS 代码块,前台原样渲染执行(不做消毒)"
|
||||||
|
extra={<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}><Plus size={14} /> 新增工具</button>}
|
||||||
|
/>
|
||||||
|
<div className="p-4 sm:p-5">
|
||||||
|
{widgets.length === 0 ? (
|
||||||
|
<AdminEmpty icon={<Blocks size={22} />} title="还没有自定义工具" description="创建后可在上方各页面的侧栏中添加使用" />
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2.5">
|
||||||
|
{widgets.map((w) => (
|
||||||
|
<li key={w.id} className="j13-ads-row !py-2">
|
||||||
|
<span
|
||||||
|
className="shrink-0 w-8 h-8 rounded-lg flex items-center justify-center"
|
||||||
|
style={{ background: w.enabled ? "var(--gold-soft)" : "var(--panel-2)", color: w.enabled ? "var(--gold)" : "var(--ink-3)" }}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Code2 size={14} />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||||
|
{w.name}
|
||||||
|
{!w.enabled && (
|
||||||
|
<span className="ml-2 rounded-full px-1.5 py-0.5 text-[10.5px] font-semibold"
|
||||||
|
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}>已停用</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="meta mt-0.5 text-[11.5px]">
|
||||||
|
ID #{w.id} · HTML {w.html.length} / CSS {w.css.length} / JS {w.js.length} 字符
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex shrink-0 items-center gap-1.5 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
|
||||||
|
<AdminSwitch label={`启用 ${w.name}`} checked={w.enabled} onChange={() => void toggleWidget(w)} />
|
||||||
|
启用
|
||||||
|
</label>
|
||||||
|
<button type="button" className="btn btn-line btn-sm shrink-0" onClick={() => openEdit(w)}>
|
||||||
|
<Pencil size={13} /> 编辑
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-line btn-sm shrink-0" onClick={() => setDeleteId(w.id)} aria-label={`删除 ${w.name}`}>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AdminSettingsWork>
|
||||||
|
|
||||||
|
{/* 吸底保存栏 */}
|
||||||
|
{dirty && (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-40 flex items-center justify-between gap-3 border-t px-4 py-3 sm:px-6"
|
||||||
|
style={{
|
||||||
|
background: "color-mix(in srgb, var(--panel) 92%, transparent)",
|
||||||
|
backdropFilter: "blur(12px)",
|
||||||
|
borderColor: "var(--line)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="meta text-[12px]">
|
||||||
|
<span className="inline-block w-2 h-2 rounded-full mr-1.5" style={{ background: "var(--gold)" }} aria-hidden />
|
||||||
|
布局有未保存的修改
|
||||||
|
</p>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void saveConfig()}>
|
||||||
|
{busy ? "保存中…" : "保存布局"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 工具编辑弹层 */}
|
||||||
|
<Modal
|
||||||
|
open={editorOpen}
|
||||||
|
title={editingId == null ? "新增自定义工具" : "编辑自定义工具"}
|
||||||
|
onClose={() => { if (!busy) setEditorOpen(false); }}
|
||||||
|
busy={busy}
|
||||||
|
maxWidthClass="max-w-2xl"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={() => setEditorOpen(false)}>取消</button>
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" disabled={busy || !draftOk} onClick={() => void submitWidget()}>
|
||||||
|
{busy ? "保存中…" : editingId == null ? "创建" : "保存"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="meta mb-1 block text-[12px]">名称(管理列表与侧栏引用中显示)</span>
|
||||||
|
<input className="field w-full" value={draftName} maxLength={40} onChange={(e) => setDraftName(e.target.value)} placeholder="如:节假日横幅" />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-1 flex items-center justify-between gap-2">
|
||||||
|
<span className="meta text-[12px]">代码一框写完(<style> / <script> 段提交时自动拆分)</span>
|
||||||
|
<span className="meta shrink-0 text-[11.5px] tabular-nums">{draftBytes} / {Math.floor(WIDGET_CODE_MAX_BYTES / 1024)} KB</span>
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
className="field w-full min-h-[240px] font-mono text-[12.5px]"
|
||||||
|
value={mixed}
|
||||||
|
onChange={(e) => setMixed(e.target.value)}
|
||||||
|
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 页面加载后在工具位置执行\n</script>`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<details className="rounded-xl border px-3 py-2" style={{ borderColor: "var(--line)" }}>
|
||||||
|
<summary className="meta cursor-pointer select-none text-[12px]">开放能力速查:window.J13 / 主题变量</summary>
|
||||||
|
<div className="mt-2 space-y-1.5 text-[12px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
|
||||||
|
<p><code>J13.user</code>:当前登录用户(未登录为 null)</p>
|
||||||
|
<p><code>J13.theme.get()</code> 返回 "light" | "dark";<code> J13.theme.onChange(fn)</code> 监听明暗切换</p>
|
||||||
|
<p><code>J13.getOverview()</code>:站点公开统计(Promise,users / posts / online 等)</p>
|
||||||
|
<p>CSS 变量自动跟随明暗主题与后台自定义主题色:<code>var(--ink) var(--accent) var(--panel)…</code></p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<p className="meta text-[11.5px]">
|
||||||
|
CSS 自动限定在本板块内、不污染其他区域;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 删除工具确认 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteId != null}
|
||||||
|
title="删除这个自定义工具?"
|
||||||
|
message="删除后各侧栏中的引用将被同步清理,且不可恢复。"
|
||||||
|
confirmLabel="删除"
|
||||||
|
danger
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={() => void deleteWidget()}
|
||||||
|
onCancel={() => setDeleteId(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 未保存离开确认 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={leaveOpen}
|
||||||
|
title="有未保存的布局修改"
|
||||||
|
message="离开页面将丢失未保存的侧栏布局修改。"
|
||||||
|
confirmLabel="离开"
|
||||||
|
onConfirm={confirmLeave}
|
||||||
|
onCancel={cancelLeave}
|
||||||
|
/>
|
||||||
|
</AdminSettingsPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 分区卡片头 ─── */
|
||||||
|
function SectionHead({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
extra,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
desc?: string;
|
||||||
|
extra?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 sm:px-5" style={{ borderBottom: "1px solid var(--line)" }}>
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<span className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0" style={{ background: "var(--accent-soft)", color: "var(--accent)" }} aria-hidden>{icon}</span>
|
||||||
|
<span className="min-w-0 leading-tight">
|
||||||
|
<span className="block text-[15px] font-semibold" style={{ color: "var(--ink)" }}>{title}</span>
|
||||||
|
{desc ? <span className="meta mt-0.5 block text-[12px] truncate">{desc}</span> : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{extra ? <div className="shrink-0">{extra}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 单侧栏块列表编辑器 ─── */
|
||||||
|
function RailEditor({
|
||||||
|
scope,
|
||||||
|
side,
|
||||||
|
list,
|
||||||
|
usedKeys,
|
||||||
|
targetFull,
|
||||||
|
widgets,
|
||||||
|
onChange,
|
||||||
|
onSendAcross,
|
||||||
|
}: {
|
||||||
|
scope: SidebarScope;
|
||||||
|
side: Side;
|
||||||
|
list: SidebarBlockRef[];
|
||||||
|
usedKeys: Set<string>;
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2.5 flex items-center justify-between gap-2">
|
||||||
|
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||||
|
{SIDE_LABEL[side]}
|
||||||
|
<span className="meta ml-2 text-[11.5px] font-normal tabular-nums">{list.length} / {SIDEBAR_MAX_BLOCKS_PER_SIDE}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{list.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border border-dashed px-4 py-6 text-center"
|
||||||
|
style={{ borderColor: "var(--line)", color: "var(--ink-3)" }}
|
||||||
|
>
|
||||||
|
<Blocks size={18} className="mx-auto mb-2" aria-hidden style={{ color: "var(--ink-3)" }} />
|
||||||
|
<p className="text-[12.5px]">该侧栏为空——前台不渲染这一列</p>
|
||||||
|
<p className="meta mt-1 text-[11px]">从下方选择块添加</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
{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 (
|
||||||
|
<li
|
||||||
|
key={`${refKey(ref)}-${i}`}
|
||||||
|
className={`j13-ads-row !py-1.5 !px-2.5 ${disabled ? "opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
{/* 类型图标圆点:内置蓝 / 自定义金 */}
|
||||||
|
<span
|
||||||
|
className="shrink-0 w-6 h-6 rounded-md flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
background: info?.custom ? "var(--gold-soft)" : "var(--accent-soft)",
|
||||||
|
color: info?.custom ? "var(--gold)" : "var(--accent)",
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{Icon ? <Icon size={12} /> : info?.custom ? <Code2 size={12} /> : <Blocks size={12} />}
|
||||||
|
</span>
|
||||||
|
{/* 序号 */}
|
||||||
|
<span className="meta w-5 shrink-0 text-center text-[11px] tabular-nums">{i + 1}</span>
|
||||||
|
{/* 块信息 */}
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||||
|
{info ? info.label : "未知模块"}
|
||||||
|
</span>
|
||||||
|
<span className="meta text-[11px]">
|
||||||
|
{info?.custom ? "自定义工具" : "内置模块"}
|
||||||
|
{disabled ? " · 已停用(前台不显示)" : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{/* 操作按钮组 */}
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm !px-1.5 !py-1"
|
||||||
|
disabled={i === 0}
|
||||||
|
onClick={() => move(i, -1)}
|
||||||
|
aria-label="上移"
|
||||||
|
>
|
||||||
|
<ArrowUp size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm !px-1.5 !py-1"
|
||||||
|
disabled={i === list.length - 1}
|
||||||
|
onClick={() => move(i, 1)}
|
||||||
|
aria-label="下移"
|
||||||
|
>
|
||||||
|
<ArrowDown size={13} />
|
||||||
|
</button>
|
||||||
|
{scope !== "board" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm !px-1.5 !py-1"
|
||||||
|
disabled={targetFull}
|
||||||
|
onClick={() => onSendAcross(i)}
|
||||||
|
title={side === "left" ? "移到右栏末尾" : "移到左栏末尾"}
|
||||||
|
aria-label={side === "left" ? "移到右栏" : "移到左栏"}
|
||||||
|
>
|
||||||
|
{side === "left" ? <ArrowRight size={13} /> : <ArrowLeft size={13} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm !px-1.5 !py-1"
|
||||||
|
onClick={() => onChange(list.filter((_, j) => j !== i))}
|
||||||
|
aria-label="从侧栏移除"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 添加块:自定义下拉浮层(不依赖原生 select/option,完全跟随主题变量) */}
|
||||||
|
<AddBlockPicker
|
||||||
|
candidates={candidates}
|
||||||
|
full={full}
|
||||||
|
onPick={(ref) => onChange([...list, ref])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 添加块下拉浮层 ─── */
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<div ref={ref} className="relative mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="field flex items-center justify-between gap-2"
|
||||||
|
disabled={full || (!hasAny && !full)}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span className="truncate text-[13px]" style={{ color: full || !hasAny ? "var(--ink-3)" : "var(--ink-3)" }}>
|
||||||
|
{full ? "已达上限" : hasAny ? "选择要添加的模块…" : "没有更多可用的块"}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={15}
|
||||||
|
className={`shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && !full && hasAny && (
|
||||||
|
<div
|
||||||
|
className="absolute z-50 mt-1 w-full overflow-y-auto rounded-xl border shadow-lg"
|
||||||
|
style={{
|
||||||
|
background: "var(--panel)",
|
||||||
|
borderColor: "var(--line)",
|
||||||
|
maxHeight: "320px",
|
||||||
|
}}
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
{candidates.builtin.length > 0 && (
|
||||||
|
<AddGroup title="内置模块" items={candidates.builtin} onPick={(r) => { onPick(r); setOpen(false); }} />
|
||||||
|
)}
|
||||||
|
{candidates.customWidgets.length > 0 && (
|
||||||
|
<AddGroup title="自定义工具" items={candidates.customWidgets} onPick={(r) => { onPick(r); setOpen(false); }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddGroup({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: CandidateItem[];
|
||||||
|
onPick: (ref: SidebarBlockRef) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div role="group" aria-label={title}>
|
||||||
|
<p
|
||||||
|
className="sticky top-0 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide"
|
||||||
|
style={{ background: "var(--panel)", color: "var(--ink-3)", borderBottom: "1px solid var(--line)" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
<ul className="p-1">
|
||||||
|
{items.map((c) => {
|
||||||
|
const Icon = c.iconKey ? ICON_MAP[c.iconKey] : undefined;
|
||||||
|
const disabled = c.widget ? !c.widget.enabled : false;
|
||||||
|
return (
|
||||||
|
<li key={refKey(c.ref)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={false}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onPick(c.ref)}
|
||||||
|
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)] focus:outline-none"
|
||||||
|
style={{ opacity: disabled ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="shrink-0 w-6 h-6 rounded-md flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
background: c.custom ? "var(--gold-soft)" : "var(--accent-soft)",
|
||||||
|
color: c.custom ? "var(--gold)" : "var(--accent)",
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{Icon ? <Icon size={12} /> : <Code2 size={12} />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
{disabled && (
|
||||||
|
<span className="meta text-[11px]">已停用(需先在工具库启用)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<Plus size={13} className="shrink-0" style={{ color: "var(--ink-3)" }} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/app/admin/sidebar/page.tsx
Normal file
46
frontend/app/admin/sidebar/page.tsx
Normal file
@@ -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 <Forbidden text="侧栏管理仅超级管理员及以上角色可操作。" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Suspense fallback={<p className="meta px-1 py-6">加载中…</p>}>
|
||||||
|
<SidebarAdmin
|
||||||
|
initialConfig={config}
|
||||||
|
initialWidgets={widgets}
|
||||||
|
initError={initError}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
栏在 scrollY=0 时的视口 top,否则第一下滚动会先空走一截再吸住 */
|
栏在 scrollY=0 时的视口 top,否则第一下滚动会先空走一截再吸住 */
|
||||||
--header-bar: 68px;
|
--header-bar: 68px;
|
||||||
--header-h: calc(var(--header-bar) + 1px);
|
--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-top: calc(var(--header-h) + var(--main-pad-y));
|
||||||
--rail-max-h: calc(100vh - var(--rail-top) - 1rem);
|
--rail-max-h: calc(100vh - var(--rail-top) - 1rem);
|
||||||
|
|
||||||
@@ -6329,29 +6329,29 @@ a.j13-mention-token:hover {
|
|||||||
|
|
||||||
/* ----- 正文时间线:日志行样式(非标题墙);日徽章为唯一强层级 ----- */
|
/* ----- 正文时间线:日志行样式(非标题墙);日徽章为唯一强层级 ----- */
|
||||||
.j13-timeline {
|
.j13-timeline {
|
||||||
|
position: relative;
|
||||||
margin: 1rem 0 1.25rem;
|
margin: 1rem 0 1.25rem;
|
||||||
padding: 0.1rem 0 0.1rem 0.1rem;
|
padding: 0.1rem 0 0.1rem 0.1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.15rem;
|
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 {
|
.j13-timeline-day {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 1.15rem;
|
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 {
|
.j13-timeline-day-label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -6401,12 +6401,12 @@ a.j13-mention-token:hover {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: -1.15rem;
|
left: -1.15rem;
|
||||||
top: 0.85rem;
|
top: 0.85rem;
|
||||||
width: 0.42rem;
|
width: 0.5rem;
|
||||||
height: 0.42rem;
|
height: 0.5rem;
|
||||||
margin-left: 0.28rem;
|
margin-left: 0.28rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--ok);
|
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));
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--ok) 40%, var(--line));
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -6532,6 +6532,77 @@ a.j13-mention-token:hover {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--panel);
|
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;
|
display: block;
|
||||||
}
|
}
|
||||||
.settings-nav-item small { margin-left:8px; }
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import {
|
|||||||
fetchPosts,
|
fetchPosts,
|
||||||
fetchOverview,
|
fetchOverview,
|
||||||
fetchBoardSidebar,
|
fetchBoardSidebar,
|
||||||
|
fetchSidebar,
|
||||||
type OverviewResponse,
|
type OverviewResponse,
|
||||||
type BoardSidebarResponse,
|
type BoardSidebarResponse,
|
||||||
|
type SidebarPublicPayload,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { defaultSidebarConfig, sidebarScopeLists } from "@/lib/sidebarMeta";
|
||||||
|
import SidebarRenderer, { type SidebarBlockProps } from "@/components/sidebar/SidebarRenderer";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { siteDocumentTitle } from "@/lib/brand";
|
import { siteDocumentTitle } from "@/lib/brand";
|
||||||
@@ -17,14 +21,9 @@ import Pagination from "@/components/Pagination";
|
|||||||
import PostRow from "@/components/PostRow";
|
import PostRow from "@/components/PostRow";
|
||||||
import FeedTabs from "@/components/home/FeedTabs";
|
import FeedTabs from "@/components/home/FeedTabs";
|
||||||
import FeedNewTip from "@/components/home/FeedNewTip";
|
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 { SiteDocBreadcrumb } from "@/components/site-doc";
|
||||||
import { CommunityPanels, CommunityPanelsMobile } from "@/components/home/CommunityPanels";
|
import { BoardMissingPanel } from "@/components/home/BoardPanels";
|
||||||
import {
|
|
||||||
BoardPanels,
|
|
||||||
BoardPanelsMobile,
|
|
||||||
BoardMissingPanel,
|
|
||||||
} from "@/components/home/BoardPanels";
|
|
||||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||||
import { defaultFeedSort, homeCanonicalQuery, normalizeFeedSort } from "@/lib/siteUrl";
|
import { defaultFeedSort, homeCanonicalQuery, normalizeFeedSort } from "@/lib/siteUrl";
|
||||||
import { listingJsonLd } from "@/lib/jsonLd";
|
import { listingJsonLd } from "@/lib/jsonLd";
|
||||||
@@ -107,13 +106,19 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
boardId > 0 && !isSearch
|
boardId > 0 && !isSearch
|
||||||
? fetchBoardSidebar(boardId, cookie).catch(() => null)
|
? fetchBoardSidebar(boardId, cookie).catch(() => null)
|
||||||
: Promise.resolve(null);
|
: Promise.resolve(null);
|
||||||
|
// 侧栏布局配置失败不阻塞页面,回落默认布局
|
||||||
|
const sidebarPromise: Promise<SidebarPublicPayload | null> = 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,
|
mePromise,
|
||||||
postsPromise,
|
postsPromise,
|
||||||
overviewPromise,
|
overviewPromise,
|
||||||
boardSidebarPromise,
|
boardSidebarPromise,
|
||||||
|
sidebarPromise,
|
||||||
]);
|
]);
|
||||||
|
const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] };
|
||||||
const user = me.user;
|
const user = me.user;
|
||||||
const boards = overview?.boards ?? [];
|
const boards = overview?.boards ?? [];
|
||||||
const canManageAnnouncements = !!user && isAdminOrAbove(user.role);
|
const canManageAnnouncements = !!user && isAdminOrAbove(user.role);
|
||||||
@@ -124,6 +129,48 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
// boardId 有效但接口失败 → 显示简卡;成功则板级右栏;否则全站右栏
|
// boardId 有效但接口失败 → 显示简卡;成功则板级右栏;否则全站右栏
|
||||||
const boardPanelsMissing = showBoardPanels && !boardSidebar;
|
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
|
const pageName = isSearch
|
||||||
? `搜索“${keyword}”`
|
? `搜索“${keyword}”`
|
||||||
: activeBoard
|
: activeBoard
|
||||||
@@ -189,7 +236,12 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
<WorkspaceNavMobile boards={boards} user={user} />
|
<WorkspaceNavMobile boards={boards} user={user} />
|
||||||
|
|
||||||
<div className={`flex items-start gap-6 xl:gap-7 ${activeBoard ? "" : "mt-5 lg:mt-0"}`}>
|
<div className={`flex items-start gap-6 xl:gap-7 ${activeBoard ? "" : "mt-5 lg:mt-0"}`}>
|
||||||
<WorkspaceNav boards={boards} user={user} />
|
{/* 左栏:按配置渲染(缺省 = 板块目录) */}
|
||||||
|
{scopeLists.left.length > 0 && (
|
||||||
|
<aside className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]">
|
||||||
|
<SidebarRenderer blocks={scopeLists.left} widgets={sidebar.widgets} props={sidebarProps} />
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 中栏:tab 栏与帖子流为同一个整体卡片(左栏切板块只替换这一列) */}
|
{/* 中栏:tab 栏与帖子流为同一个整体卡片(左栏切板块只替换这一列) */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -247,85 +299,44 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右栏:选中板块时切板级面板,否则全站社区面板 */}
|
{/* 右栏:选中板块时切板级面板,否则全站社区面板;均按配置渲染 */}
|
||||||
{showBoardPanels ? (
|
{showBoardPanels ? (
|
||||||
boardSidebar ? (
|
boardSidebar ? (
|
||||||
<BoardPanels
|
scopeLists.right.length > 0 ? (
|
||||||
board={boardSidebar.board}
|
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="板块面板">
|
||||||
moderators={boardSidebar.moderators}
|
<SidebarRenderer
|
||||||
stats={boardSidebar.stats}
|
blocks={scopeLists.right}
|
||||||
hot={boardSidebar.hot}
|
widgets={sidebar.widgets}
|
||||||
activeUsers={boardSidebar.active_users}
|
props={sidebarProps}
|
||||||
checkin={boardSidebar.checkin ?? undefined}
|
className="space-y-4"
|
||||||
allowComments={allowComments}
|
/>
|
||||||
/>
|
</aside>
|
||||||
|
) : null
|
||||||
) : boardPanelsMissing ? (
|
) : boardPanelsMissing ? (
|
||||||
<BoardMissingPanel />
|
<BoardMissingPanel />
|
||||||
) : null
|
) : null
|
||||||
) : (
|
) : overview && scopeLists.right.length > 0 ? (
|
||||||
overview && (
|
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="社区面板">
|
||||||
<CommunityPanels
|
<SidebarRenderer
|
||||||
stats={overview.stats}
|
blocks={scopeLists.right}
|
||||||
hot={overview.hot}
|
widgets={sidebar.widgets}
|
||||||
activeUsers={overview.active_users}
|
props={sidebarProps}
|
||||||
announcements={overview.announcements}
|
className="space-y-4"
|
||||||
newUsers={overview.new_users}
|
|
||||||
checkin={overview.checkin ?? undefined}
|
|
||||||
allowComments={allowComments}
|
|
||||||
canManageAnnouncements={canManageAnnouncements}
|
|
||||||
canManagePages={canManagePages}
|
|
||||||
sidebarPages={sidebarPages}
|
|
||||||
siteDescription={publicSettings.site_description}
|
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
)
|
</aside>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 窄屏:面板下沉到帖子流之后 */}
|
{/* 窄屏:面板下沉到帖子流之后(右栏列表 + 左栏列表,板块目录除外) */}
|
||||||
{showBoardPanels ? (
|
{mobileBlocks.length > 0 && (
|
||||||
boardSidebar ? (
|
<div className="mt-6 lg:hidden">
|
||||||
<div className="mt-6">
|
<SidebarRenderer
|
||||||
<BoardPanelsMobile
|
blocks={mobileBlocks}
|
||||||
board={boardSidebar.board}
|
widgets={sidebar.widgets}
|
||||||
moderators={boardSidebar.moderators}
|
props={sidebarProps}
|
||||||
stats={boardSidebar.stats}
|
className="space-y-4"
|
||||||
hot={boardSidebar.hot}
|
/>
|
||||||
activeUsers={boardSidebar.active_users}
|
</div>
|
||||||
checkin={boardSidebar.checkin ?? undefined}
|
|
||||||
allowComments={allowComments}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
) : (
|
|
||||||
overview && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<CommunityPanelsMobile
|
|
||||||
stats={overview.stats}
|
|
||||||
hot={overview.hot}
|
|
||||||
activeUsers={overview.active_users}
|
|
||||||
announcements={overview.announcements}
|
|
||||||
newUsers={overview.new_users}
|
|
||||||
checkin={overview.checkin ?? undefined}
|
|
||||||
allowComments={allowComments}
|
|
||||||
canManageAnnouncements={canManageAnnouncements}
|
|
||||||
canManagePages={canManagePages}
|
|
||||||
sidebarPages={sidebarPages}
|
|
||||||
siteDescription={publicSettings.site_description}
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Flame,
|
Flame,
|
||||||
Layers,
|
|
||||||
User,
|
|
||||||
PenLine,
|
|
||||||
ArrowRight,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -18,19 +14,20 @@ import {
|
|||||||
fetchComments,
|
fetchComments,
|
||||||
fetchPosts,
|
fetchPosts,
|
||||||
fetchUserProfile,
|
fetchUserProfile,
|
||||||
|
fetchSidebar,
|
||||||
postDeleteTypeLabel,
|
postDeleteTypeLabel,
|
||||||
type UserProfile,
|
type UserProfile,
|
||||||
type PostListItem,
|
type PostListItem,
|
||||||
type Board,
|
|
||||||
type CommentsResponse,
|
type CommentsResponse,
|
||||||
|
type SidebarPublicPayload,
|
||||||
} from "@/lib/api";
|
} 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 { postViewerCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import CommentSection from "@/components/CommentSection";
|
import CommentSection from "@/components/CommentSection";
|
||||||
import PostActions from "@/components/PostActions";
|
import PostActions from "@/components/PostActions";
|
||||||
import LikeButton from "@/components/LikeButton";
|
import LikeButton from "@/components/LikeButton";
|
||||||
import PostToc from "@/components/PostToc";
|
|
||||||
import { SiteDocBreadcrumb } from "@/components/site-doc";
|
import { SiteDocBreadcrumb } from "@/components/site-doc";
|
||||||
import MobilePostBar from "@/components/MobilePostBar";
|
import MobilePostBar from "@/components/MobilePostBar";
|
||||||
import Avatar from "@/components/Avatar";
|
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) {
|
function formatDateTime(dateStr: string) {
|
||||||
return new Date(dateStr).toLocaleString("zh-CN", {
|
return new Date(dateStr).toLocaleString("zh-CN", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
@@ -105,162 +95,6 @@ function formatDateTime(dateStr: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
左栏模块(仅 xl 三栏时展示)
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
function BoardCard({ board }: { board: Board }) {
|
|
||||||
const colorIdx = boardColor(board);
|
|
||||||
return (
|
|
||||||
<section className="j13-module shrink-0" aria-labelledby="board-card-title">
|
|
||||||
<p className="j13-module-title mb-3" id="board-card-title">
|
|
||||||
<Layers size={15} style={{ color: "var(--accent)" }} /> 所属板块
|
|
||||||
</p>
|
|
||||||
<Link href={`/?board=${board.id}`} className="flex items-center gap-3 group">
|
|
||||||
<span
|
|
||||||
className={`av-${colorIdx} w-11 h-11 rounded-xl flex items-center justify-center shrink-0`}
|
|
||||||
>
|
|
||||||
<BoardIcon icon={board.icon} name={board.name} size={20} />
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="font-semibold text-[14.5px] truncate group-hover:text-[var(--accent)] transition-colors"
|
|
||||||
style={{ color: "var(--ink)" }}
|
|
||||||
>
|
|
||||||
{board.name}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
{board.description && (
|
|
||||||
<p className="mt-2.5 text-[12.5px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
|
||||||
{board.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Link
|
|
||||||
href={`/?board=${board.id}`}
|
|
||||||
className="btn btn-line btn-sm w-full justify-center mt-3.5"
|
|
||||||
>
|
|
||||||
查看该板讨论 <ArrowRight size={13} />
|
|
||||||
</Link>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<section className="j13-module shrink-0" aria-labelledby="author-card-title">
|
|
||||||
<p className="j13-module-title mb-3" id="author-card-title">
|
|
||||||
<User size={15} style={{ color: "var(--accent)" }} /> 作者
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link href={`/u/${u.id}`} className="shrink-0" aria-label={u.nickname}>
|
|
||||||
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={48} />
|
|
||||||
</Link>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<Link
|
|
||||||
href={`/u/${u.id}`}
|
|
||||||
className="block font-semibold text-[14.5px] truncate hover:text-[var(--accent)] transition-colors"
|
|
||||||
style={{ color: "var(--ink)" }}
|
|
||||||
>
|
|
||||||
{u.nickname}
|
|
||||||
</Link>
|
|
||||||
<p className="meta mt-0.5 truncate">@{u.username}</p>
|
|
||||||
{u.role !== "user" && (
|
|
||||||
<div className="mt-1">
|
|
||||||
<RoleBadge role={u.role} boardIds={u.board_ids} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 有签名则展示;无则留空,不放占位文案 */}
|
|
||||||
{signature ? (
|
|
||||||
<p
|
|
||||||
className="mt-3 text-[12.5px] leading-relaxed line-clamp-3"
|
|
||||||
style={{ color: "var(--ink-2)" }}
|
|
||||||
title={signature}
|
|
||||||
>
|
|
||||||
{signature}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<div
|
|
||||||
className={`mt-3 grid border-t ${stats.length === 3 ? "grid-cols-3" : "grid-cols-2"}`}
|
|
||||||
style={{ borderColor: "var(--line)" }}
|
|
||||||
>
|
|
||||||
{stats.map((s, i) => (
|
|
||||||
<div
|
|
||||||
key={s.label}
|
|
||||||
className="px-1.5 py-2.5 text-center"
|
|
||||||
style={i > 0 ? { borderLeft: "1px solid var(--line)" } : undefined}
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className="text-[17px] font-semibold leading-none tabular-nums"
|
|
||||||
style={{ color: "var(--ink)" }}
|
|
||||||
>
|
|
||||||
{s.value}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
|
||||||
{s.label}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{streak > 0 && (
|
|
||||||
<p
|
|
||||||
className="mt-2 flex items-center justify-center gap-1 text-[11.5px] font-medium"
|
|
||||||
style={{ color: "var(--gold)" }}
|
|
||||||
>
|
|
||||||
<Flame size={11.5} /> 连签 {streak} 天
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm w-full justify-center mt-3">
|
|
||||||
访问主页
|
|
||||||
</Link>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthorPosts({ posts }: { posts: PostListItem[] }) {
|
|
||||||
if (posts.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<section className="j13-module flex min-h-0 flex-col" aria-labelledby="author-posts-title">
|
|
||||||
<p className="j13-module-title mb-1 shrink-0" id="author-posts-title">
|
|
||||||
<PenLine size={15} style={{ color: "var(--accent)" }} /> TA 的其他帖子
|
|
||||||
</p>
|
|
||||||
<ul className="j13-rail-scroll">
|
|
||||||
{posts.map((p) => (
|
|
||||||
<li key={p.id}>
|
|
||||||
<Link
|
|
||||||
href={`/post/${p.id}`}
|
|
||||||
data-tip={p.title}
|
|
||||||
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="block text-[13px] leading-snug line-clamp-2"
|
|
||||||
style={{ color: "var(--ink)" }}
|
|
||||||
>
|
|
||||||
{p.title}
|
|
||||||
</span>
|
|
||||||
<span className="meta mt-1 block text-[11px]">{formatYMD(p.created_at)}</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
右栏模块(lg 起展示;移动端下沉到评论之后)
|
右栏模块(lg 起展示;移动端下沉到评论之后)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -466,11 +300,14 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
};
|
};
|
||||||
const meFirst = await getMeCached(cookie || undefined);
|
const meFirst = await getMeCached(cookie || undefined);
|
||||||
const canFetchComments = allowComments && (!commentsRequireLogin || !!meFirst.user);
|
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),
|
canFetchComments ? fetchComments(id, commentPage, cookie) : Promise.resolve(emptyComments),
|
||||||
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
|
fetchUserProfile(String(post.user.id), 1, cookie).catch(() => null),
|
||||||
fetchPosts(1, 8, post.board_id, "recommended", "", false, 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 me = meFirst;
|
||||||
const comments = commentsData.comments;
|
const comments = commentsData.comments;
|
||||||
const related = (relatedData?.posts ?? []).filter((p) => p.id !== post.id).slice(0, 5);
|
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 =
|
const edited =
|
||||||
new Date(post.updated_at).getTime() - new Date(post.created_at).getTime() > 60_000;
|
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 isPublished = post.status === "published";
|
||||||
const postType = normalizePostType(post.post_type);
|
const postType = normalizePostType(post.post_type);
|
||||||
@@ -602,19 +459,22 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
allowComments={allowComments}
|
allowComments={allowComments}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏 */}
|
{/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏
|
||||||
<div className="grid grid-cols-1 items-start lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px] gap-x-6 xl:gap-x-7">
|
侧栏两列均可被站长清空,网格随之退化为两栏/单栏 */}
|
||||||
{/* 左栏:板块 / 作者 / 作者其他帖 */}
|
<div
|
||||||
|
className={`grid grid-cols-1 items-start gap-x-6 xl:gap-x-7 ${railGridClass}`}
|
||||||
|
>
|
||||||
|
{/* 左栏:按配置渲染(缺省 = 板块 / 作者 / 作者其他帖) */}
|
||||||
{/* sticky 必须直接挂在 grid 子项上:aside 作为 grid item,其可粘行程
|
{/* sticky 必须直接挂在 grid 子项上:aside 作为 grid item,其可粘行程
|
||||||
等于行高(正文高度);内层包裹会被 items-start 缩成内容高而失去行程 */}
|
等于行高(正文高度);内层包裹会被 items-start 缩成内容高而失去行程 */}
|
||||||
<aside
|
{hasLeftRail && (
|
||||||
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
|
<aside
|
||||||
aria-label="帖子导航"
|
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
|
||||||
>
|
aria-label="帖子导航"
|
||||||
<BoardCard board={post.board} />
|
>
|
||||||
{profile && <AuthorRailCard profile={profile} allowComments={allowComments} />}
|
<SidebarRenderer blocks={sidebarLists.left} widgets={sidebar.widgets} props={sidebarProps} />
|
||||||
{profile && <AuthorPosts posts={authorPosts} />}
|
</aside>
|
||||||
</aside>
|
)}
|
||||||
|
|
||||||
{/* 中栏:正文主体 */}
|
{/* 中栏:正文主体 */}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -845,14 +705,16 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右栏:目录 / 同板块热议(阅读数据已并入标题下元信息条) */}
|
{/* 右栏:按配置渲染(缺省 = 文章目录)+ 固定的同板块热议 */}
|
||||||
<aside
|
{hasRightRail && (
|
||||||
className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
|
<aside
|
||||||
aria-label="阅读辅助"
|
className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
|
||||||
>
|
aria-label="阅读辅助"
|
||||||
{toc.length > 0 && <PostToc items={toc} />}
|
>
|
||||||
<RelatedPosts posts={related} boardId={post.board_id} allowComments={allowComments} />
|
<SidebarRenderer blocks={sidebarLists.right} widgets={sidebar.widgets} props={sidebarProps} />
|
||||||
</aside>
|
<RelatedPosts posts={related} boardId={post.board_id} allowComments={allowComments} />
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 给移动端浮动操作条让位,避免遮住页尾内容 */}
|
{/* 给移动端浮动操作条让位,避免遮住页尾内容 */}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Link from "next/link";
|
|||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import {
|
import {
|
||||||
ChevronRight, FileText, MessageCircle, Flame, Calendar, Hash, Shield,
|
FileText, MessageCircle, Flame, Calendar, Hash, Shield,
|
||||||
Settings, KeyRound, Info, PenLine,
|
Settings, KeyRound, Info, PenLine,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -178,12 +178,6 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<JsonLd data={profileLd} />
|
<JsonLd data={profileLd} />
|
||||||
<nav className="meta -mt-4 sm:-mt-6 mb-1.5 flex items-center gap-1 text-[12.5px] leading-none">
|
|
||||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
|
|
||||||
<ChevronRight size={13} />
|
|
||||||
<span style={{ color: "var(--accent)" }}>{u.nickname || u.username}</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* ═══ Hero:头像 + 昵称 + 简介 + 行内统计,约 120px 高 ═══ */}
|
{/* ═══ Hero:头像 + 昵称 + 简介 + 行内统计,约 120px 高 ═══ */}
|
||||||
<section
|
<section
|
||||||
className="panel relative mb-5 transition-[background-color,box-shadow] duration-150"
|
className="panel relative mb-5 transition-[background-color,box-shadow] duration-150"
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ export default function Avatar({
|
|||||||
size = 36,
|
size = 36,
|
||||||
src,
|
src,
|
||||||
className = "",
|
className = "",
|
||||||
|
square = false,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 方形(带小圆角)轮廓,用于帖子列表行;默认圆形 */
|
||||||
|
square?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const idx = hash(name || "?") % 8;
|
const idx = hash(name || "?") % 8;
|
||||||
const initial = (name || "?").trim().charAt(0).toUpperCase();
|
const initial = (name || "?").trim().charAt(0).toUpperCase();
|
||||||
@@ -30,7 +33,9 @@ export default function Avatar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center justify-center shrink-0 select-none font-bold rounded-full overflow-hidden av-${idx} ${className}`}
|
className={`inline-flex items-center justify-center shrink-0 select-none font-bold overflow-hidden av-${idx} ${
|
||||||
|
square ? "rounded-[9px]" : "rounded-full"
|
||||||
|
} ${className}`}
|
||||||
style={{ width: size, height: size, fontSize: size * 0.42 }}
|
style={{ width: size, height: size, fontSize: size * 0.42 }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function PinnedPosts({
|
|||||||
animationDelay: `${i * 35}ms`,
|
animationDelay: `${i * 35}ms`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Avatar name={p.user.nickname || p.user.username} src={p.user.avatar || undefined} size={36} />
|
<Avatar name={p.user.nickname || p.user.username} src={p.user.avatar || undefined} size={36} square />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3
|
<h3
|
||||||
className="text-[14.5px] font-bold leading-snug truncate transition-colors group-hover:text-[var(--accent)]"
|
className="text-[14.5px] font-bold leading-snug truncate transition-colors group-hover:text-[var(--accent)]"
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export default async function PostRow({
|
|||||||
aria-label={authorName}
|
aria-label={authorName}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<Avatar name={authorName} src={post.user.avatar || undefined} size={compact ? 38 : 46} />
|
<Avatar name={authorName} src={post.user.avatar || undefined} size={compact ? 38 : 46} square />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@@ -165,6 +165,16 @@ export default function TimelineEditor({
|
|||||||
onClose();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
@@ -173,19 +183,36 @@ export default function TimelineEditor({
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
zIndexClass="z-[1200]"
|
zIndexClass="z-[1200]"
|
||||||
maxWidthClass="max-w-2xl"
|
maxWidthClass="max-w-2xl"
|
||||||
panelClassName="p-0 overflow-hidden"
|
description="从提交记录批量导入,或手动编辑后插入正文。"
|
||||||
|
panelClassName="p-5 sm:p-6 max-h-[88vh] flex flex-col overflow-hidden"
|
||||||
|
bodyClassName="flex-1 min-h-0 flex flex-col"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<div className="w-full flex items-center justify-between gap-2">
|
||||||
<button type="button" className="btn btn-line btn-sm" onClick={onClose}>
|
<span
|
||||||
取消
|
className="text-[12.5px]"
|
||||||
</button>
|
style={{ color: keptCount > 0 ? "var(--ink-3)" : "var(--danger)" }}
|
||||||
<button type="button" className="btn btn-primary btn-sm" onClick={handleInsert}>
|
>
|
||||||
{initialBody.trim() ? "更新时间线" : "插入时间线"}
|
{keptCount > 0
|
||||||
</button>
|
? `将${initialBody.trim() ? "更新" : "插入"} ${keptCount} 条`
|
||||||
</>
|
: "没有可保留的条目"}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" className="btn btn-line btn-sm" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={handleInsert}
|
||||||
|
disabled={keptCount === 0}
|
||||||
|
>
|
||||||
|
{initialBody.trim() ? "更新时间线" : "插入时间线"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="max-h-[min(70vh,640px)] overflow-y-auto px-5 py-4 space-y-4">
|
<div className="flex-1 min-h-0 overflow-y-auto space-y-4">
|
||||||
{/* 导入区 */}
|
{/* 导入区 */}
|
||||||
<section className="j13-tl-import" aria-label="从提交记录导入">
|
<section className="j13-tl-import" aria-label="从提交记录导入">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
@@ -195,16 +222,22 @@ export default function TimelineEditor({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="meta text-[12px] mb-2 leading-relaxed">
|
<p className="meta text-[12px] mb-2 leading-relaxed">
|
||||||
粘贴 Gitea / GitHub 的 commits 列表页(一行一个)。默认自动拉取后续页;也可只贴某几页。
|
粘贴 Gitea / GitHub 的 commits 列表页 URL,每行一条。默认自动拉取后续页,也可只贴某几页。
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
value={urlsText}
|
value={urlsText}
|
||||||
onChange={(e) => setUrlsText(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setUrlsText(e.target.value);
|
||||||
|
setImportError(null);
|
||||||
|
}}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={"https://github.com/org/repo/commits/main\nhttps://git.example.com/user/repo/commits/branch/main"}
|
placeholder={"https://github.com/org/repo/commits/main\nhttps://git.example.com/user/repo/commits/branch/main"}
|
||||||
className="j13-tl-urls"
|
className="j13-tl-urls"
|
||||||
disabled={importing}
|
disabled={importing}
|
||||||
/>
|
/>
|
||||||
|
{urlCount > 0 ? (
|
||||||
|
<p className="j13-tl-hint">已识别 {urlCount} 条 URL</p>
|
||||||
|
) : null}
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
|
<label className="inline-flex items-center gap-1.5 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
|
||||||
<input
|
<input
|
||||||
@@ -242,18 +275,24 @@ export default function TimelineEditor({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{importError ? (
|
{showReplaceWarning ? (
|
||||||
<p className="mt-2 text-[12.5px] leading-relaxed" style={{ color: "var(--danger)" }}>
|
<p className="j13-tl-banner is-warn">
|
||||||
{importError}
|
替换模式会丢弃当前已编辑的 {items.length} 条内容;如需保留请改选「追加」。
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{importError ? (
|
||||||
|
<p className="j13-tl-banner is-err">{importError}</p>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 条目列表 */}
|
{/* 条目列表 */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
<div className="flex items-center gap-2">
|
||||||
时间线条目
|
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||||
</p>
|
时间线条目
|
||||||
|
</p>
|
||||||
|
<span className="j13-tl-count is-muted">{items.length} 项</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<button type="button" className="btn btn-line btn-sm" onClick={sortByDate}>
|
<button type="button" className="btn btn-line btn-sm" onClick={sortByDate}>
|
||||||
按日期整理
|
按日期整理
|
||||||
@@ -268,80 +307,99 @@ export default function TimelineEditor({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-3">
|
{items.length === 0 ? (
|
||||||
{items.map((it, idx) => (
|
<div className="j13-tl-empty">
|
||||||
<li key={idx} className="j13-tl-edit-card">
|
<p>还没有时间线条目</p>
|
||||||
<div className="flex items-start gap-2">
|
<button
|
||||||
<label className="mt-2 shrink-0">
|
type="button"
|
||||||
<input
|
className="btn btn-line btn-sm"
|
||||||
type="checkbox"
|
onClick={() => setItems([emptyItem()])}
|
||||||
checked={it.selected !== false}
|
>
|
||||||
onChange={(e) => updateItem(idx, { selected: e.target.checked })}
|
<Plus size={14} /> 添加一条
|
||||||
aria-label="保留此条"
|
</button>
|
||||||
/>
|
</div>
|
||||||
</label>
|
) : (
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<ul className="space-y-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
{items.map((it, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
className={`j13-tl-edit-card${it.selected === false ? " is-excluded" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<label className="mt-2 shrink-0">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="checkbox"
|
||||||
value={it.date}
|
checked={it.selected !== false}
|
||||||
onChange={(e) => updateItem(idx, { date: e.target.value })}
|
onChange={(e) => updateItem(idx, { selected: e.target.checked })}
|
||||||
className="j13-tl-field w-[9.5rem]"
|
aria-label="保留此条"
|
||||||
/>
|
/>
|
||||||
<input
|
</label>
|
||||||
type="text"
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
value={it.title}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
maxLength={200}
|
{it.selected === false ? (
|
||||||
placeholder="标题"
|
<span className="j13-tl-tag">已排除</span>
|
||||||
onChange={(e) => updateItem(idx, { title: e.target.value })}
|
) : null}
|
||||||
className="j13-tl-field flex-1 min-w-[10rem]"
|
<input
|
||||||
|
type="date"
|
||||||
|
value={it.date}
|
||||||
|
onChange={(e) => updateItem(idx, { date: e.target.value })}
|
||||||
|
className="j13-tl-field w-[9.5rem]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={it.title}
|
||||||
|
maxLength={200}
|
||||||
|
placeholder="标题"
|
||||||
|
onChange={(e) => updateItem(idx, { title: e.target.value })}
|
||||||
|
className="j13-tl-field flex-1 min-w-[10rem]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={it.body}
|
||||||
|
rows={2}
|
||||||
|
placeholder="说明(可选,支持 Markdown)"
|
||||||
|
onChange={(e) => updateItem(idx, { body: e.target.value })}
|
||||||
|
className="j13-tl-field w-full"
|
||||||
/>
|
/>
|
||||||
|
{it.sourceUrl ? (
|
||||||
|
<p className="meta text-[11.5px] truncate" title={it.sourceUrl}>
|
||||||
|
来自提交 · {it.sourceUrl}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="j13-md-tool"
|
||||||
|
aria-label="上移"
|
||||||
|
disabled={idx === 0}
|
||||||
|
onClick={() => moveItem(idx, -1)}
|
||||||
|
>
|
||||||
|
<ArrowUp size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="j13-md-tool"
|
||||||
|
aria-label="下移"
|
||||||
|
disabled={idx === items.length - 1}
|
||||||
|
onClick={() => moveItem(idx, 1)}
|
||||||
|
>
|
||||||
|
<ArrowDown size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="j13-md-tool"
|
||||||
|
aria-label="删除"
|
||||||
|
onClick={() => setDeleteIdx(idx)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
|
||||||
value={it.body}
|
|
||||||
rows={2}
|
|
||||||
placeholder="说明(可选,支持 Markdown)"
|
|
||||||
onChange={(e) => updateItem(idx, { body: e.target.value })}
|
|
||||||
className="j13-tl-field w-full"
|
|
||||||
/>
|
|
||||||
{it.sourceUrl ? (
|
|
||||||
<p className="meta text-[11.5px] truncate" title={it.sourceUrl}>
|
|
||||||
来自提交 · {it.sourceUrl}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5 shrink-0">
|
</li>
|
||||||
<button
|
))}
|
||||||
type="button"
|
</ul>
|
||||||
className="j13-md-tool"
|
)}
|
||||||
aria-label="上移"
|
|
||||||
disabled={idx === 0}
|
|
||||||
onClick={() => moveItem(idx, -1)}
|
|
||||||
>
|
|
||||||
<ArrowUp size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="j13-md-tool"
|
|
||||||
aria-label="下移"
|
|
||||||
disabled={idx === items.length - 1}
|
|
||||||
onClick={() => moveItem(idx, 1)}
|
|
||||||
>
|
|
||||||
<ArrowDown size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="j13-md-tool"
|
|
||||||
aria-label="删除"
|
|
||||||
onClick={() => setDeleteIdx(idx)}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ export default function VisitBeacon() {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (lastRef.current.path === pathname && now - lastRef.current.at < 30_000) return;
|
if (lastRef.current.path === pathname && now - lastRef.current.at < 30_000) return;
|
||||||
lastRef.current = { path: pathname, at: now };
|
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]);
|
}, [pathname]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
Columns3,
|
Columns3,
|
||||||
|
PanelLeft,
|
||||||
|
BarChart3,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { User } from "@/lib/api";
|
import type { User } from "@/lib/api";
|
||||||
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
||||||
@@ -64,6 +66,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
title: "概览",
|
title: "概览",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
|
{ 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,
|
icon: Palette,
|
||||||
visible: (u) => isSuperOrOwner(u.role),
|
visible: (u) => isSuperOrOwner(u.role),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/sidebar",
|
||||||
|
label: "侧栏管理",
|
||||||
|
icon: PanelLeft,
|
||||||
|
visible: (u) => isSuperOrOwner(u.role),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { formatCompact } from "@/lib/format";
|
|||||||
|
|
||||||
/* ---------- 1. 板块管理员 ---------- */
|
/* ---------- 1. 板块管理员 ---------- */
|
||||||
|
|
||||||
function Moderators({ items }: { items: BoardModerator[] }) {
|
export function Moderators({ items }: { items: BoardModerator[] }) {
|
||||||
return (
|
return (
|
||||||
<section className="j13-module" aria-label="板块管理员">
|
<section className="j13-module" aria-label="板块管理员">
|
||||||
<p className="j13-module-title mb-2">
|
<p className="j13-module-title mb-2">
|
||||||
@@ -56,7 +56,7 @@ function Moderators({ items }: { items: BoardModerator[] }) {
|
|||||||
|
|
||||||
/* ---------- 2. 板块概况 ---------- */
|
/* ---------- 2. 板块概况 ---------- */
|
||||||
|
|
||||||
function BoardIntro({ board }: { board: BoardWithCount }) {
|
export function BoardIntro({ board }: { board: BoardWithCount }) {
|
||||||
return (
|
return (
|
||||||
<section className="j13-module" aria-label="板块概况">
|
<section className="j13-module" aria-label="板块概况">
|
||||||
<p className="j13-module-title mb-2">
|
<p className="j13-module-title mb-2">
|
||||||
@@ -82,7 +82,7 @@ function BoardIntro({ board }: { board: BoardWithCount }) {
|
|||||||
|
|
||||||
/* ---------- 3. 板块数据 + 签到 ---------- */
|
/* ---------- 3. 板块数据 + 签到 ---------- */
|
||||||
|
|
||||||
function BoardPulse({
|
export function BoardPulse({
|
||||||
stats,
|
stats,
|
||||||
checkin,
|
checkin,
|
||||||
allowComments,
|
allowComments,
|
||||||
@@ -156,7 +156,7 @@ function BoardPulse({
|
|||||||
|
|
||||||
/* ---------- 4. 本板热门 ---------- */
|
/* ---------- 4. 本板热门 ---------- */
|
||||||
|
|
||||||
function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
|
export function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
|
||||||
return (
|
return (
|
||||||
<section className="j13-module" aria-label="本板热门">
|
<section className="j13-module" aria-label="本板热门">
|
||||||
<p className="j13-module-title mb-1.5">
|
<p className="j13-module-title mb-1.5">
|
||||||
@@ -196,7 +196,7 @@ function BoardHot({ posts, allowComments }: { posts: PostListItem[]; allowCommen
|
|||||||
|
|
||||||
/* ---------- 5. 本板活跃 ---------- */
|
/* ---------- 5. 本板活跃 ---------- */
|
||||||
|
|
||||||
function BoardActive({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
|
export function BoardActive({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
|
||||||
return (
|
return (
|
||||||
<section className="j13-module" aria-label="本板活跃成员">
|
<section className="j13-module" aria-label="本板活跃成员">
|
||||||
<p className="j13-module-title mb-2">
|
<p className="j13-module-title mb-2">
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import { AD_IMAGE_HEIGHT, AD_IMAGE_SIZE_LABEL, AD_IMAGE_WIDTH } from "@/lib/ads"
|
|||||||
|
|
||||||
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
|
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
|
||||||
|
|
||||||
function PulseCard({
|
export function PulseCard({
|
||||||
stats,
|
stats,
|
||||||
checkin,
|
checkin,
|
||||||
allowComments,
|
allowComments,
|
||||||
@@ -130,7 +130,7 @@ function PulseCard({
|
|||||||
|
|
||||||
/* ---------- 关于本站(侧栏页目录,置于右栏最底) ---------- */
|
/* ---------- 关于本站(侧栏页目录,置于右栏最底) ---------- */
|
||||||
|
|
||||||
function AboutSite({
|
export function AboutSite({
|
||||||
pages,
|
pages,
|
||||||
siteDescription,
|
siteDescription,
|
||||||
canManagePages,
|
canManagePages,
|
||||||
@@ -175,7 +175,7 @@ function AboutSite({
|
|||||||
|
|
||||||
/* ---------- 站点公告 ---------- */
|
/* ---------- 站点公告 ---------- */
|
||||||
|
|
||||||
function Announcements({
|
export function Announcements({
|
||||||
items,
|
items,
|
||||||
canManageAnnouncements,
|
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 (
|
return (
|
||||||
<section className="j13-module" aria-label="热门讨论">
|
<section className="j13-module" aria-label="热门讨论">
|
||||||
<p className="j13-module-title mb-1.5">
|
<p className="j13-module-title mb-1.5">
|
||||||
@@ -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;
|
if (users.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<section className="j13-module" aria-label="最新注册">
|
<section className="j13-module" aria-label="最新注册">
|
||||||
@@ -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 (
|
return (
|
||||||
<section className="j13-module" aria-label="本周活跃成员">
|
<section className="j13-module" aria-label="本周活跃成员">
|
||||||
<p className="j13-module-title mb-2">
|
<p className="j13-module-title mb-2">
|
||||||
@@ -383,7 +383,7 @@ function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComme
|
|||||||
|
|
||||||
/* ---------- 友情链接 ---------- */
|
/* ---------- 友情链接 ---------- */
|
||||||
|
|
||||||
function FriendLinks() {
|
export function FriendLinks() {
|
||||||
if (FRIEND_LINKS.length === 0) return null;
|
if (FRIEND_LINKS.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<section className="j13-module" aria-label="友情链接">
|
<section className="j13-module" aria-label="友情链接">
|
||||||
@@ -441,7 +441,7 @@ type PanelsProps = {
|
|||||||
sponsorsEnabled?: boolean;
|
sponsorsEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function AdsPanel({
|
export function AdsPanel({
|
||||||
items,
|
items,
|
||||||
title,
|
title,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -529,7 +529,7 @@ function AdsPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SponsorsPanel({
|
export function SponsorsPanel({
|
||||||
items,
|
items,
|
||||||
title,
|
title,
|
||||||
enabled,
|
enabled,
|
||||||
|
|||||||
@@ -12,81 +12,95 @@ function boardHref(id: number) {
|
|||||||
return `/?board=${id}`;
|
return `/?board=${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 桌面左栏:整块模块,与右栏 j13-module 对齐
|
// 板块目录裸面板:只有 j13-module 本体,不含外层 nav / 宽度 / sticky 类,
|
||||||
export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?: User | null }) {
|
// 供侧栏注册表按管理端配置在任意侧栏渲染。
|
||||||
|
export function WorkspaceNavPanel({
|
||||||
|
boards,
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
|
boards: BoardWithCount[];
|
||||||
|
user?: User | null;
|
||||||
|
}) {
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const activeBoard = Number(params.get("board")) || 0;
|
const activeBoard = Number(params.get("board")) || 0;
|
||||||
const showAdmin = !!user && isStaff(user.role);
|
const showAdmin = !!user && isStaff(user.role);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden" aria-label="板块目录">
|
||||||
|
<p className="j13-module-title shrink-0">
|
||||||
|
<LayoutGrid size={15} style={{ color: "var(--accent)" }} aria-hidden />
|
||||||
|
板块目录
|
||||||
|
{boards.length > 0 ? (
|
||||||
|
<span className="ml-auto meta text-[11.5px] font-medium tracking-normal tabular-nums">
|
||||||
|
{boards.length} 个
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="j13-rail-scroll j13-board-list mt-2.5 min-h-0 flex-1">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="j13-bcard"
|
||||||
|
aria-current={activeBoard === 0 ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="j13-bcard-ico"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Home size={15} />
|
||||||
|
</span>
|
||||||
|
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{boards.length > 0 ? <div className="j13-board-split" role="separator" /> : null}
|
||||||
|
|
||||||
|
{boards.map((b) => (
|
||||||
|
<Link
|
||||||
|
key={b.id}
|
||||||
|
href={boardHref(b.id)}
|
||||||
|
className="j13-bcard"
|
||||||
|
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||||
|
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||||||
|
>
|
||||||
|
<span className={`av-${boardColor(b)} j13-bcard-ico`} aria-hidden="true">
|
||||||
|
<BoardIcon icon={b.icon} name={b.name} size={15} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
||||||
|
<span className="j13-bcard-name truncate">{b.name}</span>
|
||||||
|
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAdmin ? (
|
||||||
|
<div className="shrink-0 mt-1.5 pt-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
||||||
|
<Link href="/admin" className="j13-bcard">
|
||||||
|
<span
|
||||||
|
className="j13-bcard-ico"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<ShieldCheck size={15} />
|
||||||
|
</span>
|
||||||
|
<span className="j13-bcard-name flex-1 truncate">管理面板</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 桌面左栏:整块模块,与右栏 j13-module 对齐
|
||||||
|
export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?: User | null }) {
|
||||||
return (
|
return (
|
||||||
// sticky 必须挂在 flex 子项上:父级 items-start 会把内层缩成内容高,失去粘滞行程
|
// sticky 必须挂在 flex 子项上:父级 items-start 会把内层缩成内容高,失去粘滞行程
|
||||||
<nav
|
<nav
|
||||||
className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]"
|
className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]"
|
||||||
aria-label="工作台导航"
|
aria-label="工作台导航"
|
||||||
>
|
>
|
||||||
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden">
|
<WorkspaceNavPanel boards={boards} user={user} />
|
||||||
<p className="j13-module-title shrink-0">
|
|
||||||
<LayoutGrid size={15} style={{ color: "var(--accent)" }} aria-hidden />
|
|
||||||
板块目录
|
|
||||||
{boards.length > 0 ? (
|
|
||||||
<span className="ml-auto meta text-[11.5px] font-medium tracking-normal tabular-nums">
|
|
||||||
{boards.length} 个
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="j13-rail-scroll j13-board-list mt-2.5 min-h-0 flex-1">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="j13-bcard"
|
|
||||||
aria-current={activeBoard === 0 ? "page" : undefined}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="j13-bcard-ico"
|
|
||||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<Home size={15} />
|
|
||||||
</span>
|
|
||||||
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{boards.length > 0 ? <div className="j13-board-split" role="separator" /> : null}
|
|
||||||
|
|
||||||
{boards.map((b) => (
|
|
||||||
<Link
|
|
||||||
key={b.id}
|
|
||||||
href={boardHref(b.id)}
|
|
||||||
className="j13-bcard"
|
|
||||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
|
||||||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
|
||||||
>
|
|
||||||
<span className={`av-${boardColor(b)} j13-bcard-ico`} aria-hidden="true">
|
|
||||||
<BoardIcon icon={b.icon} name={b.name} size={15} />
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
|
||||||
<span className="j13-bcard-name truncate">{b.name}</span>
|
|
||||||
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAdmin ? (
|
|
||||||
<div className="shrink-0 mt-1.5 pt-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
|
||||||
<Link href="/admin" className="j13-bcard">
|
|
||||||
<span
|
|
||||||
className="j13-bcard-ico"
|
|
||||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<ShieldCheck size={15} />
|
|
||||||
</span>
|
|
||||||
<span className="j13-bcard-name flex-1 truncate">管理面板</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
170
frontend/components/post/PostRailBlocks.tsx
Normal file
170
frontend/components/post/PostRailBlocks.tsx
Normal file
@@ -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 (
|
||||||
|
<section className="j13-module shrink-0" aria-labelledby="board-card-title">
|
||||||
|
<p className="j13-module-title mb-3" id="board-card-title">
|
||||||
|
<Layers size={15} style={{ color: "var(--accent)" }} /> 所属板块
|
||||||
|
</p>
|
||||||
|
<Link href={`/?board=${board.id}`} className="flex items-center gap-3 group">
|
||||||
|
<span
|
||||||
|
className={`av-${colorIdx} w-11 h-11 rounded-xl flex items-center justify-center shrink-0`}
|
||||||
|
>
|
||||||
|
<BoardIcon icon={board.icon} name={board.name} size={20} />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="font-semibold text-[14.5px] truncate group-hover:text-[var(--accent)] transition-colors"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{board.name}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
{board.description && (
|
||||||
|
<p className="mt-2.5 text-[12.5px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{board.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/?board=${board.id}`}
|
||||||
|
className="btn btn-line btn-sm w-full justify-center mt-3.5"
|
||||||
|
>
|
||||||
|
查看该板讨论 <ArrowRight size={13} />
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<section className="j13-module shrink-0" aria-labelledby="author-card-title">
|
||||||
|
<p className="j13-module-title mb-3" id="author-card-title">
|
||||||
|
<User size={15} style={{ color: "var(--accent)" }} /> 作者
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href={`/u/${u.id}`} className="shrink-0" aria-label={u.nickname}>
|
||||||
|
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={48} />
|
||||||
|
</Link>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link
|
||||||
|
href={`/u/${u.id}`}
|
||||||
|
className="block font-semibold text-[14.5px] truncate hover:text-[var(--accent)] transition-colors"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{u.nickname}
|
||||||
|
</Link>
|
||||||
|
<p className="meta mt-0.5 truncate">@{u.username}</p>
|
||||||
|
{u.role !== "user" && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<RoleBadge role={u.role} boardIds={u.board_ids} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 有签名则展示;无则留空,不放占位文案 */}
|
||||||
|
{signature ? (
|
||||||
|
<p
|
||||||
|
className="mt-3 text-[12.5px] leading-relaxed line-clamp-3"
|
||||||
|
style={{ color: "var(--ink-2)" }}
|
||||||
|
title={signature}
|
||||||
|
>
|
||||||
|
{signature}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={`mt-3 grid border-t ${stats.length === 3 ? "grid-cols-3" : "grid-cols-2"}`}
|
||||||
|
style={{ borderColor: "var(--line)" }}
|
||||||
|
>
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={s.label}
|
||||||
|
className="px-1.5 py-2.5 text-center"
|
||||||
|
style={i > 0 ? { borderLeft: "1px solid var(--line)" } : undefined}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-[17px] font-semibold leading-none tabular-nums"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{s.value}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{s.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{streak > 0 && (
|
||||||
|
<p
|
||||||
|
className="mt-2 flex items-center justify-center gap-1 text-[11.5px] font-medium"
|
||||||
|
style={{ color: "var(--gold)" }}
|
||||||
|
>
|
||||||
|
<Flame size={11.5} /> 连签 {streak} 天
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm w-full justify-center mt-3">
|
||||||
|
访问主页
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthorPosts({ posts }: { posts: PostListItem[] }) {
|
||||||
|
if (posts.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section className="j13-module flex min-h-0 flex-col" aria-labelledby="author-posts-title">
|
||||||
|
<p className="j13-module-title mb-1 shrink-0" id="author-posts-title">
|
||||||
|
<PenLine size={15} style={{ color: "var(--accent)" }} /> TA 的其他帖子
|
||||||
|
</p>
|
||||||
|
<ul className="j13-rail-scroll">
|
||||||
|
{posts.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<Link
|
||||||
|
href={`/post/${p.id}`}
|
||||||
|
data-tip={p.title}
|
||||||
|
className="block rounded-[10px] px-2 py-2 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="block text-[13px] leading-snug line-clamp-2"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{p.title}
|
||||||
|
</span>
|
||||||
|
<span className="meta mt-1 block text-[11px]">{formatYMD(p.created_at)}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
frontend/components/sidebar/CustomWidgetBlock.tsx
Normal file
54
frontend/components/sidebar/CustomWidgetBlock.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<section
|
||||||
|
className={`j13-widget-body j13-widget-${widget.id}`}
|
||||||
|
data-widget-id={widget.id}
|
||||||
|
data-widget-name={widget.name}
|
||||||
|
>
|
||||||
|
{widget.css ? (
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: scopeWidgetCss(widget.css, widget.id) }} />
|
||||||
|
) : null}
|
||||||
|
{widget.html ? (
|
||||||
|
<div className="j13-widget-html" dangerouslySetInnerHTML={{ __html: widget.html }} />
|
||||||
|
) : null}
|
||||||
|
{/* 自定义 JS 的宿主节点:脚本由客户端创建后挂到此处执行 */}
|
||||||
|
<div ref={hostRef} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/components/sidebar/SidebarRenderer.tsx
Normal file
40
frontend/components/sidebar/SidebarRenderer.tsx
Normal file
@@ -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 <CustomWidgetBlock key={`w-${widget.id}-${i}`} widget={widget} viewer={props.user} />;
|
||||||
|
}
|
||||||
|
if (!ref.key) return null;
|
||||||
|
const node = renderSidebarBlock(ref.key, props);
|
||||||
|
if (node === null) return null;
|
||||||
|
return <Fragment key={`k-${ref.key}-${i}`}>{node}</Fragment>;
|
||||||
|
})
|
||||||
|
.filter((x) => x !== null);
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return className ? <div className={className}>{items}</div> : <>{items}</>;
|
||||||
|
}
|
||||||
155
frontend/components/sidebar/registry.tsx
Normal file
155
frontend/components/sidebar/registry.tsx
Normal file
@@ -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 ? <WorkspaceNavPanel boards={p.boards} user={p.user} /> : null;
|
||||||
|
case "pulse":
|
||||||
|
return p.stats ? (
|
||||||
|
<PulseCard stats={p.stats} checkin={p.checkin} allowComments={p.allowComments} />
|
||||||
|
) : null;
|
||||||
|
case "announcements":
|
||||||
|
return p.announcements ? (
|
||||||
|
<Announcements items={p.announcements} canManageAnnouncements={p.canManageAnnouncements} />
|
||||||
|
) : null;
|
||||||
|
case "ads":
|
||||||
|
return p.ads && p.adsEnabled !== false ? (
|
||||||
|
<AdsPanel items={p.ads} title={p.adsPanelTitle || "自助推广"} enabled />
|
||||||
|
) : null;
|
||||||
|
case "sponsors":
|
||||||
|
return p.sponsors && p.sponsorsEnabled !== false ? (
|
||||||
|
<SponsorsPanel
|
||||||
|
items={p.sponsors}
|
||||||
|
title={p.sponsorsPanelTitle || "赞助商"}
|
||||||
|
enabled
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
case "hot":
|
||||||
|
return p.hot ? <HotRanking posts={p.hot} allowComments={p.allowComments} /> : null;
|
||||||
|
case "new_users":
|
||||||
|
return p.newUsers ? <NewUsers users={p.newUsers} /> : null;
|
||||||
|
case "active_users":
|
||||||
|
return p.activeUsers ? (
|
||||||
|
<ActiveUsers users={p.activeUsers} allowComments={p.allowComments} />
|
||||||
|
) : null;
|
||||||
|
case "friend_links":
|
||||||
|
return <FriendLinks />;
|
||||||
|
case "about_site":
|
||||||
|
return p.sidebarPages || p.canManagePages ? (
|
||||||
|
<AboutSite
|
||||||
|
pages={p.sidebarPages ?? []}
|
||||||
|
siteDescription={p.siteDescription}
|
||||||
|
canManagePages={p.canManagePages}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
case "board_moderators":
|
||||||
|
return p.moderators ? <Moderators items={p.moderators} /> : null;
|
||||||
|
case "board_intro":
|
||||||
|
return p.board ? <BoardIntro board={p.board} /> : null;
|
||||||
|
case "board_pulse":
|
||||||
|
return p.boardStats ? (
|
||||||
|
<BoardPulse stats={p.boardStats} checkin={p.checkin} allowComments={p.allowComments} />
|
||||||
|
) : null;
|
||||||
|
case "board_hot":
|
||||||
|
return p.boardHot ? (
|
||||||
|
<BoardHot posts={p.boardHot} allowComments={p.allowComments} />
|
||||||
|
) : null;
|
||||||
|
case "board_active":
|
||||||
|
return p.boardActiveUsers ? (
|
||||||
|
<BoardActive users={p.boardActiveUsers} allowComments={p.allowComments} />
|
||||||
|
) : null;
|
||||||
|
case "post_board":
|
||||||
|
return p.postBoard ? <PostBoardCard board={p.postBoard} /> : null;
|
||||||
|
case "post_author":
|
||||||
|
return p.authorProfile ? (
|
||||||
|
<PostAuthorCard profile={p.authorProfile} allowComments={p.allowComments} />
|
||||||
|
) : null;
|
||||||
|
case "post_author_posts":
|
||||||
|
return p.authorPosts ? <PostAuthorPosts posts={p.authorPosts} /> : null;
|
||||||
|
case "post_toc":
|
||||||
|
return p.toc && p.toc.length > 0 ? <PostToc items={p.toc} /> : null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1269,17 +1269,257 @@ export async function apiAdminAnalytics(range: "7d" | "30d" = "7d"): Promise<Ana
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiTelemetryPageView(): Promise<void> {
|
export async function apiTelemetryPageView(path: string, ref: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await fetchWithRefresh("/api/telemetry/pageview", {
|
await fetchWithRefresh("/api/telemetry/pageview", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: clientHeaders(),
|
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||||
|
body: JSON.stringify({ path, ref }),
|
||||||
});
|
});
|
||||||
} catch {
|
} 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<T>(path: string, cookieHeader?: string): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, ssrInit(cookieHeaders(cookieHeader)));
|
||||||
|
if (!res.ok) throw new Error("failed");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户端拉取:带 CSRF + 自动续期
|
||||||
|
async function apiVisitStats<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
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<VisitOverview>(`/api/admin/stats/overview?range=${range}`, cookieHeader);
|
||||||
|
}
|
||||||
|
export function apiStatsOverview(range = "7d") {
|
||||||
|
return apiVisitStats<VisitOverview>(`/api/admin/stats/overview?range=${range}`);
|
||||||
|
}
|
||||||
|
export function fetchStatsRealtime(cookieHeader?: string) {
|
||||||
|
return fetchVisitStats<VisitRealtime>("/api/admin/stats/realtime", cookieHeader);
|
||||||
|
}
|
||||||
|
export function apiStatsRealtime() {
|
||||||
|
return apiVisitStats<VisitRealtime>("/api/admin/stats/realtime");
|
||||||
|
}
|
||||||
|
export function fetchStatsSources(cookieHeader?: string, range = "7d") {
|
||||||
|
return fetchVisitStats<VisitSources>(`/api/admin/stats/sources?range=${range}`, cookieHeader);
|
||||||
|
}
|
||||||
|
export function apiStatsSources(range = "7d") {
|
||||||
|
return apiVisitStats<VisitSources>(`/api/admin/stats/sources?range=${range}`);
|
||||||
|
}
|
||||||
|
export function fetchStatsPages(cookieHeader?: string, range = "7d", q = "", page = 1, size = 20) {
|
||||||
|
return fetchVisitStats<VisitPages>(
|
||||||
|
`/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<VisitPages>(
|
||||||
|
`/api/admin/stats/pages?range=${range}&q=${encodeURIComponent(q)}&page=${page}&size=${size}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function fetchStatsVisitors(cookieHeader?: string, range = "7d") {
|
||||||
|
return fetchVisitStats<VisitVisitors>(`/api/admin/stats/visitors?range=${range}`, cookieHeader);
|
||||||
|
}
|
||||||
|
export function apiStatsVisitors(range = "7d") {
|
||||||
|
return apiVisitStats<VisitVisitors>(`/api/admin/stats/visitors?range=${range}`);
|
||||||
|
}
|
||||||
|
export function fetchStatsSecurity(cookieHeader?: string, range = "7d") {
|
||||||
|
return fetchVisitStats<VisitSecurity>(`/api/admin/stats/security?range=${range}`, cookieHeader);
|
||||||
|
}
|
||||||
|
export function apiStatsSecurity(range = "7d") {
|
||||||
|
return apiVisitStats<VisitSecurity>(`/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<VisitEventsResult>(`/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<VisitEventsResult>(`/api/admin/stats/events?${p.toString()}`);
|
||||||
|
}
|
||||||
|
export function fetchStatsSettings(cookieHeader?: string) {
|
||||||
|
return fetchVisitStats<VisitSettingsPayload>("/api/admin/stats/settings", cookieHeader);
|
||||||
|
}
|
||||||
|
export function apiStatsSettings() {
|
||||||
|
return apiVisitStats<VisitSettingsPayload>("/api/admin/stats/settings");
|
||||||
|
}
|
||||||
|
export function apiStatsSaveSettings(enabled: boolean, retentionDays: number) {
|
||||||
|
return apiVisitStats<VisitSettingsPayload>("/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 type AdminChatRoomType = "group" | "direct";
|
||||||
|
|
||||||
export interface AdminChatParticipant {
|
export interface AdminChatParticipant {
|
||||||
@@ -3451,3 +3691,68 @@ export async function fetchAdminSponsorsConfig(cookie?: string): Promise<Sponsor
|
|||||||
if (!res.ok) throw new Error("加载赞助商配置失败");
|
if (!res.ok) throw new Error("加载赞助商配置失败");
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 侧栏管理(布局配置 + 自定义 HTML 工具) =====
|
||||||
|
|
||||||
|
import type {
|
||||||
|
SidebarConfig,
|
||||||
|
SidebarPublicPayload,
|
||||||
|
SidebarWidgetRecord,
|
||||||
|
} from "@/lib/sidebarMeta";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
SidebarConfig,
|
||||||
|
SidebarScope,
|
||||||
|
SidebarScopeConfig,
|
||||||
|
SidebarBlockRef,
|
||||||
|
SidebarPublicPayload,
|
||||||
|
SidebarWidgetPublic,
|
||||||
|
SidebarWidgetRecord,
|
||||||
|
} from "@/lib/sidebarMeta";
|
||||||
|
|
||||||
|
/** SSR 公开侧栏载荷;失败由调用方回落默认布局 */
|
||||||
|
export async function fetchSidebar(cookieHeader?: string): Promise<SidebarPublicPayload> {
|
||||||
|
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<SidebarWidgetRecord, "name" | "html" | "css" | "js">
|
||||||
|
): Promise<{ widget: SidebarWidgetRecord }> {
|
||||||
|
return apiOperations("/api/admin/sidebar/widgets", "POST", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminUpdateSidebarWidget(
|
||||||
|
id: number,
|
||||||
|
body: Pick<SidebarWidgetRecord, "name" | "html" | "css" | "js" | "enabled">
|
||||||
|
): 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");
|
||||||
|
}
|
||||||
|
|||||||
179
frontend/lib/sidebarMeta.ts
Normal file
179
frontend/lib/sidebarMeta.ts
Normal file
@@ -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<Record<SidebarScope, SidebarScopeConfig>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SidebarScope, string> = {
|
||||||
|
home: "首页",
|
||||||
|
board: "板块页",
|
||||||
|
post: "帖子详情",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SIDEBAR_SIDE_LABELS: Record<"left" | "right", string> = {
|
||||||
|
left: "左栏",
|
||||||
|
right: "右栏",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 各页面范围可用的内置块(key → 展示名),与后端 sidebarBuiltinKeys 保持一致
|
||||||
|
export const SIDEBAR_WIDGET_META: Record<SidebarScope, Record<string, string>> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
91
frontend/lib/widgetApi.ts
Normal file
91
frontend/lib/widgetApi.ts
Normal file
@@ -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<OverviewResponse | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<OverviewResponse | null> | null = null;
|
||||||
|
|
||||||
|
function loadOverview(): Promise<OverviewResponse | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
102
frontend/lib/widgetCode.test.ts
Normal file
102
frontend/lib/widgetCode.test.ts
Normal file
@@ -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(
|
||||||
|
`<div class="a">hi</div>\n<style>.a { color: red; }</style>\n<script>console.log(1);</script>`
|
||||||
|
);
|
||||||
|
assert.equal(r.html, `<div class="a">hi</div>`);
|
||||||
|
assert.equal(r.css, `.a { color: red; }`);
|
||||||
|
assert.equal(r.js, `console.log(1);`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("多段 style/script 按序拼接", () => {
|
||||||
|
const r = parseMixedWidgetCode(`<style>.a{}</style><style>.b{}</style><p>x</p><script>a();</script><script>b();</script>`);
|
||||||
|
assert.equal(r.css, ".a{}\n\n.b{}");
|
||||||
|
assert.equal(r.js, "a();\n\nb();");
|
||||||
|
assert.equal(r.html, "<p>x</p>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("纯 HTML 不误伤", () => {
|
||||||
|
const r = parseMixedWidgetCode(`<p>hello</p>`);
|
||||||
|
assert.deepEqual(r, { html: "<p>hello</p>", css: "", js: "" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("外链与非 JS type 的 script 留在 html", () => {
|
||||||
|
const code = `<script src="https://x/y.js"></script><script type="application/json">{"a":1}</script><script>z();</script>`;
|
||||||
|
const r = parseMixedWidgetCode(code);
|
||||||
|
assert.equal(r.js, "z();");
|
||||||
|
assert.equal(
|
||||||
|
r.html,
|
||||||
|
`<script src="https://x/y.js"></script><script type="application/json">{"a":1}</script>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeWidgetCode", () => {
|
||||||
|
it("合并后再解析可还原三字段", () => {
|
||||||
|
const parts = { html: "<b>go</b>", 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: "{" }`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
212
frontend/lib/widgetCode.ts
Normal file
212
frontend/lib/widgetCode.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// 自定义侧栏工具代码处理:
|
||||||
|
// - parseMixedWidgetCode / mergeWidgetCode:「混写一框」(内联 <style>/<script>)与分栏三字段互转;
|
||||||
|
// 三字段存储结构不变,拆分仅发生在管理端提交/切换视图时。
|
||||||
|
// - scopeWidgetCss:渲染时把工具 CSS 限定在其容器(.j13-widget-{id})内,
|
||||||
|
// 逐条规则加前缀,避免污染页面其他区域。
|
||||||
|
|
||||||
|
export interface WidgetCodeParts {
|
||||||
|
html: string;
|
||||||
|
css: string;
|
||||||
|
js: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STYLE_RE = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
||||||
|
const SCRIPT_RE = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
||||||
|
const TYPE_RE = /\btype\s*=\s*(?:"([^"]*)"|'([^']*)')/i;
|
||||||
|
|
||||||
|
const JS_TYPES = new Set(["", "text/javascript", "application/javascript", "module"]);
|
||||||
|
|
||||||
|
function isNonJsScript(attrs: string): boolean {
|
||||||
|
const m = attrs.match(TYPE_RE);
|
||||||
|
if (!m) return false;
|
||||||
|
return !JS_TYPES.has((m[1] ?? m[2] ?? "").toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 混写代码 → 三字段:<style>/<script> 段被摘出,剩余全部视为 HTML */
|
||||||
|
export function parseMixedWidgetCode(code: string): WidgetCodeParts {
|
||||||
|
const cssParts: string[] = [];
|
||||||
|
const jsParts: string[] = [];
|
||||||
|
let html = code.replace(STYLE_RE, (_m, body: string) => {
|
||||||
|
cssParts.push(body);
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
html = html.replace(SCRIPT_RE, (m: string, attrs: string, body: string) => {
|
||||||
|
// 外链脚本、模板/数据脚本(type 非 JS)原样留在 HTML 中
|
||||||
|
if (/\bsrc\s*=/i.test(attrs) || isNonJsScript(attrs)) return m;
|
||||||
|
jsParts.push(body);
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
html: html.trim(),
|
||||||
|
css: cssParts.join("\n\n").trim(),
|
||||||
|
js: jsParts.join("\n\n").trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 三字段 → 混写视图:HTML 在前,CSS/JS 分别以标准标签包裹 */
|
||||||
|
export function mergeWidgetCode(parts: WidgetCodeParts): string {
|
||||||
|
const out: string[] = [];
|
||||||
|
if (parts.html.trim()) out.push(parts.html.trim());
|
||||||
|
if (parts.css.trim()) out.push(`<style>\n${parts.css.trim()}\n</style>`);
|
||||||
|
if (parts.js.trim()) out.push(`<script>\n${parts.js.trim()}\n</script>`);
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3000",
|
"start": "next start -p 3000",
|
||||||
"lint": "next lint",
|
"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": {
|
"dependencies": {
|
||||||
"hast-util-to-jsx-runtime": "^2.3.6",
|
"hast-util-to-jsx-runtime": "^2.3.6",
|
||||||
|
|||||||
Reference in New Issue
Block a user