348 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|