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:
attacker committed 2026-07-09 01:07:32 +08:00
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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
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 { Plus, Search, Key, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { cloudApi } from '@/api';
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
import { CloudAccountDetailSheet } from './CloudAccountDetailSheet';
import { CloudServerImport } from '@/components/server/CloudServerImport';
import { PageShell } from '@/components/layout/PageShell';
@@ -38,17 +12,17 @@ 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';
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',
};
}
import {
type AccountFormData,
type ActionLoading,
getInitialFormData,
buildFormDataFromAccount,
computeAccountStats,
filterAccounts,
getProviderLabels,
} from './cloud-account/types';
import { AccountCard } from './cloud-account/AccountCard';
import { AccountFormDialog } from './cloud-account/AccountFormDialog';
export function CloudAccountManager() {
const { t } = useTranslation();
@@ -60,20 +34,13 @@ export function CloudAccountManager() {
// 创建/编辑账号表单状态
const [formOpen, setFormOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [editingAccount, setEditingAccount] = useState<JsonData>(null);
const [formData, setFormData] = useState({
name: '',
provider: 'aliyun',
access_key: '',
secret_key: '',
monthly_cost: '',
status: 'active',
});
const [detailAccount, setDetailAccount] = useState<JsonData>(null);
const [editingAccount, setEditingAccount] = useState<JsonData | null>(null);
const [formData, setFormData] = useState<AccountFormData>(getInitialFormData());
const [detailAccount, setDetailAccount] = useState<JsonData | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importAccountId, setImportAccountId] = useState<number | string | undefined>();
const [actionLoading, setActionLoading] = useState<Record<string, string>>({});
const [actionLoading, setActionLoading] = useState<ActionLoading>({});
const providerLabels = getProviderLabels(t);
@@ -104,7 +71,9 @@ export function CloudAccountManager() {
toast.success(t('cloud.syncRegionsSuccess'));
loadData();
} 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 {
setCardAction(account.id, null);
}
@@ -128,7 +97,9 @@ export function CloudAccountManager() {
}
loadData();
} 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 {
setCardAction(account.id, null);
}
@@ -136,14 +107,7 @@ export function CloudAccountManager() {
const resetForm = () => {
setEditingAccount(null);
setFormData({
name: '',
provider: 'aliyun',
access_key: '',
secret_key: '',
monthly_cost: '',
status: 'active',
});
setFormData(getInitialFormData());
};
const handleOpenCreate = () => {
@@ -153,14 +117,7 @@ export function CloudAccountManager() {
const handleOpenEdit = (account: JsonData) => {
setEditingAccount(account);
setFormData({
name: account.name || '',
provider: account.provider || 'aliyun',
access_key: '',
secret_key: '',
monthly_cost: String(account.monthly_cost ?? 0),
status: account.status || 'active',
});
setFormData(buildFormDataFromAccount(account));
setFormOpen(true);
};
@@ -215,20 +172,8 @@ export function CloudAccountManager() {
}
};
const filteredAccounts = accounts.filter(account => {
const accessKey = account.access_key || account.accessKey || '';
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 filteredAccounts = filterAccounts(accounts, searchQuery, selectedProvider);
const stats = computeAccountStats(accounts);
const toolbar = (
<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('cloud.normal'), value: stats.active, color: 'green' },
{ 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
}
toolbar={toolbar}
actions={(
actions={
<>
<Button variant="outline" size="sm" onClick={loadData} className="dg-btn-secondary">
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
@@ -286,7 +235,7 @@ export function CloudAccountManager() {
{t('cloud.addAccount')}
</Button>
</>
)}
}
>
{loading ? (
<PageLoading />
@@ -294,254 +243,41 @@ export function CloudAccountManager() {
<PageEmpty
icon={<Key className="h-12 w-12" />}
title={t('cloud.noAccountData')}
action={(
action={
<Button size="sm" className="dg-btn-primary" onClick={handleOpenCreate}>
<Plus className="h-4 w-4 mr-1" />
{t('cloud.addAccount')}
</Button>
)}
}
/>
) : (
<div className="grid grid-cols-2 gap-4">
{filteredAccounts.map((account) => {
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={() => 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>
);
})}
{filteredAccounts.map((account) => (
<AccountCard
key={account.id}
account={account}
providerLabels={providerLabels}
actionLoading={actionLoading}
onEdit={handleOpenEdit}
onDelete={handleDelete}
onDetail={openDetail}
onSyncRegions={handleQuickSyncRegions}
onSyncBilling={handleQuickSyncBilling}
onImport={openImport}
/>
))}
</div>
)}
{/* 创建账号 Dialog */}
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<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) => 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>
<AccountFormDialog
open={formOpen}
onOpenChange={setFormOpen}
editingAccount={editingAccount}
formData={formData}
onFormDataChange={setFormData}
submitting={submitting}
onSubmit={handleSubmit}
/>
<CloudAccountDetailSheet
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',
};
}