feat(frontend): 控制台 next-intl 中英双语

接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
ryan
2026-08-16 16:49:16 +08:00
parent 64fbaa7ef1
commit 5a8722ff07
219 changed files with 21437 additions and 5212 deletions
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart';
import {
Card,
@@ -14,18 +16,23 @@ import { formatPercent, formatTrendHour } from './dashboard-utils';
export function CapacityTrendChart({
points,
title = '24 小时容量趋势',
description = '按小时聚合 CPU 与内存使用率,判断整体容量是否持续紧张。',
title,
description,
}: {
points: CapacityTrendPoint[];
title?: string;
description?: string;
}) {
const t = useTranslations('dashboard.capacityTrend');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
<CardDescription className='text-xs'>{description}</CardDescription>
<CardTitle className='text-sm font-semibold'>
{title ?? t('title')}
</CardTitle>
<CardDescription className='text-xs'>
{description ?? t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
@@ -35,7 +42,7 @@ export function CapacityTrendChart({
yAxisValueFormatter={formatPercent}
series={[
{
label: '平均 CPU',
label: t('avgCpu'),
color: '#0f766e',
fillColor: 'rgba(15, 118, 110, 0.15)',
variant: 'area',
@@ -43,7 +50,7 @@ export function CapacityTrendChart({
valueFormatter: formatPercent,
},
{
label: '平均内存',
label: t('avgMemory'),
color: '#2563eb',
values: points.map((point) => point.average_memory_usage_percent),
valueFormatter: formatPercent,
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { RankChart } from '@/components/data/rank-chart';
import {
Card,
@@ -25,19 +27,20 @@ export function SourceDistributionChart({
}: {
items: TrafficDistributions['source_countries'];
}) {
const t = useTranslations('dashboard.distributions');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>来源分布</CardTitle>
<CardDescription className='text-xs'>
聚合最近 24 小时主要来源国家。
</CardDescription>
<CardTitle className='text-sm font-semibold'>
{t('sourceTitle')}
</CardTitle>
<CardDescription className='text-xs'>{t('sourceDesc')}</CardDescription>
</CardHeader>
<CardContent>
<RankChart
items={toRankItems(items)}
color='#38bdf8'
emptyMessage='暂无来源分布数据'
emptyMessage={t('sourceEmpty')}
/>
</CardContent>
</Card>
@@ -49,22 +52,23 @@ export function StatusCodeDistributionChart({
}: {
items: TrafficDistributions['status_codes'];
}) {
const t = useTranslations('dashboard.distributions');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>状态码分布</CardTitle>
<CardDescription className='text-xs'>
快速判断成功响应是否仍是主流,以及错误码是否有抬升。
</CardDescription>
<CardTitle className='text-sm font-semibold'>
{t('statusTitle')}
</CardTitle>
<CardDescription className='text-xs'>{t('statusDesc')}</CardDescription>
</CardHeader>
<CardContent>
<RankChart
items={toRankItems(items).map((item) => ({
...item,
label: `HTTP ${item.label}`,
label: t('httpLabel', { code: item.label }),
}))}
color='#f59e0b'
emptyMessage='暂无状态码分布'
emptyMessage={t('statusEmpty')}
/>
</CardContent>
</Card>
@@ -76,19 +80,20 @@ export function TopDomainChart({
}: {
items: TrafficDistributions['top_domains'];
}) {
const t = useTranslations('dashboard.distributions');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>Top Domain</CardTitle>
<CardDescription className='text-xs'>
观察主要流量集中在哪些域名。
</CardDescription>
<CardTitle className='text-sm font-semibold'>
{t('domainTitle')}
</CardTitle>
<CardDescription className='text-xs'>{t('domainDesc')}</CardDescription>
</CardHeader>
<CardContent>
<RankChart
items={toRankItems(items)}
color='#34d399'
emptyMessage='暂无域名分布'
emptyMessage={t('domainEmpty')}
/>
</CardContent>
</Card>
@@ -1,6 +1,7 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { Button } from '@/components/ui/button';
@@ -33,6 +34,8 @@ import {
import { formatCompactNumber, formatPercent } from './dashboard-utils';
export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
const t = useTranslations('dashboard.nodeHealth');
const tn = useTranslations('nodes');
const sortedNodes = [...nodes].sort((left, right) => {
if (right.active_event_count !== left.active_event_count) {
return right.active_event_count - left.active_event_count;
@@ -54,33 +57,33 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle className='text-sm font-semibold'>节点健康列表</CardTitle>
<CardDescription className='text-xs'>
按异常数量和资源压力排序。
</CardDescription>
<CardTitle className='text-sm font-semibold'>{t('title')}</CardTitle>
<CardDescription className='text-xs'>{t('description')}</CardDescription>
</div>
<Button variant='outline' size='sm' className='h-8 text-xs' asChild>
<Link href='/nodes'>进入节点页</Link>
<Link href='/nodes'>{t('openNodes')}</Link>
</Button>
</CardHeader>
<CardContent>
{sortedNodes.length === 0 ? (
<EmptyStateWithBorder
title='暂无节点'
description='节点接入后,这里会展示系统健康与容量摘要。'
title={t('emptyTitle')}
description={t('emptyDesc')}
/>
) : (
<div className='border border-dashed rounded-lg overflow-hidden'>
<Table>
<TableHeader>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='py-2 h-8'>节点</TableHead>
<TableHead className='py-2 h-8'>状态</TableHead>
<TableHead className='py-2 h-8'>CPU / 内存</TableHead>
<TableHead className='py-2 h-8'>请求 / 错误</TableHead>
<TableHead className='py-2 h-8'>活动异常</TableHead>
<TableHead className='py-2 h-8'>最近心跳</TableHead>
<TableHead className='py-2 h-8 text-right'>操作</TableHead>
<TableHead className='py-2 h-8'>{t('colNode')}</TableHead>
<TableHead className='py-2 h-8'>{t('colStatus')}</TableHead>
<TableHead className='py-2 h-8'>{t('colCpuMem')}</TableHead>
<TableHead className='py-2 h-8'>{t('colReqErr')}</TableHead>
<TableHead className='py-2 h-8'>{t('colEvents')}</TableHead>
<TableHead className='py-2 h-8'>{t('colHeartbeat')}</TableHead>
<TableHead className='py-2 h-8 text-right'>
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -96,18 +99,21 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
{node.node_id}
</p>
<p className='text-xs text-muted-foreground'>
{node.geo_name || '未配置地图点位'}
{node.geo_name || t('noGeo')}
</p>
</div>
</TableCell>
<TableCell className='py-3'>
<div className='flex flex-wrap gap-1.5'>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
label={getNodeStatusLabel(node.status, tn)}
tone={getNodeStatusTone(node.status)}
/>
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
label={getOpenrestyStatusLabel(
node.openresty_status,
tn,
)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
</div>
@@ -130,10 +136,10 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
</TableCell>
<TableCell className='py-3 text-xs text-muted-foreground'>
{isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
? t('wsConnected')
: node.last_seen_at
? formatRelativeTime(node.last_seen_at)
: '暂无'}
? formatRelativeTime(node.last_seen_at, tn)
: t('na')}
</TableCell>
<TableCell className='py-3 text-right'>
<Button
@@ -142,7 +148,9 @@ export function NodeHealthTable({ nodes }: { nodes: DashboardNodeHealth[] }) {
className='h-8 text-xs'
asChild
>
<Link href={`/nodes/detail?id=${node.id}`}>详情</Link>
<Link href={`/nodes/detail?id=${node.id}`}>
{t('detail')}
</Link>
</Button>
</TableCell>
</TableRow>
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { RankChart } from '@/components/data/rank-chart';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { DashboardNodeHealth } from '@/lib/services/openflare';
@@ -27,25 +29,26 @@ function buildNodeRankItems(
}
export function NodeRankPanel({ nodes }: { nodes: DashboardNodeHealth[] }) {
const t = useTranslations('dashboard.nodeRank');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>Top 节点榜单</CardTitle>
<CardTitle className='text-sm font-semibold'>{t('title')}</CardTitle>
</CardHeader>
<CardContent className='grid gap-6'>
<div>
<p className='mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase'>
流量最高节点
{t('traffic')}
</p>
<RankChart
items={buildNodeRankItems(nodes, (node) => node.request_count)}
color='#38bdf8'
emptyMessage='暂无流量榜单'
emptyMessage={t('trafficEmpty')}
/>
</div>
<div>
<p className='mb-3 text-xs tracking-[0.22em] text-muted-foreground uppercase'>
容量压力节点
{t('pressure')}
</p>
<RankChart
items={buildNodeRankItems(nodes, (node) =>
@@ -59,7 +62,7 @@ export function NodeRankPanel({ nodes }: { nodes: DashboardNodeHealth[] }) {
)}
color='#ef4444'
valueFormatter={(value) => `${value}%`}
emptyMessage='暂无容量压力数据'
emptyMessage={t('pressureEmpty')}
/>
</div>
</CardContent>
@@ -1,6 +1,7 @@
'use client';
import { Activity, Cpu, Gauge, Server, Users } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
@@ -21,6 +22,7 @@ export function DashboardStatCards({
traffic: DashboardTraffic;
capacity: DashboardCapacity;
}) {
const t = useTranslations('dashboard.stats');
const onlineRate =
summary.total_nodes > 0
? (summary.online_nodes / summary.total_nodes) * 100
@@ -31,7 +33,7 @@ export function DashboardStatCards({
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'>
24h 请求量
{t('requests24h')}
</span>
<Activity className='size-4 text-primary' />
</CardHeader>
@@ -40,9 +42,11 @@ export function DashboardStatCards({
{formatCompactNumber(traffic.request_count)}
</div>
<p className='text-[10px] text-muted-foreground'>
24h 独立访客 {formatCompactNumber(traffic.unique_visitors)} · 错误{' '}
{formatCompactNumber(traffic.error_count)} · 估算 QPS{' '}
{traffic.estimated_qps.toFixed(2)}
{t('requestsMeta', {
visitors: formatCompactNumber(traffic.unique_visitors),
errors: formatCompactNumber(traffic.error_count),
qps: traffic.estimated_qps.toFixed(2),
})}
</p>
</CardContent>
</Card>
@@ -50,7 +54,7 @@ export function DashboardStatCards({
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'>
集群容量
{t('clusterCapacity')}
</span>
<Cpu className='size-4 text-primary' />
</CardHeader>
@@ -66,9 +70,12 @@ export function DashboardStatCards({
className='h-1.5'
/>
<p className='text-[10px] text-muted-foreground'>
平均内存 {formatPercent(capacity.average_memory_usage_percent)} ·
高负载节点 CPU {capacity.high_cpu_nodes} / 内存{' '}
{capacity.high_memory_nodes} / 存储 {capacity.high_storage_nodes}
{t('avgMemoryHighLoad', {
memory: formatPercent(capacity.average_memory_usage_percent),
cpu: capacity.high_cpu_nodes,
memoryNodes: capacity.high_memory_nodes,
storage: capacity.high_storage_nodes,
})}
</p>
</CardContent>
</Card>
@@ -76,7 +83,7 @@ export function DashboardStatCards({
<Card className='border-dashed shadow-none md:col-span-2 xl:col-span-1'>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'>
节点概览
{t('nodeOverview')}
</span>
<Server className='size-4 text-primary' />
</CardHeader>
@@ -85,18 +92,21 @@ export function DashboardStatCards({
{summary.online_nodes}
<span className='text-sm font-normal text-muted-foreground'>
{' '}
/ {summary.total_nodes} 在线
{t('onlineOf', { total: summary.total_nodes })}
</span>
</div>
<Progress value={onlineRate} className='h-1.5' />
<p className='text-[10px] text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1'>
<span className='inline-flex items-center gap-1'>
<Users className='size-3' />
待接入 {summary.pending_nodes}
{t('pending', { count: summary.pending_nodes })}
</span>
<span className='inline-flex items-center gap-1'>
<Gauge className='size-3' />
异常 {summary.unhealthy_nodes} · 离线 {summary.offline_nodes}
{t('unhealthyOffline', {
unhealthy: summary.unhealthy_nodes,
offline: summary.offline_nodes,
})}
</span>
</p>
</CardContent>
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type {
@@ -17,11 +19,13 @@ export function TrafficCapacityTrendChart({
networkPoints: NetworkTrendPoint[];
capacityPoints: CapacityTrendPoint[];
}) {
const t = useTranslations('dashboard.trafficCapacity');
const tc = useTranslations('dashboard.capacityTrend');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
24 小时业务流量与容量趋势
{t('title')}
</CardTitle>
</CardHeader>
<CardContent className='space-y-6'>
@@ -31,11 +35,11 @@ export function TrafficCapacityTrendChart({
)}
height={180}
summaryScope='total'
summaryHint='近 24 小时 · 来自访问日志'
summaryHint={t('networkHint')}
yAxisValueFormatter={formatBytes}
series={[
{
label: '接收数据',
label: t('received'),
color: '#22c55e',
fillColor: 'rgba(34, 197, 94, 0.14)',
variant: 'area',
@@ -43,7 +47,7 @@ export function TrafficCapacityTrendChart({
valueFormatter: formatBytes,
},
{
label: '已提供数据',
label: t('provided'),
color: '#38bdf8',
values: networkPoints.map((point) => point.bytes_provided),
valueFormatter: formatBytes,
@@ -57,11 +61,11 @@ export function TrafficCapacityTrendChart({
)}
height={180}
summaryScope='average'
summaryHint='近 24 小时 · 宿主机容量 · 平均值'
summaryHint={t('capacityHint')}
yAxisValueFormatter={formatPercent}
series={[
{
label: '平均 CPU',
label: tc('avgCpu'),
color: '#0f766e',
fillColor: 'rgba(15, 118, 110, 0.15)',
variant: 'area',
@@ -71,7 +75,7 @@ export function TrafficCapacityTrendChart({
valueFormatter: formatPercent,
},
{
label: '平均内存',
label: tc('avgMemory'),
color: '#2563eb',
values: capacityPoints.map(
(point) => point.average_memory_usage_percent,
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart';
import {
Card,
@@ -14,18 +16,23 @@ import { formatTrendHour } from './dashboard-utils';
export function TrafficTrendChart({
points,
title = '24 小时请求趋势',
description = '按小时拆分请求总量与 2xx/4xx/5xx 状态码请求量,判断各状态是否异常抬升。',
title,
description,
}: {
points: TrafficTrendPoint[];
title?: string;
description?: string;
}) {
const t = useTranslations('dashboard.trafficTrend');
return (
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>{title}</CardTitle>
<CardDescription className='text-xs'>{description}</CardDescription>
<CardTitle className='text-sm font-semibold'>
{title ?? t('title')}
</CardTitle>
<CardDescription className='text-xs'>
{description ?? t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
@@ -33,27 +40,27 @@ export function TrafficTrendChart({
formatTrendHour(point.bucket_started_at),
)}
summaryScope='total'
summaryHint='近 24 小时'
summaryHint={t('summaryHint')}
series={[
{
label: '请求量',
label: t('requests'),
color: '#f59e0b',
fillColor: 'rgba(245, 158, 11, 0.18)',
variant: 'area',
values: points.map((point) => point.request_count),
},
{
label: '2xx 请求',
label: t('status2xx'),
color: '#22c55e',
values: points.map((point) => point.status_2xx_count),
},
{
label: '4xx 请求',
label: t('status4xx'),
color: '#f97316',
values: points.map((point) => point.status_4xx_count),
},
{
label: '5xx 请求',
label: t('status5xx'),
color: '#ef4444',
values: points.map((point) => point.status_5xx_count),
},
@@ -7,6 +7,7 @@ import { MapChart, ScatterChart } from 'echarts/charts';
import { GeoComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { useTheme } from 'next-themes';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -276,6 +277,8 @@ export function WorldStageMap({
nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) {
const t = useTranslations('dashboard.map');
const tn = useTranslations('nodes');
const { resolvedTheme } = useTheme();
const isDark = resolvedTheme === 'dark';
const router = useRouter();
@@ -503,7 +506,7 @@ export function WorldStageMap({
) {
return [
`<div style="font-weight:600;margin-bottom:6px;">${payload.name ?? data.name}</div>`,
`<div>最近 24 小时来源请求 ${data.value.toLocaleString('zh-CN')}</div>`,
`<div>${t('sourceRequests', { count: data.value.toLocaleString('zh-CN') })}</div>`,
].join('');
}
@@ -513,14 +516,22 @@ export function WorldStageMap({
const locationLine = data.derivedFromGeo
? data.geoName
: `${data.geoName} · 预设落点`;
: `${data.geoName} · ${t('presetPin')}`;
return [
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
`<div>${locationLine}</div>`,
`<div>请求量 ${data.requestCount.toLocaleString('zh-CN')} · 错误数 ${data.errorCount.toLocaleString('zh-CN')}</div>`,
`<div>活动事件 ${data.activeEventCount} · 节点状态 ${getNodeStatusLabel(data.status)}</div>`,
`<div>OpenResty 状态 ${getOpenrestyStatusLabel(data.openrestyStatus)}</div>`,
`<div>${t('requestsErrors', {
requests: data.requestCount.toLocaleString('zh-CN'),
errors: data.errorCount.toLocaleString('zh-CN'),
})}</div>`,
`<div>${t('eventsStatus', {
events: data.activeEventCount,
status: getNodeStatusLabel(data.status, tn),
})}</div>`,
`<div>${t('openrestyStatus', {
status: getOpenrestyStatusLabel(data.openrestyStatus, tn),
})}</div>`,
].join('');
},
},
@@ -610,6 +621,8 @@ export function WorldStageMap({
mapNodes,
mapPalette,
responsiveMapScale,
t,
tn,
],
);
@@ -617,12 +630,8 @@ export function WorldStageMap({
return (
<div className='flex h-full items-center justify-center'>
<EmptyState
title={mapFailed ? '全球地图加载失败' : '全球地图加载中'}
description={
mapFailed
? 'ECharts 世界地图资源未能成功注册,请稍后刷新重试。'
: '正在按需初始化全球地图,这一步会延后到首屏内容稳定后执行。'
}
title={mapFailed ? t('loadFailed') : t('loading')}
description={mapFailed ? t('loadFailedDesc') : t('loadingDesc')}
/>
</div>
);
@@ -654,8 +663,8 @@ export function WorldStageMap({
) : (
<div className='flex h-full items-center justify-center'>
<EmptyState
title='全球地图加载中'
description='正在测量地图容器尺寸...'
title={t('loading')}
description={t('measuring')}
iconSize='sm'
/>
</div>
@@ -2,6 +2,7 @@
import dynamic from 'next/dynamic';
import { type ComponentType, useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import {
Activity,
Cpu,
@@ -40,10 +41,10 @@ const WorldStageMap = dynamic(
);
const LEGEND_ITEMS = [
{ dot: 'bg-blue-500', label: '来源' },
{ dot: 'bg-emerald-500', label: '正常' },
{ dot: 'bg-amber-500', label: '承压' },
{ dot: 'bg-destructive', label: '异常' },
{ dot: 'bg-blue-500', key: 'legendSource' },
{ dot: 'bg-emerald-500', key: 'legendHealthy' },
{ dot: 'bg-amber-500', key: 'legendPressure' },
{ dot: 'bg-destructive', key: 'legendUnhealthy' },
] as const;
function SummaryMetric({
@@ -128,6 +129,7 @@ export function WorldStage({
nodes: DashboardNodeHealth[];
sourceCountries: DistributionItem[];
}) {
const t = useTranslations('dashboard.world');
const mapViewportRef = useRef<HTMLDivElement | null>(null);
const [shouldRenderMap, setShouldRenderMap] = useState(false);
@@ -177,62 +179,71 @@ export function WorldStage({
const mapModeLabel =
sourceCountries.length > 0
? '访客来源'
? t('modeVisitorSource')
: geoConfiguredNodes > 0
? '节点坐标'
: '覆盖信号';
? t('modeNodeCoords')
: t('modeCoverage');
const headlineMetrics = [
{
label: '在线覆盖',
label: t('onlineCoverage'),
value: formatPercent(onlineRate),
hint: `${summary.online_nodes}/${summary.total_nodes} 在线`,
hint: t('onlineHint', {
online: summary.online_nodes,
total: summary.total_nodes,
}),
icon: Server,
progress: onlineRate,
},
{
label: '运行健康',
label: t('runHealth'),
value: formatPercent(healthyRate),
hint: `${summary.unhealthy_nodes} 个异常`,
hint: t('unhealthyCount', { count: summary.unhealthy_nodes }),
icon: ShieldCheck,
progress: healthyRate,
},
{
label: '24小时请求',
label: t('requests24h'),
value: formatCompactNumber(traffic.request_count),
hint: `QPS ${traffic.estimated_qps.toFixed(1)} · ${traffic.reported_nodes} 个节点上报`,
hint: t('qpsReported', {
qps: traffic.estimated_qps.toFixed(1),
count: traffic.reported_nodes,
}),
icon: Activity,
},
{
label: '节点坐标',
label: t('nodeCoords'),
value: formatCompactNumber(geoConfiguredNodes),
hint:
geoConfiguredNodes > 0
? `${sourceCountries.length} 个来源国家`
: '未配置时使用预设落点',
? t('sourceCountries', { count: sourceCountries.length })
: t('fallbackPins'),
icon: Network,
},
] as const;
const detailMetrics = [
{
label: '平均 CPU',
label: t('avgCpu'),
value: formatPercent(capacity.average_cpu_usage_percent),
hint: `${capacity.high_cpu_nodes} 个偏高`,
hint: t('highCount', { count: capacity.high_cpu_nodes }),
icon: Cpu,
progress: capacity.average_cpu_usage_percent,
},
{
label: '平均内存',
label: t('avgMemory'),
value: formatPercent(capacity.average_memory_usage_percent),
hint: `${capacity.high_memory_nodes} 个偏高`,
hint: t('highCount', { count: capacity.high_memory_nodes }),
icon: MemoryStick,
progress: capacity.average_memory_usage_percent,
},
{
label: '高存储',
label: t('highStorage'),
value: formatCompactNumber(capacity.high_storage_nodes),
hint: `${summary.offline_nodes} 离线 · ${summary.pending_nodes} 待接入`,
hint: t('offlinePending', {
offline: summary.offline_nodes,
pending: summary.pending_nodes,
}),
icon: HardDrive,
},
] as const;
@@ -244,7 +255,7 @@ export function WorldStage({
<div className='min-w-0'>
<CardTitle className='flex items-center gap-1.5 text-sm font-semibold'>
<Globe2 className='size-4 shrink-0 text-primary' />
全球态势板
{t('title')}
<Badge
variant='outline'
className='ml-1 text-[10px] font-normal text-muted-foreground'
@@ -253,17 +264,17 @@ export function WorldStage({
</Badge>
</CardTitle>
<CardDescription className='mt-1 text-xs'>
汇总节点分布、来源国家热度与最近窗口运行状态。
{t('description')}
</CardDescription>
</div>
<div className='flex flex-wrap items-center gap-x-3 gap-y-1 rounded-full border border-dashed bg-muted/15 px-3 py-1.5'>
{LEGEND_ITEMS.map((item) => (
<span
key={item.label}
key={item.key}
className='inline-flex items-center gap-1 text-[10px] text-muted-foreground'
>
<span className={cn('size-1.5 rounded-full', item.dot)} />
{item.label}
{t(item.key)}
</span>
))}
</div>
@@ -291,8 +302,8 @@ export function WorldStage({
) : (
<div className='flex h-full items-center justify-center px-4'>
<EmptyState
title='地图准备中'
description='进入可视区域后加载'
title={t('mapPreparing')}
description={t('mapPreparingDesc')}
iconSize='sm'
/>
</div>
@@ -301,7 +312,7 @@ export function WorldStage({
{shouldRenderMap && nodes.length === 0 ? (
<div className='pointer-events-none absolute inset-x-3 bottom-2 z-10'>
<p className='rounded-md border border-dashed bg-background/90 px-2.5 py-1.5 text-[10px] text-muted-foreground backdrop-blur-sm'>
暂无节点,接入后将展示地理分布
{t('noNodesOverlay')}
</p>
</div>
) : null}