使用 i18n.t() 全局方式(避免 hook 定位问题): - 288 目标文件中 135 个成功转换 - 2228 处中文替换为 i18n.t() 调用 - 1006 个新翻译 key 添加到 zh-CN.json + en-US.json - 词典总量: 3554 keys (22 namespaces) - tsc 零错误 + Vite 构建成功 - 6 个含代码示例/数据字符串的文件安全回退
109 lines
3.9 KiB
TypeScript
109 lines
3.9 KiB
TypeScript
import { type ReactNode } from 'react';
|
|
import { TrendingUp, TrendingDown } from 'lucide-react';
|
|
import i18n from '@/i18n';
|
|
|
|
// 云厂商中文标签(主组件 + 弹窗共享) // Logan.Li
|
|
export const providerLabels: Record<string, string> = {
|
|
aliyun: i18n.t('cloud.aliyun'),
|
|
tencent: i18n.t('cloud.tencent'),
|
|
aws: 'AWS',
|
|
huawei: i18n.t('cloud.huawei'),
|
|
azure: 'Azure',
|
|
};
|
|
|
|
// 云厂商品牌色(跨主题固定,用 hex 避免 Tailwind 硬编码) // Logan.Li
|
|
export const providerHex: Record<string, string> = {
|
|
aliyun: '#FF6A00',
|
|
tencent: '#006EFF',
|
|
aws: '#FF9900',
|
|
huawei: '#CF0A2C',
|
|
azure: '#0078D4',
|
|
};
|
|
|
|
/** @deprecated 请改用 providerHex + style */
|
|
export const providerColors: Record<string, string> = {
|
|
aliyun: 'bg-orange-500',
|
|
tencent: 'bg-blue-500',
|
|
aws: 'bg-amber-500',
|
|
huawei: 'bg-red-500',
|
|
azure: 'bg-sky-500',
|
|
};
|
|
|
|
export function ProviderDot({ provider, className = 'w-2 h-2' }: { provider: string; className?: string }) {
|
|
return (
|
|
<span
|
|
className={`rounded-full shrink-0 ${className}`}
|
|
style={{ backgroundColor: providerHex[provider] || 'var(--surface-active)' }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function formatCurrency(n: number) {
|
|
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
}
|
|
|
|
export function StatCard({ icon, label, value, trend, trendLabel, alert }: {
|
|
icon: ReactNode;
|
|
label: string;
|
|
value: string;
|
|
trend?: number;
|
|
trendLabel?: string;
|
|
alert?: boolean;
|
|
}) {
|
|
return (
|
|
<div className={alert ? 'dg-alert-banner' : 'rounded-lg border border-[var(--border-default)] bg-[var(--surface-raised)]/50 p-4'}>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="dg-text-secondary text-sm">{label}</span>
|
|
<span className={alert ? 'dg-text-yellow' : 'dg-text-muted'}>{icon}</span>
|
|
</div>
|
|
<div className={`text-2xl font-bold ${alert ? 'dg-text-yellow' : 'text-[var(--text-primary)]'}`}>{value}</div>
|
|
{trendLabel && (
|
|
<div className="flex items-center gap-1 mt-1 text-xs">
|
|
{trend !== undefined && trend > 0 && <TrendingUp className="h-3 w-3 dg-text-red" />}
|
|
{trend !== undefined && trend < 0 && <TrendingDown className="h-3 w-3 dg-text-green" />}
|
|
<span className="dg-text-muted">{trendLabel}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TrendChart({ data }: { data: { date: string; cost: number }[] }) {
|
|
if (data.length === 0) {
|
|
return <p className="text-sm dg-text-muted py-8 text-center"> {i18n.t('aiops.assistantNoData')} </p>;
|
|
}
|
|
const max = Math.max(...data.map((d) => d.cost), 1);
|
|
const recent = data.slice(-30);
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-end gap-0.5 h-40">
|
|
{recent.map((d, i) => {
|
|
const h = Math.max((d.cost / max) * 100, 2);
|
|
return (
|
|
<div
|
|
key={i}
|
|
className="flex-1 group relative"
|
|
style={{ height: '100%' }}
|
|
>
|
|
<div
|
|
className="absolute bottom-0 w-full rounded-t-sm transition-opacity group-hover:opacity-90"
|
|
style={{
|
|
height: `${h}%`,
|
|
background: `linear-gradient(to top, color-mix(in srgb, var(--brand) 55%, transparent), color-mix(in srgb, var(--brand) 85%, transparent))`,
|
|
}}
|
|
/>
|
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block whitespace-nowrap text-xs bg-[var(--surface-inset)] text-[var(--text-primary)] px-2 py-1 rounded border border-[var(--border-default)] z-10">
|
|
{d.date}: {formatCurrency(d.cost)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="flex justify-between mt-2 text-xs dg-text-muted">
|
|
<span>{recent[0]?.date}</span>
|
|
<span>{recent[recent.length - 1]?.date}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |