feat: AI预测分析接入真实后端数据 + 修复CloudBilling文件损坏
- AIPredictiveAnalytics: 替换3组硬编码mock为实时数据驱动预测 - 指标预测: 基于dashboard metrics时序数据(cpu/memory/disk)线性回归计算斜率与R² - 容量规划: 基于日均增长率推算阈值触及时间(天),动态生成AI扩容建议 - 异常预测: 基于告警规则阈值+当前趋势+事件频率计算异常触发概率 - 概览卡片数据驱动化(置信度基于R²拟合度,高风险/容量预警计数实时计算) - CloudBilling.tsx: 修复read_file导致的行号前缀损坏(501行全部含N|前缀)
This commit is contained in:
1 parent
0ebe22d142
commit
2da4f52209
17 files changed
+2299
-322
No files matched your search
@@ -0,0 +1,728 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Wallet,
|
||||
CalendarDays,
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { billingApi } from '@/services/api';
|
||||
|
||||
const providerLabels: Record<string, string> = {
|
||||
aliyun: '阿里云',
|
||||
tencent: '腾讯云',
|
||||
aws: 'AWS',
|
||||
huawei: '华为云',
|
||||
azure: 'Azure',
|
||||
};
|
||||
|
||||
const providerColors: Record<string, string> = {
|
||||
aliyun: 'bg-orange-500',
|
||||
tencent: 'bg-blue-500',
|
||||
aws: 'bg-amber-500',
|
||||
huawei: 'bg-red-500',
|
||||
azure: 'bg-sky-500',
|
||||
};
|
||||
|
||||
function formatCurrency(n: number) {
|
||||
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
export function CloudBilling() {
|
||||
const confirm = useConfirm();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'records' | 'alerts'>('overview');
|
||||
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(true);
|
||||
|
||||
const [records, setRecords] = useState<any[]>([]);
|
||||
const [recordsLoading, setRecordsLoading] = useState(true);
|
||||
const [filterProvider, setFilterProvider] = useState('');
|
||||
const [filterStart, setFilterStart] = useState('');
|
||||
const [filterEnd, setFilterEnd] = useState('');
|
||||
|
||||
const [rules, setRules] = useState<any[]>([]);
|
||||
const [rulesLoading, setRulesLoading] = useState(true);
|
||||
|
||||
const [recordDialogOpen, setRecordDialogOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<any>(null);
|
||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<any>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const emptyRecord = { provider: 'aliyun', billing_date: '', cost: '', service: '', region: '', account_name: '', description: '' };
|
||||
const [recordForm, setRecordForm] = useState<any>(emptyRecord);
|
||||
const emptyRule = { name: '', provider: '', metric: 'daily', threshold: '', operator: '>', enabled: true };
|
||||
const [ruleForm, setRuleForm] = useState<any>(emptyRule);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setStatsLoading(true);
|
||||
try {
|
||||
const res = await billingApi.getStats();
|
||||
setStats(res);
|
||||
} catch (e: any) {
|
||||
toast.error('加载统计失败', { description: e.message });
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRecords = useCallback(async () => {
|
||||
setRecordsLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
if (filterProvider) params.provider = filterProvider;
|
||||
if (filterStart) params.start_date = filterStart;
|
||||
if (filterEnd) params.end_date = filterEnd;
|
||||
const res = await billingApi.list(params);
|
||||
setRecords(res.list || []);
|
||||
} catch (e: any) {
|
||||
toast.error('加载账单失败', { description: e.message });
|
||||
} finally {
|
||||
setRecordsLoading(false);
|
||||
}
|
||||
}, [filterProvider, filterStart, filterEnd]);
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
setRulesLoading(true);
|
||||
try {
|
||||
const res = await billingApi.listAlertRules();
|
||||
setRules(res.list || []);
|
||||
} catch (e: any) {
|
||||
toast.error('加载预警规则失败', { description: e.message });
|
||||
} finally {
|
||||
setRulesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'records') loadRecords();
|
||||
}, [activeTab, loadRecords]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'alerts') loadRules();
|
||||
}, [activeTab, loadRules]);
|
||||
|
||||
const openAddRecord = () => {
|
||||
setEditingRecord(null);
|
||||
setRecordForm({ ...emptyRecord, billing_date: new Date().toISOString().slice(0, 10) });
|
||||
setRecordDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditRecord = (r: any) => {
|
||||
setEditingRecord(r);
|
||||
setRecordForm({ provider: r.provider, billing_date: r.billing_date, cost: r.cost, service: r.service || '', region: r.region || '', account_name: r.account_name || '', description: r.description || '' });
|
||||
setRecordDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveRecord = async () => {
|
||||
if (!recordForm.provider || !recordForm.billing_date || !recordForm.cost) {
|
||||
toast.error('请填写必填字段');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const data = { ...recordForm, cost: parseFloat(recordForm.cost) };
|
||||
if (editingRecord) {
|
||||
await billingApi.remove(editingRecord.id);
|
||||
await billingApi.create(data);
|
||||
} else {
|
||||
await billingApi.create(data);
|
||||
}
|
||||
setRecordDialogOpen(false);
|
||||
loadRecords();
|
||||
loadStats();
|
||||
toast('保存成功');
|
||||
} catch (e: any) {
|
||||
toast.error('保存失败', { description: e.message });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRecord = async (id: number) => {
|
||||
const ok = await confirm({ title: '删除账单记录', description: '确定删除这条成本记录?', variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await billingApi.remove(id);
|
||||
loadRecords();
|
||||
loadStats();
|
||||
toast('删除成功');
|
||||
} catch (e: any) {
|
||||
toast.error('删除失败', { description: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const openAddRule = () => {
|
||||
setEditingRule(null);
|
||||
setRuleForm({ ...emptyRule });
|
||||
setRuleDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditRule = (r: any) => {
|
||||
setEditingRule(r);
|
||||
setRuleForm({ name: r.name, provider: r.provider || '', metric: r.metric || 'daily', threshold: r.threshold, operator: r.operator || '>', enabled: r.enabled });
|
||||
setRuleDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveRule = async () => {
|
||||
if (!ruleForm.name || !ruleForm.threshold) {
|
||||
toast.error('请填写规则名称和阈值');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const data = { ...ruleForm, threshold: parseFloat(ruleForm.threshold) };
|
||||
if (editingRule) {
|
||||
await billingApi.updateAlertRule(editingRule.id, data);
|
||||
} else {
|
||||
await billingApi.createAlertRule(data);
|
||||
}
|
||||
setRuleDialogOpen(false);
|
||||
loadRules();
|
||||
loadStats();
|
||||
toast('保存成功');
|
||||
} catch (e: any) {
|
||||
toast.error('保存失败', { description: e.message });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRule = async (id: number) => {
|
||||
const ok = await confirm({ title: '删除预警规则', description: '确定删除此预警规则?', variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await billingApi.deleteAlertRule(id);
|
||||
loadRules();
|
||||
toast('删除成功');
|
||||
} catch (e: any) {
|
||||
toast.error('删除失败', { description: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRule = async (id: number) => {
|
||||
try {
|
||||
await billingApi.toggleAlertRule(id);
|
||||
loadRules();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
toast.error('操作失败', { description: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-100">云账单管理</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => { loadStats(); if (activeTab === 'records') loadRecords(); if (activeTab === 'alerts') loadRules(); }}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />刷新
|
||||
</Button>
|
||||
{activeTab === 'records' && (
|
||||
<Button size="sm" onClick={openAddRecord}>
|
||||
<Plus className="h-4 w-4 mr-1" />记录成本
|
||||
</Button>
|
||||
)}
|
||||
{activeTab === 'alerts' && (
|
||||
<Button size="sm" onClick={openAddRule}>
|
||||
<Plus className="h-4 w-4 mr-1" />添加规则
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b border-slate-800">
|
||||
{([
|
||||
{ key: 'overview', label: '概览统计' },
|
||||
{ key: 'records', label: '成本记录' },
|
||||
{ key: 'alerts', label: '预警规则' },
|
||||
] as const).map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeTab === tab.key
|
||||
? 'text-blue-400 border-blue-400'
|
||||
: 'text-slate-400 border-transparent hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-4">
|
||||
{statsLoading ? (
|
||||
<div className="flex items-center justify-center py-20 text-slate-500"><Loader2 className="h-6 w-6 animate-spin mr-2" />加载中...</div>
|
||||
) : stats ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<StatCard
|
||||
icon={<Wallet className="h-5 w-5" />}
|
||||
label="今日成本"
|
||||
value={formatCurrency(stats.today_cost || 0)}
|
||||
trend={stats.today_cost - (stats.yesterday_cost || 0)}
|
||||
trendLabel={`昨日 ${formatCurrency(stats.yesterday_cost || 0)}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<CalendarDays className="h-5 w-5" />}
|
||||
label="本月成本"
|
||||
value={formatCurrency(stats.month_cost || 0)}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
label="年度成本"
|
||||
value={formatCurrency(stats.year_cost || 0)}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<AlertTriangle className="h-5 w-5" />}
|
||||
label="活跃预警"
|
||||
value={`${stats.alerts?.length || 0} 条`}
|
||||
alert={stats.alerts?.length > 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{stats.alerts?.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bell className="h-4 w-4 text-amber-400" />
|
||||
<span className="text-sm font-medium text-amber-300">成本预警</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{stats.alerts.map((a: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="border-amber-500/50 text-amber-300">{a.rule_name}</Badge>
|
||||
<span className="text-slate-400">
|
||||
{a.provider} · {a.metric === 'daily' ? '日' : '月'}成本
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-slate-300">
|
||||
当前 <span className="text-amber-300 font-medium">{formatCurrency(a.current)}</span>
|
||||
{' '}{a.operator}{' '}
|
||||
阈值 <span className="text-red-400 font-medium">{formatCurrency(a.threshold)}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="bg-slate-900/50 border-slate-800">
|
||||
<CardContent className="pt-4">
|
||||
<h3 className="text-sm font-medium text-slate-200 mb-4">近 30 天成本趋势</h3>
|
||||
<TrendChart data={stats.month_trend || []} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-slate-900/50 border-slate-800">
|
||||
<CardContent className="pt-4">
|
||||
<h3 className="text-sm font-medium text-slate-200 mb-4">本月厂商成本分布</h3>
|
||||
{(stats.provider_dist || []).length === 0 ? (
|
||||
<p className="text-sm text-slate-500 py-8 text-center">暂无数据</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex h-8 rounded-lg overflow-hidden">
|
||||
{stats.provider_dist.map((p: any, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${providerColors[p.provider] || 'bg-slate-600'} flex items-center justify-center text-xs text-white font-medium`}
|
||||
style={{ width: `${p.percent}%` }}
|
||||
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: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-3 h-3 rounded ${providerColors[p.provider] || 'bg-slate-600'}`} />
|
||||
<span className="text-slate-300">{providerLabels[p.provider] || p.provider}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-slate-200 font-medium">{formatCurrency(p.cost)}</span>
|
||||
<span className="text-slate-500 ml-2">{p.percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'records' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-400 mb-1">厂商</Label>
|
||||
<Select value={filterProvider || 'all'} onValueChange={(v) => setFilterProvider(v === 'all' ? '' : v)}>
|
||||
<SelectTrigger className="w-32 h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
{Object.entries(providerLabels).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-400 mb-1">开始日期</Label>
|
||||
<Input type="date" className="w-40 h-9" value={filterStart} onChange={(e) => setFilterStart(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-slate-400 mb-1">结束日期</Label>
|
||||
<Input type="date" className="w-40 h-9" value={filterEnd} onChange={(e) => setFilterEnd(e.target.value)} />
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadRecords}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />查询
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{recordsLoading ? (
|
||||
<div className="flex items-center justify-center py-20 text-slate-500"><Loader2 className="h-6 w-6 animate-spin mr-2" />加载中...</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="py-20 text-center text-slate-500">
|
||||
<Wallet className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">暂无成本记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-800">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-900/80 text-slate-400">
|
||||
<tr>
|
||||
<th className="px-4 py-2.5 text-left font-medium">日期</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium">厂商</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium">服务</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium">区域</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium">账号</th>
|
||||
<th className="px-4 py-2.5 text-right font-medium">费用</th>
|
||||
<th className="px-4 py-2.5 text-left font-medium">说明</th>
|
||||
<th className="px-4 py-2.5 text-center font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r) => (
|
||||
<tr key={r.id} className="border-t border-slate-800 hover:bg-slate-800/30">
|
||||
<td className="px-4 py-2.5 text-slate-300">{r.billing_date}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${providerColors[r.provider] || 'bg-slate-500'}`} />
|
||||
{providerLabels[r.provider] || r.provider}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-slate-400">{r.service || '-'}</td>
|
||||
<td className="px-4 py-2.5 text-slate-400">{r.region || '-'}</td>
|
||||
<td className="px-4 py-2.5 text-slate-400">{r.account_name || '-'}</td>
|
||||
<td className="px-4 py-2.5 text-right text-slate-200 font-medium">{formatCurrency(r.cost)}</td>
|
||||
<td className="px-4 py-2.5 text-slate-500 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 text-red-400 hover:text-red-300" onClick={() => handleDeleteRecord(r.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'alerts' && (
|
||||
<div className="space-y-3">
|
||||
{rulesLoading ? (
|
||||
<div className="flex items-center justify-center py-20 text-slate-500"><Loader2 className="h-6 w-6 animate-spin mr-2" />加载中...</div>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="py-20 text-center text-slate-500">
|
||||
<Bell className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">暂无预警规则</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{rules.map((r) => (
|
||||
<Card key={r.id} className="bg-slate-900/50 border-slate-800">
|
||||
<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 ? 'text-amber-400' : 'text-slate-600'}`} />
|
||||
<span className="text-sm font-medium text-slate-200">{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="text-slate-500">厂商</span>
|
||||
<span className="ml-2 text-slate-300">{r.provider ? (providerLabels[r.provider] || r.provider) : '全部'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">指标</span>
|
||||
<span className="ml-2 text-slate-300">{r.metric === 'daily' ? '日成本' : '月累计'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">阈值</span>
|
||||
<span className="ml-2 text-amber-300 font-medium">{r.operator} {formatCurrency(r.threshold)}</span>
|
||||
</div>
|
||||
{r.last_triggered && (
|
||||
<div>
|
||||
<span className="text-slate-500">最后触发</span>
|
||||
<span className="ml-2 text-slate-400">{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 text-red-400 hover:text-red-300" onClick={() => handleDeleteRule(r.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={recordDialogOpen} onOpenChange={setRecordDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingRecord ? '编辑成本记录' : '记录成本'}</DialogTitle>
|
||||
<DialogDescription>记录一条每日云资源成本</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">厂商 *</Label>
|
||||
<Select value={recordForm.provider} onValueChange={(v) => setRecordForm({ ...recordForm, provider: v })}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(providerLabels).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">日期 *</Label>
|
||||
<Input type="date" className="mt-1" value={recordForm.billing_date} onChange={(e) => setRecordForm({ ...recordForm, billing_date: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">费用(元) *</Label>
|
||||
<Input type="number" step="0.01" className="mt-1" placeholder="0.00" value={recordForm.cost} onChange={(e) => setRecordForm({ ...recordForm, cost: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">服务</Label>
|
||||
<Input className="mt-1" placeholder="ECS, RDS..." value={recordForm.service} onChange={(e) => setRecordForm({ ...recordForm, service: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">区域</Label>
|
||||
<Input className="mt-1" placeholder="cn-hangzhou" value={recordForm.region} onChange={(e) => setRecordForm({ ...recordForm, region: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">账号名称</Label>
|
||||
<Input className="mt-1" placeholder="主账号" value={recordForm.account_name} onChange={(e) => setRecordForm({ ...recordForm, account_name: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">备注</Label>
|
||||
<Input className="mt-1" placeholder="可选" value={recordForm.description} onChange={(e) => setRecordForm({ ...recordForm, description: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRecordDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSaveRecord} disabled={submitting}>
|
||||
{submitting && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={ruleDialogOpen} onOpenChange={setRuleDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingRule ? '编辑预警规则' : '添加预警规则'}</DialogTitle>
|
||||
<DialogDescription>设置成本阈值,超标时自动预警</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">规则名称 *</Label>
|
||||
<Input className="mt-1" placeholder="如:日成本超 500 元预警" value={ruleForm.name} onChange={(e) => setRuleForm({ ...ruleForm, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">厂商</Label>
|
||||
<Select value={ruleForm.provider || 'all'} onValueChange={(v) => setRuleForm({ ...ruleForm, provider: v === 'all' ? '' : v })}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部厂商</SelectItem>
|
||||
{Object.entries(providerLabels).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">指标</Label>
|
||||
<Select value={ruleForm.metric} onValueChange={(v) => setRuleForm({ ...ruleForm, metric: v })}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">日成本</SelectItem>
|
||||
<SelectItem value="monthly">月累计</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">比较</Label>
|
||||
<Select value={ruleForm.operator} onValueChange={(v) => setRuleForm({ ...ruleForm, operator: v })}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=">">大于 ></SelectItem>
|
||||
<SelectItem value=">=">大于等于 ≥</SelectItem>
|
||||
<SelectItem value="<">小于 <</SelectItem>
|
||||
<SelectItem value="<=">小于等于 ≤</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">阈值(元) *</Label>
|
||||
<Input type="number" step="0.01" className="mt-1" placeholder="500.00" value={ruleForm.threshold} onChange={(e) => setRuleForm({ ...ruleForm, threshold: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={ruleForm.enabled} onCheckedChange={(v) => setRuleForm({ ...ruleForm, enabled: v })} />
|
||||
<Label className="text-sm text-slate-300">启用</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRuleDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSaveRule} disabled={submitting}>
|
||||
{submitting && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, trend, trendLabel, alert }: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
trend?: number;
|
||||
trendLabel?: string;
|
||||
alert?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-lg border p-4 ${alert ? 'border-amber-500/30 bg-amber-500/5' : 'border-slate-800 bg-slate-900/50'}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-slate-400 text-sm">{label}</span>
|
||||
<span className={alert ? 'text-amber-400' : 'text-slate-500'}>{icon}</span>
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${alert ? 'text-amber-300' : 'text-slate-100'}`}>{value}</div>
|
||||
{trendLabel && (
|
||||
<div className="flex items-center gap-1 mt-1 text-xs">
|
||||
{trend !== undefined && trend > 0 && <TrendingUp className="h-3 w-3 text-red-400" />}
|
||||
{trend !== undefined && trend < 0 && <TrendingDown className="h-3 w-3 text-green-400" />}
|
||||
<span className="text-slate-500">{trendLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendChart({ data }: { data: { date: string; cost: number }[] }) {
|
||||
if (data.length === 0) {
|
||||
return <p className="text-sm text-slate-500 py-8 text-center">暂无数据</p>;
|
||||
}
|
||||
const max = Math.max(...data.map((d) => d.cost), 1);
|
||||
const recent = data.slice(-30);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-end gap-0.5 h-40">
|
||||
{recent.map((d, i) => {
|
||||
const h = Math.max((d.cost / max) * 100, 2);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 group relative"
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-0 w-full rounded-t-sm bg-gradient-to-t from-blue-600/60 to-blue-400/80 group-hover:from-blue-500 group-hover:to-blue-300 transition-colors"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block whitespace-nowrap text-xs bg-slate-800 text-slate-200 px-2 py-1 rounded border border-slate-700 z-10">
|
||||
{d.date}: {formatCurrency(d.cost)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-slate-500">
|
||||
<span>{recent[0]?.date}</span>
|
||||
<span>{recent[recent.length - 1]?.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user