296 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|