feat: 服务器批量导入全栈实现 + SQL审计模型修复 + 审批流程增强
服务器批量导入: - 后端 BatchCreateServers handler: 逐条校验/查重/GB→MB转换/错误明细 - 前端 CSV 解析器 + IP校验 + 模板下载 + 真实API导入 - 删除孤立占位组件 BatchImport.tsx SQL审计: - 修复 TableName 字段与 GORM 接口冲突 → TargetTable - 补全路由注册 + AutoMigrate + 种子数据 审批流程增强 + 告警/菜单微调 // Logan.Li
This commit is contained in:
1 parent
6545597412
commit
77c33a6fee
16 files changed
+863
-573
No files matched your search
@@ -1,432 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Upload,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
X,
|
||||
Server,
|
||||
FileJson,
|
||||
Copy,
|
||||
Trash2,
|
||||
Play,
|
||||
RotateCw,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ImportRecord {
|
||||
id: string;
|
||||
hostname: string;
|
||||
ip: string;
|
||||
os: string;
|
||||
cpu: string;
|
||||
memory: string;
|
||||
disk: string;
|
||||
region: string;
|
||||
tags: string;
|
||||
status: 'pending' | 'valid' | 'invalid' | 'importing' | 'success' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function BatchImport() {
|
||||
const [records, setRecords] = useState<ImportRecord[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [importProgress, setImportProgress] = useState(0);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
toast.info('文件解析功能开发中');
|
||||
}, []);
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
toast.info('文件解析功能开发中');
|
||||
}
|
||||
};
|
||||
|
||||
const removeRecord = (id: string) => {
|
||||
setRecords(records.filter((r) => r.id !== id));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
setRecords([]);
|
||||
setImportProgress(0);
|
||||
};
|
||||
|
||||
const startImport = () => {
|
||||
const validRecords = records.filter((r) => r.status === 'valid');
|
||||
if (validRecords.length === 0) return;
|
||||
|
||||
setIsImporting(true);
|
||||
setImportProgress(0);
|
||||
|
||||
// 模拟导入进度
|
||||
let progress = 0;
|
||||
const interval = setInterval(() => {
|
||||
progress += 10;
|
||||
setImportProgress(progress);
|
||||
|
||||
// 更新记录状态
|
||||
if (progress % 20 === 0) {
|
||||
const index = Math.floor(progress / 20) - 1;
|
||||
setRecords((prev) =>
|
||||
prev.map((r, i) =>
|
||||
i === index && r.status === 'valid' ? { ...r, status: 'importing' } : r
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(interval);
|
||||
setRecords((prev) =>
|
||||
prev.map((r) => (r.status === 'importing' ? { ...r, status: 'success' } : r))
|
||||
);
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const validCount = records.filter((r) => r.status === 'valid').length;
|
||||
const invalidCount = records.filter((r) => r.status === 'invalid').length;
|
||||
const successCount = records.filter((r) => r.status === 'success').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">批量导入</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">批量导入服务器资产</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<Card
|
||||
className={cn(
|
||||
'border-2 border-dashed bg-slate-900/50',
|
||||
isDragging ? 'border-blue-500 bg-blue-500/5' : 'border-slate-700'
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-slate-800 flex items-center justify-center mb-4">
|
||||
<Upload className="h-8 w-8 text-slate-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-slate-200 mb-2">拖拽文件到此处上传</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">支持 Excel (.xlsx, .xls) 和 CSV 格式文件</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label htmlFor="file-upload">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300" asChild>
|
||||
<span>选择文件</span>
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Empty State */}
|
||||
{records.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-slate-500">
|
||||
<Server className="h-10 w-10 mb-3 opacity-30" />
|
||||
<p>暂无导入记录</p>
|
||||
<p className="text-xs mt-1">上传文件后将在此预览数据</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Stats */}
|
||||
{records.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">总记录数</p>
|
||||
<p className="text-2xl font-bold text-slate-100">{records.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-slate-800 text-slate-400">
|
||||
<FileSpreadsheet className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">有效记录</p>
|
||||
<p className="text-2xl font-bold text-emerald-400">{validCount}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">无效记录</p>
|
||||
<p className="text-2xl font-bold text-red-400">{invalidCount}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-red-500/10 text-red-400">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">导入成功</p>
|
||||
<p className="text-2xl font-bold text-blue-400">{successCount}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
|
||||
<Server className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Records Table */}
|
||||
{records.length > 0 && (
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-slate-100">数据预览</CardTitle>
|
||||
<CardDescription className="text-slate-500">
|
||||
请检查数据是否正确,修复无效记录后导入
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-slate-700 text-slate-300 hover:text-slate-100"
|
||||
onClick={clearAll}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
onClick={startImport}
|
||||
disabled={isImporting || validCount === 0}
|
||||
>
|
||||
{isImporting ? (
|
||||
<RotateCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{isImporting ? '导入中...' : '开始导入'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isImporting && (
|
||||
<div className="px-6 py-4 border-b border-slate-800">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-slate-400">导入进度</span>
|
||||
<span className="text-sm text-slate-300">{importProgress}%</span>
|
||||
</div>
|
||||
<Progress value={importProgress} className="h-2 bg-slate-800" />
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-slate-800">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">主机名</TableHead>
|
||||
<TableHead className="text-slate-400">IP地址</TableHead>
|
||||
<TableHead className="text-slate-400">操作系统</TableHead>
|
||||
<TableHead className="text-slate-400">配置</TableHead>
|
||||
<TableHead className="text-slate-400">区域</TableHead>
|
||||
<TableHead className="text-slate-400">标签</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((record) => (
|
||||
<TableRow
|
||||
key={record.id}
|
||||
className={cn(
|
||||
'border-slate-800',
|
||||
record.status === 'invalid' && 'bg-red-500/5',
|
||||
record.status === 'success' && 'bg-emerald-500/5'
|
||||
)}
|
||||
>
|
||||
<TableCell>
|
||||
{record.status === 'valid' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10"
|
||||
>
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
有效
|
||||
</Badge>
|
||||
)}
|
||||
{record.status === 'invalid' && (
|
||||
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
无效
|
||||
</Badge>
|
||||
)}
|
||||
{record.status === 'importing' && (
|
||||
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
|
||||
<RotateCw className="h-3 w-3 mr-1 animate-spin" />
|
||||
导入中
|
||||
</Badge>
|
||||
)}
|
||||
{record.status === 'success' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10"
|
||||
>
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
成功
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={record.hostname}
|
||||
className={cn(
|
||||
'h-8 text-sm bg-transparent border-0 border-b rounded-none px-0 focus-visible:ring-0',
|
||||
record.status === 'invalid' && !record.hostname
|
||||
? 'border-red-500 text-red-400'
|
||||
: 'border-slate-700 text-slate-200'
|
||||
)}
|
||||
placeholder="主机名"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={record.ip}
|
||||
className={cn(
|
||||
'h-8 text-sm bg-transparent border-0 border-b rounded-none px-0 focus-visible:ring-0',
|
||||
record.status === 'invalid' && record.message?.includes('IP')
|
||||
? 'border-red-500 text-red-400'
|
||||
: 'border-slate-700 text-slate-200'
|
||||
)}
|
||||
placeholder="IP地址"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{record.os}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">
|
||||
{record.cpu}核 / {record.memory}GB / {record.disk}GB
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{record.region}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{record.tags}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-400"
|
||||
onClick={() => removeRecord(record.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Import Tips */}
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-slate-400">导入说明</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-slate-500">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-400 mt-0.5">•</span>
|
||||
<span>支持 Excel (.xlsx, .xls) 和 CSV 格式文件</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-400 mt-0.5">•</span>
|
||||
<span>文件大小不超过 10MB,单次导入不超过 1000 条记录</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-400 mt-0.5">•</span>
|
||||
<span>必填字段:主机名、IP地址、操作系统</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-400 mt-0.5">•</span>
|
||||
<span>IP地址必须是有效的IPv4格式</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-400 mt-0.5">•</span>
|
||||
<span>标签使用逗号分隔,如:web,production,frontend</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
@@ -19,8 +21,9 @@ import {
|
||||
AlertCircle,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { regionApi } from '@/services/api';
|
||||
import { regionApi, serverApi } from '@/services/api';
|
||||
|
||||
interface RegionOption {
|
||||
id: string;
|
||||
@@ -40,9 +43,73 @@ const connectionMethods = [
|
||||
|
||||
type PreviewRow = {
|
||||
row: number; hostname: string; ip: string; port: number;
|
||||
username: string; status: string; message: string;
|
||||
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 导入模板 // Logan.Li
|
||||
function downloadTemplate() {
|
||||
const csv = [
|
||||
'hostname,ip,port,username,os,cpu,memory,disk,tags',
|
||||
'web-01,192.168.1.10,22,root,CentOS 7,4,8,100,"web,prod"',
|
||||
'db-01,192.168.1.20,22,root,Ubuntu 22.04,8,16,200,"db,prod"',
|
||||
].join('\n');
|
||||
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'servers-template.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
const [step, setStep] = useState(1);
|
||||
const [selectedRegion, setSelectedRegion] = useState<string>('');
|
||||
@@ -51,6 +118,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [previewData, setPreviewData] = useState<PreviewRow[]>([]);
|
||||
const [regions, setRegions] = useState<RegionOption[]>([]);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
// 对话框打开时拉取区域列表 // Logan.Li
|
||||
useEffect(() => {
|
||||
@@ -77,14 +145,55 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleNext = () => {
|
||||
const handleNext = async () => {
|
||||
if (step === 2 && selectedFile) {
|
||||
setIsUploading(true);
|
||||
setTimeout(() => {
|
||||
setIsUploading(false);
|
||||
setPreviewData([]); // 真实解析由后端处理
|
||||
try {
|
||||
const text = await selectedFile.text();
|
||||
const rows = parseCSV(text);
|
||||
if (rows.length === 0) {
|
||||
toast.error('文件无有效数据,请使用模板格式(首行为表头)');
|
||||
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 row: PreviewRow = {
|
||||
row: idx + 2,
|
||||
hostname,
|
||||
ip,
|
||||
port: Number(r.port) || 22,
|
||||
username: (r.username ?? '').trim() || 'root',
|
||||
os: r.os ?? '',
|
||||
cpu: Number(r.cpu) || 0,
|
||||
memory: Number(r.memory) || 0,
|
||||
disk: Number(r.disk) || 0,
|
||||
tags: r.tags ?? '',
|
||||
status: 'valid',
|
||||
message: '有效',
|
||||
};
|
||||
const errs: string[] = [];
|
||||
if (!hostname) errs.push('主机名缺失');
|
||||
if (!ip) errs.push('IP缺失');
|
||||
else if (!isValidIP(ip)) errs.push('IP格式错误');
|
||||
const key = `${hostname}|${ip}`;
|
||||
if (hostname && ip && seen.has(key)) errs.push('重复行');
|
||||
else seen.add(key);
|
||||
if (errs.length > 0) {
|
||||
row.status = 'error';
|
||||
row.message = errs.join('、');
|
||||
}
|
||||
return row;
|
||||
});
|
||||
setPreviewData(preview);
|
||||
setStep(step + 1);
|
||||
}, 1500);
|
||||
} catch {
|
||||
toast.error('文件解析失败,请确认是 CSV 格式');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
} else {
|
||||
setStep(step + 1);
|
||||
}
|
||||
@@ -102,19 +211,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setStep(1);
|
||||
setSelectedRegion('');
|
||||
setConnectionMethod('password');
|
||||
setSelectedFile(null);
|
||||
setPreviewData([]);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
const resetState = () => {
|
||||
setStep(1);
|
||||
setSelectedRegion('');
|
||||
setConnectionMethod('password');
|
||||
@@ -122,6 +219,46 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
setPreviewData([]);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
const valid = previewData.filter((d) => d.status === 'valid');
|
||||
if (valid.length === 0) return;
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const servers = valid.map((d) => ({
|
||||
hostname: d.hostname,
|
||||
ip: d.ip,
|
||||
port: d.port,
|
||||
username: d.username,
|
||||
os: d.os,
|
||||
cpu: d.cpu,
|
||||
memory: d.memory,
|
||||
disk: d.disk,
|
||||
tags: d.tags,
|
||||
}));
|
||||
const resp: any = await serverApi.batchCreate({
|
||||
servers,
|
||||
region_id: selectedRegion || undefined,
|
||||
auth_type: connectionMethod,
|
||||
});
|
||||
const r = resp ?? {};
|
||||
const success = r.success ?? 0;
|
||||
const skipped = r.skipped ?? 0;
|
||||
const failed = r.failed ?? 0;
|
||||
toast.success(`导入完成:成功 ${success},跳过 ${skipped},失败 ${failed}`);
|
||||
resetState();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '导入失败,请重试');
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
resetState();
|
||||
};
|
||||
|
||||
const validCount = previewData.filter(d => d.status === 'valid').length;
|
||||
const warningCount = previewData.filter(d => d.status === 'warning').length;
|
||||
const errorCount = previewData.filter(d => d.status === 'error').length;
|
||||
@@ -137,6 +274,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
<DialogContent className="max-w-3xl bg-slate-900 border-slate-700 text-slate-100">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">Excel导入服务器</DialogTitle>
|
||||
<DialogDescription className="sr-only">通过 CSV 文件批量导入服务器资产</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Step Indicator */}
|
||||
@@ -178,7 +316,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
<p className="text-xs text-slate-500">下载Excel模板,填写服务器信息后上传</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300">
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={downloadTemplate}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
下载模板
|
||||
</Button>
|
||||
@@ -236,10 +374,10 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
>
|
||||
<Upload className="h-12 w-12 mx-auto mb-4 text-slate-500" />
|
||||
<p className="text-slate-300 mb-2">点击或拖拽文件到此处上传</p>
|
||||
<p className="text-xs text-slate-500 mb-4">支持 .xlsx, .xls 格式文件</p>
|
||||
<p className="text-xs text-slate-500 mb-4">支持 CSV 格式文件(可点击左侧"下载模板"获取)</p>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
accept=".csv,text/csv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
id="excel-upload"
|
||||
@@ -392,11 +530,15 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={errorCount > 0 || validCount === 0}
|
||||
disabled={isImporting || errorCount > 0 || validCount === 0}
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
确认导入 ({validCount})
|
||||
{isImporting ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{isImporting ? '导入中...' : `确认导入 (${validCount})`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user