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
@@ -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
@@ -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:概览统计趋势图+厂商分布/成本记录筛选+表格/预警规则卡片+开关全部正常)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user