2026-10-13 16:59:12 +08:00
|
|
|
|
<!--
|
|
|
|
|
|
仪表盘(Phase 3 Task 4)
|
|
|
|
|
|
- 超管:平台总览四指标卡(GET /api/platform/stats 合并视图)+ 租户卡片墙
|
|
|
|
|
|
(逐租户 GET /api/platform/stats?tenant=<slug> 下钻)
|
|
|
|
|
|
- 租户管理员:自己租户的指标卡(GET /api/platform/tenants/{slug}/stats,
|
|
|
|
|
|
slug 取登录态 tenant_slug)
|
|
|
|
|
|
- 时间范围快捷切换(今天 / 近7天 / 近30天 → from/to 参数,闭区间)
|
|
|
|
|
|
- 指标口径事实源:app/platform/stats.py 模块 docstring 的口径表——
|
|
|
|
|
|
会话数不要求有消息 / 线索=留资三选一任一非空 / 推送成功率分母 0 → null
|
|
|
|
|
|
(展示"—");卡片文案照口径表写
|
|
|
|
|
|
-->
|
2026-10-13 14:51:24 +08:00
|
|
|
|
<template>
|
2026-10-13 16:59:12 +08:00
|
|
|
|
<div class="dashboard-page">
|
|
|
|
|
|
<!-- 头部:标题 + 时间范围快捷切换 -->
|
|
|
|
|
|
<div class="dashboard-header">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 class="page-title">仪表盘</h2>
|
|
|
|
|
|
<p class="page-sub">平台运行状况与租户指标一览</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ElRadioGroup v-model="quickRange">
|
|
|
|
|
|
<ElRadioButton
|
|
|
|
|
|
v-for="item in STATS_QUICK_RANGES"
|
|
|
|
|
|
:key="item.value"
|
|
|
|
|
|
:value="item.value"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ item.label }}
|
|
|
|
|
|
</ElRadioButton>
|
|
|
|
|
|
</ElRadioGroup>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 指标卡:超管=平台总览(合并视图);租户管理员=自己租户 -->
|
|
|
|
|
|
<ElCard shadow="never" class="section-card">
|
|
|
|
|
|
<template #header>
|
|
|
|
|
|
<div class="card-header">
|
|
|
|
|
|
<span class="card-title">
|
|
|
|
|
|
{{ isSuper ? `平台总览(${rangeLabel})` : `我的指标(${rangeLabel})` }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span v-if="!isSuper && authStore.tenantSlug" class="card-sub">
|
|
|
|
|
|
租户:{{ authStore.tenantSlug }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 登录态异常(租户管理员无租户标识——理论不可达,防御展示) -->
|
|
|
|
|
|
<ElAlert
|
|
|
|
|
|
v-if="!isSuper && !authStore.tenantSlug"
|
|
|
|
|
|
type="warning"
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
title="登录态缺少租户标识,请重新登录"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<ElAlert
|
|
|
|
|
|
v-if="overviewError"
|
|
|
|
|
|
type="error"
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
:title="overviewError"
|
|
|
|
|
|
class="load-error"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ElButton size="small" :loading="overviewLoading" @click="reload">重试</ElButton>
|
|
|
|
|
|
</ElAlert>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 加载骨架 -->
|
|
|
|
|
|
<div v-if="overviewLoading" class="stat-grid">
|
|
|
|
|
|
<div v-for="i in 4" :key="i" class="stat-tile">
|
|
|
|
|
|
<ElSkeleton :rows="1" animated class="stat-skeleton" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else class="stat-grid">
|
|
|
|
|
|
<div v-for="tile in overviewTiles" :key="tile.label" class="stat-tile">
|
|
|
|
|
|
<p class="stat-label">{{ tile.label }}</p>
|
|
|
|
|
|
<p class="stat-value">{{ tile.value }}</p>
|
|
|
|
|
|
<p class="stat-caption">{{ tile.caption }}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</ElCard>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 租户卡片墙(仅超管) -->
|
|
|
|
|
|
<ElCard v-if="isSuper" shadow="never" class="section-card">
|
2026-10-13 14:51:24 +08:00
|
|
|
|
<template #header>
|
2026-10-13 16:59:12 +08:00
|
|
|
|
<div class="card-header">
|
|
|
|
|
|
<span class="card-title">租户卡片墙({{ rangeLabel }})</span>
|
|
|
|
|
|
<span class="card-sub">每租户独立下钻统计 · 含停用租户(历史数据仍可见)</span>
|
|
|
|
|
|
</div>
|
2026-10-13 14:51:24 +08:00
|
|
|
|
</template>
|
2026-10-13 16:59:12 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 空数据态:尚无任何租户 -->
|
|
|
|
|
|
<ElEmpty
|
|
|
|
|
|
v-if="wallError === '' && !tenantsLoading && tenants.length === 0"
|
|
|
|
|
|
description="暂无租户——到「租户管理」创建第一个租户后,这里将展示各租户的运行指标"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<ElAlert
|
|
|
|
|
|
v-else-if="wallError"
|
|
|
|
|
|
type="error"
|
|
|
|
|
|
:closable="false"
|
|
|
|
|
|
:title="wallError"
|
|
|
|
|
|
class="load-error"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ElButton size="small" :loading="tenantsLoading" @click="reload">重试</ElButton>
|
|
|
|
|
|
</ElAlert>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else v-loading="tenantsLoading" class="tenant-grid">
|
|
|
|
|
|
<div v-for="tenant in tenants" :key="tenant.slug" class="tenant-card art-card">
|
|
|
|
|
|
<div class="tenant-head">
|
|
|
|
|
|
<span class="tenant-name" :title="tenant.name">{{ tenant.name }}</span>
|
|
|
|
|
|
<ElTag :type="tenant.status === 'active' ? 'success' : 'info'" size="small">
|
|
|
|
|
|
{{ tenant.status === 'active' ? '启用' : '停用' }}
|
|
|
|
|
|
</ElTag>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p class="tenant-slug">@{{ tenant.slug }}</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="wallCards[tenant.slug]?.loading" class="tenant-state">加载中…</div>
|
|
|
|
|
|
<div v-else-if="wallCards[tenant.slug]?.error" class="tenant-state is-error">
|
|
|
|
|
|
{{ wallCards[tenant.slug]?.error }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-else class="tenant-metrics">
|
|
|
|
|
|
<div class="metric">
|
|
|
|
|
|
<span class="metric-value">{{ wallCards[tenant.slug]?.stats?.cards.conversations ?? 0 }}</span>
|
|
|
|
|
|
<span class="metric-label">会话</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="metric">
|
|
|
|
|
|
<span class="metric-value">{{ wallCards[tenant.slug]?.stats?.cards.leads ?? 0 }}</span>
|
|
|
|
|
|
<span class="metric-label">线索</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="metric">
|
|
|
|
|
|
<span class="metric-value">{{
|
|
|
|
|
|
formatRate(wallCards[tenant.slug]?.stats?.cards.push_success_rate)
|
|
|
|
|
|
}}</span>
|
|
|
|
|
|
<span class="metric-label">推送成功率</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-10-13 14:51:24 +08:00
|
|
|
|
</ElCard>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-10-13 16:59:12 +08:00
|
|
|
|
import { useAuthStore } from '@/store/modules/auth'
|
|
|
|
|
|
import { fetchTenantStats, fetchPlatformStats, type StatsResponse } from '@/api/stats'
|
|
|
|
|
|
import { fetchTenantList, type TenantListItem } from '@/api/tenants'
|
|
|
|
|
|
import {
|
|
|
|
|
|
STATS_QUICK_RANGES,
|
|
|
|
|
|
rangeToParams,
|
|
|
|
|
|
type StatsQuickRange
|
|
|
|
|
|
} from '@/utils/statsRange'
|
|
|
|
|
|
|
2026-10-13 14:51:24 +08:00
|
|
|
|
defineOptions({ name: 'DashboardConsole' })
|
2026-10-13 16:59:12 +08:00
|
|
|
|
|
|
|
|
|
|
const authStore = useAuthStore()
|
|
|
|
|
|
const isSuper = computed(() => authStore.role === 'super')
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 时间范围 -----------------------------------------------------------------
|
|
|
|
|
|
const quickRange = ref<StatsQuickRange>('today')
|
|
|
|
|
|
const rangeLabel = computed(
|
|
|
|
|
|
() => STATS_QUICK_RANGES.find((item) => item.value === quickRange.value)?.label ?? ''
|
|
|
|
|
|
)
|
|
|
|
|
|
const rangeParams = computed(() => rangeToParams(quickRange.value))
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 指标卡(平台总览 / 自己指标) ----------------------------------------------
|
|
|
|
|
|
const overview = ref<StatsResponse | null>(null)
|
|
|
|
|
|
const overviewLoading = ref(false)
|
|
|
|
|
|
const overviewError = ref('')
|
|
|
|
|
|
|
|
|
|
|
|
/** 推送成功率展示:null(分母 0)→ “—”;其余 → 百分比(整 % 去小数位) */
|
|
|
|
|
|
function formatRate(rate: number | null | undefined): string {
|
|
|
|
|
|
if (rate === null || rate === undefined) return '—'
|
|
|
|
|
|
return `${(rate * 100).toFixed(1).replace(/\.0$/, '')}%`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 四指标卡数据(口径文案照 stats.py docstring:线索=留资三选一任一非空等) */
|
|
|
|
|
|
const overviewTiles = computed(() => {
|
|
|
|
|
|
const cards = overview.value?.cards
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '会话',
|
|
|
|
|
|
value: cards ? String(cards.conversations) : '—',
|
|
|
|
|
|
caption: '发生的会话,不要求有消息'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '线索',
|
|
|
|
|
|
value: cards ? String(cards.leads) : '—',
|
|
|
|
|
|
caption: '留资三选一:电话 / WhatsApp / 邮箱任一非空'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '高意向',
|
|
|
|
|
|
value: cards ? String(cards.high_intent) : '—',
|
|
|
|
|
|
caption: '评估为「有意向」的会话'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
label: '推送成功率',
|
|
|
|
|
|
value: cards ? formatRate(cards.push_success_rate) : '—',
|
|
|
|
|
|
caption: '成功 / (成功 + 失败);无终态推送显示 —'
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
async function loadOverview(epoch: number): Promise<void> {
|
|
|
|
|
|
overviewLoading.value = true
|
|
|
|
|
|
overviewError.value = ''
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = isSuper.value
|
|
|
|
|
|
? await fetchPlatformStats(rangeParams.value)
|
|
|
|
|
|
: await fetchTenantStats(authStore.tenantSlug as string, rangeParams.value)
|
|
|
|
|
|
if (epoch !== reloadEpoch.value) return // 已切换范围,丢弃过期响应
|
|
|
|
|
|
overview.value = res
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (epoch !== reloadEpoch.value) return
|
|
|
|
|
|
overviewError.value = error instanceof Error ? error.message : '指标加载失败'
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (epoch === reloadEpoch.value) overviewLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 租户卡片墙(仅超管) --------------------------------------------------------
|
|
|
|
|
|
interface WallCard {
|
|
|
|
|
|
loading: boolean
|
|
|
|
|
|
stats: StatsResponse | null
|
|
|
|
|
|
error: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const tenants = ref<TenantListItem[]>([])
|
|
|
|
|
|
const tenantsLoading = ref(false)
|
|
|
|
|
|
const wallError = ref('')
|
|
|
|
|
|
const wallCards = ref<Record<string, WallCard>>({})
|
|
|
|
|
|
|
|
|
|
|
|
async function loadWall(epoch: number): Promise<void> {
|
|
|
|
|
|
tenantsLoading.value = true
|
|
|
|
|
|
wallError.value = ''
|
|
|
|
|
|
try {
|
|
|
|
|
|
const list = await fetchTenantList()
|
|
|
|
|
|
if (epoch !== reloadEpoch.value) return
|
|
|
|
|
|
tenants.value = list.items
|
|
|
|
|
|
wallCards.value = Object.fromEntries(
|
|
|
|
|
|
list.items.map((tenant) => [tenant.slug, { loading: true, stats: null, error: '' }])
|
|
|
|
|
|
)
|
|
|
|
|
|
// 逐租户下钻并行拉取;失败逐卡内联展示(关闭全局 toast,防 N 连弹)
|
|
|
|
|
|
await Promise.allSettled(
|
|
|
|
|
|
list.items.map(async (tenant) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const stats = await fetchPlatformStats(
|
|
|
|
|
|
{ tenant: tenant.slug, ...rangeParams.value },
|
|
|
|
|
|
{ showErrorMessage: false }
|
|
|
|
|
|
)
|
|
|
|
|
|
if (epoch !== reloadEpoch.value) return
|
|
|
|
|
|
wallCards.value[tenant.slug] = { loading: false, stats, error: '' }
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (epoch !== reloadEpoch.value) return
|
|
|
|
|
|
wallCards.value[tenant.slug] = {
|
|
|
|
|
|
loading: false,
|
|
|
|
|
|
stats: null,
|
|
|
|
|
|
error: error instanceof Error ? error.message : '加载失败'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (epoch === reloadEpoch.value) {
|
|
|
|
|
|
wallError.value = error instanceof Error ? error.message : '租户列表加载失败'
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (epoch === reloadEpoch.value) tenantsLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 加载编排(epoch 防范围快速切换的过期响应竞写) --------------------------------
|
|
|
|
|
|
const reloadEpoch = ref(0)
|
|
|
|
|
|
|
|
|
|
|
|
function reload(): void {
|
|
|
|
|
|
const epoch = ++reloadEpoch.value
|
|
|
|
|
|
loadOverview(epoch)
|
|
|
|
|
|
if (isSuper.value) loadWall(epoch)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(reload)
|
|
|
|
|
|
watch(quickRange, reload)
|
2026-10-13 14:51:24 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
2026-10-13 16:59:12 +08:00
|
|
|
|
.dashboard-page {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 16px;
|
2026-10-13 14:51:24 +08:00
|
|
|
|
padding: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-13 16:59:12 +08:00
|
|
|
|
.dashboard-header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
|
|
|
|
|
|
.page-title {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.page-sub {
|
|
|
|
|
|
margin: 4px 0 0;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: var(--art-gray-500);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.section-card {
|
|
|
|
|
|
.card-header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.card-title {
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.card-sub {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--art-gray-500);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.load-error {
|
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 指标卡行 ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
.stat-grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-tile {
|
|
|
|
|
|
padding: 16px;
|
|
|
|
|
|
border: 1px solid var(--art-card-border);
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
|
|
|
|
|
|
.stat-label {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: var(--art-gray-500);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-value {
|
|
|
|
|
|
margin: 8px 0;
|
|
|
|
|
|
font-size: 28px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-caption {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--art-gray-400);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-skeleton {
|
|
|
|
|
|
:deep(.el-skeleton__item) {
|
|
|
|
|
|
height: 28px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 租户卡片墙 ---------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
.tenant-grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
min-height: 60px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tenant-card {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
padding: 14px 16px;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
background: var(--default-box-color);
|
|
|
|
|
|
|
|
|
|
|
|
.tenant-head {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
|
|
|
|
|
|
.tenant-name {
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tenant-slug {
|
|
|
|
|
|
margin: 2px 0 12px;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--art-gray-400);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tenant-state {
|
|
|
|
|
|
padding: 12px 0;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: var(--art-gray-500);
|
|
|
|
|
|
|
|
|
|
|
|
&.is-error {
|
|
|
|
|
|
color: var(--el-color-danger);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.tenant-metrics {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
|
|
|
|
|
|
.metric {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 2px;
|
|
|
|
|
|
|
|
|
|
|
|
.metric-value {
|
|
|
|
|
|
font-size: 22px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.metric-label {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--art-gray-500);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-13 14:51:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|