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>
);
}