feat: Docker部署 + 云主机导入 + 终端优化
Docker 部署: - 新增根目录 docker-compose.yml(MySQL+Redis+前后端四容器) - 新增 frontend/Dockerfile(多阶段构建)和 nginx.conf(SPA+API反代) - 修复 backend/Dockerfile:GOPROXY国内加速、编译整个cmd包 云主机导入功能: - 新增 backend/internal/cloud 包:Provider接口 + 阿里云API客户端 - 新增 cloud_sync.go:区域同步/主机发现/导入 API - 重写 CloudServerImport.tsx:4步流程(选云→选账号→选区域→发现导入) - 修复导入外键约束失败(region_id/cloud_id 设为 NULL) 终端优化: - 修复双X关闭按钮重影(showCloseButton=false) - 修复最大化→最小化不恢复(ResizeObserver + 禁用CSS过渡) 其他: - GORM AutoMigrate 逐表迁移,避免索引冲突阻塞启动 - Makefile 更新为 AIOps + 新增 docker 命令组 - .env.example 移至 backend 目录
This commit is contained in:
1 parent
2e175daf80
commit
d0344c2f52
14 files changed
+1265
-200
No files matched your search
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
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,
|
||||
@@ -20,6 +21,9 @@ import {
|
||||
Server,
|
||||
RefreshCw,
|
||||
Check,
|
||||
MapPin,
|
||||
Search,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { cloudApi } from '@/services/api';
|
||||
|
||||
@@ -40,10 +44,19 @@ 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<string>('');
|
||||
const [selectedServers, setSelectedServers] = 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 [isLoading, setIsLoading] = useState(false);
|
||||
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 loadAccounts = async () => {
|
||||
try {
|
||||
@@ -55,72 +68,150 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
}, []);
|
||||
if (open) loadAccounts();
|
||||
}, [open]);
|
||||
|
||||
const filteredAccounts = cloudAccounts.filter(
|
||||
acc => acc.provider === selectedProvider && acc.status === 'active'
|
||||
);
|
||||
|
||||
const handleNext = () => {
|
||||
if (step < 3) {
|
||||
if (step === 2) {
|
||||
setIsLoading(true);
|
||||
// 云主机发现功能尚未实现,暂以空列表展示
|
||||
setTimeout(() => {
|
||||
setCloudServers([]);
|
||||
setIsLoading(false);
|
||||
}, 600);
|
||||
// ==================== 区域同步逻辑 ====================
|
||||
const loadRegions = useCallback(async (accountId: string | number) => {
|
||||
try {
|
||||
const res: 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: 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: any = await cloudApi.importInstances(selectedAccount.id, instancesToImport);
|
||||
setImportResult(res || { 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);
|
||||
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 = (serverId: string) => {
|
||||
const handleServerToggle = (instanceId: string) => {
|
||||
setSelectedServers(prev =>
|
||||
prev.includes(serverId)
|
||||
? prev.filter(id => id !== serverId)
|
||||
: [...prev, serverId]
|
||||
prev.includes(instanceId)
|
||||
? prev.filter(id => id !== instanceId)
|
||||
: [...prev, instanceId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const handleSelectAllServers = () => {
|
||||
if (selectedServers.length === cloudServers.length && cloudServers.length > 0) {
|
||||
setSelectedServers([]);
|
||||
} else {
|
||||
setSelectedServers(cloudServers.map(s => s.id));
|
||||
setSelectedServers(cloudServers.map(s => s.instance_id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
// 云主机发现功能尚未实现,暂以空操作占位
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setStep(1);
|
||||
setSelectedProvider('');
|
||||
setSelectedAccount('');
|
||||
setSelectedServers([]);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setStep(1);
|
||||
setSelectedProvider('');
|
||||
setSelectedAccount('');
|
||||
setSelectedAccount(null);
|
||||
setSelectedRegions([]);
|
||||
setRegions([]);
|
||||
setCloudServers([]);
|
||||
setSelectedServers([]);
|
||||
setRegionSearch('');
|
||||
setDiscoverErrors([]);
|
||||
setImportResult(null);
|
||||
};
|
||||
|
||||
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: CheckCircle },
|
||||
{ id: 3, title: '选择区域', icon: MapPin },
|
||||
{ id: 4, title: '导入确认', icon: CheckCircle },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -132,10 +223,10 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
|
||||
</DialogHeader>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<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-4 py-2 rounded-lg ${
|
||||
<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
|
||||
@@ -143,11 +234,11 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
|
||||
: 'bg-[var(--dg-bg-secondary)] text-[var(--dg-text-muted)]'
|
||||
}`}>
|
||||
<s.icon className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{s.title}</span>
|
||||
{step > s.id && <Check className="h-4 w-4 ml-1" />}
|
||||
<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-5 w-5 text-[var(--dg-text-muted)] mx-2" />
|
||||
<ChevronRight className="h-4 w-4 text-[var(--dg-text-muted)] mx-1" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -183,32 +274,27 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
|
||||
{/* Step 2: Select Account */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[var(--dg-text-secondary)] text-sm">请选择访问凭据(AccessKey)</p>
|
||||
<Button variant="outline" size="sm" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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 === account.id
|
||||
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.id)}
|
||||
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>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||
{(account.access_key || account.accessKey || '******').slice(0, 20)}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@@ -225,96 +311,241 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
|
||||
<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>
|
||||
<Button variant="outline" className="mt-3 border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
添加访问凭据
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Import Confirmation */}
|
||||
{/* 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">请选择要导入的云主机</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={handleSelectAll} disabled={cloudServers.length === 0}>
|
||||
{selectedServers.length === cloudServers.length && cloudServers.length > 0 ? '取消全选' : '全选'}
|
||||
</Button>
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-0">
|
||||
已选 {selectedServers.length} 台
|
||||
</Badge>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{isLoading ? (
|
||||
{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>
|
||||
<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="border rounded-lg border-[var(--dg-border)] overflow-hidden max-h-[350px] 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={handleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">实例ID</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>
|
||||
<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)]">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--dg-border)]">
|
||||
{cloudServers.length === 0 ? (
|
||||
<>
|
||||
{/* 搜索框 + 全选 */}
|
||||
<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>
|
||||
<td colSpan={7} className="px-3 py-12 text-center">
|
||||
<Server className="h-10 w-10 mx-auto mb-3 text-[var(--dg-text-muted)]" />
|
||||
<p className="text-[var(--dg-text-muted)]">暂无云主机</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">云主机发现功能尚未实现,请手动添加服务器</p>
|
||||
</td>
|
||||
<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>
|
||||
) : (
|
||||
cloudServers.map((server) => (
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--dg-border)]">
|
||||
{filteredRegions.map((region) => (
|
||||
<tr
|
||||
key={server.id}
|
||||
className={`hover:bg-[var(--dg-bg-hover)] ${selectedServers.includes(server.id) ? 'bg-blue-500/10' : ''}`}
|
||||
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={selectedServers.includes(server.id)}
|
||||
onCheckedChange={() => handleServerToggle(server.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{server.name}</td>
|
||||
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{server.hostname}</td>
|
||||
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{server.ip}</td>
|
||||
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{server.region}</td>
|
||||
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{server.spec}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={server.status === 'running'
|
||||
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
|
||||
: 'border-slate-500/30 text-slate-400 bg-slate-500/10'
|
||||
}
|
||||
>
|
||||
{server.status === 'running' ? '运行中' : '已停止'}
|
||||
</Badge>
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -322,7 +553,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
|
||||
{/* Footer */}
|
||||
<DialogFooter className="flex justify-between items-center">
|
||||
<div>
|
||||
{step > 1 && (
|
||||
{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" />
|
||||
上一步
|
||||
@@ -330,27 +561,48 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleClose} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
取消
|
||||
</Button>
|
||||
{step < 3 ? (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={(step === 1 && !selectedProvider) || (step === 2 && !selectedAccount)}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
下一步
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
{importResult ? (
|
||||
<Button onClick={handleClose} className="bg-blue-600 hover:bg-blue-700">
|
||||
完成
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={selectedServers.length === 0}
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
确认导入 ({selectedServers.length})
|
||||
</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>
|
||||
|
||||
@@ -86,7 +86,6 @@ export function TerminalDialog({
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
// 挂载终端到容器
|
||||
if (containerRef.current) {
|
||||
term.open(containerRef.current);
|
||||
requestAnimationFrame(() => {
|
||||
@@ -107,22 +106,7 @@ export function TerminalDialog({
|
||||
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
// resize 监听
|
||||
const handleResize = () => {
|
||||
if (fitRef.current && termRef.current) {
|
||||
try {
|
||||
fit.fit();
|
||||
const { cols, rows } = termRef.current;
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ status: 1, cols, rows }));
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
try {
|
||||
ws.close();
|
||||
term.dispose();
|
||||
@@ -133,24 +117,51 @@ export function TerminalDialog({
|
||||
};
|
||||
}, [open, serverId]);
|
||||
|
||||
// fullscreen 切换时重新 fit
|
||||
// 用 ResizeObserver 监听容器实际尺寸变化,自动 fit 终端
|
||||
// 这比 window resize 或 setTimeout 更可靠:
|
||||
// - 全屏/窗口切换时容器尺寸在 CSS 过渡期间持续变化,ResizeObserver 每次都会触发
|
||||
// - 不需要猜测延迟时间
|
||||
useEffect(() => {
|
||||
if (open && fitRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
fitRef.current?.fit();
|
||||
termRef.current?.focus();
|
||||
} catch { /* noop */ }
|
||||
});
|
||||
}
|
||||
}, [fullscreen, open]);
|
||||
if (!open) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let resizeTimer: ReturnType<typeof setTimeout>;
|
||||
const ro = new ResizeObserver(() => {
|
||||
// 防抖:CSS 过渡期间会连续触发多次,合并为最后一次
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
if (fitRef.current && termRef.current && wsRef.current) {
|
||||
try {
|
||||
fitRef.current.fit();
|
||||
termRef.current.focus();
|
||||
const { cols, rows } = termRef.current;
|
||||
if (wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ status: 1, cols, rows }));
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
}, 60);
|
||||
});
|
||||
|
||||
ro.observe(container);
|
||||
return () => {
|
||||
clearTimeout(resizeTimer);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
|
||||
fullscreen && '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
|
||||
// 禁用 CSS 过渡:终端窗口应该即时缩放,不需要动画
|
||||
'!duration-0 !transition-none',
|
||||
fullscreen
|
||||
? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
|
||||
: ''
|
||||
)}
|
||||
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '900px', width: '90vw' }}
|
||||
>
|
||||
|
||||
@@ -390,6 +390,23 @@ export const cloudApi = {
|
||||
deleteAccount: async (id: number | string) => {
|
||||
return http.delete(`/cloud-accounts/${id}`);
|
||||
},
|
||||
// ==================== 云同步 API ====================
|
||||
// 同步云账号的区域列表到本地
|
||||
syncRegions: async (id: number | string) => {
|
||||
return http.post(`/cloud-accounts/${id}/sync-regions`);
|
||||
},
|
||||
// 获取云账号的区域列表
|
||||
getAccountRegions: async (id: number | string) => {
|
||||
return http.get<ListResponse>(`/cloud-accounts/${id}/regions`);
|
||||
},
|
||||
// 发现指定区域的云主机实例
|
||||
discoverInstances: async (id: number | string, regions: string[]) => {
|
||||
return http.post<{ list: any[]; total: number; errors: string[] }>(`/cloud-accounts/${id}/discover-instances`, { regions });
|
||||
},
|
||||
// 导入选中的云主机实例为本地服务器
|
||||
importInstances: async (id: number | string, instances: any[]) => {
|
||||
return http.post<{ imported: number; skipped: number }>(`/cloud-accounts/${id}/import-instances`, { instances });
|
||||
},
|
||||
getDomains: async (params?: { page?: number; page_size?: number; keyword?: string }) => {
|
||||
return http.get<ListResponse>('/domains', params);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user