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

@@ -5,6 +5,14 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
@@ -47,10 +55,48 @@ function parseSteps(steps: any) {
return [];
}
// 解析模板的 form_schema,返回字段定义数组
function parseFields(formSchema: any): any[] {
if (!formSchema) return [];
const schema = typeof formSchema === 'string' ? (() => { try { return JSON.parse(formSchema); } catch { return null; } })() : formSchema;
if (!schema) return [];
return Array.isArray(schema.fields) ? schema.fields : [];
}
// 在详情弹窗中渲染表单数据为键值对展示
function renderFormData(formDataStr: string, template?: any) {
if (!formDataStr) return null;
let data: Record<string, any>;
try {
data = typeof formDataStr === 'string' ? JSON.parse(formDataStr) : formDataStr;
} catch {
return null;
}
if (!data || typeof data !== 'object') return null;
// 优先使用模板的 form_schema 标签,否则退化为 key
const fields = parseFields(template?.form_schema);
const labelMap = new Map<string, string>();
fields.forEach((f) => labelMap.set(f.key, f.label));
const entries = Object.entries(data).filter(([, v]) => v !== null && v !== undefined && v !== '');
if (entries.length === 0) return null;
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
{entries.map(([key, value]) => (
<div key={key} className="min-w-0">
<p className="text-xs text-[var(--dg-text-muted)] mb-0.5">{labelMap.get(key) || key}</p>
<p className="text-sm text-[var(--dg-text-primary)] break-words">{String(value)}</p>
</div>
))}
</div>
);
}
const APPROVAL_TYPE_OPTIONS = [
{ value: 'deploy', label: '发布审批', icon: GitCommit, color: 'var(--dg-blue)' },
{ value: 'server', label: '服务器操作', icon: Server, color: 'var(--dg-cyan)' },
{ value: 'permission', label: '权限申请', icon: KeyRound, color: 'var(--dg-purple)' },
{ value: 'server_apply', label: '云服务器申请', icon: Server, color: 'var(--dg-cyan)' },
{ value: 'app_deploy', label: '应用部署申请', icon: GitCommit, color: 'var(--dg-blue)' },
];
const getApprovalTypeLabel = (value: string): string =>
@@ -127,6 +173,12 @@ export function ApprovalFlow() {
const [actionComment, setActionComment] = useState('');
const [actionSubmitting, setActionSubmitting] = useState(false);
// 新建申请弹窗
const [createOpen, setCreateOpen] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<any>(null);
const [formData, setFormData] = useState<Record<string, any>>({});
const [submitting, setSubmitting] = useState(false);
const loadData = async () => {
try {
setLoading(true);
@@ -197,6 +249,36 @@ export function ApprovalFlow() {
}
};
// 提交新建申请
const handleSubmit = async () => {
if (!selectedTemplate) {
toast.error('请先选择申请类型');
return;
}
const fields = parseFields(selectedTemplate.form_schema);
for (const f of fields) {
if (f.required && !formData[f.key]) {
toast.error(`请填写${f.label}`);
return;
}
}
setSubmitting(true);
try {
await approvalApi.createProcess({
template_id: selectedTemplate.id,
title: formData.app_name || selectedTemplate.name,
form_data: JSON.stringify(formData),
});
toast.success('申请已提交');
setCreateOpen(false);
loadData();
} catch (e: any) {
toast.error('提交失败', { description: e?.message });
} finally {
setSubmitting(false);
}
};
// 统计数据
const pendingCount = processes.filter((p) => p.status === 'pending').length;
const approvedCount = processes.filter((p) => p.status === 'approved').length;
@@ -208,12 +290,15 @@ export function ApprovalFlow() {
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">审批中心</h1>
<p className="text-sm text-[var(--dg-text-muted)] mt-1">管理审批流程、实例与待办</p>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">资源申请</h1>
<p className="text-sm text-[var(--dg-text-muted)] mt-1">云服务器申请 · 应用部署申请</p>
</div>
<Button className="bg-[var(--dg-blue)] hover:opacity-90 text-white">
<Button
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
onClick={() => { setSelectedTemplate(null); setFormData({}); setCreateOpen(true); }}
>
<Plus className="h-4 w-4 mr-2" />
创建审批
新建申请
</Button>
</div>
@@ -428,6 +513,19 @@ export function ApprovalFlow() {
</div>
)}
{/* 申请表单 */}
{(() => {
const tpl = templates.find((t) => t.id === selectedInstance.template_id);
const rendered = renderFormData(selectedInstance.form_data, tpl);
if (!rendered) return null;
return (
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-4">
<p className="text-sm font-medium text-[var(--dg-text-secondary)] mb-3">申请表单</p>
{rendered}
</div>
);
})()}
{/* 审批进度 */}
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-4">
<p className="text-sm font-medium text-[var(--dg-text-secondary)] mb-3">审批进度</p>
@@ -540,6 +638,131 @@ export function ApprovalFlow() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* 新建申请弹窗 */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-2xl bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">新建资源申请</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
{selectedTemplate ? '请填写申请信息' : '请选择申请类型'}
</DialogDescription>
</DialogHeader>
{!selectedTemplate ? (
/* 步骤一:选择申请类型 */
<div className="grid grid-cols-2 gap-4 py-2">
{APPROVAL_TYPE_OPTIONS.map((opt) => {
const tpl = templates.find(
(t) => t.type === opt.value && (t.enabled !== false)
);
const Icon = opt.icon;
return (
<button
key={opt.value}
type="button"
disabled={!tpl}
onClick={() => { setSelectedTemplate(tpl); setFormData({}); }}
className={cn(
'flex flex-col items-start gap-3 p-5 rounded-xl text-left transition-colors',
'bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]',
!tpl && 'opacity-40 cursor-not-allowed hover:bg-[var(--dg-bg-tertiary)]'
)}
>
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `color-mix(in srgb, ${opt.color} 15%, transparent)`, color: opt.color }}
>
<Icon className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-medium text-[var(--dg-text-primary)]">{opt.label}</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5">
{tpl ? tpl.description || `共 ${parseFields(tpl.form_schema).length} 个字段` : '暂无可用模板'}
</p>
</div>
</button>
);
})}
</div>
) : (
/* 步骤二:动态表单 */
<div className="py-2">
<div className="flex items-center gap-2 mb-4">
<button
type="button"
onClick={() => { setSelectedTemplate(null); setFormData({}); }}
className="text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
>
← 返回选择类型
</button>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-4">
{parseFields(selectedTemplate.form_schema).map((field: any) => {
const isTextarea = field.type === 'textarea';
return (
<div key={field.key} className={cn('space-y-1.5', isTextarea && 'col-span-2')}>
<Label className="text-sm font-medium text-[var(--dg-text-secondary)]">
{field.label}
{field.required && <span className="text-red-400 ml-0.5">*</span>}
</Label>
{field.type === 'select' ? (
<Select
value={String(formData[field.key] ?? '')}
onValueChange={(val) => setFormData((prev) => ({ ...prev, [field.key]: val }))}
>
<SelectTrigger className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder={field.placeholder || `请选择${field.label}`} />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
{(field.options || []).map((opt: string) => (
<SelectItem key={opt} value={opt} className="text-[var(--dg-text-primary)] focus:bg-[var(--dg-bg-hover)]">
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
) : field.type === 'textarea' ? (
<Textarea
placeholder={field.placeholder || `请输入${field.label}`}
value={formData[field.key] ?? ''}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] min-h-[80px]"
/>
) : (
<Input
type={field.type === 'number' ? 'number' : 'text'}
placeholder={field.placeholder || `请输入${field.label}`}
value={formData[field.key] ?? ''}
onChange={(e) => setFormData((prev) => ({
...prev,
[field.key]: field.type === 'number' ? e.target.value : e.target.value,
}))}
className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
)}
</div>
);
})}
</div>
</div>
)}
<DialogFooter>
<Button variant="ghost" onClick={() => setCreateOpen(false)}>取消</Button>
{selectedTemplate && (
<Button
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
onClick={handleSubmit}
disabled={submitting}
>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
提交申请
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+1 -1
View File
@@ -96,7 +96,7 @@ const menuItems: MenuItem[] = [
{ id: 'agent-alerts', name: 'Agent告警', icon: AlertTriangle, path: '/agent-alerts' },
],
},
{ id: 'approval', name: '审批流程', icon: CheckSquare, path: '/approval' },
{ id: 'approval', name: '资源申请', icon: CheckSquare, path: '/approval' },
{ id: 'navigation', name: '站内导航', icon: Compass, path: '/navigation' },
{
id: 'automation',
@@ -217,9 +217,7 @@ export function AlertManager() {
<TabsTrigger value="rules" className="data-[state=active]:bg-slate-700">
告警规则
</TabsTrigger>
<TabsTrigger value="silences" className="data-[state=active]:bg-slate-700">
静默规则
</TabsTrigger>
</TabsList>
<TabsContent value="events">
@@ -235,13 +233,6 @@ export function AlertManager() {
/>
</TabsContent>
<TabsContent value="silences" className="mt-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="py-12 text-center text-slate-500">
静默规则功能开发中
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Dialogs */}
@@ -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>