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:
Logan committed 2026-06-18 09:22:13 +08:00
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' }}
>
+17
View File
@@ -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);
},