From 9251815695ee257ec88adfa289945ec1bb1c4143 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 15 Jul 2026 15:01:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E8=BF=90=E7=BB=B4=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=20metrics=EF=BC=9A=E8=B4=9F=E8=BD=BD=E3=80=81?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E9=94=99=E8=AF=AF=E7=8E=87=E4=B8=8E=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E6=8E=A2=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GET /admin/ops/metrics 与进程内请求统计中间件 - 运维页展示 CPU/内存/磁盘、24h 曲线与 WS/DB/存储状态 - 去掉未使用的消息队列占位项 --- server/cmd/main.go | 3 + server/go.mod | 8 + server/go.sum | 22 + server/internal/handler/admin.go | 9 +- server/internal/handler/ops_metrics.go | 230 +++++++++++ server/internal/handler/router.go | 3 +- server/internal/metrics/metrics.go | 140 +++++++ server/internal/metrics/metrics_test.go | 32 ++ server/internal/storage/memory.go | 2 + server/internal/storage/s3.go | 5 + server/internal/storage/storage.go | 2 + server/internal/ws/ws.go | 15 + web/src/pages/admin/Ops.tsx | 525 +++++++++++++----------- web/src/services/api.ts | 49 +++ 14 files changed, 797 insertions(+), 248 deletions(-) create mode 100644 server/internal/handler/ops_metrics.go create mode 100644 server/internal/metrics/metrics.go create mode 100644 server/internal/metrics/metrics_test.go diff --git a/server/cmd/main.go b/server/cmd/main.go index ac98dd5..39b082c 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "kefu-sys/server/internal/config" "kefu-sys/server/internal/handler" + "kefu-sys/server/internal/metrics" "kefu-sys/server/internal/middleware" "kefu-sys/server/internal/model" "kefu-sys/server/internal/storage" @@ -43,6 +44,8 @@ func main() { gin.SetMode(cfg.Server.Mode) r := gin.Default() + // 全站请求统计(运维监控 24h 曲线) + r.Use(metrics.Middleware()) // CORS r.Use(func(c *gin.Context) { diff --git a/server/go.mod b/server/go.mod index 7c99d5b..b785430 100644 --- a/server/go.mod +++ b/server/go.mod @@ -9,6 +9,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.1 github.com/gorilla/websocket v1.5.3 github.com/minio/minio-go/v7 v7.2.1 + github.com/shirou/gopsutil/v4 v4.25.1 golang.org/x/crypto v0.51.0 gorm.io/driver/postgres v1.5.9 gorm.io/driver/sqlite v1.5.7 @@ -22,8 +23,10 @@ require ( github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.8.2 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect @@ -41,6 +44,7 @@ require ( github.com/klauspost/crc32 v1.3.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/minio/crc64nvme v1.1.1 // indirect @@ -49,11 +53,15 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/philhofer/fwd v1.2.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/tinylib/msgp v1.6.1 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.8.0 // indirect diff --git a/server/go.sum b/server/go.sum index 000f90d..1a444d7 100644 --- a/server/go.sum +++ b/server/go.sum @@ -18,12 +18,16 @@ github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1 github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= +github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -36,6 +40,7 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -72,6 +77,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= @@ -93,10 +100,14 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs= +github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -111,10 +122,16 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= @@ -133,7 +150,11 @@ golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -141,6 +162,7 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/server/internal/handler/admin.go b/server/internal/handler/admin.go index 95ea9d4..c484b36 100644 --- a/server/internal/handler/admin.go +++ b/server/internal/handler/admin.go @@ -10,11 +10,16 @@ import ( "golang.org/x/crypto/bcrypt" "kefu-sys/server/internal/middleware" "kefu-sys/server/internal/model" + "kefu-sys/server/internal/storage" ) -type AdminHandler struct{} +type AdminHandler struct { + store storage.ObjectStorage +} -func NewAdminHandler() *AdminHandler { return &AdminHandler{} } +func NewAdminHandler(store storage.ObjectStorage) *AdminHandler { + return &AdminHandler{store: store} +} func (h *AdminHandler) Stats(c *gin.Context) { var tenantTotal, activeTotal, suspendedTotal, expiringTotal, newThisMonth int64 diff --git a/server/internal/handler/ops_metrics.go b/server/internal/handler/ops_metrics.go new file mode 100644 index 0000000..04a8567 --- /dev/null +++ b/server/internal/handler/ops_metrics.go @@ -0,0 +1,230 @@ +package handler + +import ( + "context" + "fmt" + "runtime" + "time" + + "github.com/gin-gonic/gin" + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/disk" + "github.com/shirou/gopsutil/v4/mem" + "kefu-sys/server/internal/metrics" + "kefu-sys/server/internal/middleware" + "kefu-sys/server/internal/model" + "kefu-sys/server/internal/storage" + "kefu-sys/server/internal/ws" +) + +// OpsMetrics 平台运维实时指标(当前 API 实例视角)。 +// GET /api/admin/ops/metrics +func (h *AdminHandler) OpsMetrics(c *gin.Context) { + now := time.Now() + + // —— 系统负载 —— + load := collectSystemLoad() + + // —— 请求统计 —— + reqSeries, errSeries, hourLabels, winReq, winErr := metrics.Default.Snapshot24h() + totalReq, totalErr, total5xx, startedAt := metrics.Default.Totals() + var errNow float64 + if winReq > 0 { + errNow = float64(winErr) / float64(winReq) * 100 + } + var qps float64 + uptimeSec := now.Sub(startedAt).Seconds() + if uptimeSec > 0 { + qps = float64(totalReq) / uptimeSec + } + + // —— 服务探测 —— + services := collectServiceHealth(h.store) + + // —— 整体状态 —— + overall := "normal" + for _, s := range services { + if st, _ := s["status"].(string); st == "error" { + overall = "error" + break + } + if st, _ := s["status"].(string); st == "warning" && overall == "normal" { + overall = "warning" + } + } + if load.CPUPercent >= 90 || load.MemPercent >= 90 || load.DiskPercent >= 95 { + if overall == "normal" { + overall = "warning" + } + } + if errNow >= 5 { + overall = "error" + } else if errNow >= 2 && overall == "normal" { + overall = "warning" + } + + wsTotal, wsAgents, wsVisitors := ws.DefaultHub.Stats() + + middleware.JSON(c, gin.H{ + "overall": overall, + "updated_at": now.Format(time.RFC3339), + "instance": "api", // 当前进程实例 + "uptime_sec": int64(uptimeSec), + "services": services, + "load": gin.H{ + "cpu_percent": load.CPUPercent, + "mem_percent": load.MemPercent, + "disk_percent": load.DiskPercent, + "cpu_cores": load.CPUCores, + "mem_total_mb": load.MemTotalMB, + "mem_used_mb": load.MemUsedMB, + "disk_total_gb": load.DiskTotalGB, + "disk_used_gb": load.DiskUsedGB, + "go_goroutines": runtime.NumGoroutine(), + "go_heap_mb": load.GoHeapMB, + }, + "api": gin.H{ + "requests_last_24h": reqSeries, + "error_rate_last_24h": errSeries, + "hours": hourLabels, + "qps": round2(qps), + "error_rate_now": round2(errNow), + "window_requests": winReq, + "window_errors": winErr, + "total_requests": totalReq, + "total_errors": totalErr, + "total_5xx": total5xx, + "in_flight": metrics.Default.InFlight(), + }, + "websocket": gin.H{ + "connections": wsTotal, + "agents": wsAgents, + "visitors": wsVisitors, + }, + }) +} + +type systemLoad struct { + CPUPercent float64 + MemPercent float64 + DiskPercent float64 + CPUCores int + MemTotalMB uint64 + MemUsedMB uint64 + DiskTotalGB float64 + DiskUsedGB float64 + GoHeapMB float64 +} + +func collectSystemLoad() systemLoad { + out := systemLoad{ + CPUCores: runtime.NumCPU(), + } + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + out.GoHeapMB = round2(float64(ms.HeapAlloc) / 1024 / 1024) + + if percents, err := cpu.Percent(120*time.Millisecond, false); err == nil && len(percents) > 0 { + out.CPUPercent = round2(percents[0]) + } + if vm, err := mem.VirtualMemory(); err == nil { + out.MemPercent = round2(vm.UsedPercent) + out.MemTotalMB = vm.Total / 1024 / 1024 + out.MemUsedMB = vm.Used / 1024 / 1024 + } + if du, err := disk.Usage("/"); err == nil { + out.DiskPercent = round2(du.UsedPercent) + out.DiskTotalGB = round2(float64(du.Total) / 1024 / 1024 / 1024) + out.DiskUsedGB = round2(float64(du.Used) / 1024 / 1024 / 1024) + } + return out +} + +func collectServiceHealth(store storage.ObjectStorage) []gin.H { + services := make([]gin.H, 0, 5) + + // API:本进程存活 + services = append(services, gin.H{ + "name": "API服务", + "status": "normal", + "detail": "进程运行中", + "latency_ms": 0, + }) + + // WebSocket Hub + wsTotal, wsAgents, wsVisitors := ws.DefaultHub.Stats() + services = append(services, gin.H{ + "name": "WebSocket", + "status": "normal", + "detail": fmt.Sprintf("连接 %d(坐席 %d / 访客 %d)", wsTotal, wsAgents, wsVisitors), + "connections": wsTotal, + }) + + // 数据库 + dbStatus, dbDetail, dbLatency := probeDatabase() + services = append(services, gin.H{ + "name": "数据库", + "status": dbStatus, + "detail": dbDetail, + "latency_ms": dbLatency, + }) + + // 对象存储 + stStatus, stDetail, stLatency := probeStorage(store) + services = append(services, gin.H{ + "name": "对象存储", + "status": stStatus, + "detail": stDetail, + "latency_ms": stLatency, + }) + + return services +} + +func probeDatabase() (status, detail string, latencyMs int64) { + if model.DB == nil { + return "error", "数据库未初始化", 0 + } + sqlDB, err := model.DB.DB() + if err != nil { + return "error", "获取连接失败: " + err.Error(), 0 + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + start := time.Now() + if err := sqlDB.PingContext(ctx); err != nil { + return "error", "Ping 失败: " + err.Error(), time.Since(start).Milliseconds() + } + lat := time.Since(start).Milliseconds() + status = "normal" + detail = fmt.Sprintf("Ping 正常 · %dms", lat) + if lat >= 200 { + status = "warning" + detail = fmt.Sprintf("Ping 偏慢 · %dms", lat) + } + return status, detail, lat +} + +func probeStorage(store storage.ObjectStorage) (status, detail string, latencyMs int64) { + if store == nil { + return "warning", "未配置对象存储", 0 + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + start := time.Now() + if err := store.HealthCheck(ctx); err != nil { + return "error", "不可达: " + err.Error(), time.Since(start).Milliseconds() + } + lat := time.Since(start).Milliseconds() + status = "normal" + detail = fmt.Sprintf("Bucket 可达 · %dms", lat) + if lat >= 500 { + status = "warning" + detail = fmt.Sprintf("探测偏慢 · %dms", lat) + } + return status, detail, lat +} + +func round2(v float64) float64 { + return float64(int(v*100+0.5)) / 100 +} diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 43c2b88..a973ebc 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -13,7 +13,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S customer := NewCustomerHandler() knowledge := NewKnowledgeHandler() stats := NewStatisticsHandler() - admin := NewAdminHandler() + admin := NewAdminHandler(store) channel := NewChannelHandler() settings := NewSettingsHandler() staff := NewStaffHandler() @@ -116,6 +116,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S adminGroup.Use(middleware.PlatformRequired()) { adminGroup.GET("/stats", admin.Stats) + adminGroup.GET("/ops/metrics", admin.OpsMetrics) adminGroup.GET("/tenants", admin.ListTenants) adminGroup.GET("/tenants/:id", admin.GetTenant) adminGroup.POST("/tenants", admin.CreateTenant) diff --git a/server/internal/metrics/metrics.go b/server/internal/metrics/metrics.go new file mode 100644 index 0000000..31f220b --- /dev/null +++ b/server/internal/metrics/metrics.go @@ -0,0 +1,140 @@ +package metrics + +import ( + "sync" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" +) + +// Collector 进程内请求统计:近 24 小时按整点小时桶累加。 +type Collector struct { + mu sync.Mutex + byHour map[int64]*hourBucket + + // 自进程启动以来的累计 + totalReq atomic.Int64 + totalErr atomic.Int64 // HTTP status >= 400 + total5xx atomic.Int64 + inFlight atomic.Int64 + startedAt time.Time +} + +type hourBucket struct { + requests int64 + errors int64 + status5 int64 +} + +// Default 全局采集器 +var Default = NewCollector() + +func NewCollector() *Collector { + return &Collector{ + byHour: make(map[int64]*hourBucket), + startedAt: time.Now(), + } +} + +func hourKey(t time.Time) int64 { + return t.Unix() / 3600 +} + +func (c *Collector) Record(status int) { + c.totalReq.Add(1) + isErr := status >= 400 + is5 := status >= 500 + if isErr { + c.totalErr.Add(1) + } + if is5 { + c.total5xx.Add(1) + } + + h := hourKey(time.Now()) + c.mu.Lock() + b := c.byHour[h] + if b == nil { + b = &hourBucket{} + c.byHour[h] = b + c.pruneLocked(h) + } + b.requests++ + if isErr { + b.errors++ + } + if is5 { + b.status5++ + } + c.mu.Unlock() +} + +func (c *Collector) pruneLocked(nowHour int64) { + cutoff := nowHour - 25 + for k := range c.byHour { + if k < cutoff { + delete(c.byHour, k) + } + } +} + +// Snapshot24h 返回近 24 个整点小时(含当前小时)的请求量与错误率(0–100)。 +// 下标 0 为 23 小时前,23 为当前小时;hours 为当地小时 0–23。 +func (c *Collector) Snapshot24h() (requests []int64, errorRates []float64, hours []int, totalReq, totalErr int64) { + now := time.Now() + nowH := hourKey(now) + loc := now.Location() + + requests = make([]int64, 24) + errorRates = make([]float64, 24) + hours = make([]int, 24) + + c.mu.Lock() + defer c.mu.Unlock() + + for i := 0; i < 24; i++ { + hKey := nowH - int64(23-i) + t := time.Unix(hKey*3600, 0).In(loc) + hours[i] = t.Hour() + b := c.byHour[hKey] + var r, e int64 + if b != nil { + r, e = b.requests, b.errors + } + requests[i] = r + if r > 0 { + errorRates[i] = float64(e) / float64(r) * 100 + } else { + errorRates[i] = 0 + } + totalReq += r + totalErr += e + } + return +} + +func (c *Collector) Totals() (req, err4, err5 int64, startedAt time.Time) { + return c.totalReq.Load(), c.totalErr.Load(), c.total5xx.Load(), c.startedAt +} + +func (c *Collector) InFlight() int64 { + return c.inFlight.Load() +} + +// Middleware 记录每个 HTTP 请求的状态码(跳过 WebSocket 升级失败后的统计由 status 决定)。 +func Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 健康检查可不计入业务错误曲线,但仍记请求量更直观;这里全部计入 + Default.inFlight.Add(1) + c.Next() + Default.inFlight.Add(-1) + + // WebSocket 升级成功后 status 可能是 101 + status := c.Writer.Status() + if status == 0 { + status = 200 + } + Default.Record(status) + } +} diff --git a/server/internal/metrics/metrics_test.go b/server/internal/metrics/metrics_test.go new file mode 100644 index 0000000..9355ee0 --- /dev/null +++ b/server/internal/metrics/metrics_test.go @@ -0,0 +1,32 @@ +package metrics + +import ( + "testing" +) + +func TestRecordAndSnapshot(t *testing.T) { + c := NewCollector() + c.Record(200) + c.Record(200) + c.Record(500) + c.Record(404) + + reqs, rates, hours, totalReq, totalErr := c.Snapshot24h() + if len(reqs) != 24 || len(rates) != 24 || len(hours) != 24 { + t.Fatalf("snapshot 长度应为 24: req=%d rates=%d hours=%d", len(reqs), len(rates), len(hours)) + } + if totalReq != 4 || totalErr != 2 { + t.Fatalf("窗口统计错误: totalReq=%d totalErr=%d", totalReq, totalErr) + } + // 当前小时桶应有 4 请求 + if reqs[23] != 4 { + t.Fatalf("当前小时请求量 = %d, 期望 4", reqs[23]) + } + if rates[23] < 49 || rates[23] > 51 { + t.Fatalf("当前小时错误率 = %v, 期望约 50", rates[23]) + } + tr, te, t5, _ := c.Totals() + if tr != 4 || te != 2 || t5 != 1 { + t.Fatalf("累计统计错误: req=%d err=%d 5xx=%d", tr, te, t5) + } +} diff --git a/server/internal/storage/memory.go b/server/internal/storage/memory.go index 1e528ca..43bbd82 100644 --- a/server/internal/storage/memory.go +++ b/server/internal/storage/memory.go @@ -44,6 +44,8 @@ func (m *MemoryStorage) PublicBase() string { return m.base } func (m *MemoryStorage) EnsureBucket(context.Context) error { return nil } +func (m *MemoryStorage) HealthCheck(context.Context) error { return nil } + func (m *MemoryStorage) Get(key string) ([]byte, error) { m.mu.RLock() defer m.mu.RUnlock() diff --git a/server/internal/storage/s3.go b/server/internal/storage/s3.go index 73531df..0374f34 100644 --- a/server/internal/storage/s3.go +++ b/server/internal/storage/s3.go @@ -40,6 +40,11 @@ func NewS3(cfg config.StorageConfig) (*S3Storage, error) { return &S3Storage{client: client, bucket: cfg.Bucket, base: base}, nil } +func (s *S3Storage) HealthCheck(ctx context.Context) error { + _, err := s.client.BucketExists(ctx, s.bucket) + return err +} + func (s *S3Storage) EnsureBucket(ctx context.Context) error { exists, err := s.client.BucketExists(ctx, s.bucket) if err != nil { diff --git a/server/internal/storage/storage.go b/server/internal/storage/storage.go index 5d5583b..c2c227a 100644 --- a/server/internal/storage/storage.go +++ b/server/internal/storage/storage.go @@ -17,6 +17,8 @@ type ObjectStorage interface { PublicBase() string // EnsureBucket 开发环境自动建桶 EnsureBucket(ctx context.Context) error + // HealthCheck 探测存储是否可达(运维监控用) + HealthCheck(ctx context.Context) error } // PutResult 上传结果 diff --git a/server/internal/ws/ws.go b/server/internal/ws/ws.go index c51f34f..b669077 100644 --- a/server/internal/ws/ws.go +++ b/server/internal/ws/ws.go @@ -83,6 +83,21 @@ func (h *Hub) Run() { } } +// Stats 返回当前连接统计(总连接 / 坐席 / 访客)。 +func (h *Hub) Stats() (total, agents, visitors int) { + h.mu.RLock() + defer h.mu.RUnlock() + for client := range h.clients { + total++ + if client.Kind == "visitor" { + visitors++ + } else { + agents++ + } + } + return +} + func (h *Hub) send(client *Client, message []byte) { select { case client.Send <- message: diff --git a/web/src/pages/admin/Ops.tsx b/web/src/pages/admin/Ops.tsx index 31fba93..3bc6a66 100644 --- a/web/src/pages/admin/Ops.tsx +++ b/web/src/pages/admin/Ops.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { Button, message, Modal, Form, Input, Select, Spin, Empty, Popconfirm, Pagination, } from 'antd' @@ -8,20 +8,12 @@ import { NotificationOutlined, } from '@ant-design/icons' import { - createAnnouncement, deleteAnnouncement, getAdminLogs, getAnnouncements, updateAnnouncement, - type Announcement, type OperationLog, + createAnnouncement, deleteAnnouncement, getAdminLogs, getAnnouncements, getOpsMetrics, updateAnnouncement, + type Announcement, type OperationLog, type OpsMetrics, type OpsServiceStatus, } from '@/services/api' type SvcStatus = 'normal' | 'warning' | 'error' -const services: { name: string; status: SvcStatus }[] = [ - { name: 'API服务', status: 'normal' }, - { name: 'WebSocket', status: 'normal' }, - { name: '数据库', status: 'normal' }, - { name: '消息队列', status: 'normal' }, - { name: 'CDN', status: 'normal' }, -] - const actionMeta: Record = { create_tenant: { text: '开通租户', bg: '#dbeafe', color: '#2563eb' }, suspend_tenant: { text: '暂停租户', bg: '#fef2f2', color: '#dc2626' }, @@ -48,27 +40,14 @@ function formatRelative(d: Date) { return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) } -/** 近 24 小时示意请求量(工作时段高峰) */ -function genApiTrend(seed: number) { - const bars: { hour: number; value: number }[] = [] - for (let h = 0; h < 24; h++) { - const work = h >= 8 && h <= 18 - const base = work ? 55 + Math.sin((h - 8) / 10 * Math.PI) * 40 : 8 + (h % 5) * 2 - const noise = ((seed * 17 + h * 31) % 15) - 7 - bars.push({ hour: h, value: Math.max(5, Math.round(base + noise)) }) - } - return bars -} - -/** 近 24 小时示意错误率 0–5% */ -function genErrorRate(seed: number) { - const points: number[] = [] - for (let h = 0; h < 24; h++) { - const spike = h === 9 || h === 10 ? 2.2 : 0 - const v = 0.4 + spike + ((seed + h * 3) % 10) / 20 - points.push(Math.min(4.8, Math.max(0.1, Number(v.toFixed(2))))) - } - return points +function formatUptime(sec: number) { + if (!sec || sec < 0) return '—' + const d = Math.floor(sec / 86400) + const h = Math.floor((sec % 86400) / 3600) + const m = Math.floor((sec % 3600) / 60) + if (d > 0) return `${d}天 ${h}小时` + if (h > 0) return `${h}小时 ${m}分` + return `${m} 分钟` } function loadColor(p: number) { @@ -77,6 +56,16 @@ function loadColor(p: number) { return { text: '#16a34a', bar: '#16a34a' } } +function statusMeta(status: string): { label: string; color: string; bg: string; dot: string } { + if (status === 'error') { + return { label: '异常', color: '#dc2626', bg: '#fef2f2', dot: '#dc2626' } + } + if (status === 'warning') { + return { label: '告警', color: '#d97706', bg: '#fffbeb', dot: '#d97706' } + } + return { label: '正常', color: '#16a34a', bg: '#f0fdf4', dot: '#16a34a' } +} + const Ops = () => { const [logs, setLogs] = useState([]) const [logTotal, setLogTotal] = useState(0) @@ -85,25 +74,39 @@ const Ops = () => { const [announcements, setAnnouncements] = useState([]) const [loadingLogs, setLoadingLogs] = useState(false) const [loadingAnn, setLoadingAnn] = useState(false) + const [loadingMetrics, setLoadingMetrics] = useState(false) + const [metrics, setMetrics] = useState(null) + const [metricsError, setMetricsError] = useState('') const [modalOpen, setModalOpen] = useState(false) const [editing, setEditing] = useState(null) const [saving, setSaving] = useState(false) const [form] = Form.useForm() const [refreshedAt, setRefreshedAt] = useState(new Date()) - const [seed, setSeed] = useState(0) - const [loads, setLoads] = useState({ cpu: 23, mem: 61, disk: 45 }) - const apiTrend = useMemo(() => genApiTrend(seed), [seed]) - const errorRates = useMemo(() => genErrorRate(seed), [seed]) + const apiTrend = useMemo(() => { + const series = metrics?.api?.requests_last_24h || [] + const hours = metrics?.api?.hours || [] + return series.map((value, i) => ({ + hour: hours[i] ?? i, + value: Number(value) || 0, + })) + }, [metrics]) + + const errorRates = useMemo( + () => (metrics?.api?.error_rate_last_24h || []).map(v => Number(v) || 0), + [metrics], + ) + const maxApi = Math.max(...apiTrend.map(b => b.value), 1) - const maxErr = 5 + const maxErr = Math.max(5, ...errorRates, 0.1) const errorPolyline = useMemo(() => { const w = 200 const h = 100 + if (errorRates.length === 0) return '' return errorRates .map((v, i) => { - const x = (i / 23) * w + const x = (i / Math.max(errorRates.length - 1, 1)) * w const y = h - (v / maxErr) * (h - 8) - 4 return `${x},${y}` }) @@ -113,6 +116,7 @@ const Ops = () => { const errorArea = useMemo(() => { const w = 200 const h = 100 + if (!errorPolyline) return '' return `${errorPolyline} ${w},${h} 0,${h}` }, [errorPolyline]) @@ -141,10 +145,31 @@ const Ops = () => { } } + const loadMetricsData = useCallback(async (silent = false) => { + if (!silent) setLoadingMetrics(true) + try { + const res = await getOpsMetrics() + setMetrics(res.data) + setMetricsError('') + setRefreshedAt(new Date()) + } catch (e) { + setMetricsError(e instanceof Error ? e.message : '加载 metrics 失败') + } finally { + if (!silent) setLoadingMetrics(false) + } + }, []) + useEffect(() => { loadLogs(1) loadAnnouncements() - }, []) + void loadMetricsData() + }, [loadMetricsData]) + + // 30s 自动刷新 metrics + useEffect(() => { + const timer = window.setInterval(() => { void loadMetricsData(true) }, 30000) + return () => window.clearInterval(timer) + }, [loadMetricsData]) useEffect(() => { loadLogs(logPage) @@ -205,74 +230,125 @@ const Ops = () => { } } - const refreshAll = () => { - const s = seed + 1 - setSeed(s) - setLoads({ - cpu: 18 + ((s * 11) % 40), - mem: 40 + ((s * 7) % 35), - disk: 35 + ((s * 5) % 30), - }) - setRefreshedAt(new Date()) - loadLogs(logPage) - loadAnnouncements() + const refreshAll = async () => { + await Promise.all([loadMetricsData(), loadLogs(logPage), loadAnnouncements()]) message.success('已刷新') } + const services: OpsServiceStatus[] = metrics?.services || [] + const load = metrics?.load + const overall = statusMeta(metrics?.overall || 'normal') + const loadCards = [ - { label: 'CPU', percent: loads.cpu, sub: '8核 / 使用中' }, - { label: '内存', percent: loads.mem, sub: '16GB / 使用中' }, - { label: '磁盘', percent: loads.disk, sub: '500GB / 使用中' }, + { + label: 'CPU', + percent: Math.round(load?.cpu_percent ?? 0), + sub: load ? `${load.cpu_cores} 核 · 使用中` : '—', + }, + { + label: '内存', + percent: Math.round(load?.mem_percent ?? 0), + sub: load + ? `${load.mem_used_mb} / ${load.mem_total_mb} MB · Go堆 ${load.go_heap_mb}MB` + : '—', + }, + { + label: '磁盘', + percent: Math.round(load?.disk_percent ?? 0), + sub: load + ? `${load.disk_used_gb.toFixed(1)} / ${load.disk_total_gb.toFixed(1)} GB` + : '—', + }, ] + const peakErr = errorRates.length ? Math.max(...errorRates) : 0 + const errThresholdY = 100 - (2 / maxErr) * (100 - 8) - 4 // map 2% to viewBox y + return (
- {/* 页头:对齐设计 — 标题区 + 状态 */}

系统运维

监控平台运行状态、查看操作日志、管理平台公告 + · 指标来自当前 API 实例

- - - 系统正常运行 + + + {metrics + ? (metrics.overall === 'normal' ? '系统正常运行' : metrics.overall === 'warning' ? '存在告警' : '存在异常') + : '加载中…'} 最后更新:{formatRelative(refreshedAt)} + {metrics ? ` · 运行 ${formatUptime(metrics.uptime_sec)}` : ''} -
+ {metricsError && ( +
{metricsError}
+ )}
- {/* 1. 服务状态 + 系统负载 */}
服务状态 + {metrics?.websocket != null && ( + + WS 连接 {metrics.websocket.connections} + (坐席 {metrics.websocket.agents} / 访客 {metrics.websocket.visitors}) + + )}
-
- {services.map(s => ( -
- - {s.name} - 正常 -
- ))} -
+ {loadingMetrics && !metrics ? ( +
+ ) : ( +
+ {(services.length ? services : [ + { name: 'API服务', status: 'normal' as SvcStatus }, + ]).map(s => { + const m = statusMeta(s.status) + return ( +
+ + {s.name} + {m.label} + {s.detail && ( + {s.detail} + )} +
+ ) + })} +
+ )}
系统负载 - 开发环境示意 + 本机实时 · gopsutil + {metrics?.api && ( + + QPS {metrics.api.qps.toFixed(2)} · 错误率 {metrics.api.error_rate_now.toFixed(2)}% + · 在途 {metrics.api.in_flight} + + )}
{loadCards.map(item => { @@ -291,7 +367,7 @@ const Ops = () => {

{item.sub}

@@ -301,7 +377,6 @@ const Ops = () => {
- {/* 2. API 趋势 + 错误率 */}
@@ -309,42 +384,48 @@ const Ops = () => { API请求量趋势
- 近24小时 · 示意 + + 近24小时 · 共 {metrics?.api?.window_requests ?? 0} 次 +
- - + {maxApi} + {Math.round(maxApi / 2)} 0
- {apiTrend.map(b => ( + {apiTrend.length === 0 ? ( +
暂无数据
+ ) : apiTrend.map((b, idx) => (
0 ? 4 : 0, (b.value / maxApi) * 100)}%`, backgroundColor: b.value > maxApi * 0.55 ? '#2563eb' : '#93c5fd', - minHeight: 2, + minHeight: b.value > 0 ? 2 : 0, }} />
))}
- 00 - 06 - 12 - 18 - 23 + −23h + −12h + 现在
+

+ 自进程启动累计请求 {metrics?.api?.total_requests ?? 0} · 错误 {metrics?.api?.total_errors ?? 0} + (5xx {metrics?.api?.total_5xx ?? 0}) +

@@ -353,12 +434,14 @@ const Ops = () => { 错误率监控
- 近24小时 · 示意 + + 近24小时 · 当前 {metrics?.api?.error_rate_now?.toFixed(2) ?? '0.00'}% +
- 5% - 2.5% + {maxErr.toFixed(1)}% + {(maxErr / 2).toFixed(1)}% 0%
{ viewBox="0 0 200 100" preserveAspectRatio="none" > - {/* 阈值线 2% ≈ y=60 */} - - + {errorArea && } + {errorPolyline && ( + + )}
- 00 - 06 - 12 - 18 - 23 + −23h + −12h + 现在

- 虚线为 2% 告警阈值 · 当前峰值约 {Math.max(...errorRates).toFixed(1)}% + 虚线为 2% 告警阈值 · 近24h 峰值 {peakErr.toFixed(2)}% · status≥400 计为错误

- {/* 3. 操作日志(全宽表格) */} -
-
-
- - 操作日志 - 共 {logTotal} 条 -
-
- +
+
+
+ + 操作日志 +
+ setLogSearch(e.target.value)} - className="bg-transparent border-0 outline-none text-sm text-neutral-800 w-36 placeholder:text-neutral-400" />
-
-
- {loadingLogs ? ( -
- ) : filteredLogs.length === 0 ? ( - - ) : ( - - - - - - - - - - +
+ {loadingLogs ? ( +
+ ) : filteredLogs.length === 0 ? ( + + ) : ( +
{filteredLogs.map(log => { - const meta = actionMeta[log.action] || { - text: log.action, bg: '#f1f5f9', color: '#64748b', - } + const meta = actionMeta[log.action] || { text: log.action, bg: '#f1f5f9', color: '#64748b' } return ( -
- - - - - +
+ + {meta.text} + +
+

{log.detail || '—'}

+

+ 操作者 #{log.operator_id} + {log.ip ? ` · ${log.ip}` : ''} + {' · '} + {formatTime(log.created_at)} +

+
+
) })} - -
时间操作详情IP
- {formatTime(log.created_at)} - - - {meta.text} - - - {log.detail || '—'} - - {log.ip || '—'} -
- )} -
- {logTotal > 10 && ( -
+
+ )} +
+
setLogPage(p)} showSizeChanger={false} + onChange={p => setLogPage(p)} />
- )} -
- - {/* 4. 平台公告 */} -
-
-
- - 平台公告 -
-
-
- {loadingAnn ? ( -
- ) : announcements.length === 0 ? ( - - - - ) : ( -
- {announcements.map(a => ( -
-
-

- {a.title} -

- - {a.status === 'published' ? '已发布' : '草稿'} - -
-

- {a.content || '—'} -

-
- {formatTime(a.created_at)} -
- - handleDelete(a.id)}> - - + +
+
+
+ + 平台公告 +
+ +
+
+ {loadingAnn ? ( +
+ ) : announcements.length === 0 ? ( + + ) : ( +
+ {announcements.map(a => ( +
+
+
+
{a.title}
+
{a.content}
+
+ {a.status === 'published' ? '已发布' : '草稿'} · {formatTime(a.created_at)} +
+
+
+
-
- ))} -
- )} + ))} +
+ )} +
setModalOpen(false)} onOk={() => form.submit()} confirmLoading={saving} destroyOnClose - okText="保存" - width={520} > -
- - + + + - +