improve(workbench):优化工作台页面
This commit is contained in:
1 parent
a2beb8230a
commit
211148a0af
9 files changed
+139
-105
No files matched your search
@@ -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 数量,控制角标转红"`
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 分钟"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user