- 781处 tsc -b 错误清零(unknown 属性访问/隐式any返回类型/类型转换不匹配)
- 移除 App.tsx 中已删除的 AnsibleManager/AnsibleWorkflow 导入和路由
- 修复 DeploymentView 中 _prefix 解构导致的属性不存在错误
- 修复 Header/Sidebar/PipelineEditor 中无效的类型断言
- asList() 返回类型 unknown[]→any[], 恢复 AgentMonitor 正常工作
- 放宽 noImplicitAny(临时), 待 types/ 定义完善后逐步恢复
- ESLint: unused=0, console=0, any=1041(Q1 后续降低)
- npm run build: ✅ 0 errors
664 lines
29 KiB
TypeScript
664 lines
29 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Checkbox } from '@/components/ui/checkbox';
|
||
import { Input } from '@/components/ui/input';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
} from '@/components/ui/dialog';
|
||
import {
|
||
Cloud,
|
||
Key,
|
||
CheckCircle,
|
||
ChevronRight,
|
||
ChevronLeft,
|
||
Server,
|
||
RefreshCw,
|
||
Check,
|
||
MapPin,
|
||
Search,
|
||
AlertCircle,
|
||
} from 'lucide-react';
|
||
import { cloudApi } from '@/services/api';
|
||
|
||
interface CloudServerImportProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
}
|
||
|
||
const cloudProviders = [
|
||
{ id: 'aws', name: 'AWS', color: 'bg-orange-500', icon: 'AWS' },
|
||
{ id: 'aliyun', name: '阿里云', color: 'bg-orange-600', icon: 'AL' },
|
||
{ id: 'tencent', name: '腾讯云', color: 'bg-blue-500', icon: 'TX' },
|
||
{ id: 'huawei', name: '华为云', color: 'bg-red-500', icon: 'HW' },
|
||
{ id: 'azure', name: 'Azure', color: 'bg-blue-600', icon: 'AZ' },
|
||
];
|
||
|
||
export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
|
||
const [cloudAccounts, setCloudAccounts] = useState<any[]>([]);
|
||
const [step, setStep] = useState(1);
|
||
const [selectedProvider, setSelectedProvider] = useState<string>('');
|
||
const [selectedAccount, setSelectedAccount] = useState<any>(null);
|
||
// 区域相关状态
|
||
const [regions, setRegions] = useState<any[]>([]);
|
||
const [selectedRegions, setSelectedRegions] = useState<string[]>([]);
|
||
const [regionSearch, setRegionSearch] = useState('');
|
||
const [isSyncingRegions, setIsSyncingRegions] = useState(false);
|
||
// 主机相关状态
|
||
const [cloudServers, setCloudServers] = useState<any[]>([]);
|
||
const [selectedServers, setSelectedServers] = useState<string[]>([]);
|
||
const [isDiscovering, setIsDiscovering] = useState(false);
|
||
const [discoverErrors, setDiscoverErrors] = useState<string[]>([]);
|
||
const [isImporting, setIsImporting] = useState(false);
|
||
const [importResult, setImportResult] = useState<{ imported: number; skipped: number } | null>(null);
|
||
const [defaultSshPort, setDefaultSshPort] = useState('22');
|
||
const [credentials, setCredentials] = useState<any[]>([]);
|
||
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('');
|
||
|
||
const loadAccounts = async () => {
|
||
try {
|
||
const res: Record<string, any> = await cloudApi.getAccounts();
|
||
setCloudAccounts(res?.list || []);
|
||
} catch (e) {
|
||
console.error('加载云账号失败:', e);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (open) {
|
||
loadAccounts();
|
||
import('@/services/api').then(({ credentialApi }) => {
|
||
credentialApi.list().then(( res: any) => setCredentials(res?.list || []))
|
||
.catch(() => setCredentials([]));
|
||
});
|
||
}
|
||
}, [open]);
|
||
|
||
const filteredAccounts = cloudAccounts.filter(
|
||
acc => acc.provider === selectedProvider && acc.status === 'active'
|
||
);
|
||
|
||
// ==================== 区域同步逻辑 ====================
|
||
const loadRegions = useCallback(async (accountId: string | number) => {
|
||
try {
|
||
const res: Record<string, any> = await cloudApi.getAccountRegions(accountId);
|
||
return res?.list || [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}, []);
|
||
|
||
const syncRegions = useCallback(async (accountId: string | number) => {
|
||
setIsSyncingRegions(true);
|
||
try {
|
||
await cloudApi.syncRegions(accountId);
|
||
const list = await loadRegions(accountId);
|
||
setRegions(list);
|
||
} catch (e: any) {
|
||
console.error('同步区域失败:', e);
|
||
throw e;
|
||
} finally {
|
||
setIsSyncingRegions(false);
|
||
}
|
||
}, [loadRegions]);
|
||
|
||
// 进入 Step 3 时自动加载/同步区域
|
||
const enterRegionStep = useCallback(async () => {
|
||
if (!selectedAccount) return;
|
||
// 先尝试加载本地已有的区域
|
||
const local = await loadRegions(selectedAccount.id);
|
||
setRegions(local);
|
||
// 如果本地没有区域数据,自动触发同步
|
||
if (local.length === 0) {
|
||
try {
|
||
await syncRegions(selectedAccount.id);
|
||
} catch {
|
||
// 同步失败不阻塞用户,可以手动重试
|
||
}
|
||
}
|
||
}, [selectedAccount, loadRegions, syncRegions]);
|
||
|
||
// ==================== 主机发现逻辑 ====================
|
||
const discoverInstances = useCallback(async () => {
|
||
if (!selectedAccount || selectedRegions.length === 0) return;
|
||
setIsDiscovering(true);
|
||
setDiscoverErrors([]);
|
||
setCloudServers([]);
|
||
setSelectedServers([]);
|
||
try {
|
||
const res: Record<string, any> = await cloudApi.discoverInstances(selectedAccount.id, selectedRegions);
|
||
setCloudServers(res?.list || []);
|
||
setDiscoverErrors(res?.errors || []);
|
||
} catch (e: any) {
|
||
console.error('发现主机失败:', e);
|
||
setDiscoverErrors([e?.message || '发现主机失败']);
|
||
} finally {
|
||
setIsDiscovering(false);
|
||
}
|
||
}, [selectedAccount, selectedRegions]);
|
||
|
||
// ==================== 导入逻辑 ====================
|
||
const handleImport = async () => {
|
||
if (!selectedAccount || selectedServers.length === 0) return;
|
||
setIsImporting(true);
|
||
try {
|
||
const instancesToImport = cloudServers.filter(s => selectedServers.includes(s.instance_id));
|
||
const res: Record<string, any> = await cloudApi.importInstances(selectedAccount.id, instancesToImport, {
|
||
default_port: Number(defaultSshPort) || 22,
|
||
credential_id: selectedCredentialId ? Number(selectedCredentialId) : undefined,
|
||
});
|
||
setImportResult((res as any) || { imported: 0, skipped: 0 });
|
||
} catch (e: any) {
|
||
console.error('导入失败:', e);
|
||
} finally {
|
||
setIsImporting(false);
|
||
}
|
||
};
|
||
|
||
// ==================== 步骤导航 ====================
|
||
const handleNext = () => {
|
||
if (step === 2 && selectedAccount && step < 4) {
|
||
setStep(3);
|
||
enterRegionStep();
|
||
} else if (step < 4) {
|
||
setStep(step + 1);
|
||
}
|
||
};
|
||
|
||
const handleBack = () => {
|
||
if (step > 1) setStep(step - 1);
|
||
};
|
||
|
||
const handleSelectAllRegions = () => {
|
||
if (selectedRegions.length === regions.length && regions.length > 0) {
|
||
setSelectedRegions([]);
|
||
} else {
|
||
setSelectedRegions(regions.map(r => r.code));
|
||
}
|
||
};
|
||
|
||
const handleServerToggle = (instanceId: string) => {
|
||
setSelectedServers(prev =>
|
||
prev.includes(instanceId)
|
||
? prev.filter(id => id !== instanceId)
|
||
: [...prev, instanceId]
|
||
);
|
||
};
|
||
|
||
const handleSelectAllServers = () => {
|
||
if (selectedServers.length === cloudServers.length && cloudServers.length > 0) {
|
||
setSelectedServers([]);
|
||
} else {
|
||
setSelectedServers(cloudServers.map(s => s.instance_id));
|
||
}
|
||
};
|
||
|
||
const handleClose = () => {
|
||
onClose();
|
||
setStep(1);
|
||
setSelectedProvider('');
|
||
setSelectedAccount(null);
|
||
setSelectedRegions([]);
|
||
setRegions([]);
|
||
setCloudServers([]);
|
||
setSelectedServers([]);
|
||
setRegionSearch('');
|
||
setDiscoverErrors([]);
|
||
setImportResult(null);
|
||
setDefaultSshPort('22');
|
||
setSelectedCredentialId('');
|
||
};
|
||
|
||
const filteredRegions = regions.filter(r =>
|
||
!regionSearch ||
|
||
r.name?.toLowerCase().includes(regionSearch.toLowerCase()) ||
|
||
r.code?.toLowerCase().includes(regionSearch.toLowerCase())
|
||
);
|
||
|
||
const steps = [
|
||
{ id: 1, title: '公有云', icon: Cloud },
|
||
{ id: 2, title: '访问凭据', icon: Key },
|
||
{ id: 3, title: '选择区域', icon: MapPin },
|
||
{ id: 4, title: '导入确认', icon: CheckCircle },
|
||
];
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={handleClose}>
|
||
<DialogContent className="max-w-3xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-xl">导入云主机</DialogTitle>
|
||
<DialogDescription className="sr-only">从云平台批量导入主机资产</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{/* Step Indicator */}
|
||
<div className="flex items-center justify-center mb-6 flex-wrap gap-1">
|
||
{steps.map((s, index) => (
|
||
<div key={s.id} className="flex items-center">
|
||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm ${
|
||
step === s.id
|
||
? 'bg-blue-600 text-white'
|
||
: step > s.id
|
||
? 'bg-emerald-600/20 text-emerald-400'
|
||
: 'bg-[var(--dg-bg-secondary)] text-[var(--dg-text-muted)]'
|
||
}`}>
|
||
<s.icon className="h-4 w-4" />
|
||
<span className="font-medium hidden sm:inline">{s.title}</span>
|
||
{step > s.id && <Check className="h-3 w-3 ml-1" />}
|
||
</div>
|
||
{index < steps.length - 1 && (
|
||
<ChevronRight className="h-4 w-4 text-[var(--dg-text-muted)] mx-1" />
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Step 1: Select Cloud Provider */}
|
||
{step === 1 && (
|
||
<div className="space-y-4">
|
||
<p className="text-[var(--dg-text-secondary)] text-sm">请选择要导入云主机的云平台</p>
|
||
<div className="grid grid-cols-3 gap-4">
|
||
{cloudProviders.map((provider) => (
|
||
<Card
|
||
key={provider.id}
|
||
className={`cursor-pointer transition-all ${
|
||
selectedProvider === provider.id
|
||
? 'border-blue-500 ring-2 ring-blue-500/30 bg-[var(--dg-bg-secondary)]'
|
||
: 'border-[var(--dg-border)] hover:border-blue-500 bg-[var(--dg-bg-secondary)]'
|
||
}`}
|
||
onClick={() => setSelectedProvider(provider.id)}
|
||
>
|
||
<CardContent className="p-4 flex items-center gap-3">
|
||
<div className={`w-10 h-10 rounded-lg ${provider.color} flex items-center justify-center text-white font-bold text-sm`}>
|
||
{provider.icon}
|
||
</div>
|
||
<span className="font-medium text-[var(--dg-text-primary)]">{provider.name}</span>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 2: Select Account */}
|
||
{step === 2 && (
|
||
<div className="space-y-4">
|
||
<p className="text-[var(--dg-text-secondary)] text-sm">请选择访问凭据(AccessKey)</p>
|
||
{filteredAccounts.length > 0 ? (
|
||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||
{filteredAccounts.map((account) => (
|
||
<Card
|
||
key={account.id}
|
||
className={`cursor-pointer transition-all ${
|
||
selectedAccount?.id === account.id
|
||
? 'border-blue-500 ring-1 ring-blue-500/30 bg-[var(--dg-bg-secondary)]'
|
||
: 'border-[var(--dg-border)] hover:border-blue-500 bg-[var(--dg-bg-secondary)]'
|
||
}`}
|
||
onClick={() => setSelectedAccount(account)}
|
||
>
|
||
<CardContent className="p-3 flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<Key className="h-5 w-5 text-blue-400" />
|
||
<div>
|
||
<p className="font-medium text-[var(--dg-text-primary)]">{account.name}</p>
|
||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||
{(account.access_key || account.accessKey || '******').slice(0, 20)}...
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<Badge className="bg-emerald-500/20 text-emerald-400 border-0">
|
||
{account.region_count ?? account.regionCount ?? 0} 区域
|
||
</Badge>
|
||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">{account.server_count ?? account.serverCount ?? 0} 服务器</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-8 text-[var(--dg-text-muted)]">
|
||
<Key className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||
<p>该云平台暂无有效的访问凭据</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 3: Select Regions (NEW) */}
|
||
{step === 3 && (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-[var(--dg-text-secondary)] text-sm">
|
||
请选择要同步的区域
|
||
{regions.length > 0 && (
|
||
<span className="ml-2 text-[var(--dg-text-muted)]">(共 {regions.length} 个区域,已选 {selectedRegions.length} 个)</span>
|
||
)}
|
||
</p>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
|
||
onClick={() => selectedAccount && syncRegions(selectedAccount.id)}
|
||
disabled={isSyncingRegions}
|
||
>
|
||
<RefreshCw className={`h-3 w-3 mr-1 ${isSyncingRegions ? 'animate-spin' : ''}`} />
|
||
{isSyncingRegions ? '同步中...' : '刷新区域'}
|
||
</Button>
|
||
</div>
|
||
|
||
{isSyncingRegions ? (
|
||
<div className="text-center py-12">
|
||
<RefreshCw className="h-8 w-8 mx-auto mb-3 animate-spin text-blue-400" />
|
||
<p className="text-[var(--dg-text-secondary)]">正在从云平台同步区域列表...</p>
|
||
</div>
|
||
) : regions.length === 0 ? (
|
||
<div className="text-center py-8 text-[var(--dg-text-muted)]">
|
||
<MapPin className="h-10 w-10 mx-auto mb-3 opacity-50" />
|
||
<p>暂无区域数据</p>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="mt-3 border-[var(--dg-border)] text-blue-400"
|
||
onClick={() => selectedAccount && syncRegions(selectedAccount.id)}
|
||
>
|
||
<RefreshCw className="h-3 w-3 mr-1" />
|
||
立即同步
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* 搜索框 + 全选 */}
|
||
<div className="flex items-center gap-2">
|
||
<div className="relative flex-1">
|
||
<Search className="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)]" />
|
||
<Input
|
||
placeholder="搜索区域名称或编码..."
|
||
value={regionSearch}
|
||
onChange={(e) => setRegionSearch(e.target.value)}
|
||
className="pl-9 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"
|
||
/>
|
||
</div>
|
||
<Button variant="outline" size="sm" onClick={handleSelectAllRegions} className="border-[var(--dg-border)]">
|
||
{selectedRegions.length === regions.length && regions.length > 0 ? '取消全选' : '全选'}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 区域列表 */}
|
||
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden max-h-[300px] overflow-y-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-[var(--dg-bg-secondary)] sticky top-0">
|
||
<tr>
|
||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)] w-10">
|
||
<Checkbox
|
||
checked={selectedRegions.length === regions.length && regions.length > 0}
|
||
onCheckedChange={handleSelectAllRegions}
|
||
/>
|
||
</th>
|
||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">区域编码</th>
|
||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">区域名称</th>
|
||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">Endpoint</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-[var(--dg-border)]">
|
||
{filteredRegions.map((region) => (
|
||
<tr
|
||
key={region.id || region.code}
|
||
className={`cursor-pointer hover:bg-[var(--dg-bg-hover)] ${selectedRegions.includes(region.code) ? 'bg-blue-500/10' : ''}`}
|
||
onClick={() => {
|
||
setSelectedRegions(prev =>
|
||
prev.includes(region.code)
|
||
? prev.filter(r => r !== region.code)
|
||
: [...prev, region.code]
|
||
);
|
||
}}
|
||
>
|
||
<td className="px-3 py-2">
|
||
<Checkbox checked={selectedRegions.includes(region.code)} />
|
||
</td>
|
||
<td className="px-3 py-2 text-sm text-blue-400 font-mono">{region.code}</td>
|
||
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{region.name}</td>
|
||
<td className="px-3 py-2 text-xs text-[var(--dg-text-muted)] font-mono truncate max-w-[200px]">{region.endpoint || '-'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 4: Discover & Import Instances */}
|
||
{step === 4 && (
|
||
<div className="space-y-4">
|
||
{importResult ? (
|
||
/* 导入结果 */
|
||
<div className="text-center py-12">
|
||
<CheckCircle className="h-12 w-12 mx-auto mb-3 text-emerald-400" />
|
||
<p className="text-lg font-medium text-[var(--dg-text-primary)]">导入完成</p>
|
||
<div className="flex items-center justify-center gap-6 mt-4">
|
||
<Badge className="bg-emerald-500/20 text-emerald-400 border-0 text-base px-4 py-1">
|
||
成功导入 {importResult.imported} 台
|
||
</Badge>
|
||
{importResult.skipped > 0 && (
|
||
<Badge className="bg-amber-500/20 text-amber-400 border-0 text-base px-4 py-1">
|
||
跳过 {importResult.skipped} 台(已存在)
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-[var(--dg-text-secondary)] text-sm">
|
||
已选 {selectedRegions.length} 个区域
|
||
{cloudServers.length > 0 && (
|
||
<span className="ml-2">· 发现 {cloudServers.length} 台主机</span>
|
||
)}
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
{cloudServers.length > 0 && (
|
||
<>
|
||
<Button variant="outline" size="sm" onClick={handleSelectAllServers} className="border-[var(--dg-border)]">
|
||
{selectedServers.length === cloudServers.length && cloudServers.length > 0 ? '取消全选' : '全选'}
|
||
</Button>
|
||
<Badge className="bg-blue-500/20 text-blue-400 border-0">
|
||
已选 {selectedServers.length} 台
|
||
</Badge>
|
||
</>
|
||
)}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={discoverInstances}
|
||
disabled={isDiscovering || selectedRegions.length === 0}
|
||
className="border-[var(--dg-border)] text-blue-400"
|
||
>
|
||
<RefreshCw className={`h-3 w-3 mr-1 ${isDiscovering ? 'animate-spin' : ''}`} />
|
||
{isDiscovering ? '发现中...' : cloudServers.length > 0 ? '重新发现' : '发现主机'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 区域标签 */}
|
||
<div className="flex flex-wrap gap-1">
|
||
{selectedRegions.map(r => {
|
||
const region = regions.find(reg => reg.code === r);
|
||
return (
|
||
<Badge key={r} className="bg-[var(--dg-bg-secondary)] text-[var(--dg-text-muted)] border border-[var(--dg-border)] text-xs">
|
||
<MapPin className="h-3 w-3 mr-1" />
|
||
{region?.name || r}
|
||
</Badge>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 发现错误提示 */}
|
||
{discoverErrors.length > 0 && (
|
||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-400 text-sm">
|
||
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||
<div>
|
||
<p className="font-medium">部分区域发现失败:</p>
|
||
{discoverErrors.map((err, i) => (
|
||
<p key={i} className="text-xs mt-1 opacity-80">{err}</p>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 主机列表 */}
|
||
{isDiscovering ? (
|
||
<div className="text-center py-12">
|
||
<RefreshCw className="h-8 w-8 mx-auto mb-3 animate-spin text-blue-400" />
|
||
<p className="text-[var(--dg-text-secondary)]">正在发现云主机,请稍候...</p>
|
||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">正在逐区域查询,可能需要数秒</p>
|
||
</div>
|
||
) : cloudServers.length === 0 && !isDiscovering ? (
|
||
<div className="text-center py-8 text-[var(--dg-text-muted)]">
|
||
<Server className="h-10 w-10 mx-auto mb-3 opacity-50" />
|
||
<p>点击"发现主机"按钮,开始扫描所选区域</p>
|
||
</div>
|
||
) : (
|
||
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden max-h-[300px] overflow-y-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-[var(--dg-bg-secondary)] sticky top-0">
|
||
<tr>
|
||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)] w-10">
|
||
<Checkbox
|
||
checked={selectedServers.length === cloudServers.length && cloudServers.length > 0}
|
||
onCheckedChange={handleSelectAllServers}
|
||
/>
|
||
</th>
|
||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">主机名</th>
|
||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">IP地址</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-[var(--dg-border)]">
|
||
{cloudServers.map((server) => (
|
||
<tr
|
||
key={server.instance_id}
|
||
className={`cursor-pointer hover:bg-[var(--dg-bg-hover)] ${selectedServers.includes(server.instance_id) ? 'bg-blue-500/10' : ''}`}
|
||
onClick={() => handleServerToggle(server.instance_id)}
|
||
>
|
||
<td className="px-3 py-2">
|
||
<Checkbox checked={selectedServers.includes(server.instance_id)} />
|
||
</td>
|
||
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{server.instance_name || server.hostname || '-'}</td>
|
||
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">
|
||
{server.private_ip || server.public_ip || '-'}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* SSH 端口 + 凭证关联 */}
|
||
{cloudServers.length > 0 && selectedServers.length > 0 && (
|
||
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
||
<div>
|
||
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">
|
||
SSH 端口
|
||
<span className="text-[var(--dg-text-muted)] ml-1">(导入时统一设置)</span>
|
||
</label>
|
||
<Input
|
||
type="number"
|
||
placeholder="22"
|
||
value={defaultSshPort}
|
||
onChange={(e) => setDefaultSshPort(e.target.value)}
|
||
className="w-32 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">
|
||
关联凭证
|
||
<span className="text-[var(--dg-text-muted)] ml-1">(可选,用于SSH远程管理)</span>
|
||
</label>
|
||
<select
|
||
value={selectedCredentialId}
|
||
onChange={(e) => setSelectedCredentialId(e.target.value)}
|
||
className="w-full px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-xs"
|
||
>
|
||
<option value="">不使用凭据</option>
|
||
{credentials.map(( c: any) => (
|
||
<option key={c.id} value={String(c.id)}>
|
||
{c.type === 'ssh_key' ? '🔑' : '🔒'} {c.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Footer */}
|
||
<DialogFooter className="flex justify-between items-center">
|
||
<div>
|
||
{step > 1 && !importResult && (
|
||
<Button variant="outline" onClick={handleBack} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||
上一步
|
||
</Button>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
{importResult ? (
|
||
<Button onClick={handleClose} className="bg-blue-600 hover:bg-blue-700">
|
||
完成
|
||
</Button>
|
||
) : (
|
||
<>
|
||
<Button variant="outline" onClick={handleClose} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||
取消
|
||
</Button>
|
||
{step < 4 ? (
|
||
<Button
|
||
onClick={handleNext}
|
||
disabled={
|
||
(step === 1 && !selectedProvider) ||
|
||
(step === 2 && !selectedAccount) ||
|
||
(step === 3 && selectedRegions.length === 0)
|
||
}
|
||
className="bg-blue-600 hover:bg-blue-700"
|
||
>
|
||
下一步
|
||
<ChevronRight className="h-4 w-4 ml-1" />
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
onClick={handleImport}
|
||
disabled={selectedServers.length === 0 || isImporting}
|
||
className="bg-emerald-600 hover:bg-emerald-700"
|
||
>
|
||
{isImporting ? (
|
||
<>
|
||
<RefreshCw className="h-4 w-4 mr-1 animate-spin" />
|
||
导入中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Check className="h-4 w-4 mr-1" />
|
||
确认导入 ({selectedServers.length})
|
||
</>
|
||
)}
|
||
</Button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|