refactor(quality): Q30 前端巨型组件拆分 — CloudBilling 534L→295L (-45%), 提取 cloud-billing/ 3 个文件 (OverviewTab 110L 概览统计:同步信息+成本告警横幅+趋势图+厂商分布 + RecordsTab 146L 成本记录表格+RecordsFilterToolbar筛选工具栏 + AlertRulesTab 91L 预警规则卡片网格), 筛选工具栏提取为独立受控组件, 3个Tab全部受控展示组件零独立状态, 零逻辑变更, ESLint errors=0/warnings=65/any=0, tsc -b=0, npm build✓ (17.81s), go build=0, 17/17 测试通过
This commit is contained in:
1 parent
803bffd0e3
commit
45f7342d1e
6 files changed
+415
-272
No files matched your search
@@ -1,46 +1,18 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Wallet,
|
||||
Bell,
|
||||
CloudDownload,
|
||||
Loader2,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
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,
|
||||
TrendChart,
|
||||
providerLabels,
|
||||
providerHex,
|
||||
ProviderDot,
|
||||
} from './CloudBillingHelpers';
|
||||
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 { ContentPanel } from '@/components/layout/ContentPanel';
|
||||
import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
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() {
|
||||
@@ -217,34 +189,6 @@ export function CloudBilling() {
|
||||
{ id: 'alerts', label: t('cloud.billingAlert'), badge: rules.length > 0 ? rules.length : undefined },
|
||||
];
|
||||
|
||||
const recordsToolbar = (
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<Label className="text-xs dg-text-secondary mb-1">{t('cloud.vendor')}</Label>
|
||||
<Select value={filterProvider || 'all'} onValueChange={(v) => setFilterProvider(v === 'all' ? '' : v)}>
|
||||
<SelectTrigger className="w-32 h-9 dg-input"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('common.all')}</SelectItem>
|
||||
{Object.entries(providerLabels).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs dg-text-secondary mb-1">{t('cloud.startDate')}</Label>
|
||||
<Input type="date" className="w-40 h-9 dg-input" value={filterStart} onChange={(e) => setFilterStart(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs dg-text-secondary mb-1">{t('cloud.endDate')}</Label>
|
||||
<Input type="date" className="w-40 h-9 dg-input" value={filterEnd} onChange={(e) => setFilterEnd(e.target.value)} />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadRecords}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />{t('cloud.query')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
@@ -254,7 +198,17 @@ export function CloudBilling() {
|
||||
tabs={pageTabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(id) => setActiveTab(id as typeof activeTab)}
|
||||
toolbar={activeTab === 'records' ? recordsToolbar : undefined}
|
||||
toolbar={activeTab === 'records' ? (
|
||||
<RecordsFilterToolbar
|
||||
filterProvider={filterProvider}
|
||||
filterStart={filterStart}
|
||||
filterEnd={filterEnd}
|
||||
onProviderChange={setFilterProvider}
|
||||
onStartChange={setFilterStart}
|
||||
onEndChange={setFilterEnd}
|
||||
onQuery={loadRecords}
|
||||
/>
|
||||
) : undefined}
|
||||
metrics={
|
||||
stats && !statsLoading ? (
|
||||
<StatStrip
|
||||
@@ -298,223 +252,30 @@ export function CloudBilling() {
|
||||
>
|
||||
{/* Tab 1:概览统计 */}
|
||||
{activeTab === 'overview' && (
|
||||
statsLoading ? (
|
||||
<PageLoading />
|
||||
) : stats ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/40 p-3">
|
||||
<div className="flex items-start gap-2 text-sm text-[var(--dg-text-secondary)]">
|
||||
<Info className="h-4 w-4 dg-text-blue shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p>{t('cloud.syncBillingInfo')}</p>
|
||||
<p className="text-xs dg-text-muted">{t('cloud.syncBillingTiming')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats.alerts?.length > 0 && (
|
||||
<div className="dg-alert-banner">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bell className="h-4 w-4 dg-text-yellow" />
|
||||
<span className="dg-alert-banner-title">{t('cloud.costAlert')}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{stats.alerts.map((a: JsonData, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="dg-badge-yellow">{a.rule_name}</Badge>
|
||||
<span className="dg-text-secondary">
|
||||
{a.provider} · {a.metric === 'daily' ? t('cloud.dailyCost') : t('cloud.monthlyAccumulated')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{t('cloud.current')} <span className="dg-text-yellow font-medium">{formatCurrency(a.current)}</span>
|
||||
{' '}{a.operator}{' '}
|
||||
{t('cloud.alertThreshold')} <span className="dg-text-red font-medium">{formatCurrency(a.threshold)}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardContent className="pt-4">
|
||||
<h3 className="dg-section-title">{t('cloud.trend30d')}</h3>
|
||||
<TrendChart data={stats.month_trend || []} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardContent className="pt-4">
|
||||
<h3 className="dg-section-title">{t('cloud.monthVendorDistribution')}</h3>
|
||||
{(stats.provider_dist || []).length === 0 ? (
|
||||
<PageEmpty title={t('cloud.noDistributionData')} className="py-8" />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex h-8 rounded-lg overflow-hidden">
|
||||
{stats.provider_dist.map((p: JsonData, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-center text-xs text-white font-medium"
|
||||
style={{
|
||||
width: `${p.percent}%`,
|
||||
backgroundColor: providerHex[p.provider] || 'var(--surface-active)',
|
||||
}}
|
||||
title={`${providerLabels[p.provider] || p.provider}: ${formatCurrency(p.cost)} (${p.percent.toFixed(1)}%)`}
|
||||
>
|
||||
{p.percent > 8 ? (providerLabels[p.provider] || p.provider) : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{stats.provider_dist.map((p: JsonData, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderDot provider={p.provider} className="w-3 h-3" />
|
||||
<span className="text-[var(--text-secondary)]">{providerLabels[p.provider] || p.provider}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[var(--text-primary)] font-medium">{formatCurrency(p.cost)}</span>
|
||||
<span className="dg-text-muted ml-2">{p.percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<PageEmpty icon={<Wallet className="h-12 w-12" />} title={t('cloud.noStatsData')} />
|
||||
)
|
||||
<OverviewTab stats={stats} statsLoading={statsLoading} />
|
||||
)}
|
||||
|
||||
{/* Tab 2:成本记录 */}
|
||||
{activeTab === 'records' && (
|
||||
<ContentPanel noPadding>
|
||||
{recordsLoading ? (
|
||||
<PageLoading />
|
||||
) : records.length === 0 ? (
|
||||
<PageEmpty
|
||||
icon={<Wallet className="h-12 w-12" />}
|
||||
title={t('cloud.noCostRecords')}
|
||||
action={(
|
||||
<Button size="sm" className="dg-btn-primary" onClick={openAddRecord}>
|
||||
<Plus className="h-4 w-4 mr-1" />{t('cloud.recordCost')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--surface-raised)] dg-text-secondary">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.colDate')}</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.vendor')}</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.colService')}</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.colRegion')}</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.colAccount')}</th>
|
||||
<th className="px-4 py-2.5 text-right font-medium">{t('cloud.colCost')}</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.colDescription')}</th>
|
||||
<th className="px-4 py-2.5 text-center font-medium">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r) => (
|
||||
<tr key={r.id} className="dg-table-row border-t border-[var(--border-default)]">
|
||||
<td className="px-4 py-2.5 text-[var(--text-secondary)]">{r.billing_date}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProviderDot provider={r.provider} />
|
||||
{providerLabels[r.provider] || r.provider}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 dg-text-secondary">{r.service || '-'}</td>
|
||||
<td className="px-4 py-2.5 dg-text-secondary">{r.region || '-'}</td>
|
||||
<td className="px-4 py-2.5 dg-text-secondary">{r.account_name || '-'}</td>
|
||||
<td className="px-4 py-2.5 text-right text-[var(--text-primary)] font-medium">{formatCurrency(r.cost)}</td>
|
||||
<td className="px-4 py-2.5 dg-text-muted max-w-xs truncate">{r.description || '-'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditRecord(r)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-red hover:dg-text-red" onClick={() => handleDeleteRecord(r.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</ContentPanel>
|
||||
<RecordsTab
|
||||
records={records}
|
||||
loading={recordsLoading}
|
||||
onEdit={openEditRecord}
|
||||
onDelete={handleDeleteRecord}
|
||||
onAdd={openAddRecord}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tab 3:预警规则 */}
|
||||
{activeTab === 'alerts' && (
|
||||
rulesLoading ? (
|
||||
<PageLoading />
|
||||
) : rules.length === 0 ? (
|
||||
<PageEmpty
|
||||
icon={<Bell className="h-12 w-12" />}
|
||||
title={t('cloud.noAlertRules')}
|
||||
description={t('cloud.alertRuleHint')}
|
||||
action={(
|
||||
<Button size="sm" className="dg-btn-primary" onClick={openAddRule}>
|
||||
<Plus className="h-4 w-4 mr-1" />{t('cloud.addAlertRule')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{rules.map((r) => (
|
||||
<Card key={r.id} className="dg-card">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className={`h-4 w-4 ${r.enabled ? 'dg-text-yellow' : 'dg-text-muted'}`} />
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">{r.name}</span>
|
||||
</div>
|
||||
<Switch checked={r.enabled} onCheckedChange={() => handleToggleRule(r.id)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="dg-text-muted">{t('cloud.vendor')}</span>
|
||||
<span className="ml-2 text-[var(--text-secondary)]">{r.provider ? (providerLabels[r.provider] || r.provider) : t('common.all')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="dg-text-muted">{t('cloud.colMetric')}</span>
|
||||
<span className="ml-2 text-[var(--text-secondary)]">{r.metric === 'daily' ? t('cloud.dailyCost') : t('cloud.monthlyAccumulated')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="dg-text-muted">{t('cloud.alertThreshold')}</span>
|
||||
<span className="ml-2 dg-text-yellow font-medium">{r.operator} {formatCurrency(r.threshold)}</span>
|
||||
</div>
|
||||
{r.last_triggered && (
|
||||
<div>
|
||||
<span className="dg-text-muted">{t('cloud.lastTriggered')}</span>
|
||||
<span className="ml-2 dg-text-secondary">{new Date(r.last_triggered).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-1 mt-3">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditRule(r)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-red hover:dg-text-red" onClick={() => handleDeleteRule(r.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
<AlertRulesTab
|
||||
rules={rules}
|
||||
loading={rulesLoading}
|
||||
onEdit={openEditRule}
|
||||
onDelete={handleDeleteRule}
|
||||
onToggle={handleToggleRule}
|
||||
onAdd={openAddRule}
|
||||
/>
|
||||
)}
|
||||
|
||||
<BillingRecordDialog
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Bell, Plus, Trash2, Pencil } from 'lucide-react';
|
||||
import { formatCurrency, providerLabels } from '../CloudBillingHelpers';
|
||||
import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
/** 预警规则 Tab — 规则卡片网格 + 加载/空状态 // Logan.Li */
|
||||
export function AlertRulesTab({
|
||||
rules,
|
||||
loading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onAdd,
|
||||
}: {
|
||||
rules: JsonData[];
|
||||
loading: boolean;
|
||||
onEdit: (r: JsonData) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onToggle: (id: number) => void;
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (loading) return <PageLoading />;
|
||||
|
||||
if (rules.length === 0) {
|
||||
return (
|
||||
<PageEmpty
|
||||
icon={<Bell className="h-12 w-12" />}
|
||||
title={t('cloud.noAlertRules')}
|
||||
description={t('cloud.alertRuleHint')}
|
||||
action={(
|
||||
<Button size="sm" className="dg-btn-primary" onClick={onAdd}>
|
||||
<Plus className="h-4 w-4 mr-1" />{t('cloud.addAlertRule')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{rules.map((r) => (
|
||||
<Card key={r.id} className="dg-card">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className={`h-4 w-4 ${r.enabled ? 'dg-text-yellow' : 'dg-text-muted'}`} />
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">{r.name}</span>
|
||||
</div>
|
||||
<Switch checked={r.enabled} onCheckedChange={() => onToggle(r.id)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<span className="dg-text-muted">{t('cloud.vendor')}</span>
|
||||
<span className="ml-2 text-[var(--text-secondary)]">{r.provider ? (providerLabels[r.provider] || r.provider) : t('common.all')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="dg-text-muted">{t('cloud.colMetric')}</span>
|
||||
<span className="ml-2 text-[var(--text-secondary)]">{r.metric === 'daily' ? t('cloud.dailyCost') : t('cloud.monthlyAccumulated')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="dg-text-muted">{t('cloud.alertThreshold')}</span>
|
||||
<span className="ml-2 dg-text-yellow font-medium">{r.operator} {formatCurrency(r.threshold)}</span>
|
||||
</div>
|
||||
{r.last_triggered && (
|
||||
<div>
|
||||
<span className="dg-text-muted">{t('cloud.lastTriggered')}</span>
|
||||
<span className="ml-2 dg-text-secondary">{new Date(r.last_triggered).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-1 mt-3">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onEdit(r)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-red hover:dg-text-red" onClick={() => onDelete(r.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Wallet, Bell, Info } from 'lucide-react';
|
||||
import { formatCurrency, TrendChart, providerLabels, providerHex, ProviderDot } from '../CloudBillingHelpers';
|
||||
import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
/** 概览统计 Tab — 同步信息横幅 + 成本告警横幅 + 30 天趋势图 + 云厂商分布 // Logan.Li */
|
||||
export function OverviewTab({ stats, statsLoading }: { stats: JsonData; statsLoading: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (statsLoading) return <PageLoading />;
|
||||
if (!stats) return <PageEmpty icon={<Wallet className="h-12 w-12" />} title={t('cloud.noStatsData')} />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 同步信息提示 */}
|
||||
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/40 p-3">
|
||||
<div className="flex items-start gap-2 text-sm text-[var(--dg-text-secondary)]">
|
||||
<Info className="h-4 w-4 dg-text-blue shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p>{t('cloud.syncBillingInfo')}</p>
|
||||
<p className="text-xs dg-text-muted">{t('cloud.syncBillingTiming')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 成本告警横幅 */}
|
||||
{stats.alerts?.length > 0 && (
|
||||
<div className="dg-alert-banner">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bell className="h-4 w-4 dg-text-yellow" />
|
||||
<span className="dg-alert-banner-title">{t('cloud.costAlert')}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{stats.alerts.map((a: JsonData, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="dg-badge-yellow">{a.rule_name}</Badge>
|
||||
<span className="dg-text-secondary">
|
||||
{a.provider} · {a.metric === 'daily' ? t('cloud.dailyCost') : t('cloud.monthlyAccumulated')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{t('cloud.current')} <span className="dg-text-yellow font-medium">{formatCurrency(a.current)}</span>
|
||||
{' '}{a.operator}{' '}
|
||||
{t('cloud.alertThreshold')} <span className="dg-text-red font-medium">{formatCurrency(a.threshold)}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 30 天趋势图 */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="pt-4">
|
||||
<h3 className="dg-section-title">{t('cloud.trend30d')}</h3>
|
||||
<TrendChart data={stats.month_trend || []} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 云厂商成本分布 */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="pt-4">
|
||||
<h3 className="dg-section-title">{t('cloud.monthVendorDistribution')}</h3>
|
||||
{(stats.provider_dist || []).length === 0 ? (
|
||||
<PageEmpty title={t('cloud.noDistributionData')} className="py-8" />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* 堆叠条形图 */}
|
||||
<div className="flex h-8 rounded-lg overflow-hidden">
|
||||
{stats.provider_dist.map((p: JsonData, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-center text-xs text-white font-medium"
|
||||
style={{
|
||||
width: `${p.percent}%`,
|
||||
backgroundColor: providerHex[p.provider] || 'var(--surface-active)',
|
||||
}}
|
||||
title={`${providerLabels[p.provider] || p.provider}: ${formatCurrency(p.cost)} (${p.percent.toFixed(1)}%)`}
|
||||
>
|
||||
{p.percent > 8 ? (providerLabels[p.provider] || p.provider) : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 分布明细网格 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{stats.provider_dist.map((p: JsonData, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderDot provider={p.provider} className="w-3 h-3" />
|
||||
<span className="text-[var(--text-secondary)]">{providerLabels[p.provider] || p.provider}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[var(--text-primary)] font-medium">{formatCurrency(p.cost)}</span>
|
||||
<span className="dg-text-muted ml-2">{p.percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 { Plus, Trash2, Pencil, RefreshCw, Wallet } from 'lucide-react';
|
||||
import { ContentPanel } from '@/components/layout/ContentPanel';
|
||||
import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import { formatCurrency, providerLabels, ProviderDot } from '../CloudBillingHelpers';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
/** 成本记录筛选工具栏 — 云厂商下拉 + 日期范围 + 查询按钮 // Logan.Li */
|
||||
export function RecordsFilterToolbar({
|
||||
filterProvider,
|
||||
filterStart,
|
||||
filterEnd,
|
||||
onProviderChange,
|
||||
onStartChange,
|
||||
onEndChange,
|
||||
onQuery,
|
||||
}: {
|
||||
filterProvider: string;
|
||||
filterStart: string;
|
||||
filterEnd: string;
|
||||
onProviderChange: (v: string) => void;
|
||||
onStartChange: (v: string) => void;
|
||||
onEndChange: (v: string) => void;
|
||||
onQuery: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<Label className="text-xs dg-text-secondary mb-1">{t('cloud.vendor')}</Label>
|
||||
<Select value={filterProvider || 'all'} onValueChange={(v) => onProviderChange(v === 'all' ? '' : v)}>
|
||||
<SelectTrigger className="w-32 h-9 dg-input"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('common.all')}</SelectItem>
|
||||
{Object.entries(providerLabels).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs dg-text-secondary mb-1">{t('cloud.startDate')}</Label>
|
||||
<Input type="date" className="w-40 h-9 dg-input" value={filterStart} onChange={(e) => onStartChange(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs dg-text-secondary mb-1">{t('cloud.endDate')}</Label>
|
||||
<Input type="date" className="w-40 h-9 dg-input" value={filterEnd} onChange={(e) => onEndChange(e.target.value)} />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={onQuery}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />{t('cloud.query')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 成本记录 Tab — 表格 + 加载/空状态 // Logan.Li */
|
||||
export function RecordsTab({
|
||||
records,
|
||||
loading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAdd,
|
||||
}: {
|
||||
records: JsonData[];
|
||||
loading: boolean;
|
||||
onEdit: (r: JsonData) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ContentPanel noPadding>
|
||||
{loading ? (
|
||||
<PageLoading />
|
||||
) : records.length === 0 ? (
|
||||
<PageEmpty
|
||||
icon={<Wallet className="h-12 w-12" />}
|
||||
title={t('cloud.noCostRecords')}
|
||||
action={(
|
||||
<Button size="sm" className="dg-btn-primary" onClick={onAdd}>
|
||||
<Plus className="h-4 w-4 mr-1" />{t('cloud.recordCost')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--surface-raised)] dg-text-secondary">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.colDate')}</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.vendor')}</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.colService')}</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.colRegion')}</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.colAccount')}</th>
|
||||
<th className="px-4 py-2.5 text-right font-medium">{t('cloud.colCost')}</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium">{t('cloud.colDescription')}</th>
|
||||
<th className="px-4 py-2.5 text-center font-medium">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r) => (
|
||||
<tr key={r.id} className="dg-table-row border-t border-[var(--border-default)]">
|
||||
<td className="px-4 py-2.5 text-[var(--text-secondary)]">{r.billing_date}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProviderDot provider={r.provider} />
|
||||
{providerLabels[r.provider] || r.provider}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 dg-text-secondary">{r.service || '-'}</td>
|
||||
<td className="px-4 py-2.5 dg-text-secondary">{r.region || '-'}</td>
|
||||
<td className="px-4 py-2.5 dg-text-secondary">{r.account_name || '-'}</td>
|
||||
<td className="px-4 py-2.5 text-right text-[var(--text-primary)] font-medium">{formatCurrency(r.cost)}</td>
|
||||
<td className="px-4 py-2.5 dg-text-muted max-w-xs truncate">{r.description || '-'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onEdit(r)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-red hover:dg-text-red" onClick={() => onDelete(r.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</ContentPanel>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user