Files
aiops/frontend/src/components/cloud/CloudBilling.tsx
T
Logan.Li 01e4435f29 fix: 更新多云账单同步说明
- 展示当前支持的五个云厂商
- 统一多云出账时效与只读权限提示
2026-07-29 06:51:05 +08:00

296 lines
9.7 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Plus, RefreshCw, Wallet, CloudDownload, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { billingApi } from '@/api';
import { formatCurrency } from './CloudBillingHelpers';
import { StatStrip } from '@/components/ui/stat-strip';
import { BillingRecordDialog } from './BillingRecordDialog';
import { BillingRuleDialog } from './BillingRuleDialog';
import { PageShell } from '@/components/layout/PageShell';
import { OverviewTab } from './cloud-billing/OverviewTab';
import { RecordsTab, RecordsFilterToolbar } from './cloud-billing/RecordsTab';
import { AlertRulesTab } from './cloud-billing/AlertRulesTab';
import type { JsonData } from '@/types/common';
export function CloudBilling() {
const { t } = useTranslation();
const confirm = useConfirm();
const [activeTab, setActiveTab] = useState<'overview' | 'records' | 'alerts'>('overview');
const [stats, setStats] = useState<JsonData>(null);
const [statsLoading, setStatsLoading] = useState(true);
const [records, setRecords] = useState<JsonData[]>([]);
const [recordsLoading, setRecordsLoading] = useState(true);
const [filterProvider, setFilterProvider] = useState('');
const [filterStart, setFilterStart] = useState('');
const [filterEnd, setFilterEnd] = useState('');
const [rules, setRules] = useState<JsonData[]>([]);
const [rulesLoading, setRulesLoading] = useState(true);
const [recordDialogOpen, setRecordDialogOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<JsonData>(null);
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [editingRule, setEditingRule] = useState<JsonData>(null);
const [syncing, setSyncing] = useState(false);
const loadStats = useCallback(async () => {
setStatsLoading(true);
try {
const res = await billingApi.getStats();
setStats(res);
} catch (e) {
toast.error(t('cloud.loadStatsFailed'), { description: (e as JsonData).message });
} finally {
setStatsLoading(false);
}
}, [t]);
const loadRecords = useCallback(async () => {
setRecordsLoading(true);
try {
const params: JsonData = {};
if (filterProvider) params.provider = filterProvider;
if (filterStart) params.start_date = filterStart;
if (filterEnd) params.end_date = filterEnd;
const res = await billingApi.list(params);
setRecords(res.list || []);
} catch (e) {
toast.error(t('cloud.loadBillingFailed'), { description: (e as JsonData).message });
} finally {
setRecordsLoading(false);
}
}, [filterProvider, filterStart, filterEnd, t]);
const loadRules = useCallback(async () => {
setRulesLoading(true);
try {
const res = await billingApi.listAlertRules();
setRules(res.list || []);
} catch (e) {
toast.error(t('cloud.loadAlertRulesFailed'), { description: (e as JsonData).message });
} finally {
setRulesLoading(false);
}
}, [t]);
useEffect(() => {
loadStats();
}, [loadStats]);
useEffect(() => {
if (activeTab === 'records') loadRecords();
}, [activeTab, loadRecords]);
useEffect(() => {
if (activeTab === 'alerts') loadRules();
}, [activeTab, loadRules]);
const refreshAll = () => {
loadStats();
if (activeTab === 'records') loadRecords();
if (activeTab === 'alerts') loadRules();
};
// 从活跃云账号同步各厂商昨日账单。 // Logan.Li
const handleSyncYesterday = async () => {
setSyncing(true);
try {
const res = await billingApi.sync();
const errs = res?.errors || [];
if (errs.length > 0) {
toast.warning(t('cloud.syncBillingPartial'), {
description: `${t('cloud.syncBillingResult', {
date: res.billing_date,
created: res.created,
updated: res.updated,
})}\n${errs.join('\n')}`,
});
} else {
toast.success(t('cloud.syncBillingSuccess'), {
description: t('cloud.syncBillingResult', {
date: res.billing_date,
created: res.created,
updated: res.updated,
}),
});
}
refreshAll();
} catch (e) {
toast.error(t('cloud.syncBillingFailed'), { description: (e as JsonData).message });
} finally {
setSyncing(false);
}
};
const openAddRecord = () => {
setEditingRecord(null);
setRecordDialogOpen(true);
};
const openEditRecord = (r: JsonData) => {
setEditingRecord(r);
setRecordDialogOpen(true);
};
const openAddRule = () => {
setEditingRule(null);
setRuleDialogOpen(true);
};
const openEditRule = (r: JsonData) => {
setEditingRule(r);
setRuleDialogOpen(true);
};
const handleDeleteRecord = async (id: number) => {
const ok = await confirm({ title: t('cloud.deleteBillingRecord'), description: t('cloud.deleteBillingConfirm'), variant: 'destructive' });
if (!ok) return;
try {
await billingApi.remove(id);
loadRecords();
loadStats();
toast(t('common.deleteSuccess'));
} catch (e) {
toast.error(t('common.deleteFailed'), { description: (e as JsonData).message });
}
};
const handleDeleteRule = async (id: number) => {
const ok = await confirm({ title: t('cloud.deleteAlertRule'), description: t('cloud.deleteAlertRuleConfirm'), variant: 'destructive' });
if (!ok) return;
try {
await billingApi.deleteAlertRule(id);
loadRules();
toast(t('common.deleteSuccess'));
} catch (e) {
toast.error(t('common.deleteFailed'), { description: (e as JsonData).message });
}
};
const handleToggleRule = async (id: number) => {
try {
await billingApi.toggleAlertRule(id);
loadRules();
loadStats();
} catch (e) {
toast.error(t('cloud.operationFailed'), { description: (e as JsonData).message });
}
};
const pageTabs = [
{ id: 'overview', label: t('cloud.overviewStats') },
{ id: 'records', label: t('cloud.costRecords') },
{ id: 'alerts', label: t('cloud.billingAlert'), badge: rules.length > 0 ? rules.length : undefined },
];
return (
<PageShell
variant="list"
title={t('cloud.billingTitle')}
subtitle={t('cloud.billingSubtitle')}
icon={<Wallet className="h-5 w-5" />}
tabs={pageTabs}
activeTab={activeTab}
onTabChange={(id) => setActiveTab(id as typeof activeTab)}
toolbar={activeTab === 'records' ? (
<RecordsFilterToolbar
filterProvider={filterProvider}
filterStart={filterStart}
filterEnd={filterEnd}
onProviderChange={setFilterProvider}
onStartChange={setFilterStart}
onEndChange={setFilterEnd}
onQuery={loadRecords}
/>
) : undefined}
metrics={
stats && !statsLoading ? (
<StatStrip
items={[
{ label: t('cloud.today'), value: formatCurrency(stats.today_cost || 0), color: 'blue' },
{ label: t('cloud.thisMonth'), value: formatCurrency(stats.month_cost || 0), color: 'amber' },
{ label: t('cloud.yearly'), value: formatCurrency(stats.year_cost || 0), color: 'purple' },
{ label: t('cloud.costAlert'), value: t('cloud.alertCount', { count: stats.alerts?.length || 0 }), color: stats.alerts?.length > 0 ? 'red' : 'muted' },
]}
/>
) : undefined
}
actions={(
<>
<Button
variant="outline"
size="sm"
className="dg-btn-secondary"
onClick={handleSyncYesterday}
disabled={syncing}
title={t('cloud.syncBillingHint')}
>
{syncing ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <CloudDownload className="h-4 w-4 mr-1" />}
{t('cloud.syncYesterday')}
</Button>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={refreshAll}>
<RefreshCw className="h-4 w-4 mr-1" />{t('common.refresh')}
</Button>
{activeTab === 'records' && (
<Button size="sm" className="dg-btn-primary" onClick={openAddRecord}>
<Plus className="h-4 w-4 mr-1" />{t('cloud.recordCost')}
</Button>
)}
{activeTab === 'alerts' && (
<Button size="sm" className="dg-btn-primary" onClick={openAddRule}>
<Plus className="h-4 w-4 mr-1" />{t('cloud.addAlertRule')}
</Button>
)}
</>
)}
>
{/* Tab 1:概览统计 */}
{activeTab === 'overview' && (
<OverviewTab stats={stats} statsLoading={statsLoading} />
)}
{/* Tab 2:成本记录 */}
{activeTab === 'records' && (
<RecordsTab
records={records}
loading={recordsLoading}
onEdit={openEditRecord}
onDelete={handleDeleteRecord}
onAdd={openAddRecord}
/>
)}
{/* Tab 3:预警规则 */}
{activeTab === 'alerts' && (
<AlertRulesTab
rules={rules}
loading={rulesLoading}
onEdit={openEditRule}
onDelete={handleDeleteRule}
onToggle={handleToggleRule}
onAdd={openAddRule}
/>
)}
<BillingRecordDialog
open={recordDialogOpen}
onOpenChange={setRecordDialogOpen}
editingRecord={editingRecord}
onSuccess={() => { loadRecords(); loadStats(); }}
/>
<BillingRuleDialog
open={ruleDialogOpen}
onOpenChange={setRuleDialogOpen}
editingRule={editingRule}
onSuccess={() => { loadRules(); loadStats(); }}
/>
</PageShell>
);
}