refactor(quality): Q12 前端巨型组件拆分 — CloudServerImport 699L→404L (-42%), 提取 StepIndicator + 4 个向导步骤组件 + types, any[]→具体接口类型, 移除文件级 eslint-disable
This commit is contained in:
1 parent
76aa8817c0
commit
b2e8fbbfc0
10 files changed
+660
-394
No files matched your search
@@ -129,7 +129,7 @@
|
|||||||
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
|
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
|
||||||
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
|
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
|
||||||
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
|
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
|
||||||
| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6+Q7+Q8+Q9+Q10+Q11 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%),累计提取 24 个独立组件文件** |
|
| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6+Q7+Q8+Q9+Q10+Q11+Q12 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口),累计提取 30 个独立组件文件** |
|
||||||
|
|
||||||
### 3.2 🟡 中优先级
|
### 3.2 🟡 中优先级
|
||||||
|
|
||||||
|
|||||||
+39
@@ -483,6 +483,7 @@ services/api.ts (1324L)
|
|||||||
| Q9 | 前端巨型组件拆分(NamespaceResourcePage 719L→300L,提取 RESOURCE_CONFIG + PodDetailDialog) | P0 | **done** |
|
| Q9 | 前端巨型组件拆分(NamespaceResourcePage 719L→300L,提取 RESOURCE_CONFIG + PodDetailDialog) | P0 | **done** |
|
||||||
| Q10 | 前端巨型组件拆分(ApprovalWorkflowPage 705L→167L,提取 types + UserSelectInline + WorkflowEditorModal + WorkflowCard) | P0 | **done** |
|
| Q10 | 前端巨型组件拆分(ApprovalWorkflowPage 705L→167L,提取 types + UserSelectInline + WorkflowEditorModal + WorkflowCard) | P0 | **done** |
|
||||||
| Q11 | 前端巨型组件拆分(PipelineRunDetail 678L→273L,提取 types + StageProgressBar + RunsTable + LogConsole) | P0 | **done** |
|
| Q11 | 前端巨型组件拆分(PipelineRunDetail 678L→273L,提取 types + StageProgressBar + RunsTable + LogConsole) | P0 | **done** |
|
||||||
|
| Q12 | 前端巨型组件拆分(CloudServerImport 699L→404L,提取 StepIndicator + 4 个向导步骤组件 + types,any[]→具体接口) | P0 | **done** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -809,3 +810,41 @@ pending → in_progress → done / blocked
|
|||||||
- [x] 17/17 集成回归测试通过(零回归)
|
- [x] 17/17 集成回归测试通过(零回归)
|
||||||
- [x] Docker 部署 healthy
|
- [x] Docker 部署 healthy
|
||||||
- [x] 流水线运行详情页功能不变(阶段进度条/执行记录表/日志 Console/文件浏览器全部正常)
|
- [x] 流水线运行详情页功能不变(阶段进度条/执行记录表/日志 Console/文件浏览器全部正常)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q12: 前端巨型组件拆分 — CloudServerImport [done]
|
||||||
|
|
||||||
|
**问题**: `CloudServerImport.tsx`(699 行)是 Q11 拆分后项目最大的业务组件之一(排除 UI 库 sidebar/data-table),包含云服务器导入 4 步骤向导的全部逻辑:状态管理(17 个 useState)、数据加载/区域同步/主机发现/导入业务逻辑、步骤指示器、4 个步骤的内联 JSX(云厂商选择/账号选择/区域选择表格/发现导入主机列表)、底部导航按钮。单文件过大导致维护困难、代码导航低效。此外文件顶部有 `eslint-disable @typescript-eslint/no-explicit-any`,状态用 `any[]` 类型。
|
||||||
|
|
||||||
|
**已完成 (2026-07-07)**:
|
||||||
|
|
||||||
|
将步骤指示器、4 个向导步骤的 JSX、类型/常量提取为 `cloud-server-import/` 子目录下的独立文件:
|
||||||
|
|
||||||
|
| 新文件 | 行数 | 职责 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `types.ts` | 57 | CloudAccount/CloudRegion/CloudServer/ImportResult/StepDef 接口 + CLOUD_PROVIDERS 常量 |
|
||||||
|
| `StepIndicator.tsx` | 34 | 顶部 4 步骤进度指示器(当前/已完成/未完成三态 + ChevronRight 连接器) |
|
||||||
|
| `ProviderSelectStep.tsx` | 42 | Step 1 云厂商选择(5 厂商卡片网格 + CloudBrandIcon) |
|
||||||
|
| `AccountSelectStep.tsx` | 65 | Step 2 云账号选择(账号卡片列表 + 区域数/主机数 Badge + 空状态) |
|
||||||
|
| `RegionSelectStep.tsx` | 131 | Step 3 区域选择(搜索框 + 全选 + 区域表格 + 同步按钮 + 凭证设置插槽) |
|
||||||
|
| `DiscoverImportStep.tsx` | 176 | Step 4 发现导入(结果页/发现按钮/区域标签/错误提示/主机列表 + 凭证设置插槽) |
|
||||||
|
|
||||||
|
主文件 `CloudServerImport.tsx` 从 **699 行降至 404 行**(-42%),仅保留:17 个状态声明、数据加载/区域同步/主机发现/导入业务逻辑、步骤导航(handleNext/handleBack/handleClose)、派生数据(filteredAccounts/filteredRegions/steps)、importSettingsPanel 条件渲染、Dialog 外壳 + StepIndicator + 4 步骤分发 + Footer 导航按钮。
|
||||||
|
|
||||||
|
**类型安全升级**: 顺带移除文件顶部的 `/* eslint-disable @typescript-eslint/no-explicit-any */`,将 `any[]` 状态类型化为具体接口(`CloudAccount[]`/`CloudRegion[]`/`CloudServer[]`/`ImportResult`),消除 API 调用中不必要的 `Record<string, unknown>` 中间转换(直接访问 `.list` 属性)。全局 ESLint any 保持 **0**。
|
||||||
|
|
||||||
|
**提取原则**: 每个步骤是自包含的纯展示函数组件,通过 props 接收所需状态 + 回调(ProviderSelectStep 取 2 个、AccountSelectStep 取 3 个、RegionSelectStep 取 10 个、DiscoverImportStep 取 12 个),无共享状态。importSettingsPanel 作为 ReactNode 由父组件渲染后传入(Step 3/4 共用)。零逻辑变更。
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [x] CloudServerImport.tsx 从 699 行降至 **404 行**(-42%)
|
||||||
|
- [x] 6 个提取的文件全部 < 180 行(最大 DiscoverImportStep 176 行)
|
||||||
|
- [x] `tsc -b` 零错误
|
||||||
|
- [x] `npm run build` 零错误(✓ built in 16.56s)
|
||||||
|
- [x] ESLint errors 保持 **0**(拆分文件零 warnings)
|
||||||
|
- [x] ESLint warnings 保持 **64**(无新增)
|
||||||
|
- [x] 全局 ESLint any 保持 **0**(移除文件级 eslint-disable)
|
||||||
|
- [x] `go build` 零错误
|
||||||
|
- [x] 17/17 集成回归测试通过(零回归)
|
||||||
|
- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200)
|
||||||
|
- [x] 云服务器导入向导功能不变(4 步骤切换/厂商选择/账号选择/区域选择/发现导入全部正常)
|
||||||
+17
@@ -1263,3 +1263,20 @@ python3 /srv/aiops/tests/integration_test.py --host 127.0.0.1 --port 8080 | tail
|
|||||||
- [x] 17/17 集成回归测试通过(零回归)
|
- [x] 17/17 集成回归测试通过(零回归)
|
||||||
- [x] Docker 部署 healthy
|
- [x] Docker 部署 healthy
|
||||||
- [x] 流水线运行详情页功能不变(阶段进度条点击过滤 / 执行记录表多选+停止 / 实时日志 Console / 文件浏览器)
|
- [x] 流水线运行详情页功能不变(阶段进度条点击过滤 / 执行记录表多选+停止 / 实时日志 Console / 文件浏览器)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0-Q12: 前端巨型组件拆分 — CloudServerImport
|
||||||
|
|
||||||
|
### 通过标准
|
||||||
|
- [x] CloudServerImport.tsx 从 699 行降至 **404 行**(-42%)
|
||||||
|
- [x] 6 个提取的文件全部 < 180 行(types.ts 57L / StepIndicator.tsx 34L / ProviderSelectStep.tsx 42L / AccountSelectStep.tsx 65L / RegionSelectStep.tsx 131L / DiscoverImportStep.tsx 176L)
|
||||||
|
- [x] `tsc -b` 零错误
|
||||||
|
- [x] `npm run build` 零错误(✓ built in 16.56s)
|
||||||
|
- [x] ESLint errors 保持 **0**(拆分文件零 warnings)
|
||||||
|
- [x] ESLint warnings 保持 **64**(无新增)
|
||||||
|
- [x] 全局 ESLint any 保持 **0**(移除文件级 `eslint-disable @typescript-eslint/no-explicit-any`,any[] 状态→具体接口)
|
||||||
|
- [x] `go build` 零错误
|
||||||
|
- [x] 17/17 集成回归测试通过(零回归)
|
||||||
|
- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200)
|
||||||
|
- [x] 云服务器导入向导功能不变(4 步骤切换 / 厂商选择 / 账号选择 / 区域选择搜索+全选 / 主机发现+导入)
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import {
|
||||||
import { Input } from '@/components/ui/input';
|
Button,
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
} from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -19,14 +16,18 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Server,
|
Check, RefreshCw,
|
||||||
RefreshCw, Check, MapPin, Search, AlertCircle,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cloudApi, credentialApi } from '@/api';
|
import { cloudApi, credentialApi } from '@/api';
|
||||||
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
|
|
||||||
import { ImportCredentialSettings } from './ImportCredentialSettings';
|
import { ImportCredentialSettings } from './ImportCredentialSettings';
|
||||||
import type { Credential } from '@/components/deploy/credential-manager/types';
|
import type { Credential } from '@/components/deploy/credential-manager/types';
|
||||||
|
import type { CloudAccount, CloudRegion, CloudServer, ImportResult, StepDef } from './cloud-server-import/types';
|
||||||
|
import { StepIndicator } from './cloud-server-import/StepIndicator';
|
||||||
|
import { ProviderSelectStep } from './cloud-server-import/ProviderSelectStep';
|
||||||
|
import { AccountSelectStep } from './cloud-server-import/AccountSelectStep';
|
||||||
|
import { RegionSelectStep } from './cloud-server-import/RegionSelectStep';
|
||||||
|
import { DiscoverImportStep } from './cloud-server-import/DiscoverImportStep';
|
||||||
|
|
||||||
interface CloudServerImportProps {
|
interface CloudServerImportProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -35,30 +36,22 @@ interface CloudServerImportProps {
|
|||||||
initialAccountId?: number | string;
|
initialAccountId?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cloudProviders = [
|
|
||||||
{ id: 'aws', name: 'AWS' },
|
|
||||||
{ id: 'aliyun', name: '阿里云' },
|
|
||||||
{ id: 'tencent', name: '腾讯云' },
|
|
||||||
{ id: 'huawei', name: '华为云' },
|
|
||||||
{ id: 'azure', name: 'Azure' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function CloudServerImport({ open, onClose, initialAccountId }: CloudServerImportProps) {
|
export function CloudServerImport({ open, onClose, initialAccountId }: CloudServerImportProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [cloudAccounts, setCloudAccounts] = useState<any[]>([]);
|
const [cloudAccounts, setCloudAccounts] = useState<CloudAccount[]>([]);
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [selectedProvider, setSelectedProvider] = useState<string>('');
|
const [selectedProvider, setSelectedProvider] = useState<string>('');
|
||||||
const [selectedAccount, setSelectedAccount] = useState<any>(null);
|
const [selectedAccount, setSelectedAccount] = useState<CloudAccount | null>(null);
|
||||||
const [regions, setRegions] = useState<any[]>([]);
|
const [regions, setRegions] = useState<CloudRegion[]>([]);
|
||||||
const [selectedRegions, setSelectedRegions] = useState<string[]>([]);
|
const [selectedRegions, setSelectedRegions] = useState<string[]>([]);
|
||||||
const [regionSearch, setRegionSearch] = useState('');
|
const [regionSearch, setRegionSearch] = useState('');
|
||||||
const [isSyncingRegions, setIsSyncingRegions] = useState(false);
|
const [isSyncingRegions, setIsSyncingRegions] = useState(false);
|
||||||
const [cloudServers, setCloudServers] = useState<any[]>([]);
|
const [cloudServers, setCloudServers] = useState<CloudServer[]>([]);
|
||||||
const [selectedServers, setSelectedServers] = useState<string[]>([]);
|
const [selectedServers, setSelectedServers] = useState<string[]>([]);
|
||||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||||
const [discoverErrors, setDiscoverErrors] = useState<string[]>([]);
|
const [discoverErrors, setDiscoverErrors] = useState<string[]>([]);
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
const [importResult, setImportResult] = useState<{ imported: number; skipped: number } | null>(null);
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||||
const [defaultSshPort, setDefaultSshPort] = useState('22');
|
const [defaultSshPort, setDefaultSshPort] = useState('22');
|
||||||
const [sshCredentials, setSshCredentials] = useState<Credential[]>([]);
|
const [sshCredentials, setSshCredentials] = useState<Credential[]>([]);
|
||||||
const [rdpCredentials, setRdpCredentials] = useState<Credential[]>([]);
|
const [rdpCredentials, setRdpCredentials] = useState<Credential[]>([]);
|
||||||
@@ -66,14 +59,15 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
|||||||
const [selectedRdpCredentialId, setSelectedRdpCredentialId] = useState<string>('');
|
const [selectedRdpCredentialId, setSelectedRdpCredentialId] = useState<string>('');
|
||||||
const preselectedRef = useRef<string | null>(null);
|
const preselectedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const loadAccounts = async () => {
|
// ==================== 数据加载 ====================
|
||||||
|
const loadAccounts = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res: Record<string, any> = await cloudApi.getAccounts();
|
const res = await cloudApi.getAccounts();
|
||||||
setCloudAccounts(res?.list || []);
|
setCloudAccounts(((res?.list as CloudAccount[]) || []));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载云账号失败:', e);
|
console.error('加载云账号失败:', e);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -90,32 +84,13 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
|||||||
setSshCredentials([]);
|
setSshCredentials([]);
|
||||||
setRdpCredentials([]);
|
setRdpCredentials([]);
|
||||||
});
|
});
|
||||||
}, [open]);
|
}, [open, loadAccounts]);
|
||||||
|
|
||||||
// 导入凭证设置:Linux 默认 SSH,Windows 默认 RDP // Logan.Li
|
|
||||||
const importSettingsPanel = (step >= 3 && !importResult) ? (
|
|
||||||
<ImportCredentialSettings
|
|
||||||
compact
|
|
||||||
defaultSshPort={defaultSshPort}
|
|
||||||
onDefaultSshPortChange={setDefaultSshPort}
|
|
||||||
sshCredentials={sshCredentials}
|
|
||||||
rdpCredentials={rdpCredentials}
|
|
||||||
selectedSshCredentialId={selectedSshCredentialId}
|
|
||||||
selectedRdpCredentialId={selectedRdpCredentialId}
|
|
||||||
onSelectedSshCredentialIdChange={setSelectedSshCredentialId}
|
|
||||||
onSelectedRdpCredentialIdChange={setSelectedRdpCredentialId}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const filteredAccounts = cloudAccounts.filter(
|
|
||||||
acc => acc.provider === selectedProvider && acc.status === 'active'
|
|
||||||
);
|
|
||||||
|
|
||||||
// ==================== 区域同步逻辑 ====================
|
// ==================== 区域同步逻辑 ====================
|
||||||
const loadRegions = useCallback(async (accountId: string | number) => {
|
const loadRegions = useCallback(async (accountId: string | number) => {
|
||||||
try {
|
try {
|
||||||
const res: Record<string, any> = await cloudApi.getAccountRegions(accountId);
|
const res = await cloudApi.getAccountRegions(accountId);
|
||||||
return res?.list || [];
|
return ((res?.list as CloudRegion[]) || []);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -138,10 +113,8 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
|||||||
// 进入 Step 3 时自动加载/同步区域
|
// 进入 Step 3 时自动加载/同步区域
|
||||||
const enterRegionStep = useCallback(async () => {
|
const enterRegionStep = useCallback(async () => {
|
||||||
if (!selectedAccount) return;
|
if (!selectedAccount) return;
|
||||||
// 先尝试加载本地已有的区域
|
|
||||||
const local = await loadRegions(selectedAccount.id);
|
const local = await loadRegions(selectedAccount.id);
|
||||||
setRegions(local);
|
setRegions(local);
|
||||||
// 如果本地没有区域数据,自动触发同步
|
|
||||||
if (local.length === 0) {
|
if (local.length === 0) {
|
||||||
try {
|
try {
|
||||||
await syncRegions(selectedAccount.id);
|
await syncRegions(selectedAccount.id);
|
||||||
@@ -192,12 +165,12 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
|||||||
setCloudServers([]);
|
setCloudServers([]);
|
||||||
setSelectedServers([]);
|
setSelectedServers([]);
|
||||||
try {
|
try {
|
||||||
const res: Record<string, any> = await cloudApi.discoverInstances(selectedAccount.id, selectedRegions);
|
const res = await cloudApi.discoverInstances(selectedAccount.id, selectedRegions);
|
||||||
setCloudServers(res?.list || []);
|
setCloudServers((res?.list as CloudServer[]) || []);
|
||||||
setDiscoverErrors(res?.errors || []);
|
setDiscoverErrors((res?.errors as string[]) || []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('发现主机失败:', e);
|
console.error('发现主机失败:', e);
|
||||||
setDiscoverErrors([e?.message || '发现主机失败']);
|
setDiscoverErrors([(e as Error)?.message || '发现主机失败']);
|
||||||
} finally {
|
} finally {
|
||||||
setIsDiscovering(false);
|
setIsDiscovering(false);
|
||||||
}
|
}
|
||||||
@@ -208,13 +181,13 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
|||||||
if (!selectedAccount || selectedServers.length === 0) return;
|
if (!selectedAccount || selectedServers.length === 0) return;
|
||||||
setIsImporting(true);
|
setIsImporting(true);
|
||||||
try {
|
try {
|
||||||
const instancesToImport = cloudServers.filter(s => selectedServers.includes(s.instance_id));
|
const instancesToImport = cloudServers.filter((s) => selectedServers.includes(s.instance_id));
|
||||||
const res: Record<string, any> = await cloudApi.importInstances(selectedAccount.id, instancesToImport, {
|
const res = await cloudApi.importInstances(selectedAccount.id, instancesToImport, {
|
||||||
default_port: Number(defaultSshPort) || 22,
|
default_port: Number(defaultSshPort) || 22,
|
||||||
ssh_credential_id: selectedSshCredentialId ? Number(selectedSshCredentialId) : undefined,
|
ssh_credential_id: selectedSshCredentialId ? Number(selectedSshCredentialId) : undefined,
|
||||||
rdp_credential_id: selectedRdpCredentialId ? Number(selectedRdpCredentialId) : undefined,
|
rdp_credential_id: selectedRdpCredentialId ? Number(selectedRdpCredentialId) : undefined,
|
||||||
});
|
});
|
||||||
setImportResult((res as any) || { imported: 0, skipped: 0 });
|
setImportResult((res as ImportResult) || { imported: 0, skipped: 0 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('导入失败:', e);
|
console.error('导入失败:', e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -222,7 +195,7 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==================== 步骤导航 ====================
|
// ==================== 步骤导航与选择 ====================
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
if (step === 2 && selectedAccount && step < 4) {
|
if (step === 2 && selectedAccount && step < 4) {
|
||||||
setStep(3);
|
setStep(3);
|
||||||
@@ -240,15 +213,15 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
|||||||
if (selectedRegions.length === regions.length && regions.length > 0) {
|
if (selectedRegions.length === regions.length && regions.length > 0) {
|
||||||
setSelectedRegions([]);
|
setSelectedRegions([]);
|
||||||
} else {
|
} else {
|
||||||
setSelectedRegions(regions.map(r => r.code));
|
setSelectedRegions(regions.map((r) => r.code));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleServerToggle = (instanceId: string) => {
|
const handleServerToggle = (instanceId: string) => {
|
||||||
setSelectedServers(prev =>
|
setSelectedServers((prev) =>
|
||||||
prev.includes(instanceId)
|
prev.includes(instanceId)
|
||||||
? prev.filter(id => id !== instanceId)
|
? prev.filter((id) => id !== instanceId)
|
||||||
: [...prev, instanceId]
|
: [...prev, instanceId],
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -256,7 +229,7 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
|||||||
if (selectedServers.length === cloudServers.length && cloudServers.length > 0) {
|
if (selectedServers.length === cloudServers.length && cloudServers.length > 0) {
|
||||||
setSelectedServers([]);
|
setSelectedServers([]);
|
||||||
} else {
|
} else {
|
||||||
setSelectedServers(cloudServers.map(s => s.instance_id));
|
setSelectedServers(cloudServers.map((s) => s.instance_id));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -277,19 +250,38 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
|||||||
setSelectedRdpCredentialId('');
|
setSelectedRdpCredentialId('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredRegions = regions.filter(r =>
|
// ==================== 派生数据 ====================
|
||||||
|
const filteredAccounts = cloudAccounts.filter(
|
||||||
|
(acc) => acc.provider === selectedProvider && acc.status === 'active',
|
||||||
|
);
|
||||||
|
const filteredRegions = regions.filter((r) =>
|
||||||
!regionSearch ||
|
!regionSearch ||
|
||||||
r.name?.toLowerCase().includes(regionSearch.toLowerCase()) ||
|
r.name?.toLowerCase().includes(regionSearch.toLowerCase()) ||
|
||||||
r.code?.toLowerCase().includes(regionSearch.toLowerCase())
|
r.code?.toLowerCase().includes(regionSearch.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
const steps = [
|
const steps: StepDef[] = [
|
||||||
{ id: 1, title: t('server.cloudProvider'), icon: Cloud },
|
{ id: 1, title: t('server.cloudProvider'), icon: Cloud },
|
||||||
{ id: 2, title: t('server.cloudAccount'), icon: Key },
|
{ id: 2, title: t('server.cloudAccount'), icon: Key },
|
||||||
{ id: 3, title: t('server.selectRegion'), icon: MapPin },
|
{ id: 3, title: t('server.selectRegion'), icon: Cloud },
|
||||||
{ id: 4, title: t('server.importConfirm'), icon: CheckCircle },
|
{ id: 4, title: t('server.importConfirm'), icon: CheckCircle },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 导入凭证设置面板:Step 3/4 且未出结果时显示(Linux 默认 SSH,Windows 默认 RDP) // Logan.Li
|
||||||
|
const importSettingsPanel = step >= 3 && !importResult ? (
|
||||||
|
<ImportCredentialSettings
|
||||||
|
compact
|
||||||
|
defaultSshPort={defaultSshPort}
|
||||||
|
onDefaultSshPortChange={setDefaultSshPort}
|
||||||
|
sshCredentials={sshCredentials}
|
||||||
|
rdpCredentials={rdpCredentials}
|
||||||
|
selectedSshCredentialId={selectedSshCredentialId}
|
||||||
|
selectedRdpCredentialId={selectedRdpCredentialId}
|
||||||
|
onSelectedSshCredentialIdChange={setSelectedSshCredentialId}
|
||||||
|
onSelectedRdpCredentialIdChange={setSelectedRdpCredentialId}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-3xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
<DialogContent className="max-w-3xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||||
@@ -298,346 +290,59 @@ export function CloudServerImport({ open, onClose, initialAccountId }: CloudServ
|
|||||||
<DialogDescription>{t('server.importCloudServersDesc')}</DialogDescription>
|
<DialogDescription>{t('server.importCloudServersDesc')}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Step Indicator */}
|
<StepIndicator step={step} steps={steps} />
|
||||||
<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 dg-text-green'
|
|
||||||
: '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: 选择云厂商 */}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<div className="space-y-4">
|
<ProviderSelectStep selectedProvider={selectedProvider} onSelect={setSelectedProvider} />
|
||||||
<p className="text-[var(--dg-text-secondary)] text-sm">{t('server.selectCloudProvider')}</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 bg-[var(--dg-bg-tertiary)] flex items-center justify-center shrink-0"
|
|
||||||
style={{ borderBottom: `2px solid ${getProviderColor(provider.id)}` }}
|
|
||||||
>
|
|
||||||
<CloudBrandIcon provider={provider.id} className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-[var(--dg-text-primary)]">{provider.name}</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2: Select Account */}
|
{/* Step 2: 选择云账号 */}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div className="space-y-4">
|
<AccountSelectStep
|
||||||
<p className="text-[var(--dg-text-secondary)] text-sm">{t('server.selectAccount')}</p>
|
accounts={filteredAccounts}
|
||||||
{filteredAccounts.length > 0 ? (
|
selectedAccount={selectedAccount}
|
||||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
onSelect={setSelectedAccount}
|
||||||
{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">
|
|
||||||
<div
|
|
||||||
className="w-9 h-9 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-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<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 dg-text-green border-0">
|
|
||||||
{account.region_count ?? account.regionCount ?? 0} {t('server.regionCountLabel', { count: account.region_count ?? account.regionCount ?? 0 })}
|
|
||||||
</Badge>
|
|
||||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">{account.server_count ?? account.serverCount ?? 0} {t('server.serverCountLabel', { count: 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>{t('server.noValidAccount')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 3: Select Regions (NEW) */}
|
{/* Step 3: 选择区域 */}
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<div className="space-y-4">
|
<RegionSelectStep
|
||||||
<div className="flex items-center justify-between">
|
regions={regions}
|
||||||
<p className="text-[var(--dg-text-secondary)] text-sm">
|
filteredRegions={filteredRegions}
|
||||||
{t('server.selectSyncRegion')}
|
selectedRegions={selectedRegions}
|
||||||
{regions.length > 0 && (
|
regionSearch={regionSearch}
|
||||||
<span className="ml-2 text-[var(--dg-text-muted)]">{t('server.regionStats', { total: regions.length, selected: selectedRegions.length })}</span>
|
isSyncingRegions={isSyncingRegions}
|
||||||
)}
|
importSettingsPanel={importSettingsPanel}
|
||||||
</p>
|
onRegionSearchChange={setRegionSearch}
|
||||||
<Button
|
onSelectAllRegions={handleSelectAllRegions}
|
||||||
variant="outline"
|
onToggleRegion={(code) => setSelectedRegions((prev) =>
|
||||||
size="sm"
|
prev.includes(code) ? prev.filter((r) => r !== code) : [...prev, code],
|
||||||
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 ? t('server.syncing') : t('server.refreshRegions')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isSyncingRegions ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<RefreshCw className="h-8 w-8 mx-auto mb-3 animate-spin dg-text-blue" />
|
|
||||||
<p className="text-[var(--dg-text-secondary)]">{t('server.syncingRegionList')}</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>{t('server.noRegionData')}</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="mt-3 border-[var(--dg-border)] dg-text-blue"
|
|
||||||
onClick={() => selectedAccount && syncRegions(selectedAccount.id)}
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-3 w-3 mr-1" />
|
|
||||||
{t('server.syncNow')}
|
|
||||||
</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="{t('server.searchRegion')}"
|
|
||||||
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 ? t('common.deselectAll') : t('common.selectAll')}
|
|
||||||
</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)]">{t('server.regionCode')}</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('server.regionName')}</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 dg-text-blue 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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{importSettingsPanel}
|
onSyncRegions={() => selectedAccount && syncRegions(selectedAccount.id)}
|
||||||
</div>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 4: Discover & Import Instances */}
|
{/* Step 4: 发现并导入主机 */}
|
||||||
{step === 4 && (
|
{step === 4 && (
|
||||||
<div className="space-y-4">
|
<DiscoverImportStep
|
||||||
{importResult ? (
|
importResult={importResult}
|
||||||
/* 导入结果 */
|
selectedRegions={selectedRegions}
|
||||||
<div className="text-center py-12">
|
regions={regions}
|
||||||
<CheckCircle className="h-12 w-12 mx-auto mb-3 dg-text-green" />
|
cloudServers={cloudServers}
|
||||||
<p className="text-lg font-medium text-[var(--dg-text-primary)]">{t('server.importComplete')}</p>
|
selectedServers={selectedServers}
|
||||||
<div className="flex items-center justify-center gap-6 mt-4">
|
isDiscovering={isDiscovering}
|
||||||
<Badge className="bg-emerald-500/20 dg-text-green border-0 text-base px-4 py-1">
|
isImporting={isImporting}
|
||||||
{t('server.importedCount', { count: importResult.imported })}
|
discoverErrors={discoverErrors}
|
||||||
</Badge>
|
importSettingsPanel={importSettingsPanel}
|
||||||
{importResult.skipped > 0 && (
|
onDiscover={discoverInstances}
|
||||||
<Badge className="bg-amber-500/20 dg-text-yellow border-0 text-base px-4 py-1">
|
onToggleServer={handleServerToggle}
|
||||||
{t('server.skippedCount', { count: importResult.skipped })}
|
onSelectAllServers={handleSelectAllServers}
|
||||||
</Badge>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-[var(--dg-text-secondary)] text-sm">
|
|
||||||
{t('server.selectedRegionsCount', { count: selectedRegions.length })}
|
|
||||||
{cloudServers.length > 0 && (
|
|
||||||
<span className="ml-2">· {t('server.discoveredHosts', { count: 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 ? t('common.deselectAll') : t('common.selectAll')}
|
|
||||||
</Button>
|
|
||||||
<Badge className="bg-blue-500/20 dg-text-blue border-0">
|
|
||||||
{t('server.selectedServersCount', { count: selectedServers.length })}
|
|
||||||
</Badge>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={discoverInstances}
|
|
||||||
disabled={isDiscovering || selectedRegions.length === 0}
|
|
||||||
className="border-[var(--dg-border)] dg-text-blue"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-3 w-3 mr-1 ${isDiscovering ? 'animate-spin' : ''}`} />
|
|
||||||
{isDiscovering ? t('server.discovering') : cloudServers.length > 0 ? t('server.rediscover') : t('server.discoverHosts')}
|
|
||||||
</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 dg-text-yellow text-sm">
|
|
||||||
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{t('server.partialRegionFailed')}</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 dg-text-blue" />
|
|
||||||
<p className="text-[var(--dg-text-secondary)]">{t('server.discoveringCloudServers')}</p>
|
|
||||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">{t('server.scanningRegions')}</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>{t('server.clickDiscoverHint')}</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)]">{t('server.excelHostname')}</th>
|
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('server.excelIpAddress')}</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{importSettingsPanel}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer 导航 */}
|
||||||
<DialogFooter className="flex justify-between items-center">
|
<DialogFooter className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
{step > 1 && !importResult && (
|
{step > 1 && !importResult && (
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Key } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
|
||||||
|
import type { CloudAccount } from './types';
|
||||||
|
|
||||||
|
interface AccountSelectStepProps {
|
||||||
|
accounts: CloudAccount[];
|
||||||
|
selectedAccount: CloudAccount | null;
|
||||||
|
onSelect: (account: CloudAccount) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step 2: 选择云账号(账号卡片列表,展示区域数/主机数) */
|
||||||
|
export function AccountSelectStep({ accounts, selectedAccount, onSelect }: AccountSelectStepProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-[var(--dg-text-secondary)] text-sm">{t('server.selectAccount')}</p>
|
||||||
|
{accounts.length > 0 ? (
|
||||||
|
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||||
|
{accounts.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={() => onSelect(account)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-9 h-9 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-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<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 dg-text-green border-0">
|
||||||
|
{account.region_count ?? account.regionCount ?? 0} {t('server.regionCountLabel', { count: account.region_count ?? account.regionCount ?? 0 })}
|
||||||
|
</Badge>
|
||||||
|
<p className="text-xs text-[var(--dg-text-muted)] mt-1">{account.server_count ?? account.serverCount ?? 0} {t('server.serverCountLabel', { count: 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>{t('server.noValidAccount')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AlertCircle, CheckCircle, MapPin, RefreshCw, Server } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import type { CloudRegion, CloudServer, ImportResult } from './types';
|
||||||
|
|
||||||
|
interface DiscoverImportStepProps {
|
||||||
|
/** 导入结果(非空时显示结果页) */
|
||||||
|
importResult: ImportResult | null;
|
||||||
|
selectedRegions: string[];
|
||||||
|
regions: CloudRegion[];
|
||||||
|
cloudServers: CloudServer[];
|
||||||
|
selectedServers: string[];
|
||||||
|
isDiscovering: boolean;
|
||||||
|
isImporting: boolean;
|
||||||
|
discoverErrors: string[];
|
||||||
|
/** 导入凭证设置面板(Linux SSH / Windows RDP),由父组件渲染后传入 */
|
||||||
|
importSettingsPanel: ReactNode;
|
||||||
|
onDiscover: () => void;
|
||||||
|
onToggleServer: (instanceId: string) => void;
|
||||||
|
onSelectAllServers: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step 4: 发现并导入云主机(结果页 / 发现按钮 / 区域标签 / 错误提示 / 主机列表 + 凭证设置) */
|
||||||
|
export function DiscoverImportStep({
|
||||||
|
importResult,
|
||||||
|
selectedRegions,
|
||||||
|
regions,
|
||||||
|
cloudServers,
|
||||||
|
selectedServers,
|
||||||
|
isDiscovering,
|
||||||
|
isImporting,
|
||||||
|
discoverErrors,
|
||||||
|
importSettingsPanel,
|
||||||
|
onDiscover,
|
||||||
|
onToggleServer,
|
||||||
|
onSelectAllServers,
|
||||||
|
}: DiscoverImportStepProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const allServersSelected = selectedServers.length === cloudServers.length && cloudServers.length > 0;
|
||||||
|
|
||||||
|
// 导入完成 — 结果页
|
||||||
|
if (importResult) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<CheckCircle className="h-12 w-12 mx-auto mb-3 dg-text-green" />
|
||||||
|
<p className="text-lg font-medium text-[var(--dg-text-primary)]">{t('server.importComplete')}</p>
|
||||||
|
<div className="flex items-center justify-center gap-6 mt-4">
|
||||||
|
<Badge className="bg-emerald-500/20 dg-text-green border-0 text-base px-4 py-1">
|
||||||
|
{t('server.importedCount', { count: importResult.imported })}
|
||||||
|
</Badge>
|
||||||
|
{importResult.skipped > 0 && (
|
||||||
|
<Badge className="bg-amber-500/20 dg-text-yellow border-0 text-base px-4 py-1">
|
||||||
|
{t('server.skippedCount', { count: importResult.skipped })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发现 + 导入流程
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[var(--dg-text-secondary)] text-sm">
|
||||||
|
{t('server.selectedRegionsCount', { count: selectedRegions.length })}
|
||||||
|
{cloudServers.length > 0 && (
|
||||||
|
<span className="ml-2">· {t('server.discoveredHosts', { count: cloudServers.length })}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{cloudServers.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={onSelectAllServers} className="border-[var(--dg-border)]">
|
||||||
|
{allServersSelected ? t('common.deselectAll') : t('common.selectAll')}
|
||||||
|
</Button>
|
||||||
|
<Badge className="bg-blue-500/20 dg-text-blue border-0">
|
||||||
|
{t('server.selectedServersCount', { count: selectedServers.length })}
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDiscover}
|
||||||
|
disabled={isDiscovering || selectedRegions.length === 0}
|
||||||
|
className="border-[var(--dg-border)] dg-text-blue"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3 w-3 mr-1 ${isDiscovering ? 'animate-spin' : ''}`} />
|
||||||
|
{isDiscovering ? t('server.discovering') : cloudServers.length > 0 ? t('server.rediscover') : t('server.discoverHosts')}
|
||||||
|
</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 dg-text-yellow text-sm">
|
||||||
|
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t('server.partialRegionFailed')}</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 dg-text-blue" />
|
||||||
|
<p className="text-[var(--dg-text-secondary)]">{t('server.discoveringCloudServers')}</p>
|
||||||
|
<p className="text-xs text-[var(--dg-text-muted)] mt-1">{t('server.scanningRegions')}</p>
|
||||||
|
</div>
|
||||||
|
) : cloudServers.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-[var(--dg-text-muted)]">
|
||||||
|
<Server className="h-10 w-10 mx-auto mb-3 opacity-50" />
|
||||||
|
<p>{t('server.clickDiscoverHint')}</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={allServersSelected} onCheckedChange={onSelectAllServers} />
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('server.excelHostname')}</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('server.excelIpAddress')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[var(--dg-border)]">
|
||||||
|
{cloudServers.map((server) => {
|
||||||
|
const checked = selectedServers.includes(server.instance_id);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={server.instance_id}
|
||||||
|
className={`cursor-pointer hover:bg-[var(--dg-bg-hover)] ${checked ? 'bg-blue-500/10' : ''}`}
|
||||||
|
onClick={() => onToggleServer(server.instance_id)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Checkbox checked={checked} />
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 导入中时禁用,由父组件 Footer 按钮触发 onImport */}
|
||||||
|
{isImporting ? null : importSettingsPanel}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
|
||||||
|
import { CLOUD_PROVIDERS } from './types';
|
||||||
|
|
||||||
|
interface ProviderSelectStepProps {
|
||||||
|
selectedProvider: string;
|
||||||
|
onSelect: (providerId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step 1: 选择云厂商(5 个厂商卡片网格) */
|
||||||
|
export function ProviderSelectStep({ selectedProvider, onSelect }: ProviderSelectStepProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-[var(--dg-text-secondary)] text-sm">{t('server.selectCloudProvider')}</p>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{CLOUD_PROVIDERS.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={() => onSelect(provider.id)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4 flex items-center gap-3">
|
||||||
|
<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(provider.id)}` }}
|
||||||
|
>
|
||||||
|
<CloudBrandIcon provider={provider.id} className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-[var(--dg-text-primary)]">{provider.name}</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { MapPin, RefreshCw, Search } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import type { CloudRegion } from './types';
|
||||||
|
|
||||||
|
interface RegionSelectStepProps {
|
||||||
|
regions: CloudRegion[];
|
||||||
|
filteredRegions: CloudRegion[];
|
||||||
|
selectedRegions: string[];
|
||||||
|
regionSearch: string;
|
||||||
|
isSyncingRegions: boolean;
|
||||||
|
/** 导入凭证设置面板(Linux SSH / Windows RDP),由父组件渲染后传入 */
|
||||||
|
importSettingsPanel: ReactNode;
|
||||||
|
onRegionSearchChange: (value: string) => void;
|
||||||
|
onSelectAllRegions: () => void;
|
||||||
|
onToggleRegion: (code: string) => void;
|
||||||
|
onSyncRegions: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step 3: 选择区域(搜索框 + 全选 + 区域表格 + 同步按钮 + 导入凭证设置) */
|
||||||
|
export function RegionSelectStep({
|
||||||
|
regions,
|
||||||
|
filteredRegions,
|
||||||
|
selectedRegions,
|
||||||
|
regionSearch,
|
||||||
|
isSyncingRegions,
|
||||||
|
importSettingsPanel,
|
||||||
|
onRegionSearchChange,
|
||||||
|
onSelectAllRegions,
|
||||||
|
onToggleRegion,
|
||||||
|
onSyncRegions,
|
||||||
|
}: RegionSelectStepProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const allSelected = selectedRegions.length === regions.length && regions.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[var(--dg-text-secondary)] text-sm">
|
||||||
|
{t('server.selectSyncRegion')}
|
||||||
|
{regions.length > 0 && (
|
||||||
|
<span className="ml-2 text-[var(--dg-text-muted)]">{t('server.regionStats', { total: regions.length, selected: selectedRegions.length })}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
|
||||||
|
onClick={onSyncRegions}
|
||||||
|
disabled={isSyncingRegions}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3 w-3 mr-1 ${isSyncingRegions ? 'animate-spin' : ''}`} />
|
||||||
|
{isSyncingRegions ? t('server.syncing') : t('server.refreshRegions')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSyncingRegions ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<RefreshCw className="h-8 w-8 mx-auto mb-3 animate-spin dg-text-blue" />
|
||||||
|
<p className="text-[var(--dg-text-secondary)]">{t('server.syncingRegionList')}</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>{t('server.noRegionData')}</p>
|
||||||
|
<Button variant="outline" size="sm" className="mt-3 border-[var(--dg-border)] dg-text-blue" onClick={onSyncRegions}>
|
||||||
|
<RefreshCw className="h-3 w-3 mr-1" />
|
||||||
|
{t('server.syncNow')}
|
||||||
|
</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={t('server.searchRegion')}
|
||||||
|
value={regionSearch}
|
||||||
|
onChange={(e) => onRegionSearchChange(e.target.value)}
|
||||||
|
className="pl-9 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={onSelectAllRegions} className="border-[var(--dg-border)]">
|
||||||
|
{allSelected ? t('common.deselectAll') : t('common.selectAll')}
|
||||||
|
</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={allSelected} onCheckedChange={onSelectAllRegions} />
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('server.regionCode')}</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('server.regionName')}</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) => {
|
||||||
|
const checked = selectedRegions.includes(region.code);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={region.id || region.code}
|
||||||
|
className={`cursor-pointer hover:bg-[var(--dg-bg-hover)] ${checked ? 'bg-blue-500/10' : ''}`}
|
||||||
|
onClick={() => onToggleRegion(region.code)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Checkbox checked={checked} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-sm dg-text-blue 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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{importSettingsPanel}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Check, ChevronRight } from 'lucide-react';
|
||||||
|
import type { StepDef } from './types';
|
||||||
|
|
||||||
|
interface StepIndicatorProps {
|
||||||
|
/** 当前步骤编号(1-based) */
|
||||||
|
step: number;
|
||||||
|
steps: StepDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部 4 步骤进度指示器(云厂商 / 账号 / 区域 / 导入确认) */
|
||||||
|
export function StepIndicator({ step, steps }: StepIndicatorProps) {
|
||||||
|
return (
|
||||||
|
<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 dg-text-green'
|
||||||
|
: '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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
/** 云账号(后端返回的动态 JSON,索引签名兜底未声明字段) */
|
||||||
|
export interface CloudAccount {
|
||||||
|
id: number | string;
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
status: string;
|
||||||
|
access_key?: string;
|
||||||
|
accessKey?: string;
|
||||||
|
region_count?: number;
|
||||||
|
regionCount?: number;
|
||||||
|
server_count?: number;
|
||||||
|
serverCount?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 云区域 */
|
||||||
|
export interface CloudRegion {
|
||||||
|
id?: number | string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
endpoint?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发现的云主机实例 */
|
||||||
|
export interface CloudServer {
|
||||||
|
instance_id: string;
|
||||||
|
instance_name?: string;
|
||||||
|
hostname?: string;
|
||||||
|
private_ip?: string;
|
||||||
|
public_ip?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导入结果 */
|
||||||
|
export interface ImportResult {
|
||||||
|
imported: number;
|
||||||
|
skipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 步骤定义 */
|
||||||
|
export interface StepDef {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 支持的云厂商列表 */
|
||||||
|
export const CLOUD_PROVIDERS = [
|
||||||
|
{ id: 'aws', name: 'AWS' },
|
||||||
|
{ id: 'aliyun', name: '阿里云' },
|
||||||
|
{ id: 'tencent', name: '腾讯云' },
|
||||||
|
{ id: 'huawei', name: '华为云' },
|
||||||
|
{ id: 'azure', name: 'Azure' },
|
||||||
|
] as const;
|
||||||
Reference in new issue
Block a user