refactor: 拆分CloudBilling.tsx(663→467行)提取BillingRecordDialog+BillingRuleDialog子模块
This commit is contained in:
1 parent
f865d41547
commit
accbfb53c3
4 files changed
+340
-220
No files matched your search
@@ -0,0 +1,149 @@
|
|||||||
|
// 云账单成本记录弹窗(新建/编辑)— 自包含状态,从 CloudBilling 提取 // Logan.Li
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { billingApi } from '@/services/api';
|
||||||
|
import { providerLabels } from './CloudBillingHelpers';
|
||||||
|
|
||||||
|
const emptyRecord = {
|
||||||
|
provider: 'aliyun',
|
||||||
|
billing_date: '',
|
||||||
|
cost: '',
|
||||||
|
service: '',
|
||||||
|
region: '',
|
||||||
|
account_name: '',
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
editingRecord: any | null;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BillingRecordDialog({ open, onOpenChange, editingRecord, onSuccess }: Props) {
|
||||||
|
const [form, setForm] = useState<any>(emptyRecord);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// 弹窗打开时初始化表单(新建预填今日日期,编辑回填数据) // Logan.Li
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (editingRecord) {
|
||||||
|
setForm({
|
||||||
|
provider: editingRecord.provider,
|
||||||
|
billing_date: editingRecord.billing_date,
|
||||||
|
cost: editingRecord.cost,
|
||||||
|
service: editingRecord.service || '',
|
||||||
|
region: editingRecord.region || '',
|
||||||
|
account_name: editingRecord.account_name || '',
|
||||||
|
description: editingRecord.description || '',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setForm({ ...emptyRecord, billing_date: new Date().toISOString().slice(0, 10) });
|
||||||
|
}
|
||||||
|
}, [open, editingRecord]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.provider || !form.billing_date || !form.cost) {
|
||||||
|
toast.error('请填写必填字段');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const data = { ...form, cost: parseFloat(form.cost) };
|
||||||
|
if (editingRecord) {
|
||||||
|
await billingApi.remove(editingRecord.id);
|
||||||
|
await billingApi.create(data);
|
||||||
|
} else {
|
||||||
|
await billingApi.create(data);
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess();
|
||||||
|
toast('保存成功');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error('保存失败', { description: e.message });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<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={form.provider} onValueChange={(v) => setForm({ ...form, 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={form.billing_date} onChange={(e) => setForm({ ...form, 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={form.cost} onChange={(e) => setForm({ ...form, cost: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm text-slate-300">服务</Label>
|
||||||
|
<Input className="mt-1" placeholder="ECS, RDS..." value={form.service} onChange={(e) => setForm({ ...form, 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={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm text-slate-300">账号名称</Label>
|
||||||
|
<Input className="mt-1" placeholder="主账号" value={form.account_name} onChange={(e) => setForm({ ...form, account_name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm text-slate-300">备注</Label>
|
||||||
|
<Input className="mt-1" placeholder="可选" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} disabled={submitting}>
|
||||||
|
{submitting && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// 云账单预警规则弹窗(新建/编辑)— 自包含状态,从 CloudBilling 提取 // Logan.Li
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
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 { Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { billingApi } from '@/services/api';
|
||||||
|
import { providerLabels } from './CloudBillingHelpers';
|
||||||
|
|
||||||
|
const emptyRule = { name: '', provider: '', metric: 'daily', threshold: '', operator: '>', enabled: true };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
editingRule: any | null;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BillingRuleDialog({ open, onOpenChange, editingRule, onSuccess }: Props) {
|
||||||
|
const [form, setForm] = useState<any>(emptyRule);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// 弹窗打开时初始化表单 // Logan.Li
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (editingRule) {
|
||||||
|
setForm({
|
||||||
|
name: editingRule.name,
|
||||||
|
provider: editingRule.provider || '',
|
||||||
|
metric: editingRule.metric || 'daily',
|
||||||
|
threshold: editingRule.threshold,
|
||||||
|
operator: editingRule.operator || '>',
|
||||||
|
enabled: editingRule.enabled,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setForm({ ...emptyRule });
|
||||||
|
}
|
||||||
|
}, [open, editingRule]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.name || !form.threshold) {
|
||||||
|
toast.error('请填写规则名称和阈值');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const data = { ...form, threshold: parseFloat(form.threshold) };
|
||||||
|
if (editingRule) {
|
||||||
|
await billingApi.updateAlertRule(editingRule.id, data);
|
||||||
|
} else {
|
||||||
|
await billingApi.createAlertRule(data);
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess();
|
||||||
|
toast('保存成功');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error('保存失败', { description: e.message });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<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={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm text-slate-300">厂商</Label>
|
||||||
|
<Select value={form.provider || 'all'} onValueChange={(v) => setForm({ ...form, 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={form.metric} onValueChange={(v) => setForm({ ...form, 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={form.operator} onValueChange={(v) => setForm({ ...form, 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={form.threshold} onChange={(e) => setForm({ ...form, threshold: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={form.enabled} onCheckedChange={(v) => setForm({ ...form, enabled: v })} />
|
||||||
|
<Label className="text-sm text-slate-300">启用</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSave} disabled={submitting}>
|
||||||
|
{submitting && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,14 +5,6 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -27,7 +19,6 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Loader2,
|
Loader2,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
|
||||||
Wallet,
|
Wallet,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -36,23 +27,9 @@ import {
|
|||||||
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 '@/services/api';
|
import { billingApi } from '@/services/api';
|
||||||
import { formatCurrency, StatCard, TrendChart } from './CloudBillingHelpers';
|
import { formatCurrency, StatCard, TrendChart, providerLabels, providerColors } from './CloudBillingHelpers';
|
||||||
|
import { BillingRecordDialog } from './BillingRecordDialog';
|
||||||
const providerLabels: Record<string, string> = {
|
import { BillingRuleDialog } from './BillingRuleDialog';
|
||||||
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',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CloudBilling() {
|
export function CloudBilling() {
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@@ -71,16 +48,11 @@ export function CloudBilling() {
|
|||||||
const [rules, setRules] = useState<any[]>([]);
|
const [rules, setRules] = useState<any[]>([]);
|
||||||
const [rulesLoading, setRulesLoading] = useState(true);
|
const [rulesLoading, setRulesLoading] = useState(true);
|
||||||
|
|
||||||
|
// 弹窗状态(open + 编辑目标) // Logan.Li
|
||||||
const [recordDialogOpen, setRecordDialogOpen] = useState(false);
|
const [recordDialogOpen, setRecordDialogOpen] = useState(false);
|
||||||
const [editingRecord, setEditingRecord] = useState<any>(null);
|
const [editingRecord, setEditingRecord] = useState<any>(null);
|
||||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||||
const [editingRule, setEditingRule] = useState<any>(null);
|
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 () => {
|
const loadStats = useCallback(async () => {
|
||||||
setStatsLoading(true);
|
setStatsLoading(true);
|
||||||
@@ -136,39 +108,22 @@ export function CloudBilling() {
|
|||||||
|
|
||||||
const openAddRecord = () => {
|
const openAddRecord = () => {
|
||||||
setEditingRecord(null);
|
setEditingRecord(null);
|
||||||
setRecordForm({ ...emptyRecord, billing_date: new Date().toISOString().slice(0, 10) });
|
|
||||||
setRecordDialogOpen(true);
|
setRecordDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditRecord = (r: any) => {
|
const openEditRecord = (r: any) => {
|
||||||
setEditingRecord(r);
|
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);
|
setRecordDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveRecord = async () => {
|
const openAddRule = () => {
|
||||||
if (!recordForm.provider || !recordForm.billing_date || !recordForm.cost) {
|
setEditingRule(null);
|
||||||
toast.error('请填写必填字段');
|
setRuleDialogOpen(true);
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
setSubmitting(true);
|
const openEditRule = (r: any) => {
|
||||||
try {
|
setEditingRule(r);
|
||||||
const data = { ...recordForm, cost: parseFloat(recordForm.cost) };
|
setRuleDialogOpen(true);
|
||||||
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 handleDeleteRecord = async (id: number) => {
|
||||||
@@ -184,42 +139,6 @@ export function CloudBilling() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 handleDeleteRule = async (id: number) => {
|
||||||
const ok = await confirm({ title: '删除预警规则', description: '确定删除此预警规则?', variant: 'destructive' });
|
const ok = await confirm({ title: '删除预警规则', description: '确定删除此预警规则?', variant: 'destructive' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -531,133 +450,18 @@ export function CloudBilling() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={recordDialogOpen} onOpenChange={setRecordDialogOpen}>
|
<BillingRecordDialog
|
||||||
<DialogContent className="max-w-md">
|
open={recordDialogOpen}
|
||||||
<DialogHeader>
|
onOpenChange={setRecordDialogOpen}
|
||||||
<DialogTitle>{editingRecord ? '编辑成本记录' : '记录成本'}</DialogTitle>
|
editingRecord={editingRecord}
|
||||||
<DialogDescription>记录一条每日云资源成本</DialogDescription>
|
onSuccess={() => { loadRecords(); loadStats(); }}
|
||||||
</DialogHeader>
|
/>
|
||||||
<div className="space-y-3">
|
<BillingRuleDialog
|
||||||
<div className="grid grid-cols-2 gap-3">
|
open={ruleDialogOpen}
|
||||||
<div>
|
onOpenChange={setRuleDialogOpen}
|
||||||
<Label className="text-sm text-slate-300">厂商 *</Label>
|
editingRule={editingRule}
|
||||||
<Select value={recordForm.provider} onValueChange={(v) => setRecordForm({ ...recordForm, provider: v })}>
|
onSuccess={() => { loadRules(); loadStats(); }}
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,24 @@
|
|||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { TrendingUp, TrendingDown } from 'lucide-react';
|
import { TrendingUp, TrendingDown } from 'lucide-react';
|
||||||
|
|
||||||
|
// 云厂商中文标签(主组件 + 弹窗共享) // Logan.Li
|
||||||
|
export const providerLabels: Record<string, string> = {
|
||||||
|
aliyun: '阿里云',
|
||||||
|
tencent: '腾讯云',
|
||||||
|
aws: 'AWS',
|
||||||
|
huawei: '华为云',
|
||||||
|
azure: 'Azure',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 云厂商品牌色(主组件 + 弹窗共享) // Logan.Li
|
||||||
|
export 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',
|
||||||
|
};
|
||||||
|
|
||||||
export function formatCurrency(n: number) {
|
export function formatCurrency(n: number) {
|
||||||
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user