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
+376
-250
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) => ({
|
||||
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<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;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user