feat(ui):管理后台新增工作台,可便捷查看告警信息

This commit is contained in:
chengqian committed 2026-08-31 20:07:15 +08:00
1 parent f679105430
commit bdfd1b6567
11 files changed
+2333

No files matched your search

+2
View File
@@ -282,4 +282,6 @@ type IAdminV1 interface {
UsageLogCleanupCreate(ctx context.Context, req *v1.UsageLogCleanupCreateReq) (res *v1.UsageLogCleanupCreateRes, err error)
UsageLogCleanupList(ctx context.Context, req *v1.UsageLogCleanupListReq) (res *v1.UsageLogCleanupListRes, err error)
UsageLogCleanupCancel(ctx context.Context, req *v1.UsageLogCleanupCancelReq) (res *v1.UsageLogCleanupCancelRes, err error)
AdminWorkbenchSummary(ctx context.Context, req *v1.AdminWorkbenchSummaryReq) (res *v1.AdminWorkbenchSummaryRes, err error)
AdminWorkbenchBadge(ctx context.Context, req *v1.AdminWorkbenchBadgeReq) (res *v1.AdminWorkbenchBadgeRes, err error)
}
+109
View File
@@ -0,0 +1,109 @@
package v1
import (
"github.com/gogf/gf/v2/frame/g"
)
// ============================================================
// 工作台(运营收件箱)
//
// 与仪表盘(/dashboard)的分工:仪表盘回答「现在怎么样」,工作台回答「我该做什么」。
// 只有满足「有人要处理 + 不处理会恶化 + 能被清空归零」三条的事才进工作台,
// 纯统计一律留在仪表盘。
// ============================================================
// === 严重度 / 域 常量(与前端约定,勿随意改动字面量)===
const (
WorkbenchSeverityP0 = "p0" // 紧急:正在影响服务可用性或资金安全
WorkbenchSeverityP1 = "p1" // 今日:当天要处理
WorkbenchSeverityP2 = "p2" // 本周:可排期
WorkbenchDomainAvailability = "availability" // 服务可用性
WorkbenchDomainMoney = "money" // 资金安全
WorkbenchDomainCustomer = "customer" // 客户运营
WorkbenchDomainSystem = "system" // 系统安全
)
// WorkbenchItem 一条待办。
//
// Key 是稳定可重算的业务标识(如 model_no_channel:claude-sonnet-4),
// 供前端做列表 key 与去重 —— 同一异常在两次收集之间必须算出同一个 Key,
// 所以禁止把时间戳、随机数或行 ID 拼进 Key(除非该行本身就是待办对象)。
type WorkbenchItem struct {
Key string `json:"key" dc:"待办唯一标识(稳定可重算,用于静音匹配)"`
Severity string `json:"severity" dc:"严重度:p0=紧急 / p1=今日 / p2=本周"`
Domain string `json:"domain" dc:"所属域:availability / money / customer / system"`
Title string `json:"title" dc:"标题(一句话说清是什么问题)"`
Desc string `json:"desc" dc:"补充说明(影响面、判断依据)"`
// OccurredAt 为空表示该待办是聚合计数(如「5 条反馈待处理」)而非单点事件。
OccurredAt string `json:"occurred_at" dc:"发生时间(RFC3339),聚合类待办为空"`
ActionText string `json:"action_text" dc:"跳转按钮文案"`
ActionRoute string `json:"action_route" dc:"跳转目标前端路由名"`
ActionQuery map[string]string `json:"action_query" dc:"跳转时附带的查询参数(自动带上筛选条件)"`
}
// WorkbenchMetric 首屏关键数字。
type WorkbenchMetric struct {
Key string `json:"key" dc:"指标标识"`
Label string `json:"label" dc:"展示名"`
Value float64 `json:"value" dc:"数值"`
// Unit 决定前端格式化方式:money=本位币金额 / percent=百分比 / count=计数
Unit string `json:"unit" dc:"单位类型:money / percent / count"`
// Growth 为 nil 表示该指标没有同比口径(如「余额告警租户数」)
Growth *float64 `json:"growth" dc:"环比变化百分比;null 表示无对比口径"`
Sub string `json:"sub" dc:"副标题(说明对比口径或统计窗口)"`
}
// WorkbenchDomainStat 分域计数。
type WorkbenchDomainStat struct {
Domain string `json:"domain"`
Total int `json:"total" dc:"该域待办总数"`
Urgent int `json:"urgent" dc:"该域 P0 数量"`
}
// WorkbenchModelAvail 模型可用渠道速览。
type WorkbenchModelAvail struct {
Model string `json:"model"`
Available int `json:"available" dc:"熔断均为 CLOSED 的可用渠道数"`
Total int `json:"total" dc:"承载该模型的 active 渠道数"`
}
// WorkbenchBreaker 正在熔断的渠道×模型。
type WorkbenchBreaker struct {
ChannelId int64 `json:"channel_id"`
ChannelName string `json:"channel_name"`
Model string `json:"model"`
ChannelLevel bool `json:"channel_level" dc:"true=渠道级熔断(该渠道所有模型受影响);false=仅该模型被隔离"`
HalfOpen bool `json:"half_open" dc:"是否处于半开探测态"`
}
// === 工作台汇总 ===
type AdminWorkbenchSummaryReq struct {
g.Meta `path:"/workbench/summary" method:"get" mime:"json" tags:"管理后台-工作台" summary:"工作台汇总"`
}
type AdminWorkbenchSummaryRes struct {
Metrics []WorkbenchMetric `json:"metrics" dc:"首屏关键数字"`
Items []WorkbenchItem `json:"items" dc:"待办列表(已按 p0→p1→p2 排序,已剔除静音项)"`
Domains []WorkbenchDomainStat `json:"domains" dc:"分域计数"`
Models []WorkbenchModelAvail `json:"models" dc:"模型可用渠道速览"`
Breakers []WorkbenchBreaker `json:"breakers" dc:"正在熔断的渠道×模型"`
GeneratedAt string `json:"generated_at" dc:"数据生成时间(RFC3339),命中缓存时为缓存写入时刻"`
}
// === 菜单红点 ===
// AdminWorkbenchBadgeReq 轻量计数接口,供左侧菜单红点轮询。
// 与 summary 共用同一份缓存,不会额外压库。
type AdminWorkbenchBadgeReq struct {
g.Meta `path:"/workbench/badges" method:"get" mime:"json" tags:"管理后台-工作台" summary:"菜单红点计数"`
}
type AdminWorkbenchBadgeRes struct {
Total int `json:"total" dc:"待办总数"`
Urgent int `json:"urgent" dc:"P0 数量"`
Domains map[string]int `json:"domains" dc:"各域待办数,键为域标识"`
// Menus 前端菜单路由名 → 待办数,直接挂角标,前端不需要自己映射域到菜单。
Menus map[string]int `json:"menus" dc:"菜单路由名 → 待办数"`
}
@@ -0,0 +1,15 @@
package admin
import (
"context"
"github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/service"
)
func (c *ControllerV1) AdminWorkbenchSummary(ctx context.Context, req *v1.AdminWorkbenchSummaryReq) (res *v1.AdminWorkbenchSummaryRes, err error) {
return service.Admin().GetWorkbenchSummary(ctx, req)
}
func (c *ControllerV1) AdminWorkbenchBadge(ctx context.Context, req *v1.AdminWorkbenchBadgeReq) (res *v1.AdminWorkbenchBadgeRes, err error) {
return service.Admin().GetWorkbenchBadges(ctx, req)
}
+53
View File
@@ -216,6 +216,59 @@ func (c *Catalog) ChannelRuntimeStates() map[int64]ChannelRuntimeState {
return out
}
// ModelAvail 单个模型的渠道可用性视图(工作台「零可用渠道」检测用)。
// Total 为目录快照中承载该模型的 active 渠道数(disabled / 无 enabled 能力的渠道不在快照里),
// Available 为其中渠道级与模型级熔断均为 CLOSED 的渠道数。
// Available=0 且 Total>0 即「配了渠道但一条都用不了」——最容易被大盘总量掩盖的静默故障。
type ModelAvail struct {
Available int
Total int
Breaking []ModelBreak
}
// ModelBreak 一条正在熔断的渠道×模型记录。
type ModelBreak struct {
ChannelID int64
ChannelName string
Model string
// ChannelLevel 为 true 表示渠道级熔断(该渠道所有模型都受影响),
// false 表示仅该模型被隔离(模型级熔断)。
ChannelLevel bool
HalfOpen bool // 处于半开探测态
}
// ModelAvailability 返回目录快照中每个模型的渠道可用性与正在熔断的渠道×模型列表。
// 目录未初始化时返回 nil,调用方按「无数据」处理,不要当成故障。
// 并发安全:current 为 atomic.Pointer,Load() 取到的快照不可变,无锁。
func (c *Catalog) ModelAvailability() map[string]ModelAvail {
idx := c.current.Load()
if idx == nil {
return nil
}
out := make(map[string]ModelAvail, len(idx.byModel))
for model, chans := range idx.byModel {
av := ModelAvail{Total: len(chans)}
for _, ch := range chans {
chOpen := ch.Breaker != dispatch.BreakerClosed
mdOpen := ch.ModelBreaker != dispatch.BreakerClosed
if !chOpen && !mdOpen {
av.Available++
continue
}
av.Breaking = append(av.Breaking, ModelBreak{
ChannelID: ch.ID,
ChannelName: ch.Name,
Model: model,
ChannelLevel: chOpen,
HalfOpen: (chOpen && ch.Breaker == dispatch.BreakerHalfOpen) ||
(!chOpen && ch.ModelBreaker == dispatch.BreakerHalfOpen),
})
}
out[model] = av
}
return out
}
// Invalidate 主动触发一次刷新(渠道/能力/Key 管理操作后调用;跨实例走 pub/sub)。
func (c *Catalog) Invalidate() {
select {
+266
View File
@@ -0,0 +1,266 @@
package admin
import (
"context"
"encoding/json"
"sort"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/logic/common"
)
// ============================================================
// 工作台(运营收件箱)
//
// 核心约定:待办**不落库**,每次都从各业务源表实时派生。
// 因此「归零」是自动的 —— 工单被回复、告警被确认、渠道 Key 换好之后,
// 下一轮收集自然就查不到它了,不需要任何人来点「完成」,也不存在
// 「源表已修好但工作台还挂着」的残留。待办的唯一出路就是去源头解决。
//
// 收集器约定:
// 1. 每个 collector 独立失败不影响其他项 —— 任何一个查询报错只记 warning 并返回空,
// 绝不让整个工作台因为某张表出问题而白屏(工作台本身就是故障时最该能打开的页面)。
// 2. 不做全表实时 count:高频写入表(bil_usage_logs / aud_* )一律限定时间窗 + 走
// 既有索引,整份结果进 Redis 缓存 30s,菜单红点与页面共用同一份缓存。
// ============================================================
const (
// workbenchCacheKey 整份收集结果的缓存键。菜单红点轮询与页面刷新共用,
// 避免每个管理员每 30s 各打一遍库。
workbenchCacheKey = "workbench:summary:v1"
workbenchCacheTTL = 30 * time.Second
// 各项判定阈值。选值理由见对应 collector 注释。
wbFrozenStaleHours = 2 // 冻结超过 2 小时未释放视为异常
wbOrderUnfulfilledMin = 15 // 已支付超过 15 分钟仍未履约视为卡单
wbOAuthExpiryHours = 24 // OAuth 令牌 24 小时内过期即预警
wbHealthDropPoints = 30 // 健康分 1 小时内跌幅超过 30 分视为骤降
wbCostSpikeMultiple = 8 // 单笔消费超过近期均值 8 倍视为异常
wbEmailFailThreshold = 5 // 1 小时内邮件失败超过 5 封视为链路异常
wbPlanExpiringDays = 7 // 套餐 7 天内到期
wbTicketSLAHours = 2 // 工单首次响应 SLA
wbWalletDriftRatio = 0.02 // Redis↔DB 余额偏差超过 2% 且绝对额可观时告警
)
// wbItem 内部待办结构:比 API 的 v1.WorkbenchItem 多一个 perm 字段。
// 工作台跨域聚合,必须按权限逐条过滤 —— 只有 support:view 的客服不该在工作台
// 看到钱包偏差和订单金额,否则等于绕过 RBAC 泄露财务数据。
type wbItem struct {
v1.WorkbenchItem
Perm string `json:"perm"`
}
// wbCollected 一次收集的完整结果(进缓存的就是它)。
type wbCollected struct {
Items []wbItem `json:"items"`
Models []v1.WorkbenchModelAvail `json:"models"`
Breakers []v1.WorkbenchBreaker `json:"breakers"`
Metrics []v1.WorkbenchMetric `json:"metrics"`
GeneratedAt string `json:"generated_at"`
}
// 域 → 前端菜单路由名,供菜单红点直接挂角标。
var wbDomainMenus = map[string][]string{
v1.WorkbenchDomainAvailability: {"AdminChannels"},
v1.WorkbenchDomainMoney: {"AdminOrders", "AdminTransactions"},
v1.WorkbenchDomainCustomer: {"AdminTickets", "AdminFeedback"},
v1.WorkbenchDomainSystem: {"AdminAlertEvents", "AdminErrorLogs", "AdminCronJobs"},
}
// ============================================================
// 对外接口
// ============================================================
// GetWorkbenchSummary 工作台汇总。
func (s *sAdmin) GetWorkbenchSummary(ctx context.Context, _ *v1.AdminWorkbenchSummaryReq) (*v1.AdminWorkbenchSummaryRes, error) {
col := s.collectWorkbenchCached(ctx)
items := s.filterWorkbenchItems(ctx, col.Items)
// 分域计数基于过滤后的结果 —— 计数必须与用户实际看得到的列表一致,
// 否则会出现「显示 3 条但列表只有 1 条」的诡异体验。
domainStats := make([]v1.WorkbenchDomainStat, 0, 4)
for _, d := range []string{
v1.WorkbenchDomainAvailability, v1.WorkbenchDomainMoney,
v1.WorkbenchDomainCustomer, v1.WorkbenchDomainSystem,
} {
st := v1.WorkbenchDomainStat{Domain: d}
for _, it := range items {
if it.Domain != d {
continue
}
st.Total++
if it.Severity == v1.WorkbenchSeverityP0 {
st.Urgent++
}
}
domainStats = append(domainStats, st)
}
out := make([]v1.WorkbenchItem, 0, len(items))
for _, it := range items {
out = append(out, it.WorkbenchItem)
}
res := &v1.AdminWorkbenchSummaryRes{
Metrics: col.Metrics,
Items: out,
Domains: domainStats,
Models: col.Models,
Breakers: col.Breakers,
GeneratedAt: col.GeneratedAt,
}
if res.Metrics == nil {
res.Metrics = []v1.WorkbenchMetric{}
}
if res.Models == nil {
res.Models = []v1.WorkbenchModelAvail{}
}
if res.Breakers == nil {
res.Breakers = []v1.WorkbenchBreaker{}
}
return res, nil
}
// GetWorkbenchBadges 菜单红点计数(与 summary 共用缓存,不额外压库)。
func (s *sAdmin) GetWorkbenchBadges(ctx context.Context, _ *v1.AdminWorkbenchBadgeReq) (*v1.AdminWorkbenchBadgeRes, error) {
col := s.collectWorkbenchCached(ctx)
items := s.filterWorkbenchItems(ctx, col.Items)
res := &v1.AdminWorkbenchBadgeRes{
Domains: map[string]int{},
Menus: map[string]int{},
}
for _, it := range items {
res.Total++
if it.Severity == v1.WorkbenchSeverityP0 {
res.Urgent++
}
res.Domains[it.Domain]++
}
for domain, menus := range wbDomainMenus {
n := res.Domains[domain]
if n == 0 {
continue
}
for _, m := range menus {
res.Menus[m] += n
}
}
return res, nil
}
// ============================================================
// 收集与过滤
// ============================================================
// collectWorkbenchCached 读缓存,未命中则重新收集。
// 缓存失败一律降级为直接收集 —— 工作台不能因为 Redis 抖动就打不开。
func (s *sAdmin) collectWorkbenchCached(ctx context.Context) *wbCollected {
if v, err := g.Redis().Get(ctx, workbenchCacheKey); err == nil && !v.IsEmpty() {
var col wbCollected
if json.Unmarshal(v.Bytes(), &col) == nil {
return &col
}
}
col := s.collectWorkbench(ctx)
if b, err := json.Marshal(col); err == nil {
if _, err := g.Redis().Do(ctx, "SET", workbenchCacheKey, b, "EX", int(workbenchCacheTTL.Seconds())); err != nil {
g.Log().Warningf(ctx, "workbench: 写缓存失败: %v", err)
}
}
return col
}
// filterWorkbenchItems 按当前管理员权限逐条过滤,再按严重度排序。
func (s *sAdmin) filterWorkbenchItems(ctx context.Context, items []wbItem) []wbItem {
userID := common.GetCtxUserID(ctx)
role, _ := ctx.Value("role").(string)
// 权限判定结果按 perm 记忆化:一次汇总最多 20 条待办但只有 ~10 种权限点,
// 逐条查库会把一次页面刷新放大成几十次 SQL。
permCache := make(map[string]bool, 12)
allowed := func(perm string) bool {
if perm == "" {
return true
}
if v, ok := permCache[perm]; ok {
return v
}
v := HasPermission(ctx, userID, role, perm)
permCache[perm] = v
return v
}
out := make([]wbItem, 0, len(items))
for _, it := range items {
if !allowed(it.Perm) {
continue
}
out = append(out, it)
}
sevRank := map[string]int{
v1.WorkbenchSeverityP0: 0,
v1.WorkbenchSeverityP1: 1,
v1.WorkbenchSeverityP2: 2,
}
sort.SliceStable(out, func(i, j int) bool {
ri, rj := sevRank[out[i].Severity], sevRank[out[j].Severity]
if ri != rj {
return ri < rj
}
// 同 severity 下新发生的排前面;聚合类(OccurredAt 为空)排最后
if out[i].OccurredAt != out[j].OccurredAt {
if out[i].OccurredAt == "" {
return false
}
if out[j].OccurredAt == "" {
return true
}
return out[i].OccurredAt > out[j].OccurredAt
}
return out[i].Key < out[j].Key
})
return out
}
// collectWorkbench 依次跑各 collector。单个失败不影响整体。
func (s *sAdmin) collectWorkbench(ctx context.Context) *wbCollected {
col := &wbCollected{GeneratedAt: gtime.Now().Format(time.RFC3339)}
models, breakers, availItems := s.wbCollectAvailability(ctx)
col.Models = models
col.Breakers = breakers
col.Items = append(col.Items, availItems...)
col.Items = append(col.Items, s.wbCollectMoney(ctx)...)
col.Items = append(col.Items, s.wbCollectCustomer(ctx)...)
col.Items = append(col.Items, s.wbCollectSystem(ctx)...)
col.Metrics = s.wbCollectMetrics(ctx)
return col
}
// wbNew 构造一条待办。
func wbNew(key, sev, domain, perm, title, desc, actionText, route string, query map[string]string, at *gtime.Time) wbItem {
it := wbItem{Perm: perm}
it.Key = key
it.Severity = sev
it.Domain = domain
it.Title = title
it.Desc = desc
it.ActionText = actionText
it.ActionRoute = route
it.ActionQuery = query
if at != nil {
it.OccurredAt = at.Format(time.RFC3339)
}
return it
}
@@ -0,0 +1,989 @@
package admin
import (
"context"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dispatchadapter"
"github.com/qianfree/team-api/internal/logic/billing"
)
// ============================================================
// 服务可用性(channel:view)
// ============================================================
// wbCollectAvailability 渠道与模型可用性。
// 返回模型可用渠道速览、正在熔断列表,以及派生出的待办。
func (s *sAdmin) wbCollectAvailability(ctx context.Context) ([]v1.WorkbenchModelAvail, []v1.WorkbenchBreaker, []wbItem) {
items := make([]wbItem, 0, 8)
// --- 目录快照:模型 → 可用/总渠道数 + 熔断明细 ---
var avail map[string]dispatchadapter.ModelAvail
if cat := dispatchadapter.CatalogInstance(); cat != nil {
avail = cat.ModelAvailability()
}
models := make([]v1.WorkbenchModelAvail, 0, len(avail))
breakers := make([]v1.WorkbenchBreaker, 0, 8)
for model, av := range avail {
models = append(models, v1.WorkbenchModelAvail{
Model: model, Available: av.Available, Total: av.Total,
})
for _, b := range av.Breaking {
breakers = append(breakers, v1.WorkbenchBreaker{
ChannelId: b.ChannelID,
ChannelName: b.ChannelName,
Model: b.Model,
ChannelLevel: b.ChannelLevel,
HalfOpen: b.HalfOpen,
})
}
// P0:配了渠道但一条都用不了。
// 这是最容易被大盘掩盖的静默故障 —— 单个模型全挂时,平台总请求量可能只掉几个
// 百分点,成功率曲线也被其他健康模型摊薄,但该模型的客户已经 100% 失败了。
if av.Total > 0 && av.Available == 0 {
items = append(items, wbNew(
"model_no_channel:"+model,
v1.WorkbenchSeverityP0, v1.WorkbenchDomainAvailability, "channel:view",
fmt.Sprintf("模型 %s 零可用渠道", model),
fmt.Sprintf("承载该模型的 %d 个渠道全部处于熔断或不可用状态,该模型请求当前必然失败", av.Total),
"查看渠道", "AdminChannels", map[string]string{"status": "active"}, nil,
))
}
}
// 排序保证响应稳定:map 遍历顺序随机会让前端列表每次刷新都跳动
sortModelAvail(models)
sortBreakers(breakers)
// P1:渠道级熔断(该渠道所有模型受影响,但还没到零可用)
chLevel := map[int64]string{}
for _, b := range breakers {
if b.ChannelLevel && !b.HalfOpen {
chLevel[b.ChannelId] = b.ChannelName
}
}
for id, name := range chLevel {
items = append(items, wbNew(
fmt.Sprintf("channel_breaker:%d", id),
v1.WorkbenchSeverityP1, v1.WorkbenchDomainAvailability, "channel:view",
fmt.Sprintf("渠道 %s 已熔断", name),
"该渠道全部模型已被调度器摘除,容量下降,恢复前流量会压到其余渠道",
"查看渠道", "AdminChannelDetail", map[string]string{"id": fmt.Sprint(id)}, nil,
))
}
items = append(items, s.wbChannelKeyIssues(ctx)...)
items = append(items, s.wbOAuthExpiring(ctx)...)
items = append(items, s.wbHealthDrop(ctx)...)
return models, breakers, items
}
// wbChannelKeyIssues 渠道 Key 失效 / 额度耗尽。
// Key 挂掉时渠道整体仍是 active,容量却已悄悄下降 —— 列表页看不出来。
func (s *sAdmin) wbChannelKeyIssues(ctx context.Context) []wbItem {
rows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT k.id, k.channel_id, k.name, k.status, k.last_error, c.name AS channel_name,
(SELECT COUNT(*) FROM chn_channel_keys k2 WHERE k2.channel_id = k.channel_id) AS total_keys
FROM chn_channel_keys k
JOIN chn_channels c ON c.id = k.channel_id
WHERE k.status <> 'active' AND c.status = 'active'
ORDER BY k.updated_at DESC
LIMIT 20`)
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询异常渠道 Key 失败: %v", err)
return nil
}
items := make([]wbItem, 0, len(rows))
for _, r := range rows {
status := r["status"].String()
label := "已禁用"
if status == "exhausted" {
label = "额度耗尽"
}
total := r["total_keys"].Int()
desc := fmt.Sprintf("渠道「%s」的 Key「%s」%s", r["channel_name"].String(), r["name"].String(), label)
if total > 1 {
desc += fmt.Sprintf(",该渠道可用 Key %d/%d,容量下降", total-1, total)
} else {
desc += ",该渠道已无可用 Key"
}
if e := strings.TrimSpace(r["last_error"].String()); e != "" {
desc += ";最后错误:" + truncate(e, 120)
}
items = append(items, wbNew(
fmt.Sprintf("channel_key_invalid:%d", r["id"].Int64()),
v1.WorkbenchSeverityP1, v1.WorkbenchDomainAvailability, "channel:view",
fmt.Sprintf("%s Key %s", r["channel_name"].String(), label),
desc,
"查看渠道", "AdminChannelDetail",
map[string]string{"id": r["channel_id"].String()}, nil,
))
}
return items
}
// wbOAuthExpiring OAuth 令牌临期。
// 极其隐蔽:过期那一刻整条渠道直接不可用,且没有任何渐进信号。
func (s *sAdmin) wbOAuthExpiring(ctx context.Context) []wbItem {
rows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT k.id, k.channel_id, k.name, k.token_expires_at, c.name AS channel_name
FROM chn_channel_keys k
JOIN chn_channels c ON c.id = k.channel_id
WHERE k.key_type = 'oauth'
AND c.status = 'active'
AND k.token_expires_at IS NOT NULL
AND k.token_expires_at <= ?
ORDER BY k.token_expires_at ASC
LIMIT 20`, gtime.Now().Add(wbOAuthExpiryHours*time.Hour))
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询 OAuth 临期令牌失败: %v", err)
return nil
}
items := make([]wbItem, 0, len(rows))
for _, r := range rows {
expAt := r["token_expires_at"].GTime()
expired := expAt != nil && expAt.Before(gtime.Now())
sev := v1.WorkbenchSeverityP1
title := fmt.Sprintf("%s OAuth 令牌即将过期", r["channel_name"].String())
desc := fmt.Sprintf("Key「%s」将于 %s 过期,过期后该渠道立即不可用",
r["name"].String(), fmtTime(expAt))
if expired {
// 已经过期就不是预警而是故障了
sev = v1.WorkbenchSeverityP0
title = fmt.Sprintf("%s OAuth 令牌已过期", r["channel_name"].String())
desc = fmt.Sprintf("Key「%s」已于 %s 过期,该渠道当前不可用",
r["name"].String(), fmtTime(expAt))
}
items = append(items, wbNew(
fmt.Sprintf("channel_oauth_expiring:%d", r["id"].Int64()),
sev, v1.WorkbenchDomainAvailability, "channel:view",
title, desc,
"重新授权", "AdminChannelDetail",
map[string]string{"id": r["channel_id"].String()}, nil,
))
}
return items
}
// wbHealthDrop 健康分骤降。
// 判据是「相对自己 1 小时前的跌幅」而非绝对分值 —— 一个长期 60 分的渠道是已知状态,
// 而 92 分跌到 61 分是正在发生的事故,绝对阈值分不出这两者。
func (s *sAdmin) wbHealthDrop(ctx context.Context) []wbItem {
rows, err := g.DB().Ctx(ctx).Query(ctx,
`WITH latest AS (
SELECT DISTINCT ON (channel_id) channel_id, health_score, snapshot_at
FROM chn_health_snapshots
WHERE snapshot_at >= NOW() - INTERVAL '15 minutes'
ORDER BY channel_id, snapshot_at DESC
), baseline AS (
SELECT DISTINCT ON (channel_id) channel_id, health_score
FROM chn_health_snapshots
WHERE snapshot_at BETWEEN NOW() - INTERVAL '75 minutes' AND NOW() - INTERVAL '45 minutes'
ORDER BY channel_id, snapshot_at DESC
)
SELECT l.channel_id, c.name AS channel_name,
ROUND(l.health_score)::int AS now_score,
ROUND(b.health_score)::int AS prev_score,
l.snapshot_at
FROM latest l
JOIN baseline b ON b.channel_id = l.channel_id
JOIN chn_channels c ON c.id = l.channel_id
WHERE c.status = 'active'
AND b.health_score - l.health_score >= ?
ORDER BY (b.health_score - l.health_score) DESC
LIMIT 10`, wbHealthDropPoints)
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询健康分骤降失败: %v", err)
return nil
}
items := make([]wbItem, 0, len(rows))
for _, r := range rows {
items = append(items, wbNew(
fmt.Sprintf("channel_health_drop:%d", r["channel_id"].Int64()),
v1.WorkbenchSeverityP1, v1.WorkbenchDomainAvailability, "channel:view",
fmt.Sprintf("%s 健康分骤降", r["channel_name"].String()),
fmt.Sprintf("1 小时内从 %d 分跌至 %d 分,调度权重已自动下调,尚未触发熔断",
r["prev_score"].Int(), r["now_score"].Int()),
"查看健康趋势", "AdminChannelDetail",
map[string]string{"id": r["channel_id"].String()},
r["snapshot_at"].GTime(),
))
}
return items
}
// ============================================================
// 资金安全(billing:view / order:view)
// ============================================================
func (s *sAdmin) wbCollectMoney(ctx context.Context) []wbItem {
items := make([]wbItem, 0, 8)
items = append(items, s.wbWalletDrift(ctx)...)
items = append(items, s.wbFrozenStale(ctx)...)
items = append(items, s.wbOrderUnfulfilled(ctx)...)
items = append(items, s.wbCostSpike(ctx)...)
items = append(items, s.wbLowBalanceTenants(ctx)...)
return items
}
// wbWalletDrift 钱包 Redis↔DB 物化偏差。
//
// 按架构约定 Redis 是余额唯一实时权威,DB 只是每 5s 物化一次的滞后副本。
// 正常情况下二者差异很小;持续放大的偏差意味着物化器停摆或 Lua 与账本不一致,
// 此时 DB 余额不可用于对账,且问题会随时间累积 —— 属于必须立刻知道的一类。
func (s *sAdmin) wbWalletDrift(ctx context.Context) []wbItem {
// 只抽查有余额的活跃钱包,避免全表逐个读 Redis
rows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT w.tenant_id, w.balance, t.name AS tenant_name
FROM bil_wallets w
JOIN tnt_tenants t ON t.id = w.tenant_id
WHERE w.balance > 0 AND t.status IN ('active', 'trial')
ORDER BY w.balance DESC
LIMIT 30`)
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询钱包列表失败: %v", err)
return nil
}
var (
worstTenant string
worstDrift float64
driftCount int
)
for _, r := range rows {
tenantID := r["tenant_id"].Int64()
dbBalance := r["balance"].Float64()
info, err := billing.GetWallet(ctx, tenantID)
if err != nil || info == nil {
continue
}
drift := info.Balance - dbBalance
if drift < 0 {
drift = -drift
}
// 相对阈值 + 绝对下限:小额账户的分币级差异不值得报警
if dbBalance > 0 && drift/dbBalance > wbWalletDriftRatio && drift > 1 {
driftCount++
if drift > worstDrift {
worstDrift = drift
worstTenant = r["tenant_name"].String()
}
}
}
if driftCount == 0 {
return nil
}
return []wbItem{wbNew(
"wallet_drift",
v1.WorkbenchSeverityP0, v1.WorkbenchDomainMoney, "billing:view",
"钱包 Redis 与 DB 物化余额偏差",
fmt.Sprintf("抽查发现 %d 个钱包偏差超阈值,最大偏差 %.2f(租户「%s」)。"+
"DB 余额由物化器每 5 秒覆盖,持续偏差说明物化器可能停摆,当前 DB 余额不可用于对账",
driftCount, worstDrift, worstTenant),
"查看定时任务", "AdminCronJobs", nil, nil,
)}
}
// wbFrozenStale 长时间未释放的冻结。
// 客户余额被凭空锁住,是客服工单的高发源,且往往是异步任务未回收的信号。
func (s *sAdmin) wbFrozenStale(ctx context.Context) []wbItem {
rows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT w.tenant_id, t.name AS tenant_name
FROM bil_wallets w
JOIN tnt_tenants t ON t.id = w.tenant_id
WHERE w.frozen_balance > 0
ORDER BY w.frozen_balance DESC
LIMIT 20`)
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询冻结钱包失败: %v", err)
return nil
}
items := make([]wbItem, 0, 4)
for _, r := range rows {
tenantID := r["tenant_id"].Int64()
frozen, err := billing.GetFrozenItems(ctx, tenantID)
if err != nil {
continue
}
var (
staleCount int
staleTotal float64
)
// FrozenItem.CreatedAt 是 Unix 秒(预扣 Lua 写入),与既有冻结明细接口同口径
staleBefore := time.Now().Add(-wbFrozenStaleHours * time.Hour).Unix()
for _, f := range frozen {
if f.CreatedAt > 0 && f.CreatedAt <= staleBefore {
staleCount++
staleTotal += f.Amount
}
}
if staleCount == 0 {
continue
}
items = append(items, wbNew(
fmt.Sprintf("frozen_stale:%d", tenantID),
v1.WorkbenchSeverityP1, v1.WorkbenchDomainMoney, "billing:view",
fmt.Sprintf("租户「%s」%d 笔冻结超时未释放", r["tenant_name"].String(), staleCount),
fmt.Sprintf("合计 %.6f 被锁定超过 %d 小时,客户可用余额受影响,通常是异步任务未回收",
staleTotal, wbFrozenStaleHours),
"查看冻结明细", "AdminTenantDetail",
map[string]string{"id": fmt.Sprint(tenantID), "tab": "wallet"}, nil,
))
}
return items
}
// wbOrderUnfulfilled 已支付未履约的卡单。真金白银,客户会追。
func (s *sAdmin) wbOrderUnfulfilled(ctx context.Context) []wbItem {
rows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT o.id, o.order_no, o.final_amount, o.paid_at, o.order_type, t.name AS tenant_name
FROM ord_orders o
LEFT JOIN tnt_tenants t ON t.id = o.tenant_id
WHERE o.status = 'paid'
AND o.fulfilled_at IS NULL
AND o.paid_at IS NOT NULL
AND o.paid_at <= ?
ORDER BY o.paid_at ASC
LIMIT 20`, gtime.Now().Add(-wbOrderUnfulfilledMin*time.Minute))
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询卡单失败: %v", err)
return nil
}
items := make([]wbItem, 0, len(rows))
for _, r := range rows {
paidAt := r["paid_at"].GTime()
items = append(items, wbNew(
"order_unfulfilled:"+r["order_no"].String(),
v1.WorkbenchSeverityP1, v1.WorkbenchDomainMoney, "order:view",
fmt.Sprintf("订单 %s 已支付未履约", r["order_no"].String()),
fmt.Sprintf("租户「%s」支付 ¥%.2f 于 %s,至今未履约,客户可能已在催",
r["tenant_name"].String(), r["final_amount"].Float64(), fmtTime(paidAt)),
"处理订单", "AdminOrders",
map[string]string{"order_no": r["order_no"].String()}, paidAt,
))
}
return items
}
// wbCostSpike 单笔消费异常。
// 用途有三:防刷、防客户被恶意调用、防定价配置写错一位(乘数多个 0 的杀伤力最大)。
// 判据是「相对该租户自身近 7 天均值的倍数」,而非全局绝对值 —— 大客户的正常单笔
// 对小客户就是天价,全局阈值必然两头不讨好。
func (s *sAdmin) wbCostSpike(ctx context.Context) []wbItem {
rows, err := g.DB().Ctx(ctx).Query(ctx,
`WITH recent AS (
SELECT tenant_id, id, total_cost, model_name, created_at
FROM bil_usage_logs
WHERE created_at >= NOW() - INTERVAL '2 hours' AND total_cost > 0
), baseline AS (
SELECT tenant_id, AVG(total_cost) AS avg_cost
FROM bil_usage_logs
WHERE created_at >= NOW() - INTERVAL '7 days' AND total_cost > 0
GROUP BY tenant_id
HAVING COUNT(*) >= 50
)
SELECT r.id, r.tenant_id, r.total_cost, r.model_name, r.created_at,
b.avg_cost, t.name AS tenant_name
FROM recent r
JOIN baseline b ON b.tenant_id = r.tenant_id
LEFT JOIN tnt_tenants t ON t.id = r.tenant_id
WHERE r.total_cost >= b.avg_cost * ?
ORDER BY r.total_cost DESC
LIMIT 5`, wbCostSpikeMultiple)
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询消费尖刺失败: %v", err)
return nil
}
items := make([]wbItem, 0, len(rows))
for _, r := range rows {
cost := r["total_cost"].Float64()
avg := r["avg_cost"].Float64()
mult := 0.0
if avg > 0 {
mult = cost / avg
}
items = append(items, wbNew(
fmt.Sprintf("usage_cost_spike:%d", r["id"].Int64()),
v1.WorkbenchSeverityP1, v1.WorkbenchDomainMoney, "billing:view",
fmt.Sprintf("租户「%s」单笔消费异常", r["tenant_name"].String()),
fmt.Sprintf("模型 %s 单笔 %.6f,为该租户近 7 天均值的 %.1f 倍。"+
"需确认是正常业务、刷量,还是定价配置写错",
r["model_name"].String(), cost, mult),
"查看用量日志", "AdminUsageLogs",
map[string]string{"tenant_id": r["tenant_id"].String()}, r["created_at"].GTime(),
))
}
return items
}
// wbLowBalanceTenants 余额低于预警阈值的租户。
// 一个信号两个用途:流失预警 + 催充窗口。聚合成一条,不逐租户刷屏。
func (s *sAdmin) wbLowBalanceTenants(ctx context.Context) []wbItem {
rows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT COUNT(*) AS cnt, COALESCE(SUM(w.balance - w.frozen_balance), 0) AS available
FROM bil_wallets w
JOIN tnt_tenants t ON t.id = w.tenant_id
WHERE t.status IN ('active', 'trial')
AND w.warning_threshold > 0
AND (w.balance - w.frozen_balance) < w.warning_threshold`)
if err != nil || len(rows) == 0 {
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询低余额租户失败: %v", err)
}
return nil
}
cnt := rows[0]["cnt"].Int()
if cnt == 0 {
return nil
}
return []wbItem{wbNew(
"tenant_low_balance",
v1.WorkbenchSeverityP2, v1.WorkbenchDomainCustomer, "billing:view",
fmt.Sprintf("%d 个租户余额低于预警阈值", cnt),
fmt.Sprintf("合计可用余额 %.6f,既是服务中断风险,也是催充与流失干预的窗口",
rows[0]["available"].Float64()),
"查看租户", "AdminTenants", nil, nil,
)}
}
// ============================================================
// 客户运营(support:view / plan:view)
// ============================================================
func (s *sAdmin) wbCollectCustomer(ctx context.Context) []wbItem {
items := make([]wbItem, 0, 8)
items = append(items, s.wbTicketIssues(ctx)...)
items = append(items, s.wbFeedbackPending(ctx)...)
items = append(items, s.wbPlanExpiring(ctx)...)
return items
}
// wbTicketIssues 工单:只报「超 SLA 未首响」和「未分配」两类。
// 刻意不报工单总数 —— 那是个永远不为零的数字,放进待办只会制造永久红点。
func (s *sAdmin) wbTicketIssues(ctx context.Context) []wbItem {
items := make([]wbItem, 0, 4)
// 超 SLA 未首次响应:pending 且创建超过 SLA 时长
rows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT k.id, k.title, k.urgency, k.created_at, t.name AS tenant_name
FROM spt_tickets k
LEFT JOIN tnt_tenants t ON t.id = k.tenant_id
WHERE k.status = 'pending'
AND k.created_at <= ?
ORDER BY k.created_at ASC
LIMIT 10`, gtime.Now().Add(-wbTicketSLAHours*time.Hour))
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询超时工单失败: %v", err)
} else {
for _, r := range rows {
createdAt := r["created_at"].GTime()
sev := v1.WorkbenchSeverityP1
if u := r["urgency"].String(); u == "urgent" {
sev = v1.WorkbenchSeverityP0
}
items = append(items, wbNew(
fmt.Sprintf("ticket_sla_breach:%d", r["id"].Int64()),
sev, v1.WorkbenchDomainCustomer, "support:view",
fmt.Sprintf("工单「%s」超 SLA 未首次响应", truncate(r["title"].String(), 40)),
fmt.Sprintf("提交方「%s」,已等待 %s(SLA %d 小时)",
r["tenant_name"].String(), humanizeSince(createdAt), wbTicketSLAHours),
"去回复", "AdminTickets",
map[string]string{"id": r["id"].String()}, createdAt,
))
}
}
// 未分配工单:聚合成一条
cntRows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT COUNT(*) AS cnt FROM spt_tickets
WHERE status IN ('pending', 'reopened')
AND (assigned_admin_id IS NULL OR assigned_admin_id = 0)`)
if err == nil && len(cntRows) > 0 {
if n := cntRows[0]["cnt"].Int(); n > 0 {
items = append(items, wbNew(
"ticket_unassigned",
v1.WorkbenchSeverityP2, v1.WorkbenchDomainCustomer, "support:view",
fmt.Sprintf("%d 个工单未分配处理人", n),
"无人认领的工单容易在交接中被漏掉,建议指派到人",
"去分配", "AdminTickets", nil, nil,
))
}
}
return items
}
// wbFeedbackPending 待处理反馈(聚合一条 + 最长等待时间)。
func (s *sAdmin) wbFeedbackPending(ctx context.Context) []wbItem {
rows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT COUNT(*) AS cnt, MIN(created_at) AS oldest
FROM spt_feedbacks WHERE status = 'pending'`)
if err != nil || len(rows) == 0 {
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询待处理反馈失败: %v", err)
}
return nil
}
cnt := rows[0]["cnt"].Int()
if cnt == 0 {
return nil
}
oldest := rows[0]["oldest"].GTime()
return []wbItem{wbNew(
"feedback_pending",
v1.WorkbenchSeverityP2, v1.WorkbenchDomainCustomer, "support:view",
fmt.Sprintf("%d 条用户反馈待处理", cnt),
fmt.Sprintf("最早 1 条已等待 %s", humanizeSince(oldest)),
"去处理", "AdminFeedback", nil, nil,
)}
}
// wbPlanExpiring 套餐即将到期且未开启自动续费 —— 续费转化窗口。
func (s *sAdmin) wbPlanExpiring(ctx context.Context) []wbItem {
rows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT COUNT(*) AS cnt
FROM pln_tenant_plans
WHERE status = 'active'
AND auto_renew = false
AND end_at IS NOT NULL
AND end_at BETWEEN NOW() AND ?`, gtime.Now().AddDate(0, 0, wbPlanExpiringDays))
if err != nil || len(rows) == 0 {
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询到期套餐失败: %v", err)
}
return nil
}
cnt := rows[0]["cnt"].Int()
if cnt == 0 {
return nil
}
return []wbItem{wbNew(
"plan_expiring",
v1.WorkbenchSeverityP2, v1.WorkbenchDomainCustomer, "plan:view",
fmt.Sprintf("%d 个租户套餐 %d 天内到期", cnt, wbPlanExpiringDays),
"均未开启自动续费,到期即停服,建议提前触达",
"查看租户", "AdminTenants", nil, nil,
)}
}
// ============================================================
// 系统与安全(system:view / monitor:view / audit:view)
// ============================================================
func (s *sAdmin) wbCollectSystem(ctx context.Context) []wbItem {
items := make([]wbItem, 0, 8)
items = append(items, s.wbCronStalled(ctx)...)
items = append(items, s.wbEmailFailures(ctx)...)
items = append(items, s.wbAlertUnacked(ctx)...)
items = append(items, s.wbErrorLogsUnresolved(ctx)...)
items = append(items, s.wbNewIPLogin(ctx)...)
return items
}
// wbCronStalled 定时任务失败或停摆。
// 资金相关任务(预扣清扫、钱包物化)挂掉会直接导致资损,且完全无声。
func (s *sAdmin) wbCronStalled(ctx context.Context) []wbItem {
rows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT job_name, last_status, last_started_at, last_error_message
FROM sys_cron_jobs
WHERE last_status = 'failed'
OR last_started_at IS NULL
OR last_started_at <= NOW() - INTERVAL '1 hour'
ORDER BY last_started_at ASC NULLS FIRST
LIMIT 10`)
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询定时任务状态失败: %v", err)
return nil
}
items := make([]wbItem, 0, len(rows))
for _, r := range rows {
name := r["job_name"].String()
startedAt := r["last_started_at"].GTime()
failed := r["last_status"].String() == "failed"
// 资金相关任务提级为 P0:这些停摆会累积资损,不能等到明天
sev := v1.WorkbenchSeverityP1
if isMoneyCriticalJob(name) {
sev = v1.WorkbenchSeverityP0
}
desc := ""
if failed {
desc = "最近一次执行失败"
if e := strings.TrimSpace(r["last_error_message"].String()); e != "" {
desc += ":" + truncate(e, 150)
}
} else if startedAt == nil {
desc = "自服务启动以来从未执行过,调度器可能未正常注册该任务"
} else {
desc = fmt.Sprintf("已 %s 未执行,任务可能已停摆", humanizeSince(startedAt))
}
if isMoneyCriticalJob(name) {
desc += "。该任务涉及资金结算,停摆会持续累积差错"
}
items = append(items, wbNew(
"cron_stalled:"+name,
sev, v1.WorkbenchDomainSystem, "system:view",
fmt.Sprintf("定时任务 %s 异常", name),
desc,
"查看定时任务", "AdminCronJobs", nil, startedAt,
))
}
return items
}
// isMoneyCriticalJob 判断任务停摆是否会造成资损。
func isMoneyCriticalJob(name string) bool {
n := strings.ToLower(name)
for _, kw := range []string{"prededuct", "wallet", "materiali", "settle", "billing", "refund"} {
if strings.Contains(n, kw) {
return true
}
}
return false
}
// wbEmailFailures 邮件发送失败。
// 隐蔽度极高:验证码发不出去等于新用户注册与找回密码链路整条中断,
// 而受影响的人根本没有渠道告诉你。
func (s *sAdmin) wbEmailFailures(ctx context.Context) []wbItem {
rows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT COUNT(*) AS cnt,
COUNT(*) FILTER (WHERE template_code ILIKE '%verify%' OR template_code ILIKE '%code%') AS verify_cnt,
MAX(error_message) AS sample_error
FROM ntf_send_log
WHERE channel = 'email'
AND status = 'failed'
AND created_at >= NOW() - INTERVAL '1 hour'`)
if err != nil || len(rows) == 0 {
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询邮件失败记录失败: %v", err)
}
return nil
}
cnt := rows[0]["cnt"].Int()
if cnt < wbEmailFailThreshold {
return nil
}
desc := fmt.Sprintf("近 1 小时内 %d 封邮件发送失败", cnt)
if vc := rows[0]["verify_cnt"].Int(); vc > 0 {
desc += fmt.Sprintf(",其中 %d 封为验证码类 —— 新用户注册与找回密码链路已实际中断", vc)
}
if e := strings.TrimSpace(rows[0]["sample_error"].String()); e != "" {
desc += ";样本错误:" + truncate(e, 120)
}
return []wbItem{wbNew(
"email_failures",
v1.WorkbenchSeverityP1, v1.WorkbenchDomainSystem, "system:view",
"邮件发送连续失败",
desc,
"查看发送记录", "AdminEmailLogs", map[string]string{"status": "failed"}, nil,
)}
}
// wbAlertUnacked 未确认告警(聚合一条)。
func (s *sAdmin) wbAlertUnacked(ctx context.Context) []wbItem {
rows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT COUNT(*) AS cnt,
COUNT(*) FILTER (WHERE level = 'critical') AS critical_cnt,
MIN(created_at) AS oldest
FROM ops_alert_events
WHERE status = 'firing'`)
if err != nil || len(rows) == 0 {
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询未确认告警失败: %v", err)
}
return nil
}
cnt := rows[0]["cnt"].Int()
if cnt == 0 {
return nil
}
criticalCnt := rows[0]["critical_cnt"].Int()
sev := v1.WorkbenchSeverityP2
if criticalCnt > 0 {
sev = v1.WorkbenchSeverityP1
}
desc := fmt.Sprintf("最早 1 条触发于 %s", humanizeSince(rows[0]["oldest"].GTime()))
if criticalCnt > 0 {
desc = fmt.Sprintf("其中 %d 条为严重级别;", criticalCnt) + desc
}
return []wbItem{wbNew(
"alert_unack",
sev, v1.WorkbenchDomainSystem, "monitor:view",
fmt.Sprintf("%d 条告警事件未确认", cnt),
desc,
"去确认", "AdminAlertEvents", map[string]string{"status": "firing"}, nil,
)}
}
// wbErrorLogsUnresolved 未处理系统错误日志(聚合一条)。
func (s *sAdmin) wbErrorLogsUnresolved(ctx context.Context) []wbItem {
rows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT COUNT(*) AS cnt, COUNT(DISTINCT error_message) AS distinct_cnt
FROM sys_error_logs
WHERE resolved = false
AND created_at >= NOW() - INTERVAL '7 days'`)
if err != nil || len(rows) == 0 {
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询未处理错误日志失败: %v", err)
}
return nil
}
cnt := rows[0]["cnt"].Int()
if cnt == 0 {
return nil
}
desc := "近 7 天内产生"
if d := rows[0]["distinct_cnt"].Int(); d > 0 && cnt > d {
desc += fmt.Sprintf(",去重后仅 %d 种,建议按类型合并排查", d)
}
return []wbItem{wbNew(
"error_log_unresolved",
v1.WorkbenchSeverityP2, v1.WorkbenchDomainSystem, "monitor:view",
fmt.Sprintf("%d 条系统错误日志未处理", cnt),
desc,
"查看错误日志", "AdminErrorLogs", nil, nil,
)}
}
// wbNewIPLogin 管理员从新 IP 登录成功。
// 管理后台账号是整个平台的最高权限,异地登录必须让人看见并确认。
func (s *sAdmin) wbNewIPLogin(ctx context.Context) []wbItem {
rows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT h.id, h.user_id, h.ip_address, h.location, h.created_at, u.username
FROM aud_login_history h
LEFT JOIN sys_admin_users u ON u.id = h.user_id
WHERE h.user_type = 'admin'
AND h.success = true
AND h.is_new_device = true
AND h.created_at >= NOW() - INTERVAL '24 hours'
ORDER BY h.created_at DESC
LIMIT 5`)
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询异常登录失败: %v", err)
return nil
}
items := make([]wbItem, 0, len(rows))
for _, r := range rows {
createdAt := r["created_at"].GTime()
loc := strings.TrimSpace(r["location"].String())
if loc == "" {
loc = "未知归属地"
}
items = append(items, wbNew(
fmt.Sprintf("admin_new_ip_login:%d", r["id"].Int64()),
v1.WorkbenchSeverityP1, v1.WorkbenchDomainSystem, "audit:view",
fmt.Sprintf("管理员 %s 从新设备登录", r["username"].String()),
fmt.Sprintf("IP %s(%s),此前无该设备记录,请确认是否本人操作",
maskIP(r["ip_address"].String()), loc),
"查看登录历史", "AdminLoginHistory", nil, createdAt,
))
}
return items
}
// ============================================================
// 首屏关键数字
// ============================================================
// wbCollectMetrics 首屏三个数字 + 待办计数由上层补。
//
// 收入对比刻意用「昨日同期」而非「昨日全天」:全天对比在每天早晨永远是 -80%,
// 是个看了就要在脑子里做一次除法的假信号。
func (s *sAdmin) wbCollectMetrics(ctx context.Context) []v1.WorkbenchMetric {
metrics := make([]v1.WorkbenchMetric, 0, 3)
// --- 今日收入 vs 昨日同期 ---
var todayCost, yesterdayCost float64
rows, err := g.DB().Ctx(ctx).Query(ctx,
`SELECT
COALESCE(SUM(total_cost) FILTER (WHERE created_at >= date_trunc('day', NOW())), 0) AS today_cost,
COALESCE(SUM(total_cost) FILTER (
WHERE created_at >= date_trunc('day', NOW()) - INTERVAL '1 day'
AND created_at < date_trunc('day', NOW()) - INTERVAL '1 day' + (NOW() - date_trunc('day', NOW()))
), 0) AS yesterday_cost
FROM bil_usage_logs
WHERE created_at >= date_trunc('day', NOW()) - INTERVAL '1 day'`)
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询收入指标失败: %v", err)
} else if len(rows) > 0 {
todayCost = rows[0]["today_cost"].Float64()
yesterdayCost = rows[0]["yesterday_cost"].Float64()
}
revenueMetric := v1.WorkbenchMetric{
Key: "today_revenue", Label: "今日收入", Value: todayCost,
Unit: "money", Sub: "较昨日同期",
}
if yesterdayCost > 0 {
growth := (todayCost - yesterdayCost) / yesterdayCost * 100
revenueMetric.Growth = &growth
}
metrics = append(metrics, revenueMetric)
// --- 实时错误率(近 5 分钟)---
// 刻意不用「今日成功率」:那个数被一整天的历史稀释,正在发生的故障根本顶不动它。
var errRate float64
rows, err = g.DB().Ctx(ctx).Query(ctx,
`SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE status <> 'success') AS errors
FROM bil_usage_logs
WHERE created_at >= NOW() - INTERVAL '5 minutes'`)
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询实时错误率失败: %v", err)
} else if len(rows) > 0 {
if total := rows[0]["total"].Float64(); total > 0 {
errRate = rows[0]["errors"].Float64() / total * 100
}
}
metrics = append(metrics, v1.WorkbenchMetric{
Key: "error_rate_5m", Label: "实时错误率", Value: round2(errRate),
Unit: "percent", Sub: "近 5 分钟",
})
// --- 余额告警租户数 ---
var lowBalance float64
rows, err = g.DB().Ctx(ctx).Query(ctx,
`SELECT COUNT(*) AS cnt
FROM bil_wallets w
JOIN tnt_tenants t ON t.id = w.tenant_id
WHERE t.status IN ('active', 'trial')
AND w.warning_threshold > 0
AND (w.balance - w.frozen_balance) < w.warning_threshold`)
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询余额告警租户失败: %v", err)
} else if len(rows) > 0 {
lowBalance = rows[0]["cnt"].Float64()
}
metrics = append(metrics, v1.WorkbenchMetric{
Key: "low_balance_tenants", Label: "余额告警租户", Value: lowBalance,
Unit: "count", Sub: "低于预警阈值",
})
return metrics
}
// ============================================================
// 小工具
// ============================================================
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
func round2(f float64) float64 {
return float64(int64(f*100+0.5)) / 100
}
func fmtTime(t *gtime.Time) string {
if t == nil {
return "未知时间"
}
return t.Format("Y-m-d H:i")
}
// humanizeSince 把时间差写成人话。工作台上「3 小时 12 分」比时间戳更能驱动行动。
func humanizeSince(t *gtime.Time) string {
if t == nil {
return "未知时长"
}
d := time.Since(t.Time)
if d < time.Minute {
return "不到 1 分钟"
}
if d < time.Hour {
return fmt.Sprintf("%d 分钟", int(d.Minutes()))
}
if d < 24*time.Hour {
h := int(d.Hours())
m := int(d.Minutes()) - h*60
if m == 0 {
return fmt.Sprintf("%d 小时", h)
}
return fmt.Sprintf("%d 小时 %d 分", h, m)
}
return fmt.Sprintf("%d 天", int(d.Hours()/24))
}
// maskIP 打码 IP 中间两段,兼顾可辨识与最小暴露。
func maskIP(ip string) string {
parts := strings.Split(ip, ".")
if len(parts) != 4 {
return ip
}
return parts[0] + ".**.**." + parts[3]
}
func sortModelAvail(list []v1.WorkbenchModelAvail) {
sortSlice(len(list), func(i, j int) bool {
// 零可用的排最前,其次按缺口比例,最后按名字保证确定性
ai := list[i].Available == 0 && list[i].Total > 0
aj := list[j].Available == 0 && list[j].Total > 0
if ai != aj {
return ai
}
gi := list[i].Total - list[i].Available
gj := list[j].Total - list[j].Available
if gi != gj {
return gi > gj
}
return list[i].Model < list[j].Model
}, func(i, j int) { list[i], list[j] = list[j], list[i] })
}
func sortBreakers(list []v1.WorkbenchBreaker) {
sortSlice(len(list), func(i, j int) bool {
if list[i].ChannelLevel != list[j].ChannelLevel {
return list[i].ChannelLevel
}
if list[i].ChannelName != list[j].ChannelName {
return list[i].ChannelName < list[j].ChannelName
}
return list[i].Model < list[j].Model
}, func(i, j int) { list[i], list[j] = list[j], list[i] })
}
// sortSlice 简易插入排序:工作台的列表规模都在几十条以内,
// 不值得为此引入反射版 sort.Slice 的开销。
func sortSlice(n int, less func(i, j int) bool, swap func(i, j int)) {
for i := 1; i < n; i++ {
for j := i; j > 0 && less(j, j-1); j-- {
swap(j, j-1)
}
}
}
+5
View File
@@ -306,6 +306,11 @@ var adminPermissionRules = []permissionRule{
// ── dashboard and async task management ──
{method: "GET", prefix: "/api/admin/dashboard", perm: "dashboard:view"},
// 工作台读接口只要求 dashboard:view:跨域条目由 logic 层按 channel:view / billing:view /
// support:view 等逐条过滤,进得来不等于看得到,权限收敛在数据侧而非路由侧。
// 工作台没有写接口 —— 待办由源表实时派生,唯一的处置方式是去源头解决。
{method: "GET", path: "/api/admin/workbench/summary", perm: "dashboard:view"},
{method: "GET", path: "/api/admin/workbench/badges", perm: "dashboard:view"},
{method: "GET", path: "/api/admin/tasks", perm: "task:view"},
{method: "GET", prefix: "/api/admin/tasks/", perm: "task:view"},
{method: "POST", prefix: "/api/admin/tasks/", suffix: "/cancel", perm: "task:edit"},
+4
View File
@@ -547,6 +547,10 @@ type (
UsageLogCleanupCreate(ctx context.Context, req *v1.UsageLogCleanupCreateReq) (*v1.UsageLogCleanupCreateRes, error)
UsageLogCleanupList(ctx context.Context, req *v1.UsageLogCleanupListReq) (*v1.UsageLogCleanupListRes, error)
UsageLogCleanupCancel(ctx context.Context, req *v1.UsageLogCleanupCancelReq) (*v1.UsageLogCleanupCancelRes, error)
// GetWorkbenchSummary 工作台汇总。
GetWorkbenchSummary(ctx context.Context, _ *v1.AdminWorkbenchSummaryReq) (*v1.AdminWorkbenchSummaryRes, error)
// GetWorkbenchBadges 菜单红点计数(与 summary 共用缓存,不额外压库)。
GetWorkbenchBadges(ctx context.Context, _ *v1.AdminWorkbenchBadgeReq) (*v1.AdminWorkbenchBadgeRes, error)
}
)
+6
View File
@@ -24,6 +24,12 @@ const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/views/DashboardPage.vue'),
meta: { title: '仪表盘' },
},
{
path: 'workbench',
name: 'AdminWorkbench',
component: () => import('@/views/WorkbenchPage.vue'),
meta: { title: '工作台' },
},
{
path: 'profile',
name: 'AdminProfile',
+66
View File
@@ -9,6 +9,7 @@ import RouteErrorBoundary from '@/components/RouteErrorBoundary.vue'
import { useWatermark } from '@/composables/useWatermark'
import { useSiteName } from '@/composables/useSiteName'
import {
IconThunderbolt,
IconDashboard,
IconUserGroup,
IconApps,
@@ -305,6 +306,7 @@ const menuGroups = [
label: '数据看板',
icon: IconDashboard,
items: [
{ name: 'AdminWorkbench', label: '工作台', icon: IconThunderbolt },
{ name: 'AdminDashboard', label: '仪表盘', icon: IconDashboard },
{ name: 'AdminRealtimeMonitor', label: '实时监控', icon: IconCommand },
],
@@ -453,6 +455,34 @@ function handleClickOutside(e: MouseEvent) {
}
}
// --- 工作台菜单红点 ---
// 工作台是聚合视图,红点才是常驻发现入口:管理员不会每天主动点开工作台,
// 但会看见菜单上的角标。后端 badges 与 summary 共用同一份 30s Redis 缓存,
// 60s 轮询不会额外压库。
const workbenchBadges = ref<Record<string, number>>({})
const workbenchUrgent = ref(0)
const workbenchTotal = ref(0)
let badgeTimer: ReturnType<typeof setInterval> | null = null
// 菜单项角标:工作台本身显示待办总数,其余菜单显示后端映射到该路由的待办数
function badgeOf(name: string): number {
if (name === 'AdminWorkbench') return workbenchTotal.value
return workbenchBadges.value[name] || 0
}
async function fetchWorkbenchBadges() {
try {
const res = await request.get('/admin/workbench/badges')
const data = res.data?.data
if (!data) return
workbenchBadges.value = data.menus || {}
workbenchUrgent.value = data.urgent || 0
workbenchTotal.value = data.total || 0
} catch {
// 静默:红点拉取失败不该打扰用户,也不该阻塞布局
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
updateMobile()
@@ -462,6 +492,9 @@ onMounted(() => {
checkUpdate()
fetchUpdateStatus() // 取后端实时版本,用于准确的"是否可更新"判断
fetchWorkbenchBadges()
badgeTimer = setInterval(fetchWorkbenchBadges, 60000)
axios.get('/api/settings/public').then((res) => {
const settings = res.data?.data?.settings
if (settings) {
@@ -478,6 +511,7 @@ onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', updateMobile)
clearTimeout(popupHideTimer!)
if (badgeTimer) clearInterval(badgeTimer)
stopPolling()
unmountWatermark()
})
@@ -524,6 +558,11 @@ onUnmounted(() => {
>
<component :is="item.icon" class="admin-sidebar__icon" />
<span class="admin-sidebar__text">{{ item.label }}</span>
<span
v-if="badgeOf(item.name) > 0"
class="admin-sidebar__badge"
:class="{ 'admin-sidebar__badge--urgent': item.name === 'AdminWorkbench' && workbenchUrgent > 0 }"
>{{ badgeOf(item.name) > 99 ? '99+' : badgeOf(item.name) }}</span>
</div>
</div>
</Transition>
@@ -590,6 +629,11 @@ onUnmounted(() => {
>
<component :is="item.icon" class="admin-sidebar__icon" />
<span class="admin-sidebar__text">{{ item.label }}</span>
<span
v-if="badgeOf(item.name) > 0"
class="admin-sidebar__badge"
:class="{ 'admin-sidebar__badge--urgent': item.name === 'AdminWorkbench' && workbenchUrgent > 0 }"
>{{ badgeOf(item.name) > 99 ? '99+' : badgeOf(item.name) }}</span>
</div>
</div>
</Transition>
@@ -950,6 +994,28 @@ onUnmounted(() => {
text-overflow: ellipsis;
}
/* 待办角标:默认中性灰,仅在工作台有 P0 时转红。
全部标红会让红点迅速失去意义 —— 红色必须留给「现在就得看」。 */
.admin-sidebar__badge {
margin-left: auto;
flex-shrink: 0;
min-width: 18px;
height: 18px;
padding: 0 5px;
line-height: 18px;
text-align: center;
border-radius: 9px;
font-size: 11px;
font-weight: 600;
background: var(--color-fill-3);
color: var(--color-text-2);
}
.admin-sidebar__badge--urgent {
background: #f53f3f;
color: #fff;
}
/* ===== Collapsed Sidebar Overrides ===== */
.admin-sidebar--collapsed .admin-sidebar__group-header {
justify-content: center;
+818
View File
@@ -0,0 +1,818 @@
<script setup lang="ts">
/**
* 工作台(运营收件箱)
*
* 与仪表盘的分工:仪表盘回答「现在怎么样」,工作台回答「我该做什么」。
* 只有满足「有人要处理 + 不处理会恶化 + 能被清空归零」三条的事才进这里。
*
* 待办由后端从各业务源表实时派生,前端不缓存、不自己判定严重度,也不持久化任何状态。
* 待办没有「标记完成」——唯一的出路是点进源头把问题解决掉,下一轮收集自然就没有它了。
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import {
IconThunderbolt,
IconBranch,
IconIdcard,
IconMessage,
IconSafe,
IconRefresh,
IconRight,
IconCheckCircleFill,
IconExclamationCircle,
IconFire,
IconUserGroup,
IconBarChart,
} from '@arco-design/web-vue/es/icon'
import request from '@/utils/request'
import { formatBilling } from '@/composables/useCurrency'
const router = useRouter()
// ===== 常量 =====
type Severity = 'p0' | 'p1' | 'p2'
type DomainKey = 'availability' | 'money' | 'customer' | 'system'
const DOMAINS: Record<DomainKey, { label: string; icon: any; color: string }> = {
availability: { label: '服务可用性', icon: IconBranch, color: '#f77234' },
money: { label: '资金安全', icon: IconIdcard, color: '#14b8a6' },
customer: { label: '客户运营', icon: IconMessage, color: '#3491fa' },
system: { label: '系统安全', icon: IconSafe, color: '#8b5cf6' },
}
const SEVERITY: Record<Severity, { label: string; color: string; bg: string }> = {
p0: { label: '紧急', color: '#f53f3f', bg: 'rgba(245, 63, 63, 0.06)' },
p1: { label: '今日', color: '#ff7d00', bg: 'transparent' },
p2: { label: '本周', color: '#86909c', bg: 'transparent' },
}
// 指标图标/配色按 key 固定,不跟随后端返回顺序
const METRIC_STYLE: Record<string, { icon: any; gradient: string }> = {
today_revenue: { icon: IconBarChart, gradient: 'linear-gradient(135deg, #0d9488, #14b8a6)' },
error_rate_5m: { icon: IconThunderbolt, gradient: 'linear-gradient(135deg, #ef4444, #f87171)' },
low_balance_tenants: { icon: IconUserGroup, gradient: 'linear-gradient(135deg, #f59e0b, #fbbf24)' },
pending_total: { icon: IconFire, gradient: 'linear-gradient(135deg, #6366f1, #818cf8)' },
}
// 路径参数路由:跳转时要走 params 而非 query
const PARAM_ROUTES: Record<string, string[]> = {
AdminChannelDetail: ['id'],
AdminTenantDetail: ['id'],
}
// ===== 状态 =====
const loading = ref(false)
const summary = ref<any>(null)
const onlyUrgent = ref(false)
const activeDomain = ref<DomainKey | 'all'>('all')
const lastRefresh = ref<Date | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
// ===== 派生 =====
const items = computed<any[]>(() => summary.value?.items || [])
const filtered = computed(() =>
items.value.filter(i => {
if (onlyUrgent.value && i.severity !== 'p0') return false
if (activeDomain.value !== 'all' && i.domain !== activeDomain.value) return false
return true
}),
)
const urgentCount = computed(() => items.value.filter(i => i.severity === 'p0').length)
const domainStats = computed(() => {
const raw: any[] = summary.value?.domains || []
return (Object.keys(DOMAINS) as DomainKey[]).map(key => {
const hit = raw.find(d => d.domain === key)
return { key, ...DOMAINS[key], total: hit?.total || 0, urgent: hit?.urgent || 0 }
})
})
// 首屏数字:后端三个 + 前端补一个「待处理事项」(它就是列表长度,没必要让后端再算一遍)
const metrics = computed(() => {
const list: any[] = (summary.value?.metrics || []).map((m: any) => ({
key: m.key,
label: m.label,
value: formatMetric(m),
sub: m.sub,
growth:
m.growth === null || m.growth === undefined
? undefined
: { value: Math.abs(Math.round(m.growth * 10) / 10), positive: m.growth >= 0 },
...(METRIC_STYLE[m.key] || METRIC_STYLE.pending_total),
}))
const p1 = items.value.filter(i => i.severity === 'p1').length
list.push({
key: 'pending_total',
label: '待处理事项',
value: String(items.value.length),
sub: `紧急 ${urgentCount.value} · 今日 ${p1}`,
...METRIC_STYLE.pending_total,
})
return list
})
function formatMetric(m: any): string {
const v = Number(m.value) || 0
if (m.unit === 'money') return formatBilling(v, 2)
if (m.unit === 'percent') return v.toFixed(1) + '%'
return v.toLocaleString()
}
const refreshText = computed(() =>
lastRefresh.value ? lastRefresh.value.toLocaleTimeString('zh-CN', { hour12: false }) : '—',
)
function timeAgo(iso: string) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
const diffMin = Math.floor((Date.now() - d.getTime()) / 60000)
if (diffMin < 1) return '刚刚'
if (diffMin < 60) return `${diffMin} 分钟前`
const h = Math.floor(diffMin / 60)
if (h < 24) return `${h} 小时前`
return `${Math.floor(h / 24)} 天前`
}
// ===== 数据 =====
async function fetchSummary(silent = false) {
if (!silent) loading.value = true
try {
const res = await request.get('/admin/workbench/summary')
summary.value = res.data?.data || null
lastRefresh.value = new Date()
} catch (e: any) {
if (!silent) Message.error(e.response?.data?.message || '加载工作台失败')
} finally {
if (!silent) loading.value = false
}
}
// 跳转时自动带上筛选条件,让管理员落地即看到问题现场,而不是一个空列表
function goAction(item: any) {
if (!item.action_route) return
const q = item.action_query || {}
const paramKeys = PARAM_ROUTES[item.action_route]
if (paramKeys) {
const params: Record<string, string> = {}
const query: Record<string, string> = {}
Object.entries(q).forEach(([k, v]) => {
if (paramKeys.includes(k)) params[k] = String(v)
else query[k] = String(v)
})
router.push({ name: item.action_route, params, query })
return
}
router.push({ name: item.action_route, query: q })
}
function pickDomain(key: DomainKey | 'all') {
activeDomain.value = activeDomain.value === key ? 'all' : key
}
function availClass(m: any) {
if (m.available === 0 && m.total > 0) return 'is-dead'
if (m.available < m.total) return 'is-warn'
return 'is-ok'
}
onMounted(() => {
fetchSummary()
// 后端整份结果有 30s Redis 缓存,前端 60s 轮询不会额外压库
pollTimer = setInterval(() => fetchSummary(true), 60000)
})
onBeforeUnmount(() => {
if (pollTimer) clearInterval(pollTimer)
})
</script>
<template>
<ASpin :loading="loading" style="width: 100%">
<div style="width: 100%">
<!-- 页头 -->
<div class="wb-header">
<div>
<div class="wb-header__title">工作台</div>
<div class="wb-header__desc">需要你动手处理的事。处理完就清空 —— 这里应该经常是空的。</div>
</div>
<div class="wb-header__ops">
<span class="wb-header__time">更新于 {{ refreshText }}</span>
<ASwitch v-model="onlyUrgent" size="small" />
<span class="wb-header__switch-label">仅看紧急</span>
<AButton size="small" @click="fetchSummary()">
<template #icon><IconRefresh /></template>
刷新
</AButton>
</div>
</div>
<!-- 首屏关键数字 -->
<div class="grid grid-cols-4 gap-5 mb-5 max-lg:grid-cols-2 max-md:grid-cols-2 max-md:gap-3">
<ACard
v-for="(m, idx) in metrics"
:key="m.key"
class="stat-card animate-cardAppear"
:bordered="false"
:style="{ animationDelay: `${idx * 0.06}s` }"
>
<div class="stat-card__inner">
<div>
<div class="stat-card__label">{{ m.label }}</div>
<div class="stat-card__value">{{ m.value }}</div>
<div class="stat-card__meta">
<span
v-if="m.growth"
class="stat-card__growth"
:class="m.growth.positive ? 'is-up' : 'is-down'"
>
{{ m.growth.positive ? '&#9650;' : '&#9660;' }} {{ m.growth.value }}%
</span>
<span class="stat-card__sub">{{ m.sub }}</span>
</div>
</div>
<div class="stat-card__icon" :style="{ background: m.gradient }">
<component :is="m.icon" :size="24" style="color: #fff" />
</div>
</div>
</ACard>
</div>
<!-- 紧急横幅:只有 P0 才弹,其余静默计数 -->
<div v-if="urgentCount > 0" class="wb-banner" @click="onlyUrgent = true">
<IconExclamationCircle :size="18" />
<span class="wb-banner__text">
<b>{{ urgentCount }} 项紧急事项</b>
正在影响服务可用性或资金安全,建议立即处理
</span>
<span class="wb-banner__link">只看这些 <IconRight /></span>
</div>
<div class="wb-body">
<!-- 左:统一待办流(严重度优先,不按菜单打散) -->
<ACard :bordered="false" class="wb-flow">
<template #title>
<span>待办事项</span>
<span class="wb-flow__count">{{ filtered.length }}</span>
</template>
<template #extra>
<ATag v-if="activeDomain !== 'all'" closable size="small" @close="activeDomain = 'all'">
{{ DOMAINS[activeDomain as DomainKey].label }}
</ATag>
</template>
<TransitionGroup name="wb-item" tag="div" class="wb-list">
<div
v-for="item in filtered"
:key="item.key"
class="wb-item"
:style="{
borderLeftColor: SEVERITY[item.severity as Severity].color,
background: SEVERITY[item.severity as Severity].bg,
}"
>
<div class="wb-item__head">
<ATag
size="small"
:style="{
background: SEVERITY[item.severity as Severity].color + '1a',
color: SEVERITY[item.severity as Severity].color,
border: 'none',
}"
>
{{ SEVERITY[item.severity as Severity].label }}
</ATag>
<span class="wb-item__domain" :style="{ color: DOMAINS[item.domain as DomainKey].color }">
<component :is="DOMAINS[item.domain as DomainKey].icon" :size="12" />
{{ DOMAINS[item.domain as DomainKey].label }}
</span>
<span class="wb-item__title">{{ item.title }}</span>
<span v-if="item.occurred_at" class="wb-item__time">{{ timeAgo(item.occurred_at) }}</span>
</div>
<div class="wb-item__desc">{{ item.desc }}</div>
<div class="wb-item__actions">
<AButton v-if="item.action_route" size="mini" type="primary" @click="goAction(item)">
{{ item.action_text || '去处理' }}
</AButton>
</div>
</div>
</TransitionGroup>
<div v-if="filtered.length === 0" class="wb-empty">
<IconCheckCircleFill :size="44" style="color: #10b981" />
<div class="wb-empty__title">全部处理完了</div>
<div class="wb-empty__desc">工作台清空是正常状态,不代表没数据。</div>
</div>
</ACard>
<!-- 右:分域计数 + 可用性速览 -->
<div class="wb-side">
<ACard :bordered="false" title="按域分布">
<div class="wb-domains">
<div
v-for="d in domainStats"
:key="d.key"
class="wb-domain"
:class="{ 'is-active': activeDomain === d.key }"
@click="pickDomain(d.key)"
>
<span class="wb-domain__icon" :style="{ background: d.color + '1a', color: d.color }">
<component :is="d.icon" :size="14" />
</span>
<span class="wb-domain__label">{{ d.label }}</span>
<span v-if="d.urgent > 0" class="wb-domain__urgent">{{ d.urgent }}</span>
<span class="wb-domain__total">{{ d.total }}</span>
</div>
</div>
</ACard>
<ACard :bordered="false" title="模型可用渠道">
<template #extra>
<ALink @click="router.push({ name: 'AdminChannels' })">全部</ALink>
</template>
<div v-if="(summary?.models || []).length" class="wb-avail">
<div v-for="m in (summary?.models || []).slice(0, 8)" :key="m.model" class="wb-avail__row">
<span class="wb-avail__name">{{ m.model }}</span>
<span class="wb-avail__val" :class="availClass(m)">{{ m.available }} / {{ m.total }}</span>
</div>
</div>
<AEmpty v-else description="调度目录尚未初始化" />
</ACard>
<ACard :bordered="false" title="正在熔断">
<div v-if="(summary?.breakers || []).length" class="wb-breaker">
<div v-for="(b, i) in summary.breakers" :key="i" class="wb-breaker__row">
<div class="wb-breaker__main">
<span class="wb-breaker__ch">{{ b.channel_name }}</span>
<span class="wb-breaker__model">{{ b.model }}</span>
</div>
<div class="wb-breaker__meta">
<ATag size="small" :color="b.channel_level ? 'red' : 'orange'">
{{ b.channel_level ? '渠道级' : '模型级' }}
</ATag>
<ATag v-if="b.half_open" size="small" color="arcoblue">探测中</ATag>
</div>
</div>
</div>
<AEmpty v-else description="无渠道处于熔断" />
</ACard>
</div>
</div>
</div>
</ASpin>
</template>
<style scoped>
.grid {
display: grid;
}
/* ===== 页头 ===== */
.wb-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 20px;
gap: 16px;
flex-wrap: wrap;
}
.wb-header__title {
font-size: 22px;
font-weight: 700;
color: var(--color-text-1);
letter-spacing: -0.02em;
}
.wb-header__desc {
margin-top: 4px;
font-size: 13px;
color: var(--color-text-3);
}
.wb-header__ops {
display: flex;
align-items: center;
gap: 8px;
}
.wb-header__time {
font-size: 12px;
color: var(--color-text-3);
margin-right: 4px;
}
.wb-header__switch-label {
font-size: 13px;
color: var(--color-text-2);
margin-right: 4px;
}
/* ===== 指标卡(复用仪表盘视觉语言)===== */
.stat-card {
padding: 4px 0;
}
.stat-card__inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.stat-card__label {
font-size: 13px;
color: var(--color-text-3);
margin-bottom: 10px;
font-weight: 500;
}
.stat-card__value {
font-size: 26px;
font-weight: 700;
color: var(--color-text-1);
letter-spacing: -0.02em;
line-height: 1;
}
.stat-card__meta {
margin-top: 8px;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.stat-card__growth.is-up {
color: #10b981;
}
.stat-card__growth.is-down {
color: #ef4444;
}
.stat-card__sub {
color: var(--color-text-3);
}
.stat-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 14px;
flex-shrink: 0;
}
/* ===== 紧急横幅 ===== */
.wb-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
margin-bottom: 20px;
border-radius: 8px;
background: rgba(245, 63, 63, 0.08);
border: 1px solid rgba(245, 63, 63, 0.2);
color: #f53f3f;
cursor: pointer;
transition: background 0.2s;
}
.wb-banner:hover {
background: rgba(245, 63, 63, 0.12);
}
.wb-banner__text {
flex: 1;
font-size: 13px;
color: var(--color-text-1);
}
.wb-banner__text b {
color: #f53f3f;
}
.wb-banner__link {
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
/* ===== 主体两列 ===== */
.wb-body {
display: grid;
grid-template-columns: minmax(0, 2.2fr) minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.wb-side {
display: flex;
flex-direction: column;
gap: 20px;
}
/* ===== 待办流 ===== */
.wb-flow__count {
display: inline-block;
margin-left: 8px;
padding: 0 7px;
height: 18px;
line-height: 18px;
border-radius: 9px;
background: var(--color-fill-3);
color: var(--color-text-2);
font-size: 12px;
font-weight: 600;
}
.wb-list {
display: flex;
flex-direction: column;
gap: 10px;
position: relative;
}
.wb-item {
padding: 12px 14px;
border-radius: 6px;
border: 1px solid var(--color-border-2);
border-left-width: 3px;
transition: box-shadow 0.2s;
}
.wb-item:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.wb-item__head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.wb-item__domain {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 12px;
font-weight: 500;
}
.wb-item__title {
flex: 1;
min-width: 200px;
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
}
.wb-item__time {
font-size: 12px;
color: var(--color-text-3);
white-space: nowrap;
}
.wb-item__desc {
margin-top: 6px;
font-size: 13px;
line-height: 1.6;
color: var(--color-text-3);
}
.wb-item__actions {
margin-top: 10px;
display: flex;
align-items: center;
gap: 8px;
}
/* ===== 空态:清空是正常状态 ===== */
.wb-empty {
padding: 48px 0;
text-align: center;
}
.wb-empty__title {
margin-top: 12px;
font-size: 15px;
font-weight: 600;
color: var(--color-text-1);
}
.wb-empty__desc {
margin-top: 4px;
font-size: 13px;
color: var(--color-text-3);
}
/* ===== 右栏:按域分布 ===== */
.wb-domains {
display: flex;
flex-direction: column;
gap: 4px;
}
.wb-domain {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.wb-domain:hover {
background: var(--color-fill-1);
}
.wb-domain.is-active {
background: var(--color-fill-2);
}
.wb-domain__icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
flex-shrink: 0;
}
.wb-domain__label {
flex: 1;
font-size: 13px;
color: var(--color-text-1);
}
.wb-domain__urgent {
min-width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
border-radius: 9px;
background: #f53f3f;
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 0 5px;
}
.wb-domain__total {
min-width: 22px;
text-align: right;
font-size: 14px;
font-weight: 600;
color: var(--color-text-2);
}
/* ===== 右栏:模型可用渠道 ===== */
.wb-avail {
display: flex;
flex-direction: column;
gap: 10px;
}
.wb-avail__row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
gap: 8px;
}
.wb-avail__name {
color: var(--color-text-2);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wb-avail__val {
font-weight: 600;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.wb-avail__val.is-ok {
color: #10b981;
}
.wb-avail__val.is-warn {
color: #ff7d00;
}
.wb-avail__val.is-dead {
color: #f53f3f;
}
/* ===== 右栏:熔断 ===== */
.wb-breaker {
display: flex;
flex-direction: column;
gap: 12px;
}
.wb-breaker__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.wb-breaker__main {
min-width: 0;
}
.wb-breaker__ch {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--color-text-1);
}
.wb-breaker__model {
display: block;
font-size: 11px;
color: var(--color-text-3);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.wb-breaker__meta {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* ===== 动画 ===== */
@keyframes cardAppear {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-cardAppear {
animation: cardAppear 0.4s ease both;
}
.wb-item-leave-active {
transition: all 0.3s ease;
position: absolute;
width: 100%;
}
.wb-item-leave-to {
opacity: 0;
transform: translateX(24px);
}
.wb-item-move {
transition: transform 0.3s ease;
}
/* ===== 响应式 ===== */
@media (max-width: 1200px) {
.wb-body {
grid-template-columns: 1fr;
}
}
@media (max-width: 767px) {
.stat-card__value {
font-size: 22px;
}
.stat-card__icon {
width: 40px;
height: 40px;
border-radius: 10px;
}
.wb-header {
align-items: flex-start;
flex-direction: column;
}
.wb-item__title {
min-width: 100%;
}
}
</style>