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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user