309 lines
9.5 KiB
TypeScript
309 lines
9.5 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Plus, Search, Key, RefreshCw } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { cloudApi } from '@/api';
|
|
import { CloudAccountDetailSheet } from './CloudAccountDetailSheet';
|
|
import { CloudServerImport } from '@/components/server/CloudServerImport';
|
|
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';
|
|
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();
|
|
const [accounts, setAccounts] = useState<JsonData[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedProvider, setSelectedProvider] = useState<string>('all');
|
|
|
|
// 创建/编辑账号表单状态
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
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<ActionLoading>({});
|
|
|
|
const providerLabels = getProviderLabels(t);
|
|
|
|
const setCardAction = (accountId: string | number, action: string | null) => {
|
|
setActionLoading((prev) => {
|
|
const next = { ...prev };
|
|
const key = String(accountId);
|
|
if (action) next[key] = action;
|
|
else delete next[key];
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const openDetail = (account: JsonData) => {
|
|
setDetailAccount(account);
|
|
setDetailOpen(true);
|
|
};
|
|
|
|
const openImport = (account: JsonData) => {
|
|
setImportAccountId(account.id);
|
|
setImportOpen(true);
|
|
};
|
|
|
|
const handleQuickSyncRegions = async (account: JsonData) => {
|
|
setCardAction(account.id, 'regions');
|
|
try {
|
|
await cloudApi.syncRegions(account.id);
|
|
toast.success(t('cloud.syncRegionsSuccess'));
|
|
loadData();
|
|
} catch (e) {
|
|
toast.error(t('cloud.syncRegionsFailed'), {
|
|
description: e instanceof Error ? e.message : String(e),
|
|
});
|
|
} finally {
|
|
setCardAction(account.id, null);
|
|
}
|
|
};
|
|
|
|
const handleQuickSyncBilling = async (account: JsonData) => {
|
|
setCardAction(account.id, 'billing');
|
|
try {
|
|
const res = await cloudApi.syncBilling(account.id);
|
|
const errs = res?.errors || [];
|
|
if (errs.length > 0) {
|
|
toast.warning(t('cloud.syncBillingPartial'), { description: errs.join('\n') });
|
|
} else {
|
|
toast.success(t('cloud.syncBillingSuccess'), {
|
|
description: t('cloud.syncBillingResult', {
|
|
date: res.billing_date,
|
|
created: res.created,
|
|
updated: res.updated,
|
|
}),
|
|
});
|
|
}
|
|
loadData();
|
|
} catch (e) {
|
|
toast.error(t('cloud.syncBillingFailed'), {
|
|
description: e instanceof Error ? e.message : String(e),
|
|
});
|
|
} finally {
|
|
setCardAction(account.id, null);
|
|
}
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setEditingAccount(null);
|
|
setFormData(getInitialFormData());
|
|
};
|
|
|
|
const handleOpenCreate = () => {
|
|
resetForm();
|
|
setFormOpen(true);
|
|
};
|
|
|
|
const handleOpenEdit = (account: JsonData) => {
|
|
setEditingAccount(account);
|
|
setFormData(buildFormDataFromAccount(account));
|
|
setFormOpen(true);
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!formData.name.trim() || !formData.provider.trim()) return;
|
|
try {
|
|
setSubmitting(true);
|
|
const payload: JsonData = {
|
|
name: formData.name.trim(),
|
|
provider: formData.provider,
|
|
access_key: formData.access_key.trim(),
|
|
secret_key: formData.secret_key.trim(),
|
|
monthly_cost: Number(formData.monthly_cost) || 0,
|
|
status: formData.status,
|
|
};
|
|
if (editingAccount) {
|
|
await cloudApi.updateAccount(editingAccount.id, payload);
|
|
} else {
|
|
await cloudApi.createAccount(payload);
|
|
}
|
|
setFormOpen(false);
|
|
loadData();
|
|
} catch (e) {
|
|
console.error(editingAccount ? 'update account' : 'create account', e);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const res: JsonData = await cloudApi.getAccounts();
|
|
setAccounts(res?.list || []);
|
|
} catch (_e) {
|
|
setAccounts([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
const handleDelete = async (id: string) => {
|
|
try {
|
|
await cloudApi.deleteAccount(id);
|
|
loadData();
|
|
} catch (_e) {
|
|
// silently ignored
|
|
}
|
|
};
|
|
|
|
const filteredAccounts = filterAccounts(accounts, searchQuery, selectedProvider);
|
|
const stats = computeAccountStats(accounts);
|
|
|
|
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={t('cloud.searchAccount')}
|
|
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">{t('cloud.allProviders')}</option>
|
|
<option value="aws">AWS</option>
|
|
<option value="aliyun">{t('cloud.aliyun')}</option>
|
|
<option value="tencent">{t('cloud.tencent')}</option>
|
|
<option value="huawei">{t('cloud.huawei')}</option>
|
|
<option value="azure">Azure</option>
|
|
</select>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<PageShell
|
|
variant="list"
|
|
title={t('cloud.cloudAccounts')}
|
|
subtitle={t('cloud.accountsSubtitle')}
|
|
icon={<Key className="h-5 w-5" />}
|
|
metrics={
|
|
!loading ? (
|
|
<StatStrip
|
|
items={[
|
|
{ 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',
|
|
},
|
|
]}
|
|
/>
|
|
) : 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' : ''}`} />
|
|
{t('common.refresh')}
|
|
</Button>
|
|
<Button size="sm" className="dg-btn-primary" onClick={handleOpenCreate}>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
{t('cloud.addAccount')}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
{loading ? (
|
|
<PageLoading />
|
|
) : filteredAccounts.length === 0 ? (
|
|
<PageEmpty
|
|
icon={<Key className="h-12 w-12" />}
|
|
title={t('cloud.noAccountData')}
|
|
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) => (
|
|
<AccountCard
|
|
key={account.id}
|
|
account={account}
|
|
providerLabels={providerLabels}
|
|
actionLoading={actionLoading}
|
|
onEdit={handleOpenEdit}
|
|
onDelete={handleDelete}
|
|
onDetail={openDetail}
|
|
onSyncRegions={handleQuickSyncRegions}
|
|
onSyncBilling={handleQuickSyncBilling}
|
|
onImport={openImport}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<AccountFormDialog
|
|
open={formOpen}
|
|
onOpenChange={setFormOpen}
|
|
editingAccount={editingAccount}
|
|
formData={formData}
|
|
onFormDataChange={setFormData}
|
|
submitting={submitting}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
|
|
<CloudAccountDetailSheet
|
|
account={detailAccount}
|
|
open={detailOpen}
|
|
onOpenChange={setDetailOpen}
|
|
providerLabels={providerLabels}
|
|
onSyncRegions={loadData}
|
|
onSyncBilling={loadData}
|
|
onImportHosts={() => {
|
|
if (detailAccount) {
|
|
setDetailOpen(false);
|
|
openImport(detailAccount);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<CloudServerImport
|
|
open={importOpen}
|
|
initialAccountId={importAccountId}
|
|
onClose={() => {
|
|
setImportOpen(false);
|
|
setImportAccountId(undefined);
|
|
loadData();
|
|
}}
|
|
/>
|
|
</PageShell>
|
|
);
|
|
}
|