Files
aiops/frontend/src/components/cloud/BillingAnalysisPanel.tsx
T
Logan.Li fd60361d93 chore: 统一本地服务器与远程仓库源码
- 合并 main 与 master 历史并以服务器结构为基线
- 保留迁移、数据保留、AWS 账单与部署安全能力
- 建立 SDD 规格和 CI 验证闭环
2026-08-05 20:03:25 +08:00

348 lines
17 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { ArrowDownRight, ArrowUpRight, CalendarRange, Loader2, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { billingApi, aiApi } from '@/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ContentPanel } from '@/components/layout/ContentPanel';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { PageLoading } from '@/components/layout/PageLoading';
import { AgentMarkdown } from '@/components/aiops/AgentMarkdown';
interface BillingAnalysisItem {
name: string;
cost: number;
percent: number;
}
interface BillingAnalysisMonth {
month: string;
total: number;
items: BillingAnalysisItem[];
}
interface BillingAttribution {
name: string;
previous_cost: number;
current_cost: number;
delta: number;
contribution: number;
}
interface BillingAnalysisData {
start_month: string;
end_month: string;
currency: string;
exchange_rates: Record<string, number>;
rate_source: string;
dimension: string;
data_through: string;
incomplete_period: boolean;
categories: string[];
months: BillingAnalysisMonth[];
summary: {
total_cost: number;
average_cost: number;
peak_cost: number;
peak_month: string;
mom_percent: number;
};
attributions: BillingAttribution[];
}
interface BillingChartRow {
month: string;
total: number;
[category: string]: string | number;
}
interface BillingTooltipProps {
active?: boolean;
label?: string;
currency?: string;
payload?: Array<{
name?: string;
value?: number;
color?: string;
payload?: BillingChartRow;
}>;
}
const chartColors = [
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)',
'var(--chart-5)', 'var(--chart-6)', 'var(--chart-7)', 'var(--text-tertiary)',
];
// monthOffset 返回相对当前月份的 YYYY-MM。 // Logan.Li
function monthOffset(offset: number): string {
const date = new Date();
date.setDate(1);
date.setMonth(date.getMonth() + offset);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
// formatAnalysisCurrency 按账单币种展示金额,避免多币种混算。 // Logan.Li
function formatAnalysisCurrency(amount: number, currency = 'CNY'): string {
const prefix = currency === 'CNY' ? '¥' : currency === 'USD' ? '$' : `${currency} `;
return `${prefix}${amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function BillingTooltip({ active, label, payload, currency }: BillingTooltipProps) {
if (!active || !payload?.length) return null;
const visible = payload.filter((item) => Number(item.value) !== 0);
const total = Number(payload[0]?.payload?.total || 0);
return (
<div className="min-w-60 rounded-md border border-[var(--chart-tooltip-border)] bg-[var(--chart-tooltip-bg)] p-3 shadow-lg">
<div className="mb-2 flex items-center justify-between gap-4 text-sm font-medium text-[var(--text-primary)]">
<span>{label}</span>
<span>{formatAnalysisCurrency(total, currency)}</span>
</div>
<div className="space-y-1.5">
{visible.map((item) => (
<div key={item.name} className="grid grid-cols-[10px_minmax(100px,1fr)_auto_auto] items-center gap-2 text-xs">
<span className="h-2 w-2 rounded-sm" style={{ backgroundColor: item.color }} />
<span className="truncate text-[var(--text-secondary)]">{item.name}</span>
<span className="font-medium text-[var(--text-primary)]">{formatAnalysisCurrency(Number(item.value), currency)}</span>
<span className="w-12 text-right text-[var(--text-tertiary)]">
{total ? `${(Number(item.value) / total * 100).toFixed(1)}%` : '0%'}
</span>
</div>
))}
</div>
</div>
);
}
export function BillingAnalysisPanel() {
const { t } = useTranslation();
const [startMonth, setStartMonth] = useState(monthOffset(-5));
const [endMonth, setEndMonth] = useState(monthOffset(0));
const [dimension, setDimension] = useState('service');
const [currency, setCurrency] = useState('CNY');
const [provider, setProvider] = useState('__all');
const [providers, setProviders] = useState<Array<{ value: string; label: string }>>([]);
const [data, setData] = useState<BillingAnalysisData | null>(null);
const [loading, setLoading] = useState(true);
const [analyzing, setAnalyzing] = useState(false);
const [insight, setInsight] = useState('');
const load = useCallback(async () => {
if (startMonth > endMonth) {
toast.error(t('cloud.analysisInvalidRange'));
return;
}
setLoading(true);
try {
const result = await billingApi.getCostAnalysis({
start_month: startMonth,
end_month: endMonth,
dimension,
currency,
provider: provider === '__all' ? undefined : provider,
});
setData(result as BillingAnalysisData);
setInsight('');
} catch (error) {
toast.error(t('cloud.analysisLoadFailed'), { description: error instanceof Error ? error.message : '' });
} finally {
setLoading(false);
}
}, [currency, dimension, endMonth, provider, startMonth, t]);
useEffect(() => {
void Promise.all([billingApi.getProviders(), billingApi.getSettings()]).then(([result, settings]) => {
setProviders(Array.isArray(result) ? result as Array<{ value: string; label: string }> : []);
setCurrency(settings.base_currency || 'CNY');
}).catch(() => setProviders([]));
}, []);
useEffect(() => {
const timer = window.setTimeout(load, 0);
return () => window.clearTimeout(timer);
}, [load]);
const chartData = useMemo<BillingChartRow[]>(() => (data?.months || []).map((month) => {
const row: BillingChartRow = { month: month.month, total: month.total };
month.items.forEach((item) => { row[item.name] = item.cost; });
return row;
}), [data]);
const analyze = async () => {
if (!data || data.summary.total_cost === 0) return;
setAnalyzing(true);
try {
const prompt = [
'你是企业 FinOps 专家。请只依据以下已经计算好的账单汇总进行分析,不要重新计算或虚构数据。',
'输出四段:成本概览、波动趋势、归因分析、可执行优化建议。每段使用加粗标题,建议需指明优先级和验证方法。',
`分析维度:${dimension};报告币种:${data.currency};汇率:${JSON.stringify(data.exchange_rates)};数据截止:${data.data_through || '未知'};末月未完整出账:${data.incomplete_period ? '是' : '否'}`,
`汇总:${JSON.stringify(data.summary)}`,
`月度数据:${JSON.stringify(data.months)}`,
`变化归因:${JSON.stringify(data.attributions)}`,
].join('\n');
const result = await aiApi.chat([{ role: 'user', content: prompt }], `billing-analysis-${Date.now()}`);
setInsight(result.content || '');
} catch (error) {
toast.error(t('cloud.analysisAIFailed'), { description: error instanceof Error ? error.message : '' });
} finally {
setAnalyzing(false);
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3 rounded-md border border-[var(--border-default)] bg-[var(--surface-raised)] px-4 py-3">
<div>
<Label className="mb-1 block text-xs text-[var(--text-tertiary)]">{t('cloud.analysisPeriod')}</Label>
<div className="flex items-center gap-2">
<Input type="month" value={startMonth} onChange={(event) => setStartMonth(event.target.value)} className="h-9 w-[118px] min-w-0 sm:w-36 dg-input" />
<span className="text-[var(--text-tertiary)]">-</span>
<Input type="month" value={endMonth} onChange={(event) => setEndMonth(event.target.value)} className="h-9 w-[118px] min-w-0 sm:w-36 dg-input" />
</div>
</div>
<div>
<Label className="mb-1 block text-xs text-[var(--text-tertiary)]">{t('cloud.platformLabel')}</Label>
<Select value={provider} onValueChange={setProvider}>
<SelectTrigger className="h-9 w-36 dg-input"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__all">{t('cloud.allProviders')}</SelectItem>
{providers.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1 block text-xs text-[var(--text-tertiary)]">{t('cloud.analysisDimension')}</Label>
<Select value={dimension} onValueChange={setDimension}>
<SelectTrigger className="h-9 w-36 dg-input"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="service">{t('cloud.dimensionService')}</SelectItem>
<SelectItem value="provider">{t('cloud.dimensionProvider')}</SelectItem>
<SelectItem value="account">{t('cloud.dimensionAccount')}</SelectItem>
<SelectItem value="region">{t('cloud.dimensionRegion')}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1 block text-xs text-[var(--text-tertiary)]">{t('cloud.analysisCurrency')}</Label>
<Select value={currency} onValueChange={setCurrency}>
<SelectTrigger className="h-9 w-28 dg-input"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="CNY">CNY</SelectItem>
<SelectItem value="USD">USD</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading} className="dg-btn-secondary">
{loading ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <CalendarRange className="mr-1 h-4 w-4" />}
{t('cloud.query')}
</Button>
<Button size="sm" onClick={analyze} disabled={analyzing || !data || data.summary.total_cost === 0} className="dg-btn-primary ml-auto">
{analyzing ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Sparkles className="mr-1 h-4 w-4" />}
{t('cloud.aiAnalysis')}
</Button>
</div>
{loading ? <PageLoading /> : !data || data.summary.total_cost === 0 ? (
<PageEmpty title={t('cloud.analysisNoData')} />
) : (
<>
<ContentPanel>
<div className="grid grid-cols-2 gap-x-6 gap-y-4 lg:grid-cols-4">
<div><p className="text-xs text-[var(--text-tertiary)]">{t('cloud.analysisTotal')}</p><p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{formatAnalysisCurrency(data.summary.total_cost, data.currency)}</p></div>
<div><p className="text-xs text-[var(--text-tertiary)]">{t('cloud.analysisAverage')}</p><p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{formatAnalysisCurrency(data.summary.average_cost, data.currency)}</p></div>
<div><p className="text-xs text-[var(--text-tertiary)]">{t('cloud.analysisPeak')}</p><p className="mt-1 text-xl font-semibold text-[var(--text-primary)]">{formatAnalysisCurrency(data.summary.peak_cost, data.currency)}</p><p className="text-xs text-[var(--text-tertiary)]">{data.summary.peak_month}</p></div>
<div>
<p className="text-xs text-[var(--text-tertiary)]">{t('cloud.analysisMom')}</p>
<p className={`mt-1 flex items-center text-xl font-semibold ${data.summary.mom_percent > 0 ? 'dg-text-red' : data.summary.mom_percent < 0 ? 'dg-text-green' : 'text-[var(--text-primary)]'}`}>
{data.summary.mom_percent > 0 ? <ArrowUpRight className="mr-1 h-5 w-5" /> : data.summary.mom_percent < 0 ? <ArrowDownRight className="mr-1 h-5 w-5" /> : null}
{Math.abs(data.summary.mom_percent).toFixed(1)}%
</p>
{data.incomplete_period && <p className="text-xs text-[var(--text-tertiary)]">{t('cloud.analysisIncomplete')}</p>}
</div>
</div>
</ContentPanel>
{insight && (
<ContentPanel>
<div className="mb-3 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-[var(--brand)]" />
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{t('cloud.aiAnalysisResult')}</h3>
</div>
<AgentMarkdown content={insight} />
</ContentPanel>
)}
<ContentPanel>
<div className="mb-4 flex flex-wrap items-start justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{t('cloud.analysisTrendTitle')}</h3>
<p className="mt-0.5 text-xs text-[var(--text-tertiary)]">{t('cloud.analysisDataThrough', { date: data.data_through || '-' })}</p>
<p className="mt-0.5 text-xs text-[var(--text-tertiary)]">{t('cloud.analysisConversionNote', { currency: data.currency, rates: Object.entries(data.exchange_rates || {}).map(([source, rate]) => `1 ${source} = ${rate} ${data.currency}`).join(' · ') })}</p>
</div>
<div className="flex max-w-full flex-wrap justify-end gap-x-3 gap-y-1">
{data.categories.map((category, index) => (
<span key={category} className="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: chartColors[index % chartColors.length] }} />{category}
</span>
))}
</div>
</div>
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="var(--chart-grid)" strokeDasharray="3 3" />
<XAxis dataKey="month" tickLine={false} axisLine={{ stroke: 'var(--chart-axis)' }} tick={{ fill: 'var(--text-tertiary)', fontSize: 12 }} />
<YAxis tickLine={false} axisLine={false} width={66} tick={{ fill: 'var(--text-tertiary)', fontSize: 12 }} tickFormatter={(value: number) => value >= 10000 ? `${(value / 10000).toFixed(1)}万` : value.toLocaleString('zh-CN')} />
<Tooltip content={<BillingTooltip currency={data.currency} />} cursor={{ fill: 'var(--surface-hover)' }} />
{data.categories.map((category, index) => (
<Bar key={category} dataKey={category} name={category} stackId="cost" fill={chartColors[index % chartColors.length]} maxBarSize={88} radius={index === data.categories.length - 1 ? [3, 3, 0, 0] : 0} />
))}
</BarChart>
</ResponsiveContainer>
</div>
</ContentPanel>
<ContentPanel noPadding>
<div className="overflow-x-auto">
<table className="w-full min-w-[720px] text-sm">
<thead className="bg-[var(--surface-inset)] text-[var(--text-secondary)]">
<tr>
<th className="sticky left-0 z-10 bg-[var(--surface-inset)] px-4 py-3 text-left font-medium">{t('cloud.analysisCategory')}</th>
<th className="px-4 py-3 text-right font-medium">{t('cloud.totalCost')}</th>
{data.months.map((month) => <th key={month.month} className="px-4 py-3 text-right font-medium">{month.month}</th>)}
</tr>
</thead>
<tbody>
<tr className="border-t border-[var(--border-default)] font-semibold text-[var(--text-primary)]">
<td className="sticky left-0 bg-[var(--surface-raised)] px-4 py-3">{t('cloud.analysisTotalRow')}</td>
<td className="px-4 py-3 text-right">{formatAnalysisCurrency(data.summary.total_cost, data.currency)}</td>
{data.months.map((month) => <td key={month.month} className="px-4 py-3 text-right">{formatAnalysisCurrency(month.total, data.currency)}</td>)}
</tr>
{data.categories.map((category, index) => (
<tr key={category} className="border-t border-[var(--border-default)] text-[var(--text-secondary)] hover:bg-[var(--surface-hover)]">
<td className="sticky left-0 bg-[var(--surface-raised)] px-4 py-3"><span className="inline-flex items-center gap-2"><span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: chartColors[index % chartColors.length] }} />{category}</span></td>
<td className="px-4 py-3 text-right font-medium text-[var(--text-primary)]">{formatAnalysisCurrency(data.months.reduce((sum, month) => sum + (month.items.find((item) => item.name === category)?.cost || 0), 0), data.currency)}</td>
{data.months.map((month) => <td key={month.month} className="px-4 py-3 text-right">{formatAnalysisCurrency(month.items.find((item) => item.name === category)?.cost || 0, data.currency)}</td>)}
</tr>
))}
</tbody>
</table>
</div>
</ContentPanel>
</>
)}
</div>
);
}