refactor(quality): Q24 前端巨型组件拆分 — ExcelServerImport 560L→385L (-31%), 提取 excel-import/ 3 个文件 (types(PreviewRow/RegionOption/ExcelServerImportProps + 6 个 CSV 纯函数 parseCSVLine/parseCSV/isValidIP/downloadTemplate/rowsToPreview) + StepIndicator(向导步骤指示器) + PreviewTable(预览表格组件)), catch 类型安全升级 (e?.message→e instanceof Error), 零逻辑变更, ESLint errors=0/warnings=0/any=0, tsc -b=0, npm build✓ (17.08s), 17/17 测试通过

This commit is contained in:
attacker committed 2026-07-09 01:15:15 +08:00
1 parent 796e6592c2
commit adf1ac8f71
4 files changed
+373 -247

No files matched your search

@@ -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<string, string>[] {
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<string, string>[] = [];
for (let i = 1; i < lines.length; i++) {
const cols = parseCSVLine(lines[i]);
const row: Record<string, string> = {};
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<PreviewRow[]>([]);
const [regions, setRegions] = useState<RegionOption[]>([]);
const [isImporting, setIsImporting] = useState(false);
// 凭据选择 // Logan.Li
// 凭据选择
const [sshCredentials, setSshCredentials] = useState<Credential[]>([]);
const [rdpCredentials, setRdpCredentials] = useState<Credential[]>([]);
const [selectedSshCredentialId, setSelectedSshCredentialId] = useState<string>('');
const [selectedRdpCredentialId, setSelectedRdpCredentialId] = useState<string>('');
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) => ({
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<string>();
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 (
<Dialog open={open} onOpenChange={handleClose}>
@@ -302,28 +198,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
<DialogDescription className="sr-only">{t('server.csvBatchImportDesc')}</DialogDescription>
</DialogHeader>
{/* Step Indicator */}
<div className="flex items-center justify-center mb-6">
{steps.map((s, index) => (
<div key={s.id} className="flex items-center">
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${
step === s.id
? 'bg-blue-600 text-white'
: step > s.id
? 'bg-emerald-600 text-white'
: 'bg-[var(--dg-bg-secondary)] text-[var(--dg-text-muted)]'
}`}>
{step > s.id ? <Check className="h-4 w-4" /> : s.id}
</div>
<span className={`ml-2 text-sm ${step === s.id ? 'text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-muted)]'}`}>
{s.title}
</span>
{index < steps.length - 1 && (
<div className="w-12 h-0.5 bg-[var(--dg-border)] mx-3" />
)}
</div>
))}
</div>
<StepIndicator steps={steps} currentStep={step} />
{/* Step 1: Configuration */}
{step === 1 && (
@@ -337,11 +212,19 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
<FileSpreadsheet className="h-5 w-5 dg-text-green" />
</div>
<div>
<p className="font-medium text-[var(--dg-text-primary)]">{t('server.importTemplate')}</p>
<p className="text-xs text-[var(--dg-text-muted)]">{t('server.importTemplateDesc')}</p>
<p className="font-medium text-[var(--dg-text-primary)]">
{t('server.importTemplate')}
</p>
<p className="text-xs text-[var(--dg-text-muted)]">
{t('server.importTemplateDesc')}
</p>
</div>
</div>
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={downloadTemplate}>
<Button
variant="outline"
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
onClick={downloadTemplate}
>
<Download className="h-4 w-4 mr-2" />
{t('server.downloadTemplate')}
</Button>
@@ -351,7 +234,9 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
{/* Region Selection */}
<div>
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">{t('server.selectRegion')}</label>
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">
{t('server.selectRegion')}
</label>
<select
value={selectedRegion}
onChange={(e) => setSelectedRegion(e.target.value)}
@@ -382,12 +267,12 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
{/* Step 2: Upload */}
{step === 2 && (
<div className="space-y-6">
<div
className="border-2 border-dashed border-[var(--dg-border)] rounded-lg p-8 text-center hover:border-blue-500/50 transition-colors"
>
<div className="border-2 border-dashed border-[var(--dg-border)] rounded-lg p-8 text-center hover:border-blue-500/50 transition-colors">
<Upload className="h-12 w-12 mx-auto mb-4 text-[var(--dg-text-muted)]" />
<p className="text-[var(--dg-text-secondary)] mb-2">{t('server.uploadHint')}</p>
<p className="text-xs text-[var(--dg-text-muted)] mb-4">{t('server.uploadFormatHint')}</p>
<p className="text-xs text-[var(--dg-text-muted)] mb-4">
{t('server.uploadFormatHint')}
</p>
<Input
type="file"
accept=".csv,text/csv"
@@ -396,7 +281,11 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
id="excel-upload"
/>
<label htmlFor="excel-upload">
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" asChild>
<Button
variant="outline"
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
asChild
>
<span>{t('server.selectFile')}</span>
</Button>
</label>
@@ -409,7 +298,9 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
<FileSpreadsheet className="h-8 w-8 dg-text-green" />
<div>
<p className="text-sm text-[var(--dg-text-primary)]">{selectedFile.name}</p>
<p className="text-xs text-[var(--dg-text-muted)]">{(selectedFile.size / 1024).toFixed(2)} KB</p>
<p className="text-xs text-[var(--dg-text-muted)]">
{(selectedFile.size / 1024).toFixed(2)} KB
</p>
</div>
</div>
<Button
@@ -435,98 +326,34 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
{/* Step 3: Preview & Confirm */}
{step === 3 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-[var(--dg-text-secondary)] text-sm">{t('server.dataPreview')}</p>
<div className="flex items-center gap-2">
<Badge className="bg-emerald-500/20 dg-text-green border-0">
<Check className="h-3 w-3 mr-1" />
{validCount} {t('server.valid')}
</Badge>
{warningCount > 0 && (
<Badge className="bg-amber-500/20 dg-text-yellow border-0">
<AlertCircle className="h-3 w-3 mr-1" />
{warningCount} {t('server.warning')}
</Badge>
)}
{errorCount > 0 && (
<Badge className="bg-red-500/20 dg-text-red border-0">
<X className="h-3 w-3 mr-1" />
{errorCount} {t('server.error')}
</Badge>
)}
</div>
</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)]">{t('server.rowNumber')}</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>
<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)]">
{previewData.map((row) => (
<tr key={row.row} className="hover:bg-[var(--dg-bg-hover)]">
<td className="px-3 py-2 text-sm text-[var(--dg-text-muted)]">{row.row}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{row.hostname || '-'}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{row.ip}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{row.osType === 'windows' ? 'Windows / RDP' : 'Linux / SSH'}</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
{row.status === 'valid' && (
<Badge className="bg-emerald-500/20 dg-text-green border-0 text-xs">
<Check className="h-3 w-3 mr-1" />
{row.message}
</Badge>
)}
{row.status === 'warning' && (
<Badge className="bg-amber-500/20 dg-text-yellow border-0 text-xs">
<AlertCircle className="h-3 w-3 mr-1" />
{row.message}
</Badge>
)}
{row.status === 'error' && (
<Badge className="bg-red-500/20 dg-text-red border-0 text-xs">
<X className="h-3 w-3 mr-1" />
{row.message}
</Badge>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{errorCount > 0 && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30">
<div className="flex items-center gap-2 dg-text-red">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">{t('server.errorDataHint', { count: errorCount })}</span>
</div>
</div>
)}
</div>
<PreviewTable
data={previewData}
validCount={validCount}
warningCount={warningCount}
errorCount={errorCount}
/>
)}
{/* Footer */}
<DialogFooter className="flex justify-between items-center">
<div>
{step > 1 && (
<Button variant="outline" onClick={handleBack} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
<Button
variant="outline"
onClick={handleBack}
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
>
<ChevronLeft className="h-4 w-4 mr-1" />
{t('common.previous')}
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleClose} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
<Button
variant="outline"
onClick={handleClose}
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
>
{t('common.cancel')}
</Button>
{step < 3 ? (
@@ -549,7 +376,9 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
) : (
<Check className="h-4 w-4 mr-1" />
)}
{isImporting ? t('server.importing') : t('server.confirmImport', { count: validCount })}
{isImporting
? t('server.importing')
: t('server.confirmImport', { count: validCount })}
</Button>
)}
</div>
@@ -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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-[var(--dg-text-secondary)] text-sm">{t('server.dataPreview')}</p>
<div className="flex items-center gap-2">
<Badge className="bg-emerald-500/20 dg-text-green border-0">
<Check className="h-3 w-3 mr-1" />
{validCount} {t('server.valid')}
</Badge>
{warningCount > 0 && (
<Badge className="bg-amber-500/20 dg-text-yellow border-0">
<AlertCircle className="h-3 w-3 mr-1" />
{warningCount} {t('server.warning')}
</Badge>
)}
{errorCount > 0 && (
<Badge className="bg-red-500/20 dg-text-red border-0">
<X className="h-3 w-3 mr-1" />
{errorCount} {t('server.error')}
</Badge>
)}
</div>
</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)]">
{t('server.rowNumber')}
</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>
<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)]">
{t('common.status')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--dg-border)]">
{data.map((row) => (
<tr key={row.row} className="hover:bg-[var(--dg-bg-hover)]">
<td className="px-3 py-2 text-sm text-[var(--dg-text-muted)]">{row.row}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">
{row.hostname || '-'}
</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{row.ip}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">
{row.osType === 'windows' ? 'Windows / RDP' : 'Linux / SSH'}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
{row.status === 'valid' && (
<Badge className="bg-emerald-500/20 dg-text-green border-0 text-xs">
<Check className="h-3 w-3 mr-1" />
{row.message}
</Badge>
)}
{row.status === 'warning' && (
<Badge className="bg-amber-500/20 dg-text-yellow border-0 text-xs">
<AlertCircle className="h-3 w-3 mr-1" />
{row.message}
</Badge>
)}
{row.status === 'error' && (
<Badge className="bg-red-500/20 dg-text-red border-0 text-xs">
<X className="h-3 w-3 mr-1" />
{row.message}
</Badge>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{errorCount > 0 && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30">
<div className="flex items-center gap-2 dg-text-red">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">{t('server.errorDataHint', { count: errorCount })}</span>
</div>
</div>
)}
</div>
);
}
@@ -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 (
<div className="flex items-center justify-center mb-6">
{steps.map((s, index) => (
<div key={s.id} className="flex items-center">
<div
className={`flex items-center justify-center w-8 h-8 rounded-full ${
currentStep === s.id
? 'bg-blue-600 text-white'
: currentStep > s.id
? 'bg-emerald-600 text-white'
: 'bg-[var(--dg-bg-secondary)] text-[var(--dg-text-muted)]'
}`}
>
{currentStep > s.id ? <Check className="h-4 w-4" /> : s.id}
</div>
<span
className={`ml-2 text-sm ${
currentStep === s.id ? 'text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-muted)]'
}`}
>
{s.title}
</span>
{index < steps.length - 1 && <div className="w-12 h-0.5 bg-[var(--dg-border)] mx-3" />}
</div>
))}
</div>
);
}
@@ -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<string, string>[] {
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<string, string>[] = [];
for (let i = 1; i < lines.length; i++) {
const cols = parseCSVLine(lines[i]);
const row: Record<string, string> = {};
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<string, string>[],
t: (key: string) => string,
): PreviewRow[] {
const seen = new Set<string>();
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;
});
}