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:
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 { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -18,7 +17,6 @@ import {
|
|||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
AlertCircle,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -28,87 +26,16 @@ import { regionApi, serverApi, credentialApi } from '@/api';
|
|||||||
import type { JsonData } from '@/types/common';
|
import type { JsonData } from '@/types/common';
|
||||||
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 {
|
||||||
interface RegionOption {
|
type ExcelServerImportProps,
|
||||||
id: string;
|
type RegionOption,
|
||||||
name: string;
|
type PreviewRow,
|
||||||
code: string;
|
parseCSV,
|
||||||
}
|
downloadTemplate,
|
||||||
|
rowsToPreview,
|
||||||
interface ExcelServerImportProps {
|
} from './excel-import/types';
|
||||||
open: boolean;
|
import { StepIndicator, type StepDef } from './excel-import/StepIndicator';
|
||||||
onClose: () => void;
|
import { PreviewTable } from './excel-import/PreviewTable';
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -120,14 +47,20 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
const [previewData, setPreviewData] = useState<PreviewRow[]>([]);
|
const [previewData, setPreviewData] = useState<PreviewRow[]>([]);
|
||||||
const [regions, setRegions] = useState<RegionOption[]>([]);
|
const [regions, setRegions] = useState<RegionOption[]>([]);
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
// 凭据选择 // Logan.Li
|
// 凭据选择
|
||||||
const [sshCredentials, setSshCredentials] = useState<Credential[]>([]);
|
const [sshCredentials, setSshCredentials] = useState<Credential[]>([]);
|
||||||
const [rdpCredentials, setRdpCredentials] = useState<Credential[]>([]);
|
const [rdpCredentials, setRdpCredentials] = useState<Credential[]>([]);
|
||||||
const [selectedSshCredentialId, setSelectedSshCredentialId] = useState<string>('');
|
const [selectedSshCredentialId, setSelectedSshCredentialId] = useState<string>('');
|
||||||
const [selectedRdpCredentialId, setSelectedRdpCredentialId] = useState<string>('');
|
const [selectedRdpCredentialId, setSelectedRdpCredentialId] = useState<string>('');
|
||||||
const [defaultSshPort, setDefaultSshPort] = useState('22');
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -140,11 +73,13 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const list = regionResp?.list ?? [];
|
const list = regionResp?.list ?? [];
|
||||||
setRegions(list.map((r: JsonData) => ({
|
setRegions(
|
||||||
|
list.map((r: JsonData) => ({
|
||||||
id: String(r.id ?? ''),
|
id: String(r.id ?? ''),
|
||||||
name: r.name ?? '',
|
name: r.name ?? '',
|
||||||
code: r.code ?? '',
|
code: r.code ?? '',
|
||||||
})));
|
})),
|
||||||
|
);
|
||||||
setSshCredentials((sshCredResp?.list ?? []) as Credential[]);
|
setSshCredentials((sshCredResp?.list ?? []) as Credential[]);
|
||||||
setRdpCredentials((rdpCredResp?.list ?? []) as Credential[]);
|
setRdpCredentials((rdpCredResp?.list ?? []) as Credential[]);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -155,7 +90,9 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
@@ -169,42 +106,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const seen = new Set<string>();
|
const preview = rowsToPreview(rows, t);
|
||||||
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;
|
|
||||||
});
|
|
||||||
setPreviewData(preview);
|
setPreviewData(preview);
|
||||||
setStep(step + 1);
|
setStep(step + 1);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -273,7 +175,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
resetState();
|
resetState();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e?.message || t('server.importFailed'));
|
toast.error(e instanceof Error ? e.message : String(e) || t('server.importFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsImporting(false);
|
setIsImporting(false);
|
||||||
}
|
}
|
||||||
@@ -284,15 +186,9 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
resetState();
|
resetState();
|
||||||
};
|
};
|
||||||
|
|
||||||
const validCount = previewData.filter(d => d.status === 'valid').length;
|
const validCount = previewData.filter((d) => d.status === 'valid').length;
|
||||||
const warningCount = previewData.filter(d => d.status === 'warning').length;
|
const warningCount = previewData.filter((d) => d.status === 'warning').length;
|
||||||
const errorCount = previewData.filter(d => d.status === 'error').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') },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
@@ -302,28 +198,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
<DialogDescription className="sr-only">{t('server.csvBatchImportDesc')}</DialogDescription>
|
<DialogDescription className="sr-only">{t('server.csvBatchImportDesc')}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Step Indicator */}
|
<StepIndicator steps={steps} currentStep={step} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Step 1: Configuration */}
|
{/* Step 1: Configuration */}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
@@ -337,11 +212,19 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
<FileSpreadsheet className="h-5 w-5 dg-text-green" />
|
<FileSpreadsheet className="h-5 w-5 dg-text-green" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-[var(--dg-text-primary)]">{t('server.importTemplate')}</p>
|
<p className="font-medium text-[var(--dg-text-primary)]">
|
||||||
<p className="text-xs text-[var(--dg-text-muted)]">{t('server.importTemplateDesc')}</p>
|
{t('server.importTemplate')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||||
|
{t('server.importTemplateDesc')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
{t('server.downloadTemplate')}
|
{t('server.downloadTemplate')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -351,7 +234,9 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
|
|
||||||
{/* Region Selection */}
|
{/* Region Selection */}
|
||||||
<div>
|
<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
|
<select
|
||||||
value={selectedRegion}
|
value={selectedRegion}
|
||||||
onChange={(e) => setSelectedRegion(e.target.value)}
|
onChange={(e) => setSelectedRegion(e.target.value)}
|
||||||
@@ -382,12 +267,12 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
{/* Step 2: Upload */}
|
{/* Step 2: Upload */}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div
|
<div className="border-2 border-dashed border-[var(--dg-border)] rounded-lg p-8 text-center hover:border-blue-500/50 transition-colors">
|
||||||
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)]" />
|
<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-[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
|
<Input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".csv,text/csv"
|
accept=".csv,text/csv"
|
||||||
@@ -396,7 +281,11 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
id="excel-upload"
|
id="excel-upload"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="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>
|
<span>{t('server.selectFile')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</label>
|
</label>
|
||||||
@@ -409,7 +298,9 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
<FileSpreadsheet className="h-8 w-8 dg-text-green" />
|
<FileSpreadsheet className="h-8 w-8 dg-text-green" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-[var(--dg-text-primary)]">{selectedFile.name}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -435,98 +326,34 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
|
|
||||||
{/* Step 3: Preview & Confirm */}
|
{/* Step 3: Preview & Confirm */}
|
||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<div className="space-y-4">
|
<PreviewTable
|
||||||
<div className="flex items-center justify-between">
|
data={previewData}
|
||||||
<p className="text-[var(--dg-text-secondary)] text-sm">{t('server.dataPreview')}</p>
|
validCount={validCount}
|
||||||
<div className="flex items-center gap-2">
|
warningCount={warningCount}
|
||||||
<Badge className="bg-emerald-500/20 dg-text-green border-0">
|
errorCount={errorCount}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<DialogFooter className="flex justify-between items-center">
|
<DialogFooter className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
{step > 1 && (
|
{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" />
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
{t('common.previous')}
|
{t('common.previous')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
{step < 3 ? (
|
{step < 3 ? (
|
||||||
@@ -549,7 +376,9 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
|||||||
) : (
|
) : (
|
||||||
<Check className="h-4 w-4 mr-1" />
|
<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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user