refactor(quality): Q23 前端巨型组件拆分 — CloudAccountManager 572L→308L (-46%), 提取 cloud-account/ 3 个文件 (types(AccountFormData/AccountStats/ActionLoading + 6 个纯函数 helper) + AccountCard(178L 受控展示组件) + AccountFormDialog(162L 创建/编辑表单 Dialog)), catch 类型安全升级 (e?.message→e instanceof Error), 零逻辑变更, ESLint errors=0/warnings=0/any=0, tsc -b=0, npm build✓ (17.14s), 17/17 测试通过
This commit is contained in:
1 parent
e14b6d0e10
commit
796e6592c2
4 files changed
+484
-322
No files matched your search
@@ -2,35 +2,9 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Plus, Search, Key, RefreshCw } from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Trash2,
|
|
||||||
Pencil,
|
|
||||||
Key,
|
|
||||||
Cloud,
|
|
||||||
CheckCircle2,
|
|
||||||
XCircle,
|
|
||||||
RefreshCw,
|
|
||||||
Loader2,
|
|
||||||
MapPin,
|
|
||||||
Wallet,
|
|
||||||
CloudDownload,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cloudApi } from '@/api';
|
import { cloudApi } from '@/api';
|
||||||
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
|
|
||||||
import { CloudAccountDetailSheet } from './CloudAccountDetailSheet';
|
import { CloudAccountDetailSheet } from './CloudAccountDetailSheet';
|
||||||
import { CloudServerImport } from '@/components/server/CloudServerImport';
|
import { CloudServerImport } from '@/components/server/CloudServerImport';
|
||||||
import { PageShell } from '@/components/layout/PageShell';
|
import { PageShell } from '@/components/layout/PageShell';
|
||||||
@@ -38,17 +12,17 @@ import { PageLoading } from '@/components/layout/PageLoading';
|
|||||||
import { PageEmpty } from '@/components/layout/PageEmpty';
|
import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||||
import { StatStrip } from '@/components/ui/stat-strip';
|
import { StatStrip } from '@/components/ui/stat-strip';
|
||||||
import type { JsonData } from '@/types/common';
|
import type { JsonData } from '@/types/common';
|
||||||
|
import {
|
||||||
function getProviderLabels(t: (key: string) => string): Record<string, string> {
|
type AccountFormData,
|
||||||
return {
|
type ActionLoading,
|
||||||
aws: 'AWS',
|
getInitialFormData,
|
||||||
aliyun: t('cloud.aliyun'),
|
buildFormDataFromAccount,
|
||||||
tencent: t('cloud.tencent'),
|
computeAccountStats,
|
||||||
huawei: t('cloud.huawei'),
|
filterAccounts,
|
||||||
azure: 'Azure',
|
getProviderLabels,
|
||||||
gcp: 'GCP',
|
} from './cloud-account/types';
|
||||||
};
|
import { AccountCard } from './cloud-account/AccountCard';
|
||||||
}
|
import { AccountFormDialog } from './cloud-account/AccountFormDialog';
|
||||||
|
|
||||||
export function CloudAccountManager() {
|
export function CloudAccountManager() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -60,20 +34,13 @@ export function CloudAccountManager() {
|
|||||||
// 创建/编辑账号表单状态
|
// 创建/编辑账号表单状态
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [editingAccount, setEditingAccount] = useState<JsonData>(null);
|
const [editingAccount, setEditingAccount] = useState<JsonData | null>(null);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState<AccountFormData>(getInitialFormData());
|
||||||
name: '',
|
const [detailAccount, setDetailAccount] = useState<JsonData | null>(null);
|
||||||
provider: 'aliyun',
|
|
||||||
access_key: '',
|
|
||||||
secret_key: '',
|
|
||||||
monthly_cost: '',
|
|
||||||
status: 'active',
|
|
||||||
});
|
|
||||||
const [detailAccount, setDetailAccount] = useState<JsonData>(null);
|
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [importAccountId, setImportAccountId] = useState<number | string | undefined>();
|
const [importAccountId, setImportAccountId] = useState<number | string | undefined>();
|
||||||
const [actionLoading, setActionLoading] = useState<Record<string, string>>({});
|
const [actionLoading, setActionLoading] = useState<ActionLoading>({});
|
||||||
|
|
||||||
const providerLabels = getProviderLabels(t);
|
const providerLabels = getProviderLabels(t);
|
||||||
|
|
||||||
@@ -104,7 +71,9 @@ export function CloudAccountManager() {
|
|||||||
toast.success(t('cloud.syncRegionsSuccess'));
|
toast.success(t('cloud.syncRegionsSuccess'));
|
||||||
loadData();
|
loadData();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(t('cloud.syncRegionsFailed'), { description: (e as JsonData).message });
|
toast.error(t('cloud.syncRegionsFailed'), {
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setCardAction(account.id, null);
|
setCardAction(account.id, null);
|
||||||
}
|
}
|
||||||
@@ -128,7 +97,9 @@ export function CloudAccountManager() {
|
|||||||
}
|
}
|
||||||
loadData();
|
loadData();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(t('cloud.syncBillingFailed'), { description: (e as JsonData).message });
|
toast.error(t('cloud.syncBillingFailed'), {
|
||||||
|
description: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setCardAction(account.id, null);
|
setCardAction(account.id, null);
|
||||||
}
|
}
|
||||||
@@ -136,14 +107,7 @@ export function CloudAccountManager() {
|
|||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setEditingAccount(null);
|
setEditingAccount(null);
|
||||||
setFormData({
|
setFormData(getInitialFormData());
|
||||||
name: '',
|
|
||||||
provider: 'aliyun',
|
|
||||||
access_key: '',
|
|
||||||
secret_key: '',
|
|
||||||
monthly_cost: '',
|
|
||||||
status: 'active',
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenCreate = () => {
|
const handleOpenCreate = () => {
|
||||||
@@ -153,14 +117,7 @@ export function CloudAccountManager() {
|
|||||||
|
|
||||||
const handleOpenEdit = (account: JsonData) => {
|
const handleOpenEdit = (account: JsonData) => {
|
||||||
setEditingAccount(account);
|
setEditingAccount(account);
|
||||||
setFormData({
|
setFormData(buildFormDataFromAccount(account));
|
||||||
name: account.name || '',
|
|
||||||
provider: account.provider || 'aliyun',
|
|
||||||
access_key: '',
|
|
||||||
secret_key: '',
|
|
||||||
monthly_cost: String(account.monthly_cost ?? 0),
|
|
||||||
status: account.status || 'active',
|
|
||||||
});
|
|
||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,20 +172,8 @@ export function CloudAccountManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredAccounts = accounts.filter(account => {
|
const filteredAccounts = filterAccounts(accounts, searchQuery, selectedProvider);
|
||||||
const accessKey = account.access_key || account.accessKey || '';
|
const stats = computeAccountStats(accounts);
|
||||||
const matchesSearch = (account.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
accessKey.toLowerCase().includes(searchQuery.toLowerCase());
|
|
||||||
const matchesProvider = selectedProvider === 'all' || account.provider === selectedProvider;
|
|
||||||
return matchesSearch && matchesProvider;
|
|
||||||
});
|
|
||||||
|
|
||||||
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 = (
|
const toolbar = (
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
@@ -269,13 +214,17 @@ export function CloudAccountManager() {
|
|||||||
{ label: t('common.total'), value: stats.total, color: 'blue' },
|
{ label: t('common.total'), value: stats.total, color: 'blue' },
|
||||||
{ label: t('cloud.normal'), value: stats.active, color: 'green' },
|
{ label: t('cloud.normal'), value: stats.active, color: 'green' },
|
||||||
{ label: t('cloud.disabled'), value: stats.inactive, color: 'muted' },
|
{ label: t('cloud.disabled'), value: stats.inactive, color: 'muted' },
|
||||||
{ label: t('cloud.error'), value: stats.error, color: stats.error > 0 ? 'red' : 'muted' },
|
{
|
||||||
|
label: t('cloud.error'),
|
||||||
|
value: stats.error,
|
||||||
|
color: stats.error > 0 ? 'red' : 'muted',
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
toolbar={toolbar}
|
toolbar={toolbar}
|
||||||
actions={(
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={loadData} className="dg-btn-secondary">
|
<Button variant="outline" size="sm" onClick={loadData} className="dg-btn-secondary">
|
||||||
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
|
||||||
@@ -286,7 +235,7 @@ export function CloudAccountManager() {
|
|||||||
{t('cloud.addAccount')}
|
{t('cloud.addAccount')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<PageLoading />
|
<PageLoading />
|
||||||
@@ -294,254 +243,41 @@ export function CloudAccountManager() {
|
|||||||
<PageEmpty
|
<PageEmpty
|
||||||
icon={<Key className="h-12 w-12" />}
|
icon={<Key className="h-12 w-12" />}
|
||||||
title={t('cloud.noAccountData')}
|
title={t('cloud.noAccountData')}
|
||||||
action={(
|
action={
|
||||||
<Button size="sm" className="dg-btn-primary" onClick={handleOpenCreate}>
|
<Button size="sm" className="dg-btn-primary" onClick={handleOpenCreate}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
{t('cloud.addAccount')}
|
{t('cloud.addAccount')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{filteredAccounts.map((account) => {
|
{filteredAccounts.map((account) => (
|
||||||
const accessKey = account.access_key || account.accessKey || '';
|
<AccountCard
|
||||||
const serverCount = account.server_count ?? account.serverCount ?? 0;
|
key={account.id}
|
||||||
const lastSync = account.last_sync || account.lastSync || account.created_at || '-';
|
account={account}
|
||||||
const monthlyCost = account.monthly_cost ?? account.monthlyCost ?? 0;
|
providerLabels={providerLabels}
|
||||||
return (
|
actionLoading={actionLoading}
|
||||||
<Card key={account.id} className="dg-card dg-card-hover">
|
onEdit={handleOpenEdit}
|
||||||
<CardContent className="p-4">
|
onDelete={handleDelete}
|
||||||
<div className="flex items-start justify-between">
|
onDetail={openDetail}
|
||||||
<div className="flex items-start gap-3 min-w-0">
|
onSyncRegions={handleQuickSyncRegions}
|
||||||
<div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center shrink-0" style={{ borderBottom: `2px solid ${getProviderColor(account.provider)}` }}>
|
onSyncBilling={handleQuickSyncBilling}
|
||||||
<CloudBrandIcon provider={account.provider} className="h-6 w-6" />
|
onImport={openImport}
|
||||||
</div>
|
/>
|
||||||
<div className="min-w-0">
|
))}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="font-medium text-[var(--dg-text-primary)] hover:text-[var(--dg-blue)] text-left truncate block max-w-full"
|
|
||||||
onClick={() => openDetail(account)}
|
|
||||||
>
|
|
||||||
{account.name}
|
|
||||||
</button>
|
|
||||||
<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="dg-badge-muted">
|
|
||||||
<Key className="h-3 w-3 mr-1" />
|
|
||||||
{accessKey.slice(0, 8)}...
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{account.status === 'active' && (
|
|
||||||
<Badge className="dg-status-running border-0">
|
|
||||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
|
||||||
{t('cloud.normal')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{account.status === 'inactive' && (
|
|
||||||
<Badge className="dg-status-stopped border-0">
|
|
||||||
<XCircle className="h-3 w-3 mr-1" />
|
|
||||||
{t('cloud.stopped')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{account.status === 'error' && (
|
|
||||||
<Badge className="dg-status-error border-0">
|
|
||||||
<Key className="h-3 w-3 mr-1" />
|
|
||||||
{t('cloud.error')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 dg-text-muted hover:text-[var(--dg-blue)]"
|
|
||||||
onClick={() => handleOpenEdit(account)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
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-[var(--dg-border)]">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs dg-text-secondary">{t('cloud.monthlyFee')}</p>
|
|
||||||
<p className="text-lg font-medium text-[var(--dg-green)]">¥{Number(monthlyCost).toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs dg-text-secondary">{t('cloud.serverCount')}</p>
|
|
||||||
<p className="text-lg font-medium text-[var(--dg-text-primary)]">{serverCount}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs dg-text-secondary">{t('cloud.syncTime')}</p>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)]">
|
|
||||||
{lastSync !== '-' ? new Date(lastSync).toLocaleString() : '-'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t border-[var(--dg-border)]">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs dg-btn-secondary"
|
|
||||||
disabled={actionLoading[String(account.id)] === 'regions'}
|
|
||||||
onClick={() => handleQuickSyncRegions(account)}
|
|
||||||
>
|
|
||||||
{actionLoading[String(account.id)] === 'regions'
|
|
||||||
? <Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
|
||||||
: <MapPin className="h-3 w-3 mr-1" />}
|
|
||||||
{t('cloud.syncRegions')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs dg-btn-secondary"
|
|
||||||
disabled={actionLoading[String(account.id)] === 'billing'}
|
|
||||||
onClick={() => handleQuickSyncBilling(account)}
|
|
||||||
>
|
|
||||||
{actionLoading[String(account.id)] === 'billing'
|
|
||||||
? <Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
|
||||||
: <Wallet className="h-3 w-3 mr-1" />}
|
|
||||||
{t('cloud.syncBilling')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs dg-btn-primary"
|
|
||||||
onClick={() => openImport(account)}
|
|
||||||
>
|
|
||||||
<CloudDownload className="h-3 w-3 mr-1" />
|
|
||||||
{t('cloud.importHosts')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 创建账号 Dialog */}
|
<AccountFormDialog
|
||||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
open={formOpen}
|
||||||
<DialogContent className="max-w-lg">
|
onOpenChange={setFormOpen}
|
||||||
<DialogHeader>
|
editingAccount={editingAccount}
|
||||||
<DialogTitle className="flex items-center gap-2">
|
formData={formData}
|
||||||
<Cloud className="h-5 w-5 text-[var(--dg-blue)]" />
|
onFormDataChange={setFormData}
|
||||||
{editingAccount ? t('cloud.editAccount') : t('cloud.addAccount')}
|
submitting={submitting}
|
||||||
</DialogTitle>
|
onSubmit={handleSubmit}
|
||||||
<DialogDescription>
|
/>
|
||||||
{editingAccount ? t('cloud.editAccountDesc') : t('cloud.addAccountDesc')}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4 py-2">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>
|
|
||||||
{t('cloud.accountName')} <span className="text-[var(--dg-red)]">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
placeholder={t('cloud.accountNamePlaceholder')}
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
className="dg-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>
|
|
||||||
{t('cloud.providerLabel')} <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 rounded-md text-sm dg-input"
|
|
||||||
>
|
|
||||||
<option value="aliyun">{t('cloud.aliyun')}</option>
|
|
||||||
<option value="tencent">{t('cloud.tencent')}</option>
|
|
||||||
<option value="aws">AWS</option>
|
|
||||||
<option value="huawei">{t('cloud.huawei')}</option>
|
|
||||||
<option value="azure">Azure</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>
|
|
||||||
AccessKey
|
|
||||||
{editingAccount && <span className="text-xs dg-text-muted ml-2">{t('cloud.keepBlank')}</span>}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="LTAI..."
|
|
||||||
value={formData.access_key}
|
|
||||||
onChange={(e) => setFormData({ ...formData, access_key: e.target.value })}
|
|
||||||
className="dg-input font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>
|
|
||||||
SecretKey
|
|
||||||
{editingAccount && <span className="text-xs dg-text-muted ml-2">{t('cloud.keepBlank')}</span>}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
value={formData.secret_key}
|
|
||||||
onChange={(e) => setFormData({ ...formData, secret_key: e.target.value })}
|
|
||||||
className="dg-input font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>{t('cloud.monthlyFeeUnit')}</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
placeholder="0"
|
|
||||||
value={formData.monthly_cost}
|
|
||||||
onChange={(e) => setFormData({ ...formData, monthly_cost: e.target.value })}
|
|
||||||
className="dg-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>{t('common.status')}</Label>
|
|
||||||
<select
|
|
||||||
value={formData.status}
|
|
||||||
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 rounded-md text-sm dg-input"
|
|
||||||
>
|
|
||||||
<option value="active">{t('cloud.normal')}</option>
|
|
||||||
<option value="inactive">{t('cloud.stopped')}</option>
|
|
||||||
<option value="error">{t('cloud.error')}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setFormOpen(false)} className="dg-btn-secondary">
|
|
||||||
{t('common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="dg-btn-primary"
|
|
||||||
disabled={!formData.name.trim() || submitting}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
>
|
|
||||||
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : editingAccount ? <Pencil className="h-4 w-4 mr-2" /> : <Plus className="h-4 w-4 mr-2" />}
|
|
||||||
{editingAccount ? t('cloud.saveChanges') : t('cloud.createAccount')}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<CloudAccountDetailSheet
|
<CloudAccountDetailSheet
|
||||||
account={detailAccount}
|
account={detailAccount}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Trash2,
|
||||||
|
Pencil,
|
||||||
|
Key,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
MapPin,
|
||||||
|
Wallet,
|
||||||
|
CloudDownload,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
|
||||||
|
import type { JsonData } from '@/types/common';
|
||||||
|
import type { ActionLoading } from './types';
|
||||||
|
|
||||||
|
export interface AccountCardProps {
|
||||||
|
account: JsonData;
|
||||||
|
providerLabels: Record<string, string>;
|
||||||
|
actionLoading: ActionLoading;
|
||||||
|
onEdit: (account: JsonData) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onDetail: (account: JsonData) => void;
|
||||||
|
onSyncRegions: (account: JsonData) => void;
|
||||||
|
onSyncBilling: (account: JsonData) => void;
|
||||||
|
onImport: (account: JsonData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountCard({
|
||||||
|
account,
|
||||||
|
providerLabels,
|
||||||
|
actionLoading,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onDetail,
|
||||||
|
onSyncRegions,
|
||||||
|
onSyncBilling,
|
||||||
|
onImport,
|
||||||
|
}: AccountCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const accountIdStr = String(account.id);
|
||||||
|
const accessKey = account.access_key || account.accessKey || '';
|
||||||
|
const serverCount = account.server_count ?? account.serverCount ?? 0;
|
||||||
|
const lastSync = account.last_sync || account.lastSync || account.created_at || '-';
|
||||||
|
const monthlyCost = account.monthly_cost ?? account.monthlyCost ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 min-w-0">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center shrink-0"
|
||||||
|
style={{ borderBottom: `2px solid ${getProviderColor(account.provider)}` }}
|
||||||
|
>
|
||||||
|
<CloudBrandIcon provider={account.provider} className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="font-medium text-[var(--dg-text-primary)] hover:text-[var(--dg-blue)] text-left truncate block max-w-full"
|
||||||
|
onClick={() => onDetail(account)}
|
||||||
|
>
|
||||||
|
{account.name}
|
||||||
|
</button>
|
||||||
|
<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="dg-badge-muted">
|
||||||
|
<Key className="h-3 w-3 mr-1" />
|
||||||
|
{accessKey.slice(0, 8)}...
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{account.status === 'active' && (
|
||||||
|
<Badge className="dg-status-running border-0">
|
||||||
|
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||||
|
{t('cloud.normal')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{account.status === 'inactive' && (
|
||||||
|
<Badge className="dg-status-stopped border-0">
|
||||||
|
<XCircle className="h-3 w-3 mr-1" />
|
||||||
|
{t('cloud.stopped')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{account.status === 'error' && (
|
||||||
|
<Badge className="dg-status-error border-0">
|
||||||
|
<Key className="h-3 w-3 mr-1" />
|
||||||
|
{t('cloud.error')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 dg-text-muted hover:text-[var(--dg-blue)]"
|
||||||
|
onClick={() => onEdit(account)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 dg-text-muted hover:text-[var(--dg-red)]"
|
||||||
|
onClick={() => onDelete(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-[var(--dg-border)]">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs dg-text-secondary">{t('cloud.monthlyFee')}</p>
|
||||||
|
<p className="text-lg font-medium text-[var(--dg-green)]">
|
||||||
|
¥{Number(monthlyCost).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs dg-text-secondary">{t('cloud.serverCount')}</p>
|
||||||
|
<p className="text-lg font-medium text-[var(--dg-text-primary)]">{serverCount}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs dg-text-secondary">{t('cloud.syncTime')}</p>
|
||||||
|
<p className="text-sm text-[var(--dg-text-secondary)]">
|
||||||
|
{lastSync !== '-' ? new Date(lastSync).toLocaleString() : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t border-[var(--dg-border)]">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs dg-btn-secondary"
|
||||||
|
disabled={actionLoading[accountIdStr] === 'regions'}
|
||||||
|
onClick={() => onSyncRegions(account)}
|
||||||
|
>
|
||||||
|
{actionLoading[accountIdStr] === 'regions' ? (
|
||||||
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MapPin className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
{t('cloud.syncRegions')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs dg-btn-secondary"
|
||||||
|
disabled={actionLoading[accountIdStr] === 'billing'}
|
||||||
|
onClick={() => onSyncBilling(account)}
|
||||||
|
>
|
||||||
|
{actionLoading[accountIdStr] === 'billing' ? (
|
||||||
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Wallet className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
{t('cloud.syncBilling')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs dg-btn-primary"
|
||||||
|
onClick={() => onImport(account)}
|
||||||
|
>
|
||||||
|
<CloudDownload className="h-3 w-3 mr-1" />
|
||||||
|
{t('cloud.importHosts')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
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 { Plus, Pencil, Cloud, Loader2 } from 'lucide-react';
|
||||||
|
import type { JsonData } from '@/types/common';
|
||||||
|
import type { AccountFormData } from './types';
|
||||||
|
|
||||||
|
export interface AccountFormDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
editingAccount: JsonData | null;
|
||||||
|
formData: AccountFormData;
|
||||||
|
onFormDataChange: (data: AccountFormData) => void;
|
||||||
|
submitting: boolean;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
editingAccount,
|
||||||
|
formData,
|
||||||
|
onFormDataChange,
|
||||||
|
submitting,
|
||||||
|
onSubmit,
|
||||||
|
}: AccountFormDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Cloud className="h-5 w-5 text-[var(--dg-blue)]" />
|
||||||
|
{editingAccount ? t('cloud.editAccount') : t('cloud.addAccount')}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editingAccount ? t('cloud.editAccountDesc') : t('cloud.addAccountDesc')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>
|
||||||
|
{t('cloud.accountName')} <span className="text-[var(--dg-red)]">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder={t('cloud.accountNamePlaceholder')}
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, name: e.target.value })}
|
||||||
|
className="dg-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>
|
||||||
|
{t('cloud.providerLabel')} <span className="text-[var(--dg-red)]">*</span>
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
value={formData.provider}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, provider: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-md text-sm dg-input"
|
||||||
|
>
|
||||||
|
<option value="aliyun">{t('cloud.aliyun')}</option>
|
||||||
|
<option value="tencent">{t('cloud.tencent')}</option>
|
||||||
|
<option value="aws">AWS</option>
|
||||||
|
<option value="huawei">{t('cloud.huawei')}</option>
|
||||||
|
<option value="azure">Azure</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>
|
||||||
|
AccessKey
|
||||||
|
{editingAccount && (
|
||||||
|
<span className="text-xs dg-text-muted ml-2">{t('cloud.keepBlank')}</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="LTAI..."
|
||||||
|
value={formData.access_key}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, access_key: e.target.value })}
|
||||||
|
className="dg-input font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>
|
||||||
|
SecretKey
|
||||||
|
{editingAccount && (
|
||||||
|
<span className="text-xs dg-text-muted ml-2">{t('cloud.keepBlank')}</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={formData.secret_key}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, secret_key: e.target.value })}
|
||||||
|
className="dg-input font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('cloud.monthlyFeeUnit')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="0"
|
||||||
|
value={formData.monthly_cost}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, monthly_cost: e.target.value })}
|
||||||
|
className="dg-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{t('common.status')}</Label>
|
||||||
|
<select
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, status: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 rounded-md text-sm dg-input"
|
||||||
|
>
|
||||||
|
<option value="active">{t('cloud.normal')}</option>
|
||||||
|
<option value="inactive">{t('cloud.stopped')}</option>
|
||||||
|
<option value="error">{t('cloud.error')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} className="dg-btn-secondary">
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="dg-btn-primary"
|
||||||
|
disabled={!formData.name.trim() || submitting}
|
||||||
|
onClick={onSubmit}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : editingAccount ? (
|
||||||
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
|
) : (
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{editingAccount ? t('cloud.saveChanges') : t('cloud.createAccount')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { JsonData } from '@/types/common';
|
||||||
|
|
||||||
|
/** Create/Edit account form state */
|
||||||
|
export interface AccountFormData {
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
access_key: string;
|
||||||
|
secret_key: string;
|
||||||
|
monthly_cost: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggregated stats derived from account list */
|
||||||
|
export interface AccountStats {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
inactive: number;
|
||||||
|
error: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-account action loading state (key=accountId, value=action name) */
|
||||||
|
export type ActionLoading = Record<string, string>;
|
||||||
|
|
||||||
|
/** Default form data for new account creation */
|
||||||
|
export function getInitialFormData(): AccountFormData {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
provider: 'aliyun',
|
||||||
|
access_key: '',
|
||||||
|
secret_key: '',
|
||||||
|
monthly_cost: '',
|
||||||
|
status: 'active',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build form data from an existing account (for editing) */
|
||||||
|
export function buildFormDataFromAccount(account: JsonData): AccountFormData {
|
||||||
|
return {
|
||||||
|
name: account.name || '',
|
||||||
|
provider: account.provider || 'aliyun',
|
||||||
|
access_key: '',
|
||||||
|
secret_key: '',
|
||||||
|
monthly_cost: String(account.monthly_cost ?? 0),
|
||||||
|
status: account.status || 'active',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute stats from a list of accounts */
|
||||||
|
export function computeAccountStats(accounts: JsonData[]): AccountStats {
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter accounts by search query + provider selection */
|
||||||
|
export function filterAccounts(
|
||||||
|
accounts: JsonData[],
|
||||||
|
searchQuery: string,
|
||||||
|
selectedProvider: string,
|
||||||
|
): JsonData[] {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return accounts.filter((account) => {
|
||||||
|
const accessKey = account.access_key || account.accessKey || '';
|
||||||
|
const matchesSearch =
|
||||||
|
(account.name || '').toLowerCase().includes(q) || accessKey.toLowerCase().includes(q);
|
||||||
|
const matchesProvider = selectedProvider === 'all' || account.provider === selectedProvider;
|
||||||
|
return matchesSearch && matchesProvider;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider label map builder (provider code → display label) */
|
||||||
|
export function getProviderLabels(
|
||||||
|
t: (key: string) => string,
|
||||||
|
): Record<string, string> {
|
||||||
|
return {
|
||||||
|
aws: 'AWS',
|
||||||
|
aliyun: t('cloud.aliyun'),
|
||||||
|
tencent: t('cloud.tencent'),
|
||||||
|
huawei: t('cloud.huawei'),
|
||||||
|
azure: 'Azure',
|
||||||
|
gcp: 'GCP',
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user