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:
attacker committed 2026-07-09 18:36:13 +08:00
1 parent 803bffd0e3
commit 45f7342d1e
6 files changed
+415 -272

No files matched your search

+32 -271
View File
@@ -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