feat: 全站 PageShell 设计系统与专业 UI 统一
- 新增 PageShell/PageTabs/ContentPanel/PageLoading/PageEmpty 页面骨架 - index.css 设计令牌文档化,布局令牌与纯色主按钮(去除渐变粉彩) - 全站路由页接入 PageShell,云账单三 Tab 为标杆 - 主题体系:跟随系统 + 主流/开发者分组,新增 Dracula/Tokyo Night,One Dark 暖灰差异化 - 登录页/侧栏/顶栏去渐变光球,企业克制风 - 修复 AIAlertAnalyzer JSX 与构建依赖导入问题
This commit is contained in:
1 parent
1acb0c49af
commit
e363ded445
213 files changed
+7148
-6818
No files matched your search
@@ -97,7 +97,7 @@ export function BillingRecordDialog({ open, onOpenChange, editingRecord, onSucce
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">厂商 *</Label>
|
||||
<Label className="text-sm text-[var(--dg-text-secondary)]">厂商 *</Label>
|
||||
<Select value={form.provider} onValueChange={(v) => setForm({ ...form, provider: v })}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -108,32 +108,32 @@ export function BillingRecordDialog({ open, onOpenChange, editingRecord, onSucce
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">日期 *</Label>
|
||||
<Label className="text-sm text-[var(--dg-text-secondary)]">日期 *</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>
|
||||
<Label className="text-sm text-[var(--dg-text-secondary)]">费用(元) *</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>
|
||||
<Label className="text-sm text-[var(--dg-text-secondary)]">服务</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>
|
||||
<Label className="text-sm text-[var(--dg-text-secondary)]">区域</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>
|
||||
<Label className="text-sm text-[var(--dg-text-secondary)]">账号名称</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>
|
||||
<Label className="text-sm text-[var(--dg-text-secondary)]">备注</Label>
|
||||
<Input className="mt-1" placeholder="可选" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,12 +87,12 @@ export function BillingRuleDialog({ open, onOpenChange, editingRule, onSuccess }
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">规则名称 *</Label>
|
||||
<Label className="text-sm text-[var(--dg-text-secondary)]">规则名称 *</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>
|
||||
<Label className="text-sm text-[var(--dg-text-secondary)]">厂商</Label>
|
||||
<Select value={form.provider || 'all'} onValueChange={(v) => setForm({ ...form, provider: v === 'all' ? '' : v })}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -104,7 +104,7 @@ export function BillingRuleDialog({ open, onOpenChange, editingRule, onSuccess }
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">指标</Label>
|
||||
<Label className="text-sm text-[var(--dg-text-secondary)]">指标</Label>
|
||||
<Select value={form.metric} onValueChange={(v) => setForm({ ...form, metric: v })}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -116,7 +116,7 @@ export function BillingRuleDialog({ open, onOpenChange, editingRule, onSuccess }
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">比较</Label>
|
||||
<Label className="text-sm text-[var(--dg-text-secondary)]">比较</Label>
|
||||
<Select value={form.operator} onValueChange={(v) => setForm({ ...form, operator: v })}>
|
||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -128,13 +128,13 @@ export function BillingRuleDialog({ open, onOpenChange, editingRule, onSuccess }
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm text-slate-300">阈值(元) *</Label>
|
||||
<Label className="text-sm text-[var(--dg-text-secondary)]">阈值(元) *</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>
|
||||
<Label className="text-sm text-[var(--dg-text-secondary)]">启用</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { cloudApi } from '@/api';
|
||||
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
const providerLabels: Record<string, string> = {
|
||||
@@ -146,70 +150,84 @@ export function CloudAccountManager() {
|
||||
return matchesSearch && matchesProvider;
|
||||
});
|
||||
|
||||
const _stats = {
|
||||
const stats = {
|
||||
total: accounts.length,
|
||||
active: accounts.filter(a => a.status === 'active').length,
|
||||
inactive: accounts.filter(a => a.status === 'inactive').length,
|
||||
error: accounts.filter(a => a.status === 'error').length,
|
||||
};
|
||||
|
||||
const toolbar = (
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
||||
<Input
|
||||
placeholder="搜索账号名称或AccessKey..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 h-9 dg-input"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selectedProvider}
|
||||
onChange={(e) => setSelectedProvider(e.target.value)}
|
||||
className="px-3 py-2 h-9 rounded-md text-sm dg-input"
|
||||
>
|
||||
<option value="all">全部平台</option>
|
||||
<option value="aws">AWS</option>
|
||||
<option value="aliyun">阿里云</option>
|
||||
<option value="tencent">腾讯云</option>
|
||||
<option value="huawei">华为云</option>
|
||||
<option value="azure">Azure</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">云账号管理</h1>
|
||||
<p className="text-slate-400 mt-1">管理多云平台的访问凭证和账号信息</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={loadData} className="border-slate-700 text-slate-300">
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
<PageShell
|
||||
variant="list"
|
||||
title="云账号管理"
|
||||
subtitle="管理多云平台的访问凭证和账号信息"
|
||||
icon={<Key className="h-5 w-5" />}
|
||||
metrics={
|
||||
!loading ? (
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '总计', value: stats.total, color: 'blue' },
|
||||
{ label: '正常', value: stats.active, color: 'green' },
|
||||
{ label: '停用', value: stats.inactive, color: 'muted' },
|
||||
{ label: '异常', value: stats.error, color: stats.error > 0 ? 'red' : 'muted' },
|
||||
]}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={toolbar}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={loadData} className="dg-btn-secondary">
|
||||
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={handleOpenCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
<Button size="sm" className="dg-btn-primary" onClick={handleOpenCreate}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加账号
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="搜索账号名称或AccessKey..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-slate-800 border-slate-700 text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selectedProvider}
|
||||
onChange={(e) => setSelectedProvider(e.target.value)}
|
||||
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded-md text-slate-100 text-sm"
|
||||
>
|
||||
<option value="all">全部平台</option>
|
||||
<option value="aws">AWS</option>
|
||||
<option value="aliyun">阿里云</option>
|
||||
<option value="tencent">腾讯云</option>
|
||||
<option value="huawei">华为云</option>
|
||||
<option value="azure">Azure</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Account List */}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-12 text-center text-slate-500">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-3" />
|
||||
加载中...
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PageLoading />
|
||||
) : filteredAccounts.length === 0 ? (
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-12 text-center text-slate-500">暂无云账号数据</CardContent>
|
||||
</Card>
|
||||
<PageEmpty
|
||||
icon={<Key className="h-12 w-12" />}
|
||||
title="暂无云账号数据"
|
||||
action={(
|
||||
<Button size="sm" className="dg-btn-primary" onClick={handleOpenCreate}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加账号
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{filteredAccounts.map((account) => {
|
||||
@@ -218,37 +236,37 @@ export function CloudAccountManager() {
|
||||
const lastSync = account.last_sync || account.lastSync || account.created_at || '-';
|
||||
const monthlyCost = account.monthly_cost ?? account.monthlyCost ?? 0;
|
||||
return (
|
||||
<Card key={account.id} className="bg-slate-800 border-slate-700">
|
||||
<Card key={account.id} className="dg-card dg-card-hover">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center" style={{ borderBottom: `2px solid ${getProviderColor(account.provider)}` }}>
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center" style={{ borderBottom: `2px solid ${getProviderColor(account.provider)}` }}>
|
||||
<CloudBrandIcon provider={account.provider} className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-100">{account.name}</h3>
|
||||
<p className="text-sm text-slate-400 mt-0.5">{providerLabels[account.provider] || account.provider}</p>
|
||||
<h3 className="font-medium text-[var(--dg-text-primary)]">{account.name}</h3>
|
||||
<p className="text-sm dg-text-secondary mt-0.5">{providerLabels[account.provider] || account.provider}</p>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
{accessKey && (
|
||||
<Badge variant="secondary" className="bg-slate-700 text-slate-300">
|
||||
<Badge variant="secondary" className="dg-badge-muted">
|
||||
<Key className="h-3 w-3 mr-1" />
|
||||
{accessKey.slice(0, 8)}...
|
||||
</Badge>
|
||||
)}
|
||||
{account.status === 'active' && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0">
|
||||
<Badge className="dg-status-running border-0">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
正常
|
||||
</Badge>
|
||||
)}
|
||||
{account.status === 'inactive' && (
|
||||
<Badge className="bg-slate-500/20 text-slate-400 border-0">
|
||||
<Badge className="dg-status-stopped border-0">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
已停用
|
||||
</Badge>
|
||||
)}
|
||||
{account.status === 'error' && (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0">
|
||||
<Badge className="dg-status-error border-0">
|
||||
<Key className="h-3 w-3 mr-1" />
|
||||
异常
|
||||
</Badge>
|
||||
@@ -260,7 +278,7 @@ export function CloudAccountManager() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400"
|
||||
className="h-8 w-8 dg-text-muted hover:text-[var(--dg-blue)]"
|
||||
onClick={() => handleOpenEdit(account)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
@@ -268,25 +286,25 @@ export function CloudAccountManager() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-400"
|
||||
className="h-8 w-8 dg-text-muted hover:text-[var(--dg-red)]"
|
||||
onClick={() => handleDelete(account.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-slate-700">
|
||||
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-[var(--dg-border)]">
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">月费用</p>
|
||||
<p className="text-lg font-medium text-emerald-400">¥{Number(monthlyCost).toLocaleString()}</p>
|
||||
<p className="text-xs dg-text-secondary">月费用</p>
|
||||
<p className="text-lg font-medium text-[var(--dg-green)]">¥{Number(monthlyCost).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">服务器数</p>
|
||||
<p className="text-lg font-medium text-slate-100">{serverCount}</p>
|
||||
<p className="text-xs dg-text-secondary">服务器数</p>
|
||||
<p className="text-lg font-medium text-[var(--dg-text-primary)]">{serverCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">最后同步</p>
|
||||
<p className="text-sm text-slate-300">
|
||||
<p className="text-xs dg-text-secondary">最后同步</p>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)]">
|
||||
{lastSync !== '-' ? new Date(lastSync).toLocaleString() : '-'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -300,13 +318,13 @@ export function CloudAccountManager() {
|
||||
|
||||
{/* 创建账号 Dialog */}
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700 text-slate-100 max-w-lg">
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Cloud className="h-5 w-5 text-blue-400" />
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Cloud className="h-5 w-5 text-[var(--dg-blue)]" />
|
||||
{editingAccount ? '编辑云账号' : '添加云账号'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
<DialogDescription>
|
||||
{editingAccount ? '修改账号信息和访问凭证,留空则保持原凭证不变' : '录入多云平台的访问凭证,用于资源同步与成本统计'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -314,24 +332,24 @@ export function CloudAccountManager() {
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">
|
||||
账号名称 <span className="text-red-400">*</span>
|
||||
<Label>
|
||||
账号名称 <span className="text-[var(--dg-red)]">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="阿里云生产账号"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="bg-slate-900 border-slate-700 text-slate-100 placeholder:text-slate-500"
|
||||
className="dg-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">
|
||||
云平台 <span className="text-red-400">*</span>
|
||||
<Label>
|
||||
云平台 <span className="text-[var(--dg-red)]">*</span>
|
||||
</Label>
|
||||
<select
|
||||
value={formData.provider}
|
||||
onChange={(e) => setFormData({ ...formData, provider: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded-md text-slate-100 text-sm"
|
||||
className="w-full px-3 py-2 rounded-md text-sm dg-input"
|
||||
>
|
||||
<option value="aliyun">阿里云</option>
|
||||
<option value="tencent">腾讯云</option>
|
||||
@@ -343,50 +361,50 @@ export function CloudAccountManager() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">
|
||||
<Label>
|
||||
AccessKey
|
||||
{editingAccount && <span className="text-xs text-slate-500 ml-2">留空保持不变</span>}
|
||||
{editingAccount && <span className="text-xs dg-text-muted ml-2">留空保持不变</span>}
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="LTAI..."
|
||||
value={formData.access_key}
|
||||
onChange={(e) => setFormData({ ...formData, access_key: e.target.value })}
|
||||
className="bg-slate-900 border-slate-700 text-slate-100 placeholder:text-slate-500 font-mono text-sm"
|
||||
className="dg-input font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">
|
||||
<Label>
|
||||
SecretKey
|
||||
{editingAccount && <span className="text-xs text-slate-500 ml-2">留空保持不变</span>}
|
||||
{editingAccount && <span className="text-xs dg-text-muted ml-2">留空保持不变</span>}
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={formData.secret_key}
|
||||
onChange={(e) => setFormData({ ...formData, secret_key: e.target.value })}
|
||||
className="bg-slate-900 border-slate-700 text-slate-100 placeholder:text-slate-500 font-mono text-sm"
|
||||
className="dg-input font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">月费用 (元)</Label>
|
||||
<Label>月费用 (元)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={formData.monthly_cost}
|
||||
onChange={(e) => setFormData({ ...formData, monthly_cost: e.target.value })}
|
||||
className="bg-slate-900 border-slate-700 text-slate-100 placeholder:text-slate-500"
|
||||
className="dg-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-300">状态</Label>
|
||||
<Label>状态</Label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded-md text-slate-100 text-sm"
|
||||
className="w-full px-3 py-2 rounded-md text-sm dg-input"
|
||||
>
|
||||
<option value="active">正常</option>
|
||||
<option value="inactive">已停用</option>
|
||||
@@ -397,11 +415,11 @@ export function CloudAccountManager() {
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)} className="border-slate-700 text-slate-300 hover:bg-slate-700">
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)} className="dg-btn-secondary">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
className="dg-btn-primary"
|
||||
disabled={!formData.name.trim() || submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
@@ -411,6 +429,6 @@ export function CloudAccountManager() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -17,17 +17,26 @@ import {
|
||||
Trash2,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Wallet,
|
||||
Bell,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { billingApi } from '@/api';
|
||||
import { formatCurrency, TrendChart, providerLabels, providerColors } from './CloudBillingHelpers';
|
||||
import {
|
||||
formatCurrency,
|
||||
TrendChart,
|
||||
providerLabels,
|
||||
providerHex,
|
||||
ProviderDot,
|
||||
} from './CloudBillingHelpers';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import { BillingRecordDialog } from './BillingRecordDialog';
|
||||
import { BillingRuleDialog } from './BillingRuleDialog';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { ContentPanel } from '@/components/layout/ContentPanel';
|
||||
import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
export function CloudBilling() {
|
||||
@@ -47,7 +56,6 @@ export function CloudBilling() {
|
||||
const [rules, setRules] = useState<JsonData[]>([]);
|
||||
const [rulesLoading, setRulesLoading] = useState(true);
|
||||
|
||||
// 弹窗状态(open + 编辑目标) // Logan.Li
|
||||
const [recordDialogOpen, setRecordDialogOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<JsonData>(null);
|
||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||
@@ -105,6 +113,12 @@ export function CloudBilling() {
|
||||
if (activeTab === 'alerts') loadRules();
|
||||
}, [activeTab, loadRules]);
|
||||
|
||||
const refreshAll = () => {
|
||||
loadStats();
|
||||
if (activeTab === 'records') loadRecords();
|
||||
if (activeTab === 'alerts') loadRules();
|
||||
};
|
||||
|
||||
const openAddRecord = () => {
|
||||
setEditingRecord(null);
|
||||
setRecordDialogOpen(true);
|
||||
@@ -160,176 +174,184 @@ export function CloudBilling() {
|
||||
}
|
||||
};
|
||||
|
||||
const pageTabs = [
|
||||
{ id: 'overview', label: '概览统计' },
|
||||
{ id: 'records', label: '成本记录' },
|
||||
{ id: 'alerts', label: '预警规则', 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">厂商</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">全部</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">开始日期</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">结束日期</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" />查询
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
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(); }}>
|
||||
<PageShell
|
||||
variant="list"
|
||||
title="云账单管理"
|
||||
subtitle="多云成本统计、记录与预警"
|
||||
icon={<Wallet className="h-5 w-5" />}
|
||||
tabs={pageTabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(id) => setActiveTab(id as typeof activeTab)}
|
||||
toolbar={activeTab === 'records' ? recordsToolbar : undefined}
|
||||
metrics={
|
||||
stats && !statsLoading ? (
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '今日', value: formatCurrency(stats.today_cost || 0), color: 'blue' },
|
||||
{ label: '本月', value: formatCurrency(stats.month_cost || 0), color: 'amber' },
|
||||
{ label: '年度', value: formatCurrency(stats.year_cost || 0), color: 'purple' },
|
||||
{ label: '预警', value: `${stats.alerts?.length || 0} 条`, color: stats.alerts?.length > 0 ? 'red' : 'muted' },
|
||||
]}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={refreshAll}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />刷新
|
||||
</Button>
|
||||
{activeTab === 'records' && (
|
||||
<Button size="sm" onClick={openAddRecord}>
|
||||
<Button size="sm" className="dg-btn-primary" onClick={openAddRecord}>
|
||||
<Plus className="h-4 w-4 mr-1" />记录成本
|
||||
</Button>
|
||||
)}
|
||||
{activeTab === 'alerts' && (
|
||||
<Button size="sm" onClick={openAddRule}>
|
||||
<Button size="sm" className="dg-btn-primary" 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>
|
||||
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{/* Tab 1:概览统计 */}
|
||||
{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 ? (
|
||||
<>
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '今日', value: formatCurrency(stats.today_cost || 0), color: 'blue' },
|
||||
{ label: '本月', value: formatCurrency(stats.month_cost || 0), color: 'amber' },
|
||||
{ label: '年度', value: formatCurrency(stats.year_cost || 0), color: 'purple' },
|
||||
{ label: '预警', value: `${stats.alerts?.length || 0} 条`, color: stats.alerts?.length > 0 ? 'red' : 'muted' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{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: JsonData, 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>
|
||||
statsLoading ? (
|
||||
<PageLoading />
|
||||
) : stats ? (
|
||||
<div className="space-y-4">
|
||||
{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">成本预警</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' ? '日' : '月'}成本
|
||||
</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: JsonData, 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: JsonData, 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>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
当前 <span className="dg-text-yellow font-medium">{formatCurrency(a.current)}</span>
|
||||
{' '}{a.operator}{' '}
|
||||
阈值 <span className="dg-text-red font-medium">{formatCurrency(a.threshold)}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardContent className="pt-4">
|
||||
<h3 className="dg-section-title">近 30 天成本趋势</h3>
|
||||
<TrendChart data={stats.month_trend || []} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardContent className="pt-4">
|
||||
<h3 className="dg-section-title">本月厂商成本分布</h3>
|
||||
{(stats.provider_dist || []).length === 0 ? (
|
||||
<PageEmpty title="暂无分布数据" 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="暂无统计数据" />
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Tab 2:成本记录 */}
|
||||
{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>
|
||||
|
||||
<ContentPanel noPadding>
|
||||
{recordsLoading ? (
|
||||
<div className="flex items-center justify-center py-20 text-slate-500"><Loader2 className="h-6 w-6 animate-spin mr-2" />加载中...</div>
|
||||
<PageLoading />
|
||||
) : 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>
|
||||
<PageEmpty
|
||||
icon={<Wallet className="h-12 w-12" />}
|
||||
title="暂无成本记录"
|
||||
action={(
|
||||
<Button size="sm" className="dg-btn-primary" onClick={openAddRecord}>
|
||||
<Plus className="h-4 w-4 mr-1" />记录成本
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-800">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-900/80 text-slate-400">
|
||||
<thead className="bg-[var(--surface-raised)] dg-text-secondary">
|
||||
<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>
|
||||
@@ -343,25 +365,25 @@ export function CloudBilling() {
|
||||
</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>
|
||||
<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">
|
||||
<span className={`w-2 h-2 rounded-full ${providerColors[r.provider] || 'bg-slate-500'}`} />
|
||||
<ProviderDot provider={r.provider} />
|
||||
{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 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 text-red-400 hover:text-red-300" onClick={() => handleDeleteRecord(r.id)}>
|
||||
<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>
|
||||
@@ -372,64 +394,69 @@ export function CloudBilling() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ContentPanel>
|
||||
)}
|
||||
|
||||
{/* Tab 3:预警规则 */}
|
||||
{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)} />
|
||||
rulesLoading ? (
|
||||
<PageLoading />
|
||||
) : rules.length === 0 ? (
|
||||
<PageEmpty
|
||||
icon={<Bell className="h-12 w-12" />}
|
||||
title="暂无预警规则"
|
||||
description="添加规则后,成本超阈值将在此页管理"
|
||||
action={(
|
||||
<Button size="sm" className="dg-btn-primary" onClick={openAddRule}>
|
||||
<Plus className="h-4 w-4 mr-1" />添加规则
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
<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">厂商</span>
|
||||
<span className="ml-2 text-[var(--text-secondary)]">{r.provider ? (providerLabels[r.provider] || r.provider) : '全部'}</span>
|
||||
</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>
|
||||
<span className="dg-text-muted">指标</span>
|
||||
<span className="ml-2 text-[var(--text-secondary)]">{r.metric === 'daily' ? '日成本' : '月累计'}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="dg-text-muted">阈值</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">最后触发</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
|
||||
@@ -444,6 +471,6 @@ export function CloudBilling() {
|
||||
editingRule={editingRule}
|
||||
onSuccess={() => { loadRules(); loadStats(); }}
|
||||
/>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,16 @@ export const providerLabels: Record<string, string> = {
|
||||
azure: 'Azure',
|
||||
};
|
||||
|
||||
// 云厂商品牌色(主组件 + 弹窗共享) // Logan.Li
|
||||
// 云厂商品牌色(跨主题固定,用 hex 避免 Tailwind 硬编码) // Logan.Li
|
||||
export const providerHex: Record<string, string> = {
|
||||
aliyun: '#FF6A00',
|
||||
tencent: '#006EFF',
|
||||
aws: '#FF9900',
|
||||
huawei: '#CF0A2C',
|
||||
azure: '#0078D4',
|
||||
};
|
||||
|
||||
/** @deprecated 请改用 providerHex + style */
|
||||
export const providerColors: Record<string, string> = {
|
||||
aliyun: 'bg-orange-500',
|
||||
tencent: 'bg-blue-500',
|
||||
@@ -19,6 +28,15 @@ export const providerColors: Record<string, string> = {
|
||||
azure: 'bg-sky-500',
|
||||
};
|
||||
|
||||
export function ProviderDot({ provider, className = 'w-2 h-2' }: { provider: string; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full shrink-0 ${className}`}
|
||||
style={{ backgroundColor: providerHex[provider] || 'var(--surface-active)' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCurrency(n: number) {
|
||||
return `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
@@ -32,17 +50,17 @@ export function StatCard({ icon, label, value, trend, trendLabel, alert }: {
|
||||
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={alert ? 'dg-alert-banner' : 'rounded-lg border border-[var(--border-default)] bg-[var(--surface-raised)]/50 p-4'}>
|
||||
<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>
|
||||
<span className="dg-text-secondary text-sm">{label}</span>
|
||||
<span className={alert ? 'dg-text-yellow' : 'dg-text-muted'}>{icon}</span>
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${alert ? 'text-amber-300' : 'text-slate-100'}`}>{value}</div>
|
||||
<div className={`text-2xl font-bold ${alert ? 'dg-text-yellow' : 'text-[var(--text-primary)]'}`}>{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>
|
||||
{trend !== undefined && trend > 0 && <TrendingUp className="h-3 w-3 dg-text-red" />}
|
||||
{trend !== undefined && trend < 0 && <TrendingDown className="h-3 w-3 dg-text-green" />}
|
||||
<span className="dg-text-muted">{trendLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -51,7 +69,7 @@ export function StatCard({ icon, label, value, trend, trendLabel, alert }: {
|
||||
|
||||
export 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>;
|
||||
return <p className="text-sm dg-text-muted py-8 text-center">暂无数据</p>;
|
||||
}
|
||||
const max = Math.max(...data.map((d) => d.cost), 1);
|
||||
const recent = data.slice(-30);
|
||||
@@ -68,20 +86,23 @@ export function TrendChart({ data }: { data: { date: string; cost: number }[] })
|
||||
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}%` }}
|
||||
className="absolute bottom-0 w-full rounded-t-sm transition-opacity group-hover:opacity-90"
|
||||
style={{
|
||||
height: `${h}%`,
|
||||
background: `linear-gradient(to top, color-mix(in srgb, var(--brand) 55%, transparent), color-mix(in srgb, var(--brand) 85%, transparent))`,
|
||||
}}
|
||||
/>
|
||||
<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">
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block whitespace-nowrap text-xs bg-[var(--surface-inset)] text-[var(--text-primary)] px-2 py-1 rounded border border-[var(--border-default)] z-10">
|
||||
{d.date}: {formatCurrency(d.cost)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-slate-500">
|
||||
<div className="flex justify-between mt-2 text-xs dg-text-muted">
|
||||
<span>{recent[0]?.date}</span>
|
||||
<span>{recent[recent.length - 1]?.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@ import {
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { cloudApi } from '@/api';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
export function DomainManager() {
|
||||
@@ -145,7 +149,7 @@ export function DomainManager() {
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const _stats = {
|
||||
const stats = {
|
||||
total: domains.length,
|
||||
active: domains.filter(d => d.status === 'active').length,
|
||||
expired: domains.filter(d => d.status === 'expired').length,
|
||||
@@ -159,61 +163,75 @@ export function DomainManager() {
|
||||
return days;
|
||||
};
|
||||
|
||||
const toolbar = (
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
||||
<Input
|
||||
placeholder="搜索域名..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 h-9 dg-input"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||
className="px-3 py-2 h-9 rounded-md text-sm dg-input"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="active">正常</option>
|
||||
<option value="expired">已过期</option>
|
||||
<option value="pending">待审核</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">域名管理</h1>
|
||||
<p className="text-slate-400 mt-1">管理域名注册信息和DNS解析记录</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={loadData} className="border-slate-700 text-slate-300">
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
<PageShell
|
||||
variant="list"
|
||||
title="域名管理"
|
||||
subtitle="管理域名注册信息和DNS解析记录"
|
||||
icon={<Globe className="h-5 w-5" />}
|
||||
metrics={
|
||||
!loading ? (
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '总计', value: stats.total, color: 'blue' },
|
||||
{ label: '正常', value: stats.active, color: 'green' },
|
||||
{ label: '已过期', value: stats.expired, color: stats.expired > 0 ? 'red' : 'muted' },
|
||||
{ label: 'SSL', value: stats.sslEnabled, color: 'cyan' },
|
||||
]}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={toolbar}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={loadData} className="dg-btn-secondary">
|
||||
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={handleOpenCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
<Button size="sm" className="dg-btn-primary" onClick={handleOpenCreate}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加域名
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="搜索域名..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-slate-800 border-slate-700 text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded-md text-slate-100 text-sm"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="active">正常</option>
|
||||
<option value="expired">已过期</option>
|
||||
<option value="pending">待审核</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Domain List */}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-12 text-center text-slate-500">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-3" />
|
||||
加载中...
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PageLoading />
|
||||
) : filteredDomains.length === 0 ? (
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-12 text-center text-slate-500">暂无域名数据</CardContent>
|
||||
</Card>
|
||||
<PageEmpty
|
||||
icon={<Globe className="h-12 w-12" />}
|
||||
title="暂无域名数据"
|
||||
action={(
|
||||
<Button size="sm" className="dg-btn-primary" onClick={handleOpenCreate}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加域名
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredDomains.map((domain) => {
|
||||
@@ -222,42 +240,42 @@ export function DomainManager() {
|
||||
const sslExpireDate = domain.ssl_expire_date || domain.sslExpireDate || '';
|
||||
const daysUntilExpiry = getDaysUntilExpiry(expireDate);
|
||||
return (
|
||||
<Card key={domain.id} className="bg-slate-800 border-slate-700">
|
||||
<Card key={domain.id} className="dg-card dg-card-hover">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center">
|
||||
<Globe className="h-5 w-5 text-white" />
|
||||
<div className="dg-icon-box">
|
||||
<Globe className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-slate-100 text-lg">{domain.name}</h3>
|
||||
<h3 className="font-medium text-[var(--dg-text-primary)] text-lg">{domain.name}</h3>
|
||||
{domain.status === 'active' && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0">
|
||||
<Badge className="dg-status-running border-0">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
正常
|
||||
</Badge>
|
||||
)}
|
||||
{domain.status === 'expired' && (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0">
|
||||
<Badge className="dg-status-error border-0">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
已过期
|
||||
</Badge>
|
||||
)}
|
||||
{domain.status === 'pending' && (
|
||||
<Badge className="bg-yellow-500/20 text-yellow-400 border-0">
|
||||
<Badge className="dg-status-warning border-0">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
待审核
|
||||
</Badge>
|
||||
)}
|
||||
{sslEnabled && (
|
||||
<Badge className="bg-cyan-500/20 text-cyan-400 border-0">
|
||||
<Badge className="dg-badge-cyan border-0">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
SSL
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-1 text-sm text-slate-400">
|
||||
<div className="flex items-center gap-4 mt-1 text-sm dg-text-secondary">
|
||||
<span>注册商: {domain.registrar || '-'}</span>
|
||||
<span>DNS: {domain.dns_provider || domain.dnsProvider || '-'}</span>
|
||||
<span>解析记录: {domain.record_count ?? domain.recordCount ?? 0}条</span>
|
||||
@@ -266,11 +284,11 @@ export function DomainManager() {
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-slate-400">到期时间</p>
|
||||
<p className={`text-sm font-medium ${daysUntilExpiry < 30 ? 'text-red-400' : 'text-slate-200'}`}>
|
||||
<p className="text-sm dg-text-secondary">到期时间</p>
|
||||
<p className={`text-sm font-medium ${daysUntilExpiry < 30 ? 'text-[var(--dg-red)]' : 'text-[var(--dg-text-primary)]'}`}>
|
||||
{expireDate ? expireDate.substring(0, 10) : '-'}{expireDate && ` (${daysUntilExpiry}天)`}
|
||||
{sslEnabled && sslExpireDate && (
|
||||
<span className="text-slate-500"> · SSL: {sslExpireDate.substring(0, 10)}</span>
|
||||
<span className="dg-text-muted"> · SSL: {sslExpireDate.substring(0, 10)}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -278,7 +296,7 @@ export function DomainManager() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-blue-400"
|
||||
className="h-8 w-8 dg-text-muted hover:text-[var(--dg-blue)]"
|
||||
onClick={() => handleOpenEdit(domain)}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
@@ -286,7 +304,7 @@ export function DomainManager() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-400"
|
||||
className="h-8 w-8 dg-text-muted hover:text-[var(--dg-red)]"
|
||||
onClick={() => handleDelete(domain.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -303,44 +321,44 @@ export function DomainManager() {
|
||||
|
||||
{/* 创建/编辑域名 Dialog */}
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700 text-slate-100 max-w-lg">
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-blue-400" />
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-[var(--dg-blue)]" />
|
||||
{editingDomain ? '编辑域名' : '添加域名'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
<DialogDescription>
|
||||
{editingDomain ? '修改域名注册信息、DNS 配置和 SSL 证书状态' : '录入域名信息,包括注册商、DNS 服务商和到期时间'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">域名 *</Label>
|
||||
<Label>域名 *</Label>
|
||||
<Input
|
||||
placeholder="example.com"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="bg-slate-900 border-slate-700 text-slate-100"
|
||||
className="dg-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">注册商</Label>
|
||||
<Label>注册商</Label>
|
||||
<Input
|
||||
placeholder="阿里云 / Cloudflare ..."
|
||||
value={formData.registrar}
|
||||
onChange={(e) => setFormData({ ...formData, registrar: e.target.value })}
|
||||
className="bg-slate-900 border-slate-700 text-slate-100"
|
||||
className="dg-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">状态</Label>
|
||||
<Label>状态</Label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded-md text-slate-100 text-sm"
|
||||
className="w-full px-3 py-2 rounded-md text-sm dg-input"
|
||||
>
|
||||
<option value="active">正常</option>
|
||||
<option value="expired">已过期</option>
|
||||
@@ -351,45 +369,45 @@ export function DomainManager() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">到期时间</Label>
|
||||
<Label>到期时间</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.expire_date}
|
||||
onChange={(e) => setFormData({ ...formData, expire_date: e.target.value })}
|
||||
className="bg-slate-900 border-slate-700 text-slate-100"
|
||||
className="dg-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">DNS 服务商</Label>
|
||||
<Label>DNS 服务商</Label>
|
||||
<Input
|
||||
placeholder="Cloudflare / DNSPod ..."
|
||||
value={formData.dns_provider}
|
||||
onChange={(e) => setFormData({ ...formData, dns_provider: e.target.value })}
|
||||
className="bg-slate-900 border-slate-700 text-slate-100"
|
||||
className="dg-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">解析记录数</Label>
|
||||
<Label>解析记录数</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={formData.record_count}
|
||||
onChange={(e) => setFormData({ ...formData, record_count: e.target.value })}
|
||||
className="bg-slate-900 border-slate-700 text-slate-100"
|
||||
className="dg-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 items-end">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">SSL 证书到期</Label>
|
||||
<Label>SSL 证书到期</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.ssl_expire_date}
|
||||
onChange={(e) => setFormData({ ...formData, ssl_expire_date: e.target.value })}
|
||||
disabled={!formData.ssl_enabled}
|
||||
className="bg-slate-900 border-slate-700 text-slate-100 disabled:opacity-40"
|
||||
className="dg-input disabled:opacity-40"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer pb-2">
|
||||
@@ -397,19 +415,19 @@ export function DomainManager() {
|
||||
type="checkbox"
|
||||
checked={formData.ssl_enabled}
|
||||
onChange={(e) => setFormData({ ...formData, ssl_enabled: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-slate-600 bg-slate-900 accent-cyan-500"
|
||||
className="h-4 w-4 rounded border-[var(--dg-border)] accent-[var(--dg-cyan)]"
|
||||
/>
|
||||
<span className="text-slate-300 text-sm">启用 SSL</span>
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">启用 SSL</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)} className="border-slate-700 text-slate-300">
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)} className="dg-btn-secondary">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
className="dg-btn-primary"
|
||||
disabled={!formData.name.trim() || submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
@@ -419,6 +437,6 @@ export function DomainManager() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -28,8 +28,13 @@ import {
|
||||
XCircle,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { platformApi } from '@/api';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
@@ -39,9 +44,9 @@ const typeLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
public: 'text-blue-400',
|
||||
private: 'text-purple-400',
|
||||
hybrid: 'text-cyan-400',
|
||||
public: 'text-[var(--dg-blue)]',
|
||||
private: 'text-[var(--dg-purple)]',
|
||||
hybrid: 'text-[var(--dg-cyan)]',
|
||||
};
|
||||
|
||||
export function PlatformManager() {
|
||||
@@ -121,94 +126,127 @@ export function PlatformManager() {
|
||||
loadData();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="relative w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索平台名称或编码..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<Select value={selectedType} onValueChange={setSelectedType}>
|
||||
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="public">公有云</SelectItem>
|
||||
<SelectItem value="private">私有云</SelectItem>
|
||||
<SelectItem value="hybrid">混合云</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加平台
|
||||
</Button>
|
||||
</div>
|
||||
const stats = {
|
||||
total: platforms.length,
|
||||
active: platforms.filter(p => p.status === 'active').length,
|
||||
public: platforms.filter(p => p.type === 'public').length,
|
||||
};
|
||||
|
||||
{/* List */}
|
||||
const toolbar = (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
||||
<Input
|
||||
placeholder="搜索平台名称或编码..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9 dg-input"
|
||||
/>
|
||||
</div>
|
||||
<Select value={selectedType} onValueChange={setSelectedType}>
|
||||
<SelectTrigger className="w-32 h-9 dg-input">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="public">公有云</SelectItem>
|
||||
<SelectItem value="private">私有云</SelectItem>
|
||||
<SelectItem value="hybrid">混合云</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title="云平台管理"
|
||||
subtitle="管理公有云、私有云与混合云平台信息"
|
||||
icon={<CloudCog className="h-5 w-5" />}
|
||||
metrics={
|
||||
!loading ? (
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '总计', value: stats.total, color: 'blue' },
|
||||
{ label: '启用', value: stats.active, color: 'green' },
|
||||
{ label: '公有云', value: stats.public, color: 'cyan' },
|
||||
]}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={toolbar}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadData}>
|
||||
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button size="sm" className="dg-btn-primary" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加平台
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
|
||||
</div>
|
||||
<PageLoading />
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-20 text-slate-500">
|
||||
<CloudCog className="h-12 w-12 mx-auto mb-3 opacity-40" />
|
||||
<p>暂无云平台数据</p>
|
||||
</div>
|
||||
<PageEmpty
|
||||
icon={<CloudCog className="h-12 w-12" />}
|
||||
title="暂无云平台数据"
|
||||
action={(
|
||||
<Button size="sm" className="dg-btn-primary" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加平台
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="bg-slate-800/30 rounded-lg p-4 hover:bg-slate-800/50 transition-colors group"
|
||||
className="dg-card dg-card-hover p-4 group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||
<CloudCog className="h-5 w-5 text-blue-400" />
|
||||
<div className="w-10 h-10 rounded-lg bg-[var(--dg-blue)]/15 flex items-center justify-center">
|
||||
<CloudCog className="h-5 w-5 text-[var(--dg-blue)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-100">{p.name}</h3>
|
||||
<span className="text-xs text-slate-500 font-mono">{p.code}</span>
|
||||
<h3 className="font-medium text-[var(--dg-text-primary)]">{p.name}</h3>
|
||||
<span className="text-xs dg-text-muted font-mono">{p.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{p.website && (
|
||||
<a href={p.website} target="_blank" rel="noreferrer" className="p-1.5 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200">
|
||||
<a href={p.website} target="_blank" rel="noreferrer" className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] dg-text-muted hover:text-[var(--dg-text-primary)]">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
<button onClick={() => openEdit(p)} className="p-1.5 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200">
|
||||
<button onClick={() => openEdit(p)} className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] dg-text-muted hover:text-[var(--dg-text-primary)]">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(p.id)} className="p-1.5 rounded hover:bg-slate-700 text-slate-400 hover:text-red-400">
|
||||
<button onClick={() => handleDelete(p.id)} className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] dg-text-muted hover:text-[var(--dg-red)]">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{p.description && (
|
||||
<p className="text-sm text-slate-400 mb-3 line-clamp-2">{p.description}</p>
|
||||
<p className="text-sm dg-text-secondary mb-3 line-clamp-2">{p.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className={typeColors[p.type] || 'text-slate-400'}>
|
||||
<Badge variant="outline" className={typeColors[p.type] || 'dg-text-muted'}>
|
||||
{typeLabels[p.type] || p.type}
|
||||
</Badge>
|
||||
{p.status === 'active' ? (
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 gap-1">
|
||||
<Badge className="dg-status-running gap-1 border-0">
|
||||
<CheckCircle2 className="h-3 w-3" /> 启用
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-slate-500/20 text-slate-400 gap-1">
|
||||
<Badge className="dg-status-stopped gap-1 border-0">
|
||||
<XCircle className="h-3 w-3" /> 禁用
|
||||
</Badge>
|
||||
)}
|
||||
@@ -233,7 +271,7 @@ export function PlatformManager() {
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="如:阿里云"
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
className="dg-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -242,7 +280,7 @@ export function PlatformManager() {
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||
placeholder="如:aliyun"
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
className="dg-input"
|
||||
disabled={!!editingItem}
|
||||
/>
|
||||
</div>
|
||||
@@ -251,7 +289,7 @@ export function PlatformManager() {
|
||||
<div className="space-y-1.5">
|
||||
<Label>类型</Label>
|
||||
<Select value={formData.type} onValueChange={(v) => setFormData({ ...formData, type: v })}>
|
||||
<SelectTrigger className="bg-slate-800/50 border-slate-700">
|
||||
<SelectTrigger className="dg-input">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -264,7 +302,7 @@ export function PlatformManager() {
|
||||
<div className="space-y-1.5">
|
||||
<Label>状态</Label>
|
||||
<Select value={formData.status} onValueChange={(v) => setFormData({ ...formData, status: v })}>
|
||||
<SelectTrigger className="bg-slate-800/50 border-slate-700">
|
||||
<SelectTrigger className="dg-input">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -280,7 +318,7 @@ export function PlatformManager() {
|
||||
value={formData.website}
|
||||
onChange={(e) => setFormData({ ...formData, website: e.target.value })}
|
||||
placeholder="https://..."
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
className="dg-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -289,7 +327,7 @@ export function PlatformManager() {
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="平台描述..."
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
className="dg-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,13 +348,13 @@ export function PlatformManager() {
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription className="sr-only">描述</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-slate-400">确定删除此云平台?此操作不可撤销。</p>
|
||||
<p className="text-sm dg-text-secondary">确定删除此云平台?此操作不可撤销。</p>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDeleteId(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>删除</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -27,10 +27,16 @@ import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { regionApi } from '@/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { ContentPanel } from '@/components/layout/ContentPanel';
|
||||
import { PageLoading } from '@/components/layout/PageLoading';
|
||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
const providerNames: Record<string, string> = {
|
||||
@@ -42,11 +48,11 @@ const providerNames: Record<string, string> = {
|
||||
};
|
||||
|
||||
const providerColors: Record<string, string> = {
|
||||
aws: 'text-orange-400',
|
||||
aliyun: 'text-orange-400',
|
||||
tencent: 'text-blue-400',
|
||||
huawei: 'text-red-400',
|
||||
azure: 'text-blue-400',
|
||||
aws: 'dg-text-orange',
|
||||
aliyun: 'dg-text-orange',
|
||||
tencent: 'dg-text-blue',
|
||||
huawei: 'dg-text-red',
|
||||
azure: 'dg-text-blue',
|
||||
};
|
||||
|
||||
export function RegionManager() {
|
||||
@@ -171,9 +177,9 @@ export function RegionManager() {
|
||||
{
|
||||
key: 'status', header: '状态', sortable: true,
|
||||
render: (r) => r.status === 'active' ? (
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 gap-1"><CheckCircle2 className="h-3 w-3" /> 启用</Badge>
|
||||
<Badge className="dg-status-running gap-1 border-0"><CheckCircle2 className="h-3 w-3" /> 启用</Badge>
|
||||
) : (
|
||||
<Badge className="bg-slate-500/20 text-slate-400 gap-1"><XCircle className="h-3 w-3" /> 禁用</Badge>
|
||||
<Badge className="dg-status-stopped gap-1 border-0"><XCircle className="h-3 w-3" /> 禁用</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -183,7 +189,7 @@ export function RegionManager() {
|
||||
<button onClick={() => openEdit(r)} className="p-1.5 rounded hover:bg-[var(--dg-bg-active)] text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(r.id)} className="p-1.5 rounded hover:bg-[var(--dg-bg-active)] text-[var(--dg-text-muted)] hover:text-red-400">
|
||||
<button onClick={() => handleDelete(r.id)} className="p-1.5 rounded hover:bg-[var(--dg-bg-active)] text-[var(--dg-text-muted)] hover:dg-text-red">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -191,74 +197,105 @@ export function RegionManager() {
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* 批量操作栏 */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-2.5 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">
|
||||
已选择 <span className="font-semibold text-[var(--dg-text-primary)]">{selectedIds.length}</span> 个区域
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedIds([])} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
|
||||
取消选择
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||
onClick={handleBatchDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1.5" />
|
||||
批量删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
const stats = {
|
||||
total: regions.length,
|
||||
active: regions.filter(r => r.status === 'active').length,
|
||||
inactive: regions.filter(r => r.status !== 'active').length,
|
||||
};
|
||||
|
||||
{/* DataTable */}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filtered}
|
||||
rowKey={(r) => r.id}
|
||||
loading={loading}
|
||||
searchable
|
||||
searchPlaceholder="搜索区域名称或编码..."
|
||||
searchKeys={['name', 'code']}
|
||||
sortable
|
||||
selectable
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
columnToggle
|
||||
exportable
|
||||
exportFilename="regions.csv"
|
||||
emptyText="暂无区域数据"
|
||||
emptyRender={() => (
|
||||
<div className="flex flex-col items-center gap-3 py-8">
|
||||
<MapPin className="h-12 w-12 text-[var(--dg-text-muted)] opacity-40" />
|
||||
<p className="text-[var(--dg-text-muted)]">暂无区域数据</p>
|
||||
</div>
|
||||
)}
|
||||
toolbarExtra={
|
||||
<>
|
||||
<Select value={selectedProvider} onValueChange={setSelectedProvider}>
|
||||
<SelectTrigger className="w-32 h-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部云商</SelectItem>
|
||||
{Object.entries(providerNames).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={openCreate} size="sm" className="h-8 gap-1.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
const toolbar = (
|
||||
<Select value={selectedProvider} onValueChange={setSelectedProvider}>
|
||||
<SelectTrigger className="w-32 h-9 dg-input">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部云商</SelectItem>
|
||||
{Object.entries(providerNames).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
const batchToolbar = selectedIds.length > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm dg-text-secondary">
|
||||
已选择 <span className="font-semibold text-[var(--text-primary)]">{selectedIds.length}</span> 个区域
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedIds([])} className="dg-text-muted">
|
||||
取消选择
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="dg-btn-danger" onClick={handleBatchDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
批量删除
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title="区域管理"
|
||||
subtitle="管理各云厂商的区域与接入端点"
|
||||
icon={<MapPin className="h-5 w-5" />}
|
||||
metrics={
|
||||
!loading ? (
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: '总计', value: stats.total, color: 'blue' },
|
||||
{ label: '启用', value: stats.active, color: 'green' },
|
||||
{ label: '禁用', value: stats.inactive, color: 'muted' },
|
||||
]}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={toolbar}
|
||||
actions={(
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={loadData}>
|
||||
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Button size="sm" className="dg-btn-primary" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加区域
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<PageLoading />
|
||||
) : filtered.length === 0 ? (
|
||||
<PageEmpty
|
||||
icon={<MapPin className="h-12 w-12" />}
|
||||
title="暂无区域数据"
|
||||
action={(
|
||||
<Button size="sm" className="dg-btn-primary" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加区域
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ContentPanel toolbar={batchToolbar}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filtered}
|
||||
rowKey={(r) => r.id}
|
||||
searchable
|
||||
searchPlaceholder="搜索区域名称或编码..."
|
||||
searchKeys={['name', 'code']}
|
||||
sortable
|
||||
selectable
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
columnToggle
|
||||
exportable
|
||||
exportFilename="regions.csv"
|
||||
emptyText="暂无匹配区域"
|
||||
/>
|
||||
</ContentPanel>
|
||||
)}
|
||||
|
||||
{/* Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
@@ -275,7 +312,7 @@ export function RegionManager() {
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="如:华北-北京"
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
className="dg-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -284,7 +321,7 @@ export function RegionManager() {
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||
placeholder="如:cn-north-1"
|
||||
className="bg-slate-800/50 border-slate-700 font-mono"
|
||||
className="dg-input font-mono"
|
||||
disabled={!!editingItem}
|
||||
/>
|
||||
</div>
|
||||
@@ -293,7 +330,7 @@ export function RegionManager() {
|
||||
<div className="space-y-1.5">
|
||||
<Label>云商</Label>
|
||||
<Select value={formData.provider} onValueChange={(v) => setFormData({ ...formData, provider: v })}>
|
||||
<SelectTrigger className="bg-slate-800/50 border-slate-700">
|
||||
<SelectTrigger className="dg-input">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -306,7 +343,7 @@ export function RegionManager() {
|
||||
<div className="space-y-1.5">
|
||||
<Label>状态</Label>
|
||||
<Select value={formData.status} onValueChange={(v) => setFormData({ ...formData, status: v })}>
|
||||
<SelectTrigger className="bg-slate-800/50 border-slate-700">
|
||||
<SelectTrigger className="dg-input">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -322,7 +359,7 @@ export function RegionManager() {
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||
placeholder="如:中国北京"
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
className="dg-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -331,7 +368,7 @@ export function RegionManager() {
|
||||
value={formData.endpoint}
|
||||
onChange={(e) => setFormData({ ...formData, endpoint: e.target.value })}
|
||||
placeholder="如:ecs.cn-north-1.aliyuncs.com"
|
||||
className="bg-slate-800/50 border-slate-700 font-mono text-sm"
|
||||
className="dg-input font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,6 +381,6 @@ export function RegionManager() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user