feat:添加rpm/tpm指标
This commit is contained in:
1 parent
8cb93d897e
commit
141eac106a
9 files changed
+380
-10
No files matched your search
@@ -12,6 +12,8 @@ type TenantDashboardRes struct {
|
||||
Today map[string]any `json:"today"`
|
||||
Month map[string]any `json:"month"`
|
||||
Wallet map[string]any `json:"wallet"`
|
||||
Rpm int64 `json:"rpm" dc:"最近60秒滑动窗口请求数(本租户)"`
|
||||
Tpm int64 `json:"tpm" dc:"最近60秒滑动窗口token数(本租户)"`
|
||||
ActiveKeys int `json:"active_keys"`
|
||||
MemberCount int `json:"member_count"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// 实时 RPM/TPM 统计(参考 new-api 的"最近 60 秒"口径,但不查日志表):
|
||||
// 写入侧在每条用量记录落地时向 Redis 的 10 秒桶原子累加请求数与 token 数(全局 + 租户两个维度),
|
||||
// 读取侧对最近 60 秒做滑动窗口聚合(6 个整桶 + 最旧桶按窗口覆盖比例加权)。
|
||||
// 相比 new-api 每次实时 COUNT 日志表,写入 O(1)、读取 O(桶数),与 bil_usage_logs 的规模解耦。
|
||||
// 指标为 best-effort:Redis 不可用时计数丢弃、查询返回 0,不影响请求主链路。
|
||||
const (
|
||||
rtMetricsKeyPrefix = "rt_metrics:" // rt_metrics:{scope}:{bucketStartUnix}
|
||||
rtMetricsBucketSeconds = 10 // 桶粒度(秒)
|
||||
rtMetricsWindowSeconds = 60 // 统计窗口(秒),与 new-api 的 RPM/TPM 口径一致
|
||||
rtMetricsKeyTTL = 180 // 桶 TTL(秒),覆盖读取窗口即可
|
||||
)
|
||||
|
||||
// rtMetricsWindowBuckets 窗口内整桶数
|
||||
const rtMetricsWindowBuckets = rtMetricsWindowSeconds / rtMetricsBucketSeconds
|
||||
|
||||
// rtMetricsIncrLua 一次 RTT 原子累加所有维度的桶计数并续期。
|
||||
// KEYS = 各维度的当前桶 key;ARGV[1] = token 数,ARGV[2] = TTL 秒。
|
||||
const rtMetricsIncrLua = `
|
||||
for i = 1, #KEYS do
|
||||
redis.call("HINCRBY", KEYS[i], "req", 1)
|
||||
redis.call("HINCRBY", KEYS[i], "tok", tonumber(ARGV[1]))
|
||||
redis.call("EXPIRE", KEYS[i], tonumber(ARGV[2]))
|
||||
end
|
||||
return 1
|
||||
`
|
||||
|
||||
// rtMetricsReadLua 一次 RTT 读取一组桶的 req/tok 计数。
|
||||
// KEYS = 桶 key(从旧到新);返回扁平数组 [req1, tok1, req2, tok2, ...],缺失桶为 0。
|
||||
const rtMetricsReadLua = `
|
||||
local res = {}
|
||||
for i = 1, #KEYS do
|
||||
res[2*i-1] = tonumber(redis.call("HGET", KEYS[i], "req") or "0")
|
||||
res[2*i] = tonumber(redis.call("HGET", KEYS[i], "tok") or "0")
|
||||
end
|
||||
return res
|
||||
`
|
||||
|
||||
// rtMetricsBucketKey 桶 key:scope 为 "g"(全局)或 "t:{tenantID}"(租户)
|
||||
func rtMetricsBucketKey(scope string, bucketStart int64) string {
|
||||
return fmt.Sprintf("%s%s:%d", rtMetricsKeyPrefix, scope, bucketStart)
|
||||
}
|
||||
|
||||
func rtMetricsTenantScope(tenantID int64) string {
|
||||
return fmt.Sprintf("t:%d", tenantID)
|
||||
}
|
||||
|
||||
// RecordRealtimeMetrics 累加实时 RPM/TPM 计数(全局 + 租户维度)。
|
||||
// 在用量记录入口(DataProvider.RecordUsage)调用,每条用量记录计 1 次请求、tokens 个 token。
|
||||
// 内部异步执行且脱离请求 ctx 的取消,失败仅记 debug 日志,不影响主链路。
|
||||
func RecordRealtimeMetrics(ctx context.Context, tenantID int64, tokens int64) {
|
||||
if tokens < 0 {
|
||||
tokens = 0
|
||||
}
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
g.Log().Warningf(context.Background(), "realtime metrics incr panic: tenant=%d panic=%v", tenantID, r)
|
||||
}
|
||||
}()
|
||||
bgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
now := time.Now().Unix()
|
||||
bucketStart := now - now%rtMetricsBucketSeconds
|
||||
|
||||
keys := []any{rtMetricsBucketKey("g", bucketStart)}
|
||||
if tenantID > 0 {
|
||||
keys = append(keys, rtMetricsBucketKey(rtMetricsTenantScope(tenantID), bucketStart))
|
||||
}
|
||||
|
||||
args := make([]any, 0, len(keys)+4)
|
||||
args = append(args, rtMetricsIncrLua, len(keys))
|
||||
args = append(args, keys...)
|
||||
args = append(args, tokens, rtMetricsKeyTTL)
|
||||
if _, err := g.Redis().Do(bgCtx, "EVAL", args...); err != nil {
|
||||
// 指标是 best-effort,Redis 抖动只记 debug,不刷告警
|
||||
g.Log().Debugf(bgCtx, "realtime metrics incr failed: tenant=%d err=%v", tenantID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// GetRealtimeMetrics 返回最近 60 秒滑动窗口的 RPM(请求数)与 TPM(token 数)。
|
||||
// tenantID <= 0 时返回全局维度,否则返回指定租户维度。
|
||||
// Redis 不可用时返回 0, 0(看板容忍指标短暂缺失)。
|
||||
func GetRealtimeMetrics(ctx context.Context, tenantID int64) (rpm int64, tpm int64) {
|
||||
scope := "g"
|
||||
if tenantID > 0 {
|
||||
scope = rtMetricsTenantScope(tenantID)
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
bucketStart := now - now%rtMetricsBucketSeconds
|
||||
elapsed := now - bucketStart
|
||||
|
||||
// 桶 key 从旧到新:窗口涉及 windowBuckets+1 个桶(最旧桶只有部分时间落在窗口内)
|
||||
keys := make([]any, 0, rtMetricsWindowBuckets+1)
|
||||
for i := rtMetricsWindowBuckets; i >= 0; i-- {
|
||||
keys = append(keys, rtMetricsBucketKey(scope, bucketStart-int64(i)*rtMetricsBucketSeconds))
|
||||
}
|
||||
|
||||
args := make([]any, 0, len(keys)+2)
|
||||
args = append(args, rtMetricsReadLua, len(keys))
|
||||
args = append(args, keys...)
|
||||
result, err := g.Redis().Do(ctx, "EVAL", args...)
|
||||
if err != nil {
|
||||
g.Log().Debugf(ctx, "realtime metrics read failed: scope=%s err=%v", scope, err)
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
vals := result.Vars()
|
||||
reqBuckets := make([]int64, 0, rtMetricsWindowBuckets+1)
|
||||
tokBuckets := make([]int64, 0, rtMetricsWindowBuckets+1)
|
||||
for i := 0; i+1 < len(vals); i += 2 {
|
||||
reqBuckets = append(reqBuckets, vals[i].Int64())
|
||||
tokBuckets = append(tokBuckets, vals[i+1].Int64())
|
||||
}
|
||||
|
||||
return slidingWindowSum(reqBuckets, elapsed), slidingWindowSum(tokBuckets, elapsed)
|
||||
}
|
||||
|
||||
// slidingWindowSum 对从旧到新排列的桶计数做 60 秒滑动窗口聚合。
|
||||
// 最旧桶只有 (bucketSeconds - elapsed) 秒落在窗口内,按覆盖比例加权,其余桶全额计入;
|
||||
// elapsed 为当前时间在当前桶内经过的秒数(0 ~ bucketSeconds-1)。
|
||||
func slidingWindowSum(buckets []int64, elapsed int64) int64 {
|
||||
if len(buckets) == 0 {
|
||||
return 0
|
||||
}
|
||||
oldestWeight := float64(rtMetricsBucketSeconds-elapsed) / float64(rtMetricsBucketSeconds)
|
||||
if oldestWeight < 0 {
|
||||
oldestWeight = 0
|
||||
}
|
||||
if oldestWeight > 1 {
|
||||
oldestWeight = 1
|
||||
}
|
||||
sum := float64(buckets[0]) * oldestWeight
|
||||
for _, v := range buckets[1:] {
|
||||
sum += float64(v)
|
||||
}
|
||||
return int64(math.Round(sum))
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package common
|
||||
|
||||
import "testing"
|
||||
|
||||
// 滑动窗口聚合的纯函数单测:桶从旧到新排列,最旧桶按 (bucketSeconds-elapsed)/bucketSeconds 加权。
|
||||
func TestSlidingWindowSum(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
buckets []int64
|
||||
elapsed int64
|
||||
want int64
|
||||
}{
|
||||
{
|
||||
name: "空桶返回0",
|
||||
buckets: nil,
|
||||
elapsed: 5,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "全零桶",
|
||||
buckets: []int64{0, 0, 0, 0, 0, 0, 0},
|
||||
elapsed: 3,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
// elapsed=0:当前桶刚开始,最旧桶完整落在窗口内(权重 1),7 桶全额相加
|
||||
name: "桶起点最旧桶全额计入",
|
||||
buckets: []int64{10, 10, 10, 10, 10, 10, 10},
|
||||
elapsed: 0,
|
||||
want: 70,
|
||||
},
|
||||
{
|
||||
// elapsed=5:最旧桶权重 (10-5)/10 = 0.5 → 10*0.5 + 60 = 65
|
||||
name: "桶中点最旧桶半权重",
|
||||
buckets: []int64{10, 10, 10, 10, 10, 10, 10},
|
||||
elapsed: 5,
|
||||
want: 65,
|
||||
},
|
||||
{
|
||||
// elapsed=9:最旧桶权重 0.1 → 10*0.1 + 60 = 61
|
||||
name: "桶末尾最旧桶接近零权重",
|
||||
buckets: []int64{10, 10, 10, 10, 10, 10, 10},
|
||||
elapsed: 9,
|
||||
want: 61,
|
||||
},
|
||||
{
|
||||
// 只有最旧桶有值:elapsed=5 → 100*0.5 = 50
|
||||
name: "仅最旧桶有计数",
|
||||
buckets: []int64{100, 0, 0, 0, 0, 0, 0},
|
||||
elapsed: 5,
|
||||
want: 50,
|
||||
},
|
||||
{
|
||||
// 只有当前桶有值:不加权,全额计入
|
||||
name: "仅当前桶有计数",
|
||||
buckets: []int64{0, 0, 0, 0, 0, 0, 100},
|
||||
elapsed: 5,
|
||||
want: 100,
|
||||
},
|
||||
{
|
||||
// 加权结果四舍五入:7*0.3 = 2.1 → 2 + 5 = 7
|
||||
name: "加权结果四舍五入",
|
||||
buckets: []int64{7, 5},
|
||||
elapsed: 7,
|
||||
want: 7,
|
||||
},
|
||||
{
|
||||
// 异常 elapsed 超过桶宽:权重钳制为 0,最旧桶不计入
|
||||
name: "elapsed超界权重钳制为零",
|
||||
buckets: []int64{100, 10},
|
||||
elapsed: 15,
|
||||
want: 10,
|
||||
},
|
||||
{
|
||||
// 异常负 elapsed:权重钳制为 1,最旧桶全额计入
|
||||
name: "负elapsed权重钳制为一",
|
||||
buckets: []int64{100, 10},
|
||||
elapsed: -3,
|
||||
want: 110,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := slidingWindowSum(tt.buckets, tt.elapsed); got != tt.want {
|
||||
t.Errorf("slidingWindowSum(%v, %d) = %d, want %d", tt.buckets, tt.elapsed, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 桶 key 生成格式校验
|
||||
func TestRtMetricsBucketKey(t *testing.T) {
|
||||
if got := rtMetricsBucketKey("g", 1700000000); got != "rt_metrics:g:1700000000" {
|
||||
t.Errorf("global bucket key = %q", got)
|
||||
}
|
||||
if got := rtMetricsBucketKey(rtMetricsTenantScope(42), 1700000000); got != "rt_metrics:t:42:1700000000" {
|
||||
t.Errorf("tenant bucket key = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
"github.com/qianfree/team-api/internal/service"
|
||||
@@ -172,7 +173,10 @@ func (s *sMonitor) RedisPool(ctx context.Context, _ *v1.MonitorRedisPoolReq) (*v
|
||||
}
|
||||
|
||||
func (s *sMonitor) Realtime(ctx context.Context, _ *v1.MonitorRealtimeReq) (*v1.MonitorRealtimeRes, error) {
|
||||
return &v1.MonitorRealtimeRes{Data: GetRealtimeData()}, nil
|
||||
data := GetRealtimeData()
|
||||
// 实时 RPM/TPM(Redis 60 秒滑动窗口,全平台维度;Redis 不可用时为 0)
|
||||
data.Rpm, data.Tpm = lcommon.GetRealtimeMetrics(ctx, 0)
|
||||
return &v1.MonitorRealtimeRes{Data: data}, nil
|
||||
}
|
||||
|
||||
// ===================== Alert Rules =====================
|
||||
|
||||
@@ -55,6 +55,8 @@ type RealtimeData struct {
|
||||
ByChannel map[string]int `json:"by_channel"`
|
||||
ByTenant map[int64]int `json:"by_tenant"`
|
||||
SyncImagePool SyncImagePoolSnapshot `json:"sync_image_pool"`
|
||||
Rpm int64 `json:"rpm"` // 最近60秒滑动窗口请求数(全平台,由 Realtime 方法填充)
|
||||
Tpm int64 `json:"tpm"` // 最近60秒滑动窗口token数(全平台,由 Realtime 方法填充)
|
||||
}
|
||||
|
||||
// snapshotRing is a fixed-size circular buffer for arbitrary snapshot types.
|
||||
|
||||
@@ -106,9 +106,9 @@ func (p *DataProviderImpl) CheckTenantModelAccess(ctx context.Context, tenantID
|
||||
|
||||
// 缓存结构体(与 accessRow 一致,用于缓存序列化)
|
||||
type cachedAccess struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ChannelScope string `json:"channel_scope"`
|
||||
IsNil bool `json:"is_nil"` // 标记数据库中无记录(需走分组权限)
|
||||
Enabled bool `json:"enabled"`
|
||||
ChannelScope string `json:"channel_scope"`
|
||||
IsNil bool `json:"is_nil"` // 标记数据库中无记录(需走分组权限)
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%d:%s", tenantID, modelName)
|
||||
@@ -325,6 +325,13 @@ func loadModelCached(ctx context.Context, modelName string) (*modelInfoCached, e
|
||||
// RecordUsage 实现 DataProvider.RecordUsage
|
||||
func (p *DataProviderImpl) RecordUsage(ctx context.Context, record *common.UsageRecord) {
|
||||
lcommon.DefaultUsageLogWriter.Submit(buildUsageLogDO(record))
|
||||
|
||||
// 实时 RPM/TPM 计数(Redis 滑动窗口桶,内部异步、best-effort)
|
||||
tokens := record.TotalTokens
|
||||
if tokens == 0 {
|
||||
tokens = record.PromptTokens + record.CompletionTokens
|
||||
}
|
||||
lcommon.RecordRealtimeMetrics(ctx, record.TenantID, int64(tokens))
|
||||
}
|
||||
|
||||
// buildUsageLogDO 将 UsageRecord 转换为 DO 对象
|
||||
|
||||
@@ -98,6 +98,9 @@ func (s *sTenant) Dashboard(ctx context.Context, req *v1.TenantDashboardReq) (*v
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 实时 RPM/TPM(Redis 滑动窗口,本租户维度;Redis 不可用时为 0)
|
||||
rpm, tpm := common.GetRealtimeMetrics(ctx, tenantID)
|
||||
|
||||
return &v1.TenantDashboardRes{
|
||||
Today: map[string]any{
|
||||
"requests": todayRow.Requests,
|
||||
@@ -117,6 +120,8 @@ func (s *sTenant) Dashboard(ctx context.Context, req *v1.TenantDashboardReq) (*v
|
||||
"available": roundUSD(wallet.Balance - wallet.FrozenBalance),
|
||||
"warning_threshold": roundUSD(wallet.WarningThreshold),
|
||||
},
|
||||
Rpm: rpm,
|
||||
Tpm: tpm,
|
||||
ActiveKeys: activeKeys,
|
||||
MemberCount: memberCount,
|
||||
}, nil
|
||||
|
||||
@@ -291,6 +291,13 @@ onUnmounted(() => {
|
||||
<div class="row row-2 mb-16">
|
||||
<a-card :bordered="false">
|
||||
<template #title><span class="card-title"><IconBarChart :size="16" style="color:#165DFF" /> 并发趋势</span></template>
|
||||
<template #extra>
|
||||
<span class="rt-rate-group" title="最近 60 秒滑动窗口">
|
||||
<span class="rt-rate"><span class="rt-rate-label">RPM</span><strong class="rt-rate-value" style="color:#165DFF">{{ (data.rpm || 0).toLocaleString() }}</strong></span>
|
||||
<span class="rt-rate-sep" />
|
||||
<span class="rt-rate"><span class="rt-rate-label">TPM</span><strong class="rt-rate-value" style="color:#00B42A">{{ (data.tpm || 0).toLocaleString() }}</strong></span>
|
||||
</span>
|
||||
</template>
|
||||
<v-chart ref="concurrencyChart" :option="concurrencyOption" style="height: 280px" autoresize />
|
||||
</a-card>
|
||||
<a-card :bordered="false">
|
||||
@@ -721,6 +728,35 @@ onUnmounted(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 并发趋势卡片 header 右侧的实时 RPM/TPM */
|
||||
.rt-rate-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.rt-rate {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
}
|
||||
.rt-rate-label {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-3);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.rt-rate-value {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
line-height: 1;
|
||||
}
|
||||
.rt-rate-sep {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* Sync-image worker pool */
|
||||
.pool-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import ModelDistChart from '@/components/charts/ModelDistChart.vue'
|
||||
import TokenTrendChart from '@/components/charts/TokenTrendChart.vue'
|
||||
@@ -24,6 +24,8 @@ interface DashboardData {
|
||||
today: DayStats | null
|
||||
month: DayStats | null
|
||||
wallet: WalletInfo | null
|
||||
rpm: number
|
||||
tpm: number
|
||||
active_keys: number
|
||||
member_count: number
|
||||
}
|
||||
@@ -297,7 +299,26 @@ watch(selectedDays, () => {
|
||||
void fetchMemberUsage()
|
||||
})
|
||||
|
||||
onMounted(refreshAll)
|
||||
// 实时 RPM/TPM 依赖 60 秒滑动窗口,仅静默轮询 /tenant/dashboard 保持指标新鲜(30s 一次)
|
||||
let dashboardTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function refreshDashboardSilently() {
|
||||
try {
|
||||
const response: any = await request.get('/tenant/dashboard')
|
||||
dashboardData.value = response.data?.data || dashboardData.value
|
||||
} catch {
|
||||
// 静默失败,保留上次数据
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshAll()
|
||||
dashboardTimer = setInterval(refreshDashboardSilently, 30000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (dashboardTimer) clearInterval(dashboardTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -310,10 +331,19 @@ onMounted(refreshAll)
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">今天是 {{ formattedDate }},查看团队 API 的最新运行状态。</p>
|
||||
</div>
|
||||
<button class="refresh-button" :disabled="loading || chartsLoading" @click="refreshAll">
|
||||
<Icon name="refresh" size="sm" :class="{ 'animate-spin': loading || chartsLoading }" />
|
||||
刷新数据
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="dashboardData" class="rate-pill" title="最近 60 秒滑动窗口实时速率">
|
||||
<span class="rate-pill-label">RPM</span>
|
||||
<span class="rate-pill-value text-primary-600">{{ formatNumber(dashboardData.rpm) }}</span>
|
||||
<span class="rate-pill-sep"></span>
|
||||
<span class="rate-pill-label">TPM</span>
|
||||
<span class="rate-pill-value text-teal-600">{{ formatNumber(dashboardData.tpm) }}</span>
|
||||
</div>
|
||||
<button class="refresh-button" :disabled="loading || chartsLoading" @click="refreshAll">
|
||||
<Icon name="refresh" size="sm" :class="{ 'animate-spin': loading || chartsLoading }" />
|
||||
刷新数据
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@@ -468,6 +498,39 @@ onMounted(refreshAll)
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/* 顶部实时 RPM/TPM 速率胶囊(刷新按钮左侧),外观与刷新按钮呼应 */
|
||||
.rate-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.88);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
box-shadow: 0 8px 24px rgba(87, 102, 151, 0.08);
|
||||
}
|
||||
|
||||
.rate-pill-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.rate-pill-value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rate-pill-sep {
|
||||
width: 1px;
|
||||
height: 0.875rem;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 152px;
|
||||
padding: 1.125rem 1.25rem;
|
||||
|
||||
Reference in new issue
Block a user