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; 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 (
{label} {formatAnalysisCurrency(total, currency)}
{visible.map((item) => (
{item.name} {formatAnalysisCurrency(Number(item.value), currency)} {total ? `${(Number(item.value) / total * 100).toFixed(1)}%` : '0%'}
))}
); } 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>([]); const [data, setData] = useState(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(() => (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 (
setStartMonth(event.target.value)} className="h-9 w-[118px] min-w-0 sm:w-36 dg-input" /> - setEndMonth(event.target.value)} className="h-9 w-[118px] min-w-0 sm:w-36 dg-input" />
{loading ? : !data || data.summary.total_cost === 0 ? ( ) : ( <>

{t('cloud.analysisTotal')}

{formatAnalysisCurrency(data.summary.total_cost, data.currency)}

{t('cloud.analysisAverage')}

{formatAnalysisCurrency(data.summary.average_cost, data.currency)}

{t('cloud.analysisPeak')}

{formatAnalysisCurrency(data.summary.peak_cost, data.currency)}

{data.summary.peak_month}

{t('cloud.analysisMom')}

0 ? 'dg-text-red' : data.summary.mom_percent < 0 ? 'dg-text-green' : 'text-[var(--text-primary)]'}`}> {data.summary.mom_percent > 0 ? : data.summary.mom_percent < 0 ? : null} {Math.abs(data.summary.mom_percent).toFixed(1)}%

{data.incomplete_period &&

{t('cloud.analysisIncomplete')}

}
{insight && (

{t('cloud.aiAnalysisResult')}

)}

{t('cloud.analysisTrendTitle')}

{t('cloud.analysisDataThrough', { date: data.data_through || '-' })}

{t('cloud.analysisConversionNote', { currency: data.currency, rates: Object.entries(data.exchange_rates || {}).map(([source, rate]) => `1 ${source} = ${rate} ${data.currency}`).join(' · ') })}

{data.categories.map((category, index) => ( {category} ))}
value >= 10000 ? `${(value / 10000).toFixed(1)}万` : value.toLocaleString('zh-CN')} /> } cursor={{ fill: 'var(--surface-hover)' }} /> {data.categories.map((category, index) => ( ))}
{data.months.map((month) => )} {data.months.map((month) => )} {data.categories.map((category, index) => ( {data.months.map((month) => )} ))}
{t('cloud.analysisCategory')} {t('cloud.totalCost')}{month.month}
{t('cloud.analysisTotalRow')} {formatAnalysisCurrency(data.summary.total_cost, data.currency)}{formatAnalysisCurrency(month.total, data.currency)}
{category} {formatAnalysisCurrency(data.months.reduce((sum, month) => sum + (month.items.find((item) => item.name === category)?.cost || 0), 0), data.currency)}{formatAnalysisCurrency(month.items.find((item) => item.name === category)?.cost || 0, data.currency)}
)}
); }