diff --git a/frontend/src/components/server/ExcelServerImport.tsx b/frontend/src/components/server/ExcelServerImport.tsx index b9d8aa8..eb8b8e6 100644 --- a/frontend/src/components/server/ExcelServerImport.tsx +++ b/frontend/src/components/server/ExcelServerImport.tsx @@ -2,7 +2,6 @@ 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, @@ -18,7 +17,6 @@ import { FileSpreadsheet, Check, X, - AlertCircle, ChevronRight, ChevronLeft, Loader2, @@ -28,87 +26,16 @@ import { regionApi, serverApi, credentialApi } from '@/api'; import type { JsonData } from '@/types/common'; import { ImportCredentialSettings } from './ImportCredentialSettings'; import type { Credential } from '@/components/deploy/credential-manager/types'; - -interface RegionOption { - id: string; - name: string; - code: string; -} - -interface ExcelServerImportProps { - open: boolean; - onClose: () => void; -} - -type PreviewRow = { - row: number; hostname: string; ip: string; osType: string; protocol: string; port: number; rdpPort: 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 导入模板,Windows 行可填写 os_type=windows 自动匹配 RDP 凭证 // Logan.Li -function downloadTemplate() { - const csv = [ - 'hostname,ip,os_type,protocol,port,rdp_port,username', - 'web-01,192.168.1.10,linux,ssh,22,,root', - 'db-01,192.168.1.20,linux,ssh,22,,root', - 'win-01,192.168.1.30,windows,rdp,,3389,Administrator', - ].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); -} +import { + type ExcelServerImportProps, + type RegionOption, + type PreviewRow, + parseCSV, + downloadTemplate, + rowsToPreview, +} from './excel-import/types'; +import { StepIndicator, type StepDef } from './excel-import/StepIndicator'; +import { PreviewTable } from './excel-import/PreviewTable'; export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) { const { t } = useTranslation(); @@ -120,14 +47,20 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) { const [previewData, setPreviewData] = useState([]); const [regions, setRegions] = useState([]); const [isImporting, setIsImporting] = useState(false); - // 凭据选择 // Logan.Li + // 凭据选择 const [sshCredentials, setSshCredentials] = useState([]); const [rdpCredentials, setRdpCredentials] = useState([]); const [selectedSshCredentialId, setSelectedSshCredentialId] = useState(''); const [selectedRdpCredentialId, setSelectedRdpCredentialId] = useState(''); const [defaultSshPort, setDefaultSshPort] = useState('22'); - // 对话框打开时拉取区域列表 + 凭据列表 // Logan.Li + const steps: StepDef[] = [ + { id: 1, title: t('server.excelConfig') }, + { id: 2, title: t('server.excelUpload') }, + { id: 3, title: t('common.confirm') }, + ]; + + // 对话框打开时拉取区域列表 + 凭据列表 useEffect(() => { if (!open) return; let cancelled = false; @@ -140,11 +73,13 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) { ]); if (cancelled) return; const list = regionResp?.list ?? []; - setRegions(list.map((r: JsonData) => ({ - id: String(r.id ?? ''), - name: r.name ?? '', - code: r.code ?? '', - }))); + setRegions( + list.map((r: JsonData) => ({ + id: String(r.id ?? ''), + name: r.name ?? '', + code: r.code ?? '', + })), + ); setSshCredentials((sshCredResp?.list ?? []) as Credential[]); setRdpCredentials((rdpCredResp?.list ?? []) as Credential[]); } catch { @@ -155,7 +90,9 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) { } } })(); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [open]); const handleNext = async () => { @@ -169,42 +106,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) { 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 osType = (r.os_type ?? '').trim().toLowerCase() || ((r.os ?? '').toLowerCase().includes('windows') ? 'windows' : 'linux'); - const protocol = (r.protocol ?? '').trim().toLowerCase() || (osType === 'windows' ? 'rdp' : 'ssh'); - const row: PreviewRow = { - row: idx + 2, - hostname, - ip, - osType, - protocol, - port: Number(r.port) || 22, - rdpPort: Number(r.rdp_port) || 3389, - username: (r.username ?? '').trim() || (protocol === 'rdp' ? 'Administrator' : '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: t('server.valid'), - }; - const errs: string[] = []; - if (!hostname) errs.push(t('server.excelHostname') + ' missing'); - if (!ip) errs.push(t('server.excelIpAddress') + ' missing'); - else if (!isValidIP(ip)) errs.push(t('server.excelIpAddress') + ' format error'); - const key = `${hostname}|${ip}`; - if (hostname && ip && seen.has(key)) errs.push('Duplicate row'); - else seen.add(key); - if (errs.length > 0) { - row.status = 'error'; - row.message = errs.join('、'); - } - return row; - }); + const preview = rowsToPreview(rows, t); setPreviewData(preview); setStep(step + 1); } catch { @@ -273,7 +175,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) { resetState(); onClose(); } catch (e) { - toast.error(e?.message || t('server.importFailed')); + toast.error(e instanceof Error ? e.message : String(e) || t('server.importFailed')); } finally { setIsImporting(false); } @@ -284,15 +186,9 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) { 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: t('server.excelConfig') }, - { id: 2, title: t('server.excelUpload') }, - { id: 3, title: t('common.confirm') }, - ]; + 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; return ( @@ -302,28 +198,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) { {t('server.csvBatchImportDesc')} - {/* 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 && ( @@ -337,11 +212,19 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
-

{t('server.importTemplate')}

-

{t('server.importTemplateDesc')}

+

+ {t('server.importTemplate')} +

+

+ {t('server.importTemplateDesc')} +

- @@ -351,7 +234,9 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) { {/* Region Selection */}
- + @@ -409,7 +298,9 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {

{selectedFile.name}

-

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

+

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

)}
- {step < 3 ? ( @@ -549,7 +376,9 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) { ) : ( )} - {isImporting ? t('server.importing') : t('server.confirmImport', { count: validCount })} + {isImporting + ? t('server.importing') + : t('server.confirmImport', { count: validCount })} )}
diff --git a/frontend/src/components/server/excel-import/PreviewTable.tsx b/frontend/src/components/server/excel-import/PreviewTable.tsx new file mode 100644 index 0000000..5e51ddf --- /dev/null +++ b/frontend/src/components/server/excel-import/PreviewTable.tsx @@ -0,0 +1,110 @@ +import { useTranslation } from 'react-i18next'; +import { Badge } from '@/components/ui/badge'; +import { Check, X, AlertCircle } from 'lucide-react'; +import type { PreviewRow } from './types'; + +export interface PreviewTableProps { + data: PreviewRow[]; + validCount: number; + warningCount: number; + errorCount: number; +} + +export function PreviewTable({ data, validCount, warningCount, errorCount }: PreviewTableProps) { + const { t } = useTranslation(); + + return ( +
+
+

{t('server.dataPreview')}

+
+ + + {validCount} {t('server.valid')} + + {warningCount > 0 && ( + + + {warningCount} {t('server.warning')} + + )} + {errorCount > 0 && ( + + + {errorCount} {t('server.error')} + + )} +
+
+ +
+ + + + + + + + + + + + {data.map((row) => ( + + + + + + + + ))} + +
+ {t('server.rowNumber')} + + {t('server.excelHostname')} + + {t('server.excelIpAddress')} + + 类型 + + {t('common.status')} +
{row.row} + {row.hostname || '-'} + {row.ip} + {row.osType === 'windows' ? 'Windows / RDP' : 'Linux / SSH'} + +
+ {row.status === 'valid' && ( + + + {row.message} + + )} + {row.status === 'warning' && ( + + + {row.message} + + )} + {row.status === 'error' && ( + + + {row.message} + + )} +
+
+
+ + {errorCount > 0 && ( +
+
+ + {t('server.errorDataHint', { count: errorCount })} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/server/excel-import/StepIndicator.tsx b/frontend/src/components/server/excel-import/StepIndicator.tsx new file mode 100644 index 0000000..3386635 --- /dev/null +++ b/frontend/src/components/server/excel-import/StepIndicator.tsx @@ -0,0 +1,36 @@ +import { Check } from 'lucide-react'; + +export interface StepDef { + id: number; + title: string; +} + +export function StepIndicator({ steps, currentStep }: { steps: StepDef[]; currentStep: number }) { + return ( +
+ {steps.map((s, index) => ( +
+
s.id + ? 'bg-emerald-600 text-white' + : 'bg-[var(--dg-bg-secondary)] text-[var(--dg-text-muted)]' + }`} + > + {currentStep > s.id ? : s.id} +
+ + {s.title} + + {index < steps.length - 1 &&
} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/server/excel-import/types.ts b/frontend/src/components/server/excel-import/types.ts new file mode 100644 index 0000000..fe8f312 --- /dev/null +++ b/frontend/src/components/server/excel-import/types.ts @@ -0,0 +1,151 @@ +/** CSV batch import types + utility functions (pure logic, no React) */ + +export interface RegionOption { + id: string; + name: string; + code: string; +} + +export interface ExcelServerImportProps { + open: boolean; + onClose: () => void; +} + +export type PreviewRow = { + row: number; + hostname: string; + ip: string; + osType: string; + protocol: string; + port: number; + rdpPort: number; + username: string; + os: string; + cpu: number; + memory: number; + disk: number; + tags: string; + status: string; + message: string; +}; + +/** Parse a single CSV line (supports quoted fields and escaped quotes) */ +export 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()); +} + +/** Parse CSV text into array of row objects (first line = headers) */ +export 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 format validation */ +export 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); +} + +/** Download CSV import template (Windows rows: os_type=windows auto-matches RDP credential) */ +export function downloadTemplate(): void { + const csv = [ + 'hostname,ip,os_type,protocol,port,rdp_port,username', + 'web-01,192.168.1.10,linux,ssh,22,,root', + 'db-01,192.168.1.20,linux,ssh,22,,root', + 'win-01,192.168.1.30,windows,rdp,,3389,Administrator', + ].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); +} + +/** Convert parsed CSV rows to PreviewRow array with validation */ +export function rowsToPreview( + rows: Record[], + t: (key: string) => string, +): PreviewRow[] { + const seen = new Set(); + return rows.map((r, idx) => { + const hostname = (r.hostname ?? '').trim(); + const ip = (r.ip ?? '').trim(); + const osType = + (r.os_type ?? '').trim().toLowerCase() || + (r.os ?? '').toLowerCase().includes('windows') + ? 'windows' + : 'linux'; + const protocol = (r.protocol ?? '').trim().toLowerCase() || (osType === 'windows' ? 'rdp' : 'ssh'); + const row: PreviewRow = { + row: idx + 2, + hostname, + ip, + osType, + protocol, + port: Number(r.port) || 22, + rdpPort: Number(r.rdp_port) || 3389, + username: (r.username ?? '').trim() || (protocol === 'rdp' ? 'Administrator' : '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: t('server.valid'), + }; + const errs: string[] = []; + if (!hostname) errs.push(t('server.excelHostname') + ' missing'); + if (!ip) errs.push(t('server.excelIpAddress') + ' missing'); + else if (!isValidIP(ip)) errs.push(t('server.excelIpAddress') + ' format error'); + const key = `${hostname}|${ip}`; + if (hostname && ip && seen.has(key)) errs.push('Duplicate row'); + else seen.add(key); + if (errs.length > 0) { + row.status = 'error'; + row.message = errs.join('、'); + } + return row; + }); +}