import { useState, useEffect } from 'react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from '@/components/ui/dialog'; import { Download, Upload, FileSpreadsheet, Check, X, AlertCircle, ChevronRight, ChevronLeft, Loader2, } from 'lucide-react'; import { regionApi, serverApi, credentialApi } from '@/services/api'; interface RegionOption { id: string; name: string; code: string; } interface ExcelServerImportProps { open: boolean; onClose: () => void; } type PreviewRow = { row: number; hostname: string; ip: string; port: number; username: string; os: string; cpu: number; memory: number; disk: number; tags: string; status: string; message: string; }; // 解析单行 CSV(支持引号包裹与转义) // Logan.Li function parseCSVLine(line: string): string[] { const result: string[] = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const ch = line[i]; if (inQuotes) { if (ch === '"') { if (line[i + 1] === '"') { current += '"'; i++; } else inQuotes = false; } else { current += ch; } } else if (ch === '"') { inQuotes = true; } else if (ch === ',') { result.push(current); current = ''; } else { current += ch; } } result.push(current); return result.map((s) => s.trim()); } // 解析整个 CSV 文本为对象数组(首行为表头) // Logan.Li function parseCSV(text: string): Record[] { const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter((l) => l.trim()); if (lines.length < 2) return []; const headers = parseCSVLine(lines[0]).map((h) => h.toLowerCase()); const rows: Record[] = []; for (let i = 1; i < lines.length; i++) { const cols = parseCSVLine(lines[i]); const row: Record = {}; headers.forEach((h, idx) => { row[h] = cols[idx] ?? ''; }); rows.push(row); } return rows; } // IPv4 格式校验 // Logan.Li function isValidIP(ip: string): boolean { return /^(\d{1,3}\.){3}\d{1,3}$/.test(ip) && ip.split('.').every((o) => Number(o) >= 0 && Number(o) <= 255); } // 下载 CSV 导入模板 — 主机名+IP+端口(默认22),账号root,Agent 安装后自动补全 // Logan.Li function downloadTemplate() { const csv = [ 'hostname,ip,port', 'web-01,192.168.1.10,22', 'db-01,192.168.1.20,22', 'cache-01,192.168.1.30,2222', ].join('\n'); const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'servers-template.csv'; a.click(); URL.revokeObjectURL(url); } export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) { const [step, setStep] = useState(1); const [selectedRegion, setSelectedRegion] = useState(''); const [connectionMethod, setConnectionMethod] = useState('password'); const [selectedFile, setSelectedFile] = useState(null); const [isUploading, setIsUploading] = useState(false); const [previewData, setPreviewData] = useState([]); const [regions, setRegions] = useState([]); const [isImporting, setIsImporting] = useState(false); // 凭据选择 // Logan.Li const [credentials, setCredentials] = useState([]); const [selectedCredentialId, setSelectedCredentialId] = useState(''); const [defaultSshPort, setDefaultSshPort] = useState('22'); // 对话框打开时拉取区域列表 + 凭据列表 // Logan.Li useEffect(() => { if (!open) return; let cancelled = false; (async () => { try { const [regionResp, credResp] = await Promise.all([ regionApi.list(), credentialApi.list(), ]); if (cancelled) return; const list = regionResp?.list ?? []; setRegions(list.map((r: any) => ({ id: String(r.id ?? ''), name: r.name ?? '', code: r.code ?? '', }))); setCredentials(credResp?.list ?? []); } catch { if (!cancelled) { setRegions([]); setCredentials([]); } } })(); return () => { cancelled = true; }; }, [open]); const handleNext = async () => { if (step === 2 && selectedFile) { setIsUploading(true); try { const text = await selectedFile.text(); const rows = parseCSV(text); if (rows.length === 0) { toast.error('文件无有效数据,请使用模板格式(首行为表头)'); setIsUploading(false); return; } const seen = new Set(); const preview: PreviewRow[] = rows.map((r, idx) => { const hostname = (r.hostname ?? '').trim(); const ip = (r.ip ?? '').trim(); const row: PreviewRow = { row: idx + 2, hostname, ip, port: Number(r.port) || 22, username: (r.username ?? '').trim() || 'root', os: r.os ?? '', cpu: Number(r.cpu) || 0, memory: Number(r.memory) || 0, disk: Number(r.disk) || 0, tags: r.tags ?? '', status: 'valid', message: '有效', }; const errs: string[] = []; if (!hostname) errs.push('主机名缺失'); if (!ip) errs.push('IP缺失'); else if (!isValidIP(ip)) errs.push('IP格式错误'); const key = `${hostname}|${ip}`; if (hostname && ip && seen.has(key)) errs.push('重复行'); else seen.add(key); if (errs.length > 0) { row.status = 'error'; row.message = errs.join('、'); } return row; }); setPreviewData(preview); setStep(step + 1); } catch { toast.error('文件解析失败,请确认是 CSV 格式'); } finally { setIsUploading(false); } } else { setStep(step + 1); } }; const handleBack = () => { if (step > 1) { setStep(step - 1); } }; const handleFileChange = (e: React.ChangeEvent) => { if (e.target.files && e.target.files[0]) { setSelectedFile(e.target.files[0]); } }; const resetState = () => { setStep(1); setSelectedRegion(''); setDefaultSshPort('22'); setSelectedCredentialId(''); setSelectedFile(null); setPreviewData([]); }; const handleImport = async () => { const valid = previewData.filter((d) => d.status === 'valid'); if (valid.length === 0) return; setIsImporting(true); try { const servers = valid.map((d) => ({ hostname: d.hostname, ip: d.ip, port: d.port, username: d.username, os: d.os, cpu: d.cpu, memory: d.memory, disk: d.disk, tags: d.tags, })); const resp: any = await serverApi.batchCreate({ servers, region_id: selectedRegion || undefined, credential_id: selectedCredentialId ? Number(selectedCredentialId) : undefined, default_port: Number(defaultSshPort) || 22, }); const r = resp ?? {}; const success = r.success ?? 0; const skipped = r.skipped ?? 0; const failed = r.failed ?? 0; toast.success(`导入完成:成功 ${success},跳过 ${skipped},失败 ${failed}`); resetState(); onClose(); } catch (e: any) { toast.error(e?.message || '导入失败,请重试'); } finally { setIsImporting(false); } }; const handleClose = () => { onClose(); resetState(); }; const validCount = previewData.filter(d => d.status === 'valid').length; const warningCount = previewData.filter(d => d.status === 'warning').length; const errorCount = previewData.filter(d => d.status === 'error').length; const steps = [ { id: 1, title: '配置' }, { id: 2, title: '上传' }, { id: 3, title: '确认' }, ]; return ( CSV 批量导入服务器 通过 CSV 文件批量导入服务器资产 {/* Step Indicator */}
{steps.map((s, index) => (
s.id ? 'bg-emerald-600 text-white' : 'bg-[var(--dg-bg-secondary)] text-[var(--dg-text-muted)]' }`}> {step > s.id ? : s.id}
{s.title} {index < steps.length - 1 && (
)}
))}
{/* Step 1: Configuration */} {step === 1 && (
{/* Download Template */}

主机导入模板

下载 CSV 模板,只需填主机名、IP和端口(默认22),Agent安装后自动补全

{/* Region Selection */}
{/* 凭据选择(SSH密钥/密码) — 选中后自动关联到导入的每台服务器 // Logan.Li */}
{credentials.length === 0 ? (

暂无凭据,先去「凭证配置」添加 SSH 密钥或密码

) : ( )}
{/* SSH 端口配置 */}
setDefaultSshPort(e.target.value)} className="w-32 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
)} {/* Step 2: Upload */} {step === 2 && (

点击或拖拽文件到此处上传

支持 CSV 格式文件(可点击左侧"下载模板"获取)

{selectedFile && (

{selectedFile.name}

{(selectedFile.size / 1024).toFixed(2)} KB

)} {isUploading && (

正在解析文件...

)}
)} {/* Step 3: Preview & Confirm */} {step === 3 && (

数据预览

{validCount} 有效 {warningCount > 0 && ( {warningCount} 警告 )} {errorCount > 0 && ( {errorCount} 错误 )}
{previewData.map((row) => ( ))}
行号 主机名 IP地址 状态
{row.row} {row.hostname || '-'} {row.ip}
{row.status === 'valid' && ( {row.message} )} {row.status === 'warning' && ( {row.message} )} {row.status === 'error' && ( {row.message} )}
{errorCount > 0 && (
存在 {errorCount} 条错误数据,请修正后重新上传
)}
)} {/* Footer */}
{step > 1 && ( )}
{step < 3 ? ( ) : ( )}
); }