feat: 服务器批量导入全栈实现 + SQL审计模型修复 + 审批流程增强

服务器批量导入:
- 后端 BatchCreateServers handler: 逐条校验/查重/GB→MB转换/错误明细
- 前端 CSV 解析器 + IP校验 + 模板下载 + 真实API导入
- 删除孤立占位组件 BatchImport.tsx

SQL审计:
- 修复 TableName 字段与 GORM 接口冲突 → TargetTable
- 补全路由注册 + AutoMigrate + 种子数据

审批流程增强 + 告警/菜单微调

// Logan.Li
This commit is contained in:
attacker committed 2026-06-15 02:12:05 +08:00
1 parent 6545597412
commit 77c33a6fee
16 files changed
+863 -573

No files matched your search

@@ -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>