improve(workbench):优化工作台页面

This commit is contained in:
qian committed 2026-08-31 23:29:21 +08:00
1 parent a2beb8230a
commit 211148a0af
9 files changed
+139 -105

No files matched your search

+3 -6
View File
@@ -97,13 +97,10 @@ type AdminWorkbenchSummaryRes struct {
// AdminWorkbenchBadgeReq 轻量计数接口,供左侧菜单红点轮询。
// 与 summary 共用同一份缓存,不会额外压库。
type AdminWorkbenchBadgeReq struct {
g.Meta `path:"/workbench/badges" method:"get" mime:"json" tags:"管理后台-工作台" summary:"菜单红点计数"`
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:"菜单路由名 → 待办数"`
Total int `json:"total" dc:"待办总数,仅挂在工作台菜单角标"`
Urgent int `json:"urgent" dc:"P0 数量,控制角标转红"`
}
+3 -3
View File
@@ -148,13 +148,13 @@ func runMaintenance(ctx context.Context) {
// 覆盖摘要:目录只装载 active × 有启用能力行的渠道,未覆盖渠道的健康分不会落盘
// (探测却正常的渠道长期停在历史分数时,先看这里是否被跳过)
g.Log().Infof(ctx, "[ChannelHealth] 健康快照维护轮次完成: 覆盖渠道 %d 个 %v", len(covered), covered)
g.Log().Debugf(ctx, "[ChannelHealth] 健康快照维护轮次完成: 覆盖渠道 %d 个 %v", len(covered), covered)
if len(skippedDegraded) > 0 {
g.Log().Warningf(ctx, "[ChannelHealth] 本轮 %d 个渠道因 Redis 健康读取失败跳过落盘(保留库中旧值): %v",
len(skippedDegraded), skippedDegraded)
}
if len(skippedNoData) > 0 {
g.Log().Infof(ctx, "[ChannelHealth] 本轮 %d 个渠道全部模型无真实上报,保留库中旧值: %v",
g.Log().Debugf(ctx, "[ChannelHealth] 本轮 %d 个渠道全部模型无真实上报,保留库中旧值: %v",
len(skippedNoData), skippedNoData)
}
}
@@ -258,7 +258,7 @@ func snapshotHealthScore(ctx context.Context, channelID int64, succ, latMs, alph
// 分数有实际变化才记 Info;稳态渠道降为 Debug(避免每 5 分钟 × 渠道数的固定噪音)。
// 两种情况都带各模型 succ 明细:排查"健康分不符合预期"时可直接定位是哪个模型拉低了均值。
if math.Abs(healthScore-prevScore) >= 0.05 {
g.Log().Infof(ctx, "[ChannelHealth] 渠道 %d 健康分落盘: %.1f → %.1f | avg_succ %.4f | 模型: %s",
g.Log().Debugf(ctx, "[ChannelHealth] 渠道 %d 健康分落盘: %.1f → %.1f | avg_succ %.4f | 模型: %s",
channelID, prevScore, healthScore, succ, modelDetail)
} else {
g.Log().Debugf(ctx, "[ChannelHealth] 渠道 %d 健康快照无变化: %.1f | avg_succ %.4f | 模型: %s",
+10 -24
View File
@@ -39,11 +39,14 @@ const (
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% 且绝对额可观时告警
// wbCronStallFallbackInterval 停摆判定的调度周期兜底值:任务不在注册表中
// (改名/下线后的遗留行)或 cron 表达式解析失败时按「日任务」保守处理。
wbCronStallFallbackInterval = 24 * time.Hour
)
// wbItem 内部待办结构:比 API 的 v1.WorkbenchItem 多一个 perm 字段。
@@ -63,14 +66,6 @@ type wbCollected struct {
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"},
}
// ============================================================
// 对外接口
// ============================================================
@@ -126,30 +121,21 @@ func (s *sAdmin) GetWorkbenchSummary(ctx context.Context, _ *v1.AdminWorkbenchSu
return res, nil
}
// GetWorkbenchBadges 菜单红点计数(与 summary 共用缓存,不额外压库)。
// 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{},
}
res := &v1.AdminWorkbenchBadgeRes{}
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
}
+44 -64
View File
@@ -12,6 +12,7 @@ import (
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dispatchadapter"
"github.com/qianfree/team-api/internal/logic/billing"
"github.com/qianfree/team-api/internal/logic/common"
)
// ============================================================
@@ -235,7 +236,6 @@ func (s *sAdmin) wbCollectMoney(ctx context.Context) []wbItem {
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
}
@@ -383,58 +383,6 @@ func (s *sAdmin) wbOrderUnfulfilled(ctx context.Context) []wbItem {
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 {
@@ -606,38 +554,53 @@ 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`)
ORDER BY last_started_at ASC NULLS FIRST`)
if err != nil {
g.Log().Warningf(ctx, "workbench: 查询定时任务状态失败: %v", err)
return nil
}
// 注册表快照:任务名 → 调度表达式。停摆判定必须按各任务自身的调度周期换算,
// 固定 1 小时停摆线会把日任务(日对账、日清理等)一天里 23 个小时都误报成停摆。
schedules := make(map[string]string, 16)
for _, j := range common.GetCronScheduler().ListJobs() {
schedules[j.Name] = j.Schedule
}
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"
// 停摆 = 距上次执行超过「正常间隔 × 2」,下限 1 小时——
// 下限避免分钟级任务被一次调度抖动(锁等待、实例重启)误报
interval := wbCronExpectedInterval(schedules, name)
stallAfter := max(2*interval, time.Hour)
stalled := startedAt != nil && time.Since(startedAt.Time) > stallAfter
if !failed && !stalled && startedAt != nil {
continue
}
// 资金相关任务提级为 P0:这些停摆会累积资损,不能等到明天
sev := v1.WorkbenchSeverityP1
if isMoneyCriticalJob(name) {
sev = v1.WorkbenchSeverityP0
}
desc := ""
if failed {
var desc string
switch {
case failed:
desc = "最近一次执行失败"
if e := strings.TrimSpace(r["last_error_message"].String()); e != "" {
desc += ":" + truncate(e, 150)
}
} else if startedAt == nil {
case startedAt == nil:
desc = "自服务启动以来从未执行过,调度器可能未正常注册该任务"
} else {
desc = fmt.Sprintf("已 %s 未执行,任务可能已停摆", humanizeSince(startedAt))
default:
desc = fmt.Sprintf("正常约 %s 执行一次,已 %s 未执行,任务可能已停摆",
humanizeDuration(interval), humanizeSince(startedAt))
}
if isMoneyCriticalJob(name) {
desc += "。该任务涉及资金结算,停摆会持续累积差错"
@@ -654,6 +617,19 @@ func (s *sAdmin) wbCronStalled(ctx context.Context) []wbItem {
return items
}
// wbCronExpectedInterval 从任务注册表查正常执行间隔。
// 任务不在注册表中(改名/下线后的遗留行)或表达式解析失败时退回保守兜底值:
// 宁可晚报一条真正死掉的任务,也不要每天误报一串健康任务。
func wbCronExpectedInterval(schedules map[string]string, name string) time.Duration {
interval := wbCronStallFallbackInterval
if schedule, ok := schedules[name]; ok {
if d, err := common.ScheduleInterval(schedule); err == nil {
interval = d
}
}
return interval
}
// isMoneyCriticalJob 判断任务停摆是否会造成资损。
func isMoneyCriticalJob(name string) bool {
n := strings.ToLower(name)
@@ -922,7 +898,11 @@ func humanizeSince(t *gtime.Time) string {
if t == nil {
return "未知时长"
}
d := time.Since(t.Time)
return humanizeDuration(time.Since(t.Time))
}
// humanizeDuration 把时长写成人话(分钟/小时/天)。
func humanizeDuration(d time.Duration) string {
if d < time.Minute {
return "不到 1 分钟"
}
+15
View File
@@ -370,6 +370,21 @@ func (cs *CronScheduler) Reschedule(ctx context.Context, name, schedule string)
return nil
}
// ScheduleInterval 解析 cron 表达式,估算两次触发之间的正常间隔。
// 取「接下来两次触发时刻之差」,对分钟/小时/日级表达式及 @every 描述符均适用
// (@every 的触发时刻相对计算起点平移,间隔不受影响)。解析失败(非法表达式)返回错误,
// 由调用方决定兜底策略——工作台的停摆判定靠它把「固定 1 小时」换成「按任务自身周期」。
func ScheduleInterval(schedule string) (time.Duration, error) {
sched, err := cron.ParseStandard(schedule)
if err != nil {
return 0, gerror.Wrapf(err, "invalid cron schedule %q", schedule)
}
now := time.Now()
next1 := sched.Next(now)
next2 := sched.Next(next1)
return next2.Sub(next1), nil
}
// cronLockTTL 分布式锁过期时间:仅用于实例崩溃时的兜底自动释放(正常完成会主动释放)。
// 取值需大于绝大多数 job 的执行时长;过长则崩溃后会阻塞该 job 至多这么久。
const cronLockTTL = 10 * time.Minute
+27
View File
@@ -3,6 +3,7 @@ package common
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -93,6 +94,32 @@ func TestReschedule_ValidExprReplacesEntry(t *testing.T) {
assert.NotEqual(t, oldID, cs.entryIDs["job_c"], "条目 ID 应已更新")
}
// TestScheduleInterval 停摆判定靠它把「固定 1 小时」换成「按任务自身周期」,
// 各类表达式的间隔必须算准——尤其日任务(24h),算错就会退回误报。
func TestScheduleInterval(t *testing.T) {
cases := []struct {
schedule string
want time.Duration
}{
{"* * * * *", time.Minute}, // 每分钟
{"*/5 * * * *", 5 * time.Minute}, // 每 5 分钟
{"*/10 * * * *", 10 * time.Minute}, // 每 10 分钟
{"0 */6 * * *", 6 * time.Hour}, // 每 6 小时(update_check)
{"0 3 * * *", 24 * time.Hour}, // 每日 3 点(各类日清理)
{"20 5 * * *", 24 * time.Hour}, // 每日 5:20(计费日对账)
{"@every 5m", 5 * time.Minute}, // @every 描述符(渠道自动探测默认)
{"@every 90m", 90 * time.Minute}, // @every 大间隔(*/N 在 N>=60 时会失真,故用 @every)
}
for _, c := range cases {
got, err := ScheduleInterval(c.schedule)
require.NoError(t, err, "schedule %q 应可解析", c.schedule)
assert.Equal(t, c.want, got, "schedule %q 的正常间隔不符", c.schedule)
}
_, err := ScheduleInterval("不是合法表达式")
assert.Error(t, err, "非法表达式应返回错误,由调用方兜底")
}
// TestOnSettingsChanged_CallbackPanicDoesNotAffectOthers 回调在订阅 goroutine 内串行执行,
// 一个回调 panic 不能带崩订阅循环或阻断后续回调。
func TestOnSettingsChanged_CallbackPanicDoesNotAffectOthers(t *testing.T) {
+5 -6
View File
@@ -9,7 +9,7 @@ import RouteErrorBoundary from '@/components/RouteErrorBoundary.vue'
import { useWatermark } from '@/composables/useWatermark'
import { useSiteName } from '@/composables/useSiteName'
import {
IconThunderbolt,
IconDesktop,
IconDashboard,
IconUserGroup,
IconApps,
@@ -306,7 +306,7 @@ const menuGroups = [
label: '数据看板',
icon: IconDashboard,
items: [
{ name: 'AdminWorkbench', label: '工作台', icon: IconThunderbolt },
{ name: 'AdminWorkbench', label: '工作台', icon: IconDesktop },
{ name: 'AdminDashboard', label: '仪表盘', icon: IconDashboard },
{ name: 'AdminRealtimeMonitor', label: '实时监控', icon: IconCommand },
],
@@ -459,15 +459,15 @@ 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
return 0
}
async function fetchWorkbenchBadges() {
@@ -475,7 +475,6 @@ async function fetchWorkbenchBadges() {
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 {
+31 -1
View File
@@ -533,6 +533,22 @@ onBeforeUnmount(() => {
font-weight: 600;
}
/* 卡片固定高度:告警再多也在卡片内部滚动,不无限撑开页面 */
.wb-flow :deep(.arco-card-body) {
height: 640px;
overflow-y: auto;
}
/* 细滚动条,贴合整体视觉 */
.wb-flow :deep(.arco-card-body)::-webkit-scrollbar {
width: 6px;
}
.wb-flow :deep(.arco-card-body)::-webkit-scrollbar-thumb {
background: var(--color-fill-3);
border-radius: 3px;
}
.wb-list {
display: flex;
flex-direction: column;
@@ -596,8 +612,13 @@ onBeforeUnmount(() => {
}
/* ===== 空态:清空是正常状态 ===== */
/* 卡片 body 定高后,空态在固定高度内垂直居中 */
.wb-empty {
padding: 48px 0;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
@@ -793,6 +814,11 @@ onBeforeUnmount(() => {
.wb-body {
grid-template-columns: 1fr;
}
/* 单列布局下视口纵向空间更紧张,卡片定高同步收紧 */
.wb-flow :deep(.arco-card-body) {
height: 560px;
}
}
@media (max-width: 767px) {
@@ -814,5 +840,9 @@ onBeforeUnmount(() => {
.wb-item__title {
min-width: 100%;
}
.wb-flow :deep(.arco-card-body) {
height: 480px;
}
}
</style>
@@ -38,7 +38,7 @@ const values = useFormValues()
<AInput v-model="values['email_smtp_from']" placeholder="noreply@example.com" />
</AFormItem>
<AFormItem label="发件人名称" help="收件人看到的发件人显示名,留空则使用站点名称">
<AInput v-model="values['email_smtp_from_name']" placeholder="aifree" />
<AInput v-model="values['email_smtp_from_name']" placeholder="team-api" />
</AFormItem>
<div class="switch-row">
<span class="switch-label">启用 TLS</span>