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

+1 -1
View File
@@ -129,7 +129,7 @@
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done | | ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) | | 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done | | ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q27 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%) + RdpDialog 642L→442L (-31%) + DeploymentEditService 627L→394L (-37%) + TraceManager 605L→331L (-45%,修复 SpanTreeNode 内联定义反模式) + GrafanaDashboard 624L→78L (-87%,DRY 合并 Zabbix/Prometheus Tab + 移除文件级 eslint-disable + 类型安全升级) + TopologyView 596L→472L (-21%) + DeploymentEditAdvanced 590L→289L (-51%) + WorkloadViewPage 572L→348L (-39%) + CloudAccountManager 572L→308L + ExcelServerImport 560L→385L + PipelineCreateDialog 559L→498L + PodLogsViewer 553L→331L (-40%) + HostMonitor 547L→224L (-59%) + KubernetesClusterDetail 547L→130L (-76%) + **AlertCenter 542L→312L (-42%,提取 4 个受控组件: AlertsToolbar + AlertsTab + ContactsTab + ContactGroupsTab,客户端搜索过滤+删除确认逻辑下沉到子组件)**,累计提取 103 个独立组件文件** | | 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q27 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%) + RdpDialog 642L→442L (-31%) + DeploymentEditService 627L→394L (-37%) + TraceManager 605L→331L (-45%,修复 SpanTreeNode 内联定义反模式) + GrafanaDashboard 624L→78L (-87%,DRY 合并 Zabbix/Prometheus Tab + 移除文件级 eslint-disable + 类型安全升级) + TopologyView 596L→472L (-21%) + DeploymentEditAdvanced 590L→289L (-51%) + WorkloadViewPage 572L→348L (-39%) + CloudAccountManager 572L→308L + ExcelServerImport 560L→385L + PipelineCreateDialog 559L→498L + PodLogsViewer 553L→331L (-40%) + HostMonitor 547L→224L (-59%) + KubernetesClusterDetail 547L→130L (-76%) + **AlertCenter 542L→312L (-42%,提取 4 个受控组件: AlertsToolbar + AlertsTab + ContactsTab + ContactGroupsTab,客户端搜索过滤+删除确认逻辑下沉到子组件)** + **CloudBilling 534L→295L (-45%,提取 cloud-billing/ 3 个文件: OverviewTab + RecordsTab(含 RecordsFilterToolbar) + AlertRulesTab)**,累计提取 106 个独立组件文件** |
### 3.2 🟡 中优先级 ### 3.2 🟡 中优先级
+35
View File
@@ -501,6 +501,41 @@ services/api.ts (1324L)
| Q27 | 前端巨型组件拆分(HostMonitor 547L→224L (-59%),提取 host-monitor/tabs/ 8 个文件 (BasicTab + DiskTab + NetworkTab + PortMonitorTab + ProcessTable + FDUsageCard + ContextSwitchCard + ProcessTab),TABS 常量数组提取为模块级,PCT_REF_LINES 常量提升到 types.ts,端口/进程数组类型 JsonData→JsonData[] 类型安全升级) | P0 | **done** | | Q27 | 前端巨型组件拆分(HostMonitor 547L→224L (-59%),提取 host-monitor/tabs/ 8 个文件 (BasicTab + DiskTab + NetworkTab + PortMonitorTab + ProcessTable + FDUsageCard + ContextSwitchCard + ProcessTab),TABS 常量数组提取为模块级,PCT_REF_LINES 常量提升到 types.ts,端口/进程数组类型 JsonData→JsonData[] 类型安全升级) | P0 | **done** |
| Q28 | 前端巨型组件拆分(KubernetesClusterDetail 547L→130L (-76%),提取 cluster-detail/ 8 个文件 (types(computeClusterMetrics/buildTrendData 纯函数) + OverviewTab + NodesTab + PodsTab + DeploymentsTab + ServicesTab + IngressesTab + EventsTab)) | P0 | **done** | | Q28 | 前端巨型组件拆分(KubernetesClusterDetail 547L→130L (-76%),提取 cluster-detail/ 8 个文件 (types(computeClusterMetrics/buildTrendData 纯函数) + OverviewTab + NodesTab + PodsTab + DeploymentsTab + ServicesTab + IngressesTab + EventsTab)) | P0 | **done** |
| Q29 | 前端巨型组件拆分(AlertCenter 542L→312L (-42%),提取 4 个受控组件 (AlertsToolbar 89L 筛选工具栏 + AlertsTab 149L 告警列表表格+分页 + ContactsTab 87L 联系人卡片网格 + ContactGroupsTab 88L 联系组卡片网格),客户端搜索过滤逻辑移入 AlertsTab,删除确认逻辑下沉到子组件) | P0 | **done** | | Q29 | 前端巨型组件拆分(AlertCenter 542L→312L (-42%),提取 4 个受控组件 (AlertsToolbar 89L 筛选工具栏 + AlertsTab 149L 告警列表表格+分页 + ContactsTab 87L 联系人卡片网格 + ContactGroupsTab 88L 联系组卡片网格),客户端搜索过滤逻辑移入 AlertsTab,删除确认逻辑下沉到子组件) | P0 | **done** |
| Q30 | 前端巨型组件拆分(CloudBilling 534L→295L (-45%),提取 cloud-billing/ 3 个文件 (OverviewTab 110L 概览统计:同步信息+成本告警横幅+趋势图+厂商分布 + RecordsTab 146L 成本记录表格+筛选工具栏 + AlertRulesTab 91L 预警规则卡片网格)) | P0 | **done** |
---
## Q30: 前端巨型组件拆分 — CloudBilling [done]
**问题**: `CloudBilling.tsx`(534 行)是 Q29 拆分后项目最大的云管理业务组件之一,包含云账单管理页面的全部逻辑:3 个 Tab(概览统计/成本记录/预警规则)、状态管理(~15 个 useState)、数据加载(loadStats/loadRecords/loadRules)、业务逻辑(同步账单/删除/切换规则)、以及 3 个 Tab 的完整内联 JSX(概览 ~93 行含同步信息横幅+成本告警横幅+趋势图+厂商分布堆叠条形图+明细网格 / 记录 ~62 行含筛选工具栏+表格 / 预警 ~60 行含规则卡片网格)。单文件过大导致维护困难。
**已完成 (2026-07-09)**:
将 3 个 Tab 内容区提取为 `cloud-billing/` 子目录下的独立文件:
| 新文件 | 行数 | 职责 |
|--------|------|------|
| `OverviewTab.tsx` | 110 | 概览统计 Tab(同步信息横幅 + 成本告警横幅 + 30 天趋势图 + 云厂商分布堆叠条形图+明细网格),受控展示组件接收 stats + statsLoading,内部用 useTranslation + formatCurrency + TrendChart + providerLabels/providerHex/ProviderDot |
| `RecordsTab.tsx` | 146 | 成本记录 Tab(含 `RecordsFilterToolbar` 导出组件: 云厂商下拉+日期范围+查询按钮 + `RecordsTab` 表格组件: 表头+表体+加载/空状态),RecordsFilterToolbar 接收 7 个 props(filter 值+setter+onQuery),RecordsTab 接收 records/loading + onEdit/onDelete/onAdd 回调 |
| `AlertRulesTab.tsx` | 91 | 预警规则 Tab(规则卡片网格: 名称+启用 Switch+厂商+指标+阈值+最近触发时间+编辑/删除按钮 + 加载/空状态),受控展示组件接收 rules/loading + onEdit/onDelete/onToggle/onAdd 回调 |
主文件 `CloudBilling.tsx` 从 **534 行降至 295 行**(-45%),仅保留:~15 个 useState 状态声明、数据加载(loadStats/loadRecords/loadRules useCallback + useEffect 触发)、业务逻辑(handleSyncYesterday 账单同步 + handleDeleteRecord/handleDeleteRule 带 useConfirm 二次确认 + handleToggleRule)、openAdd/openEdit 回调、pageTabs 配置、PageShell 布局(StatStrip 统计条 + actions 按钮组 + RecordsFilterToolbar 条件传入 toolbar + 3 个 Tab 子组件编排 + 2 个 Dialog 挂载)。
**关键改进**:
- **筛选工具栏提取**: 原 `recordsToolbar` 内联 JSX(27 行: Select 下拉+2 个 date Input+查询 Button)提取为 `RecordsFilterToolbar` 独立组件,主组件通过 7 个 props(filter 值+setter+onQuery)控制,零逻辑变更。
- **受控组件架构**: 3 个 Tab 子组件全部为受控展示组件(接收数据 + 回调 props),无独立状态。所有状态管理保留在主组件,拆分为纯机械操作(移动 JSX + 添加 props 透传),零逻辑变更。
**验收标准**:
- [x] CloudBilling.tsx 从 534 行降至 **295 行**(-45%)
- [x] 3 个提取的文件全部 < 150 行(最大 RecordsTab 146 行含 toolbar+table 双导出)
- [x] `tsc -b` 零错误
- [x] `npm run build` 零错误(✓ built in 17.81s)
- [x] ESLint errors 保持 **0**(CloudBilling + cloud-billing/ 目录零 errors/warnings)
- [x] ESLint warnings 保持 **65**(无新增)
- [x] 全局 ESLint any 保持 **0**
- [x] `go build` 零错误
- [x] 17/17 集成回归测试通过(零回归)
- [x] 云账单页功能不变(3 个 Tab:概览统计趋势图+厂商分布/成本记录筛选+表格/预警规则卡片+开关全部正常)
--- ---
+32 -271
View File
@@ -1,46 +1,18 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Plus, RefreshCw, Wallet, CloudDownload, Loader2 } from 'lucide-react';
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 { toast } from 'sonner'; import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import { billingApi } from '@/api'; import { billingApi } from '@/api';
import { import { formatCurrency } from './CloudBillingHelpers';
formatCurrency,
TrendChart,
providerLabels,
providerHex,
ProviderDot,
} from './CloudBillingHelpers';
import { StatStrip } from '@/components/ui/stat-strip'; import { StatStrip } from '@/components/ui/stat-strip';
import { BillingRecordDialog } from './BillingRecordDialog'; import { BillingRecordDialog } from './BillingRecordDialog';
import { BillingRuleDialog } from './BillingRuleDialog'; import { BillingRuleDialog } from './BillingRuleDialog';
import { PageShell } from '@/components/layout/PageShell'; import { PageShell } from '@/components/layout/PageShell';
import { ContentPanel } from '@/components/layout/ContentPanel'; import { OverviewTab } from './cloud-billing/OverviewTab';
import { PageLoading } from '@/components/layout/PageLoading'; import { RecordsTab, RecordsFilterToolbar } from './cloud-billing/RecordsTab';
import { PageEmpty } from '@/components/layout/PageEmpty'; import { AlertRulesTab } from './cloud-billing/AlertRulesTab';
import type { JsonData } from '@/types/common'; import type { JsonData } from '@/types/common';
export function CloudBilling() { export function CloudBilling() {
@@ -217,34 +189,6 @@ export function CloudBilling() {
{ id: 'alerts', label: t('cloud.billingAlert'), badge: rules.length > 0 ? rules.length : undefined }, { 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 ( return (
<PageShell <PageShell
variant="list" variant="list"
@@ -254,7 +198,17 @@ export function CloudBilling() {
tabs={pageTabs} tabs={pageTabs}
activeTab={activeTab} activeTab={activeTab}
onTabChange={(id) => setActiveTab(id as typeof 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={ metrics={
stats && !statsLoading ? ( stats && !statsLoading ? (
<StatStrip <StatStrip
@@ -298,223 +252,30 @@ export function CloudBilling() {
> >
{/* Tab 1:概览统计 */} {/* Tab 1:概览统计 */}
{activeTab === 'overview' && ( {activeTab === 'overview' && (
statsLoading ? ( <OverviewTab stats={stats} statsLoading={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')} />
)
)} )}
{/* Tab 2:成本记录 */} {/* Tab 2:成本记录 */}
{activeTab === 'records' && ( {activeTab === 'records' && (
<ContentPanel noPadding> <RecordsTab
{recordsLoading ? ( records={records}
<PageLoading /> loading={recordsLoading}
) : records.length === 0 ? ( onEdit={openEditRecord}
<PageEmpty onDelete={handleDeleteRecord}
icon={<Wallet className="h-12 w-12" />} onAdd={openAddRecord}
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>
)} )}
{/* Tab 3:预警规则 */} {/* Tab 3:预警规则 */}
{activeTab === 'alerts' && ( {activeTab === 'alerts' && (
rulesLoading ? ( <AlertRulesTab
<PageLoading /> rules={rules}
) : rules.length === 0 ? ( loading={rulesLoading}
<PageEmpty onEdit={openEditRule}
icon={<Bell className="h-12 w-12" />} onDelete={handleDeleteRule}
title={t('cloud.noAlertRules')} onToggle={handleToggleRule}
description={t('cloud.alertRuleHint')} onAdd={openAddRule}
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>
)
)} )}
<BillingRecordDialog <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>
);
}