feat: K8s集群拓扑图接入真实后端数据 — 分层可视化Cluster→Ingress→Service→Deployment→Node + http.ts类型修复
This commit is contained in:
1 parent
4b094f4645
commit
9f65faff6f
17 files changed
+3021
-1205
No files matched your search
@@ -1,33 +1,34 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Edit2,
|
||||
Trash2,
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Pencil,
|
||||
CloudCog,
|
||||
Server,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
ExternalLink
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { platforms } from '@/mocks/data';
|
||||
|
||||
interface Platform {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
type: 'public' | 'private' | 'hybrid';
|
||||
status: 'active' | 'inactive';
|
||||
accountCount: number;
|
||||
regionCount: number;
|
||||
serverCount: number;
|
||||
website: string;
|
||||
description: string;
|
||||
}
|
||||
import { platformApi } from '@/services/api';
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
public: '公有云',
|
||||
@@ -36,184 +37,282 @@ const typeLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
public: 'bg-blue-500/20 text-blue-400',
|
||||
private: 'bg-purple-500/20 text-purple-400',
|
||||
hybrid: 'bg-cyan-500/20 text-cyan-400',
|
||||
public: 'text-blue-400',
|
||||
private: 'text-purple-400',
|
||||
hybrid: 'text-cyan-400',
|
||||
};
|
||||
|
||||
export function PlatformManager() {
|
||||
const [platforms, setPlatforms] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedType, setSelectedType] = useState<string>('all');
|
||||
|
||||
const filteredPlatforms = platforms.filter(platform => {
|
||||
const matchesSearch = platform.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
platform.code.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesType = selectedType === 'all' || platform.type === selectedType;
|
||||
return matchesSearch && matchesType;
|
||||
const [selectedType, setSelectedType] = useState('all');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '', code: '', type: 'public', status: 'active',
|
||||
website: '', description: '',
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: platforms.length,
|
||||
public: platforms.filter(p => p.type === 'public').length,
|
||||
private: platforms.filter(p => p.type === 'private').length,
|
||||
totalServers: platforms.reduce((sum, p) => sum + p.serverCount, 0),
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await platformApi.list();
|
||||
const items = (res as any)?.list || [];
|
||||
setPlatforms(items);
|
||||
} catch {
|
||||
setPlatforms([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const filtered = platforms.filter(p => {
|
||||
const matchSearch = p.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.code?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchType = selectedType === 'all' || p.type === selectedType;
|
||||
return matchSearch && matchType;
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingItem(null);
|
||||
setFormData({ name: '', code: '', type: 'public', status: 'active', website: '', description: '' });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: any) => {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
name: item.name || '', code: item.code || '', type: item.type || 'public',
|
||||
status: item.status || 'active', website: item.website || '', description: item.description || '',
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim() || !formData.code.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (editingItem) {
|
||||
await platformApi.update(editingItem.id, formData);
|
||||
} else {
|
||||
await platformApi.create(formData);
|
||||
}
|
||||
setDialogOpen(false);
|
||||
loadData();
|
||||
} catch {
|
||||
// error handled by http interceptor
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const handleDelete = (id: number) => setDeleteId(id);
|
||||
const confirmDelete = async () => {
|
||||
if (deleteId === null) return;
|
||||
await platformApi.delete(deleteId);
|
||||
setDeleteId(null);
|
||||
loadData();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">云平台管理</h1>
|
||||
<p className="text-slate-400 mt-1">管理多云平台和云服务提供商</p>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="relative w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索平台名称或编码..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<Select value={selectedType} onValueChange={setSelectedType}>
|
||||
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="public">公有云</SelectItem>
|
||||
<SelectItem value="private">私有云</SelectItem>
|
||||
<SelectItem value="hybrid">混合云</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
<Button onClick={openCreate} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加平台
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">平台总数</p>
|
||||
<p className="text-2xl font-bold text-slate-100 mt-1">{stats.total}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||
<CloudCog className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">公有云</p>
|
||||
<p className="text-2xl font-bold text-blue-400 mt-1">{stats.public}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||
<CloudCog className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">私有云</p>
|
||||
<p className="text-2xl font-bold text-purple-400 mt-1">{stats.private}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
|
||||
<CloudCog className="h-5 w-5 text-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">服务器总数</p>
|
||||
<p className="text-2xl font-bold text-green-400 mt-1">{stats.totalServers}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||
<Server className="h-5 w-5 text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="搜索平台名称或代码..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-slate-800 border-slate-700 text-slate-100"
|
||||
/>
|
||||
{/* List */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
|
||||
</div>
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) => setSelectedType(e.target.value)}
|
||||
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded-md text-slate-100 text-sm"
|
||||
>
|
||||
<option value="all">全部类型</option>
|
||||
<option value="public">公有云</option>
|
||||
<option value="private">私有云</option>
|
||||
<option value="hybrid">混合云</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Platform List */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{filteredPlatforms.map((platform) => (
|
||||
<Card key={platform.id} className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center text-white font-bold text-lg">
|
||||
{platform.name.slice(0, 2)}
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-20 text-slate-500">
|
||||
<CloudCog className="h-12 w-12 mx-auto mb-3 opacity-40" />
|
||||
<p>暂无云平台数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="bg-slate-800/30 rounded-lg p-4 hover:bg-slate-800/50 transition-colors group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||
<CloudCog className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-slate-100">{platform.name}</h3>
|
||||
<Badge className={typeColors[platform.type]}>
|
||||
{typeLabels[platform.type]}
|
||||
</Badge>
|
||||
{platform.status === 'active' ? (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
启用
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-slate-500/20 text-slate-400 border-0">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
停用
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 mt-0.5">{platform.code}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{platform.description}</p>
|
||||
<h3 className="font-medium text-slate-100">{p.name}</h3>
|
||||
<span className="text-xs text-slate-500 font-mono">{p.code}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{p.website && (
|
||||
<a href={p.website} target="_blank" rel="noreferrer" className="p-1.5 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
<button onClick={() => openEdit(p)} className="p-1.5 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(p.id)} className="p-1.5 rounded hover:bg-slate-700 text-slate-400 hover:text-red-400">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-slate-700">
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">账号数</p>
|
||||
<p className="text-lg font-medium text-slate-100">{platform.accountCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">区域数</p>
|
||||
<p className="text-lg font-medium text-slate-100">{platform.regionCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">服务器数</p>
|
||||
<p className="text-lg font-medium text-slate-100">{platform.serverCount}</p>
|
||||
</div>
|
||||
|
||||
{p.description && (
|
||||
<p className="text-sm text-slate-400 mb-3 line-clamp-2">{p.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className={typeColors[p.type] || 'text-slate-400'}>
|
||||
{typeLabels[p.type] || p.type}
|
||||
</Badge>
|
||||
{p.status === 'active' ? (
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" /> 启用
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-slate-500/20 text-slate-400 gap-1">
|
||||
<XCircle className="h-3 w-3" /> 禁用
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingItem ? '编辑云平台' : '添加云平台'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>平台名称 *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="如:阿里云"
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>编码 *</Label>
|
||||
<Input
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||
placeholder="如:aliyun"
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
disabled={!!editingItem}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>类型</Label>
|
||||
<Select value={formData.type} onValueChange={(v) => setFormData({ ...formData, type: v })}>
|
||||
<SelectTrigger className="bg-slate-800/50 border-slate-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="public">公有云</SelectItem>
|
||||
<SelectItem value="private">私有云</SelectItem>
|
||||
<SelectItem value="hybrid">混合云</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>状态</Label>
|
||||
<Select value={formData.status} onValueChange={(v) => setFormData({ ...formData, status: v })}>
|
||||
<SelectTrigger className="bg-slate-800/50 border-slate-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">启用</SelectItem>
|
||||
<SelectItem value="inactive">禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>官网地址</Label>
|
||||
<Input
|
||||
value={formData.website}
|
||||
onChange={(e) => setFormData({ ...formData, website: e.target.value })}
|
||||
placeholder="https://..."
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>描述</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="平台描述..."
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting || !formData.name.trim() || !formData.code.trim()}>
|
||||
{submitting && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||
{editingItem ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Dialog open={deleteId !== null} onOpenChange={(v) => !v && setDeleteId(null)}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-slate-400">确定删除此云平台?此操作不可撤销。</p>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDeleteId(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>删除</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Edit2,
|
||||
Trash2,
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Pencil,
|
||||
MapPin,
|
||||
Server,
|
||||
Cloud,
|
||||
CheckCircle2,
|
||||
XCircle
|
||||
XCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { regions } from '@/mocks/data';
|
||||
|
||||
interface Region {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
provider: string;
|
||||
location: string;
|
||||
status: 'active' | 'inactive';
|
||||
serverCount: number;
|
||||
vpcCount: number;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
const providerColors: Record<string, string> = {
|
||||
aws: 'bg-orange-500',
|
||||
aliyun: 'bg-orange-600',
|
||||
tencent: 'bg-blue-500',
|
||||
huawei: 'bg-red-500',
|
||||
azure: 'bg-blue-600',
|
||||
};
|
||||
import { regionApi } from '@/services/api';
|
||||
|
||||
const providerNames: Record<string, string> = {
|
||||
aws: 'AWS',
|
||||
@@ -44,179 +45,283 @@ const providerNames: Record<string, string> = {
|
||||
azure: 'Azure',
|
||||
};
|
||||
|
||||
export function RegionManager() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>('all');
|
||||
const providerColors: Record<string, string> = {
|
||||
aws: 'text-orange-400',
|
||||
aliyun: 'text-orange-400',
|
||||
tencent: 'text-blue-400',
|
||||
huawei: 'text-red-400',
|
||||
azure: 'text-blue-400',
|
||||
};
|
||||
|
||||
const filteredRegions = regions.filter(region => {
|
||||
const matchesSearch = region.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
region.code.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
region.location.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesProvider = selectedProvider === 'all' || region.provider === selectedProvider;
|
||||
return matchesSearch && matchesProvider;
|
||||
export function RegionManager() {
|
||||
const [regions, setRegions] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedProvider, setSelectedProvider] = useState('all');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '', code: '', provider: 'aliyun', location: '',
|
||||
status: 'active', endpoint: '',
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: regions.length,
|
||||
active: regions.filter(r => r.status === 'active').length,
|
||||
inactive: regions.filter(r => r.status === 'inactive').length,
|
||||
totalServers: regions.reduce((sum, r) => sum + r.serverCount, 0),
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await regionApi.list();
|
||||
const items = (res as any)?.list || [];
|
||||
setRegions(items);
|
||||
} catch {
|
||||
setRegions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const filtered = regions.filter(r => {
|
||||
const matchSearch = r.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
r.code?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchProvider = selectedProvider === 'all' || r.provider === selectedProvider;
|
||||
return matchSearch && matchProvider;
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingItem(null);
|
||||
setFormData({ name: '', code: '', provider: 'aliyun', location: '', status: 'active', endpoint: '' });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: any) => {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
name: item.name || '', code: item.code || '', provider: item.provider || 'aliyun',
|
||||
location: item.location || '', status: item.status || 'active', endpoint: item.endpoint || '',
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim() || !formData.code.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (editingItem) {
|
||||
await regionApi.update(editingItem.id, formData);
|
||||
} else {
|
||||
await regionApi.create(formData);
|
||||
}
|
||||
setDialogOpen(false);
|
||||
loadData();
|
||||
} catch {
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const handleDelete = (id: number) => setDeleteId(id);
|
||||
const confirmDelete = async () => {
|
||||
if (deleteId === null) return;
|
||||
await regionApi.delete(deleteId);
|
||||
setDeleteId(null);
|
||||
loadData();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-100">区域管理</h1>
|
||||
<p className="text-slate-400 mt-1">管理云平台可用区和区域信息</p>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className="relative w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索区域名称或编码..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<Select value={selectedProvider} onValueChange={setSelectedProvider}>
|
||||
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部云商</SelectItem>
|
||||
{Object.entries(providerNames).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
<Button onClick={openCreate} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加区域
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">区域总数</p>
|
||||
<p className="text-2xl font-bold text-slate-100 mt-1">{stats.total}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||
<MapPin className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">可用</p>
|
||||
<p className="text-2xl font-bold text-green-400 mt-1">{stats.active}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">不可用</p>
|
||||
<p className="text-2xl font-bold text-slate-400 mt-1">{stats.inactive}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-slate-500/20 flex items-center justify-center">
|
||||
<XCircle className="h-5 w-5 text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-slate-400 text-sm">服务器总数</p>
|
||||
<p className="text-2xl font-bold text-cyan-400 mt-1">{stats.totalServers}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-lg bg-cyan-500/20 flex items-center justify-center">
|
||||
<Server className="h-5 w-5 text-cyan-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="搜索区域名称、代码或位置..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-slate-800 border-slate-700 text-slate-100"
|
||||
/>
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
|
||||
</div>
|
||||
<select
|
||||
value={selectedProvider}
|
||||
onChange={(e) => setSelectedProvider(e.target.value)}
|
||||
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded-md text-slate-100 text-sm"
|
||||
>
|
||||
<option value="all">全部平台</option>
|
||||
<option value="aws">AWS</option>
|
||||
<option value="aliyun">阿里云</option>
|
||||
<option value="tencent">腾讯云</option>
|
||||
<option value="huawei">华为云</option>
|
||||
<option value="azure">Azure</option>
|
||||
</select>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-20 text-slate-500">
|
||||
<MapPin className="h-12 w-12 mx-auto mb-3 opacity-40" />
|
||||
<p>暂无区域数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<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">云商</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>
|
||||
{filtered.map((r) => (
|
||||
<TableRow key={r.id} className="border-slate-800/50 group">
|
||||
<TableCell className="font-medium text-slate-200">{r.name}</TableCell>
|
||||
<TableCell className="font-mono text-sm text-slate-400">{r.code}</TableCell>
|
||||
<TableCell>
|
||||
<span className={providerColors[r.provider] || 'text-slate-400'}>
|
||||
{providerNames[r.provider] || r.provider}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400">{r.location || '-'}</TableCell>
|
||||
<TableCell className="text-slate-400 font-mono text-xs">{r.endpoint || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{r.status === 'active' ? (
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" /> 启用
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-slate-500/20 text-slate-400 gap-1">
|
||||
<XCircle className="h-3 w-3" /> 禁用
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => openEdit(r)} className="p-1.5 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(r.id)} className="p-1.5 rounded hover:bg-slate-700 text-slate-400 hover:text-red-400">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Region List */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{filteredRegions.map((region) => (
|
||||
<Card key={region.id} className="bg-slate-800 border-slate-700">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${providerColors[region.provider] || 'bg-blue-500'} flex items-center justify-center text-white font-bold text-xs`}>
|
||||
{providerNames[region.provider]?.slice(0, 2) || region.provider.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-slate-100">{region.name}</h3>
|
||||
{region.status === 'active' ? (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
可用
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-slate-500/20 text-slate-400 border-0">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
不可用
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 mt-0.5">{region.code}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<MapPin className="h-3 w-3 text-slate-500" />
|
||||
<span className="text-xs text-slate-400">{region.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingItem ? '编辑区域' : '添加区域'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>区域名称 *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="如:华北-北京"
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-slate-700">
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">服务器</p>
|
||||
<p className="text-lg font-medium text-slate-100">{region.serverCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">VPC</p>
|
||||
<p className="text-lg font-medium text-slate-100">{region.vpcCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Endpoint</p>
|
||||
<p className="text-xs text-slate-300 truncate">{region.endpoint}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>编码 *</Label>
|
||||
<Input
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||
placeholder="如:cn-north-1"
|
||||
className="bg-slate-800/50 border-slate-700 font-mono"
|
||||
disabled={!!editingItem}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>云商</Label>
|
||||
<Select value={formData.provider} onValueChange={(v) => setFormData({ ...formData, provider: v })}>
|
||||
<SelectTrigger className="bg-slate-800/50 border-slate-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(providerNames).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>状态</Label>
|
||||
<Select value={formData.status} onValueChange={(v) => setFormData({ ...formData, status: v })}>
|
||||
<SelectTrigger className="bg-slate-800/50 border-slate-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">启用</SelectItem>
|
||||
<SelectItem value="inactive">禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>地理位置</Label>
|
||||
<Input
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||
placeholder="如:中国北京"
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>接入端点</Label>
|
||||
<Input
|
||||
value={formData.endpoint}
|
||||
onChange={(e) => setFormData({ ...formData, endpoint: e.target.value })}
|
||||
placeholder="如:ecs.cn-north-1.aliyuncs.com"
|
||||
className="bg-slate-800/50 border-slate-700 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting || !formData.name.trim() || !formData.code.trim()}>
|
||||
{submitting && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||
{editingItem ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Dialog open={deleteId !== null} onOpenChange={(v) => !v && setDeleteId(null)}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-slate-400">确定删除此区域?此操作不可撤销。</p>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDeleteId(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>删除</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -20,161 +20,274 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Network,
|
||||
Plus,
|
||||
Search,
|
||||
Server,
|
||||
Database,
|
||||
Layers,
|
||||
Share2,
|
||||
Shield,
|
||||
HardDrive,
|
||||
Cloud,
|
||||
RefreshCw,
|
||||
Download,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Move,
|
||||
Edit,
|
||||
Trash2,
|
||||
Save,
|
||||
Loader2,
|
||||
Globe,
|
||||
Boxes,
|
||||
} from 'lucide-react';
|
||||
import { topologyNodes, topologyEdges } from '@/mocks/data';
|
||||
import { k8sApi } from '@/services/api';
|
||||
import type { TopologyNode, TopologyEdge } from '@/types';
|
||||
|
||||
// K8s resource type → topology node type label
|
||||
const NODE_TYPE_LABELS: Record<string, string> = {
|
||||
cluster: '集群',
|
||||
loadbalancer: 'Ingress',
|
||||
service: 'Service',
|
||||
app: 'Deployment',
|
||||
server: 'Node',
|
||||
};
|
||||
|
||||
/**
|
||||
* buildTopology — 从真实 K8s 资源构建分层拓扑图
|
||||
*
|
||||
* 层级 (自上而下): Cluster → Ingress → Service → Deployment → Node
|
||||
*/
|
||||
function buildTopology(
|
||||
cluster: any,
|
||||
ingresses: any[],
|
||||
services: any[],
|
||||
deployments: any[],
|
||||
nodes: any[],
|
||||
): { nodes: TopologyNode[]; edges: TopologyEdge[] } {
|
||||
const tNodes: TopologyNode[] = [];
|
||||
const tEdges: TopologyEdge[] = [];
|
||||
const MAX_PER_LAYER = 12;
|
||||
|
||||
const maxLayerCount = Math.max(1, ingresses.length, services.length, deployments.length, nodes.length);
|
||||
const canvasW = Math.max(900, Math.min(maxLayerCount * 130, 2400));
|
||||
const layerY = [60, 180, 320, 480, 640];
|
||||
|
||||
const distributeX = (count: number): number[] => {
|
||||
const cap = Math.min(count, MAX_PER_LAYER);
|
||||
if (cap === 0) return [];
|
||||
if (cap === 1) return [canvasW / 2];
|
||||
const step = canvasW / (cap + 1);
|
||||
return Array.from({ length: cap }, (_, i) => step * (i + 1));
|
||||
};
|
||||
|
||||
// Layer 0: Cluster
|
||||
const clusterId = `cluster-${cluster.id}`;
|
||||
tNodes.push({
|
||||
id: clusterId,
|
||||
name: cluster.name || '集群',
|
||||
type: 'cluster',
|
||||
x: canvasW / 2,
|
||||
y: layerY[0],
|
||||
data: {
|
||||
kind: 'cluster',
|
||||
version: cluster.version,
|
||||
status: cluster.status,
|
||||
api_server: cluster.api_server,
|
||||
node_count: cluster.node_count,
|
||||
pod_count: cluster.pod_count,
|
||||
},
|
||||
});
|
||||
|
||||
// Layer 1: Ingress
|
||||
const ingCap = ingresses.slice(0, MAX_PER_LAYER);
|
||||
const ingXs = distributeX(ingCap.length);
|
||||
ingCap.forEach((ing, i) => {
|
||||
const id = `ingress-${ing.id}`;
|
||||
tNodes.push({ id, name: ing.name, type: 'loadbalancer', x: ingXs[i], y: layerY[1], data: { kind: 'ingress', namespace: ing.namespace, backend: ing.backend, tls: ing.tls, hosts: ing.hosts } });
|
||||
tEdges.push({ id: `e-${clusterId}-${id}`, source: clusterId, target: id, label: 'route' });
|
||||
});
|
||||
|
||||
// Layer 2: Service
|
||||
const svcCap = services.slice(0, MAX_PER_LAYER);
|
||||
const svcXs = distributeX(svcCap.length);
|
||||
svcCap.forEach((svc, i) => {
|
||||
const id = `service-${svc.id}`;
|
||||
tNodes.push({ id, name: svc.name, type: 'service', x: svcXs[i], y: layerY[2], data: { kind: 'service', namespace: svc.namespace, type: svc.type, cluster_ip: svc.cluster_ip, external_ip: svc.external_ip, selector: svc.selector } });
|
||||
});
|
||||
|
||||
// Ingress → Service edges
|
||||
if (ingCap.length > 0 && svcCap.length > 0) {
|
||||
ingCap.forEach((ing) => {
|
||||
const ingNodeId = `ingress-${ing.id}`;
|
||||
const backendSvc = (ing.backend || '').split(':')[0].trim();
|
||||
let matched = svcCap.filter((s) => s.name === backendSvc);
|
||||
if (matched.length === 0) matched = svcCap.filter((s) => s.namespace === ing.namespace);
|
||||
if (matched.length === 0) matched = [svcCap[0]];
|
||||
matched.forEach((svc) => tEdges.push({ id: `e-${ingNodeId}-service-${svc.id}`, source: ingNodeId, target: `service-${svc.id}`, label: '→' }));
|
||||
});
|
||||
} else if (svcCap.length > 0) {
|
||||
svcCap.forEach((svc) => tEdges.push({ id: `e-${clusterId}-service-${svc.id}`, source: clusterId, target: `service-${svc.id}`, label: 'expose' }));
|
||||
}
|
||||
|
||||
// Layer 3: Deployment
|
||||
const depCap = deployments.slice(0, MAX_PER_LAYER);
|
||||
const depXs = distributeX(depCap.length);
|
||||
depCap.forEach((dep, i) => {
|
||||
const id = `deploy-${dep.id}`;
|
||||
tNodes.push({ id, name: dep.name, type: 'app', x: depXs[i], y: layerY[3], data: { kind: 'deployment', namespace: dep.namespace, replicas: dep.replicas, ready_replicas: dep.ready_replicas, available_replicas: dep.available_replicas, image: dep.image } });
|
||||
});
|
||||
|
||||
// Service → Deployment edges (selector match or namespace fallback)
|
||||
if (svcCap.length > 0 && depCap.length > 0) {
|
||||
svcCap.forEach((svc) => {
|
||||
const svcNodeId = `service-${svc.id}`;
|
||||
let matched: any[] = [];
|
||||
try {
|
||||
const selector = typeof svc.selector === 'string' ? JSON.parse(svc.selector || '{}') : (svc.selector || {});
|
||||
const selectorEntries = Object.entries(selector);
|
||||
if (selectorEntries.length > 0) {
|
||||
matched = depCap.filter((d) => {
|
||||
let labels: Record<string, string> = {};
|
||||
try { labels = typeof d.labels === 'string' ? JSON.parse(d.labels || '{}') : (d.labels || {}); } catch { /* */ }
|
||||
return selectorEntries.every(([k, v]) => labels[k] === v);
|
||||
});
|
||||
}
|
||||
} catch { /* */ }
|
||||
if (matched.length === 0) matched = depCap.filter((d) => d.name === svc.name);
|
||||
if (matched.length === 0) matched = depCap.filter((d) => d.namespace === svc.namespace);
|
||||
if (matched.length === 0) matched = [depCap[0]];
|
||||
matched.slice(0, 4).forEach((dep) => tEdges.push({ id: `e-${svcNodeId}-deploy-${dep.id}`, source: svcNodeId, target: `deploy-${dep.id}`, label: 'select' }));
|
||||
});
|
||||
}
|
||||
|
||||
// Layer 4: Node
|
||||
const nodeCap = nodes.slice(0, MAX_PER_LAYER);
|
||||
const nodeXs = distributeX(nodeCap.length);
|
||||
nodeCap.forEach((n, i) => {
|
||||
const id = `k8snode-${n.id}`;
|
||||
tNodes.push({ id, name: n.name, type: 'server', x: nodeXs[i], y: layerY[4], data: { kind: 'node', ip: n.ip, role: n.role, status: n.status, os: n.os, cpu: n.cpu, memory: n.memory, cpu_usage: n.cpu_usage, memory_usage: n.memory_usage, pod_count: n.pod_count } });
|
||||
});
|
||||
|
||||
// Deployment → Node edges (Pods scheduled on worker nodes)
|
||||
const workerNodes = nodeCap.filter((n) => (n.role || '').toLowerCase() === 'worker');
|
||||
const schedulableNodes = workerNodes.length > 0 ? workerNodes : nodeCap;
|
||||
if (depCap.length > 0 && schedulableNodes.length > 0) {
|
||||
depCap.forEach((dep) => {
|
||||
const depNodeId = `deploy-${dep.id}`;
|
||||
schedulableNodes.slice(0, 3).forEach((n) => tEdges.push({ id: `e-${depNodeId}-k8snode-${n.id}`, source: depNodeId, target: `k8snode-${n.id}`, label: 'schedule' }));
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes: tNodes, edges: tEdges };
|
||||
}
|
||||
|
||||
export function TopologyView() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [clusters, setClusters] = useState<any[]>([]);
|
||||
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
|
||||
const [topology, setTopology] = useState<{ nodes: TopologyNode[]; edges: TopologyEdge[] }>({ nodes: [], edges: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedNode, setSelectedNode] = useState<TopologyNode | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [viewType, setViewType] = useState<string>('all');
|
||||
|
||||
const filteredNodes = topologyNodes.filter(
|
||||
const loadClusters = useCallback(async () => {
|
||||
try {
|
||||
const res: any = await k8sApi.getClusters();
|
||||
const list = res?.list || [];
|
||||
setClusters(list);
|
||||
if (list.length > 0 && !selectedClusterId) {
|
||||
setSelectedClusterId(String(list[0].id));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载集群列表失败:', e);
|
||||
setClusters([]);
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
loadClusters();
|
||||
}, [loadClusters]);
|
||||
|
||||
const loadTopology = useCallback(async () => {
|
||||
if (!selectedClusterId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || []));
|
||||
const [nodesRes, svcRes, depRes, ingRes] = await Promise.all([
|
||||
k8sApi.getNodes(selectedClusterId).catch(() => []),
|
||||
k8sApi.getServices(selectedClusterId).catch(() => []),
|
||||
k8sApi.getDeployments(selectedClusterId).catch(() => []),
|
||||
k8sApi.getIngresses(selectedClusterId).catch(() => []),
|
||||
]);
|
||||
const cluster = clusters.find((c) => String(c.id) === selectedClusterId) || {};
|
||||
setTopology(buildTopology(cluster, toArray(ingRes), toArray(svcRes), toArray(depRes), toArray(nodesRes)));
|
||||
} catch (e) {
|
||||
console.error('加载拓扑数据失败:', e);
|
||||
setTopology({ nodes: [], edges: [] });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedClusterId, clusters]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTopology();
|
||||
}, [loadTopology]);
|
||||
|
||||
const filteredNodes = topology.nodes.filter(
|
||||
(node) =>
|
||||
node.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
(viewType === 'all' || node.type === viewType)
|
||||
(viewType === 'all' || node.type === viewType),
|
||||
);
|
||||
const visibleIds = new Set(filteredNodes.map((n) => n.id));
|
||||
|
||||
const getNodeIcon = (type: TopologyNode['type']) => {
|
||||
const icons = {
|
||||
server: <Server className="h-5 w-5" />,
|
||||
database: <Database className="h-5 w-5" />,
|
||||
cache: <Layers className="h-5 w-5" />,
|
||||
loadbalancer: <Share2 className="h-5 w-5" />,
|
||||
cluster: <Cloud className="h-5 w-5" />,
|
||||
app: <HardDrive className="h-5 w-5" />,
|
||||
service: <Network className="h-5 w-5" />,
|
||||
};
|
||||
return icons[type] || <Server className="h-5 w-5" />;
|
||||
const getNodeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'server': return <Server className="h-5 w-5" />;
|
||||
case 'loadbalancer': return <Globe className="h-5 w-5" />;
|
||||
case 'cluster': return <Cloud className="h-5 w-5" />;
|
||||
case 'app': return <Boxes className="h-5 w-5" />;
|
||||
case 'service': return <Network className="h-5 w-5" />;
|
||||
default: return <Server className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getNodeColor = (type: TopologyNode['type']) => {
|
||||
const colors = {
|
||||
server: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
database: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
cache: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
loadbalancer: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
cluster: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
|
||||
app: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
service: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
|
||||
};
|
||||
return colors[type] || 'bg-slate-500/20 text-slate-400 border-slate-500/30';
|
||||
const getNodeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'server': return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||
case 'loadbalancer': return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
|
||||
case 'cluster': return 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30';
|
||||
case 'app': return 'bg-rose-500/20 text-rose-400 border-rose-500/30';
|
||||
case 'service': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30';
|
||||
default: return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
|
||||
}
|
||||
};
|
||||
|
||||
// 简化的SVG拓扑图渲染
|
||||
const maxX = topology.nodes.length > 0 ? Math.max(...topology.nodes.map((n) => n.x)) + 80 : 900;
|
||||
const maxY = topology.nodes.length > 0 ? Math.max(...topology.nodes.map((n) => n.y)) + 80 : 700;
|
||||
const canvasW = Math.max(maxX, 900);
|
||||
const canvasH = Math.max(maxY, 700);
|
||||
|
||||
const renderTopology = () => {
|
||||
const width = 800;
|
||||
const height = 500;
|
||||
|
||||
if (topology.nodes.length === 0) return null;
|
||||
const ys = [60, 180, 320, 480, 640];
|
||||
const labels = ['集群', 'Ingress', 'Service', 'Deployment', 'Node'];
|
||||
return (
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="bg-slate-950 rounded-lg"
|
||||
>
|
||||
{/* Grid background */}
|
||||
<svg width="100%" height="100%" viewBox={`0 0 ${canvasW} ${canvasH}`} className="bg-slate-950 rounded-lg" preserveAspectRatio="xMidYMin meet">
|
||||
<defs>
|
||||
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#1e293b" strokeWidth="0.5" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
{/* Edges */}
|
||||
{topologyEdges.map((edge) => {
|
||||
const source = topologyNodes.find((n) => n.id === edge.source);
|
||||
const target = topologyNodes.find((n) => n.id === edge.target);
|
||||
if (!source || !target) return null;
|
||||
|
||||
return (
|
||||
<g key={edge.id}>
|
||||
<line
|
||||
x1={source.x}
|
||||
y1={source.y}
|
||||
x2={target.x}
|
||||
y2={target.y}
|
||||
stroke="#475569"
|
||||
strokeWidth="2"
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
<text
|
||||
x={(source.x + target.x) / 2}
|
||||
y={(source.y + target.y) / 2 - 5}
|
||||
fill="#64748b"
|
||||
fontSize="10"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{edge.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Arrow marker */}
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="28"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="28" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#475569" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Nodes */}
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
{labels.map((label, i) => (
|
||||
<text key={label} x={14} y={ys[i] + 4} fill="#475569" fontSize="11" className="font-medium select-none">{label}</text>
|
||||
))}
|
||||
{topology.edges.map((edge) => {
|
||||
const source = topology.nodes.find((n) => n.id === edge.source);
|
||||
const target = topology.nodes.find((n) => n.id === edge.target);
|
||||
if (!source || !target || !visibleIds.has(source.id) || !visibleIds.has(target.id)) return null;
|
||||
return <line key={edge.id} x1={source.x} y1={source.y} x2={target.x} y2={target.y} stroke="#475569" strokeWidth="1.5" markerEnd="url(#arrowhead)" opacity={0.6} />;
|
||||
})}
|
||||
{filteredNodes.map((node) => (
|
||||
<g
|
||||
key={node.id}
|
||||
transform={`translate(${node.x}, ${node.y})`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSelectedNode(node)}
|
||||
>
|
||||
<circle
|
||||
r="28"
|
||||
fill="#0f172a"
|
||||
stroke={selectedNode?.id === node.id ? '#3b82f6' : '#334155'}
|
||||
strokeWidth={selectedNode?.id === node.id ? 3 : 2}
|
||||
className="transition-all hover:stroke-blue-500"
|
||||
/>
|
||||
<g key={node.id} transform={`translate(${node.x}, ${node.y})`} className="cursor-pointer" onClick={() => setSelectedNode(node)}>
|
||||
<circle r="28" fill="#0f172a" stroke={selectedNode?.id === node.id ? '#3b82f6' : '#334155'} strokeWidth={selectedNode?.id === node.id ? 3 : 2} className="transition-all hover:stroke-blue-500" />
|
||||
<foreignObject x="-12" y="-12" width="24" height="24">
|
||||
<div className={`flex items-center justify-center w-full h-full ${getNodeColor(node.type).split(' ')[1]}`}>
|
||||
{getNodeIcon(node.type)}
|
||||
</div>
|
||||
<div className={`flex items-center justify-center w-full h-full ${getNodeColor(node.type).split(' ')[1]}`}>{getNodeIcon(node.type)}</div>
|
||||
</foreignObject>
|
||||
<text
|
||||
y="45"
|
||||
fill="#94a3b8"
|
||||
fontSize="12"
|
||||
textAnchor="middle"
|
||||
className="font-medium"
|
||||
>
|
||||
{node.name}
|
||||
<text y="45" fill="#94a3b8" fontSize="12" textAnchor="middle" className="font-medium select-none">
|
||||
{node.name.length > 20 ? node.name.slice(0, 18) + '…' : node.name}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
@@ -182,153 +295,111 @@ export function TopologyView() {
|
||||
);
|
||||
};
|
||||
|
||||
const countByType = (type: string) => topology.nodes.filter((n) => n.type === type).length;
|
||||
|
||||
const renderNodeDetailFields = (node: TopologyNode) => {
|
||||
const data = node.data || {};
|
||||
const entries = Object.entries(data).filter(([k]) => k !== 'kind');
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{entries.map(([key, val]) => (
|
||||
<div key={key} className="p-2 rounded-lg bg-slate-800/50">
|
||||
<p className="text-xs text-slate-500 mb-0.5">{key}</p>
|
||||
<p className="text-sm text-slate-200 truncate">{val === null || val === undefined ? '—' : String(val)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
{/* 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>
|
||||
<p className="text-sm text-slate-500 mt-1">K8s 集群资源拓扑可视化</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>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加节点
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 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">{topologyNodes.length}</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>
|
||||
<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">{topologyEdges.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
|
||||
<Network 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">
|
||||
{topologyNodes.filter((n) => n.type === 'database').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
<Database 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-amber-400">
|
||||
{topologyNodes.filter((n) => n.type === 'cache').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
|
||||
<Layers 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-cyan-400">
|
||||
{topologyNodes.filter((n) => n.type === 'loadbalancer').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
|
||||
<Share2 className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters & Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索节点..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<Select value={viewType} onValueChange={setViewType}>
|
||||
<SelectTrigger className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectValue placeholder="节点类型" />
|
||||
<Select value={selectedClusterId} onValueChange={(v) => { setSelectedClusterId(v); setSelectedNode(null); }}>
|
||||
<SelectTrigger className="w-44 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectValue placeholder="选择集群" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="server">服务器</SelectItem>
|
||||
<SelectItem value="database">数据库</SelectItem>
|
||||
<SelectItem value="cache">缓存</SelectItem>
|
||||
<SelectItem value="loadbalancer">负载均衡</SelectItem>
|
||||
<SelectItem value="cluster">集群</SelectItem>
|
||||
{clusters.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400" onClick={loadTopology} disabled={loading}>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Topology Canvas */}
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{[
|
||||
{ label: '总节点数', value: topology.nodes.length, icon: <Server className="h-5 w-5" />, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||
{ label: '连接数', value: topology.edges.length, icon: <Network className="h-5 w-5" />, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||
{ label: 'Ingress', value: countByType('loadbalancer'), icon: <Globe className="h-5 w-5" />, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||
{ label: 'Service', value: countByType('service'), icon: <Network className="h-5 w-5" />, color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
|
||||
{ label: 'Deployment', value: countByType('app'), icon: <Boxes className="h-5 w-5" />, color: 'text-rose-400', bg: 'bg-rose-500/10' },
|
||||
].map((s) => (
|
||||
<Card key={s.label} 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">{s.label}</p>
|
||||
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-lg ${s.bg} ${s.color}`}>{s.icon}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-48">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input placeholder="搜索节点..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" />
|
||||
</div>
|
||||
<Select value={viewType} onValueChange={setViewType}>
|
||||
<SelectTrigger className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
||||
<SelectValue placeholder="节点类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="cluster">集群</SelectItem>
|
||||
<SelectItem value="loadbalancer">Ingress</SelectItem>
|
||||
<SelectItem value="service">Service</SelectItem>
|
||||
<SelectItem value="app">Deployment</SelectItem>
|
||||
<SelectItem value="server">Node</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Canvas + Node List */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<div className="lg:col-span-3">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-4">
|
||||
<div className="h-[500px]">
|
||||
{renderTopology()}
|
||||
<div className="h-[600px] overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full"><Loader2 className="h-8 w-8 animate-spin text-slate-600" /></div>
|
||||
) : topology.nodes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-600">
|
||||
<Cloud className="h-12 w-12 mb-3 opacity-50" />
|
||||
<p className="text-sm">{selectedClusterId ? '该集群暂无拓扑数据' : '请选择集群查看拓扑'}</p>
|
||||
</div>
|
||||
) : renderTopology()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Node List */}
|
||||
<div>
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
@@ -336,24 +407,20 @@ export function TopologyView() {
|
||||
<CardDescription className="text-slate-500">点击节点查看详情</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 max-h-[420px] overflow-auto">
|
||||
{filteredNodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors ${
|
||||
selectedNode?.id === node.id ? 'bg-blue-500/10 border border-blue-500/30' : 'hover:bg-slate-800/50'
|
||||
}`}
|
||||
onClick={() => setSelectedNode(node)}
|
||||
>
|
||||
<div className={`p-2 rounded-lg ${getNodeColor(node.type)}`}>
|
||||
{getNodeIcon(node.type)}
|
||||
<div className="space-y-2 max-h-[520px] overflow-auto">
|
||||
{filteredNodes.length === 0 ? (
|
||||
<p className="text-sm text-slate-600 text-center py-8">暂无节点</p>
|
||||
) : (
|
||||
filteredNodes.map((node) => (
|
||||
<div key={node.id} className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors ${selectedNode?.id === node.id ? 'bg-blue-500/10 border border-blue-500/30' : 'hover:bg-slate-800/50'}`} onClick={() => setSelectedNode(node)}>
|
||||
<div className={`p-2 rounded-lg ${getNodeColor(node.type)}`}>{getNodeIcon(node.type)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-200 truncate">{node.name}</p>
|
||||
<p className="text-xs text-slate-500">{NODE_TYPE_LABELS[node.type] || node.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-200 truncate">{node.name}</p>
|
||||
<p className="text-xs text-slate-500">{node.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -365,87 +432,36 @@ export function TopologyView() {
|
||||
<DialogContent className="bg-slate-900 border-slate-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<div className={`p-2 rounded-lg ${getNodeColor(selectedNode?.type || 'server')}`}>
|
||||
{selectedNode && getNodeIcon(selectedNode.type)}
|
||||
</div>
|
||||
<div className={`p-2 rounded-lg ${getNodeColor(selectedNode?.type || 'server')}`}>{selectedNode && getNodeIcon(selectedNode.type)}</div>
|
||||
{selectedNode?.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
类型: {selectedNode?.type}
|
||||
</DialogDescription>
|
||||
<DialogDescription className="text-slate-500">类型: {NODE_TYPE_LABELS[selectedNode?.type || ''] || selectedNode?.type}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-slate-800/50">
|
||||
{selectedNode && (
|
||||
<div className="space-y-4">
|
||||
{renderNodeDetailFields(selectedNode)}
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">节点ID</p>
|
||||
<p className="text-sm text-slate-200">{selectedNode?.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">节点名称</p>
|
||||
<p className="text-sm text-slate-200">{selectedNode?.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">坐标位置</p>
|
||||
<p className="text-sm text-slate-200">X: {selectedNode?.x}, Y: {selectedNode?.y}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">关联连接</p>
|
||||
<p className="text-sm text-slate-200">
|
||||
{topologyEdges.filter((e) => e.source === selectedNode?.id || e.target === selectedNode?.id).length} 条
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedNode?.data && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-300 mb-2">扩展数据</h4>
|
||||
<div className="p-3 rounded-lg bg-slate-800/50">
|
||||
<pre className="text-xs text-slate-400 overflow-auto">
|
||||
{JSON.stringify(selectedNode.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-300 mb-2">关联节点</h4>
|
||||
<div className="space-y-2">
|
||||
{topologyEdges
|
||||
.filter((e) => e.source === selectedNode?.id || e.target === selectedNode?.id)
|
||||
.map((edge) => {
|
||||
const isSource = edge.source === selectedNode?.id;
|
||||
<h4 className="text-sm font-medium text-slate-300 mb-2">关联连接</h4>
|
||||
<div className="space-y-2">
|
||||
{topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).map((edge) => {
|
||||
const isSource = edge.source === selectedNode.id;
|
||||
const relatedNodeId = isSource ? edge.target : edge.source;
|
||||
const relatedNode = topologyNodes.find((n) => n.id === relatedNodeId);
|
||||
const relatedNode = topology.nodes.find((n) => n.id === relatedNodeId);
|
||||
if (!relatedNode) return null;
|
||||
|
||||
return (
|
||||
<div key={edge.id} className="flex items-center gap-2 p-2 rounded-lg bg-slate-800/50">
|
||||
<span className="text-sm text-slate-400">{isSource ? '→' : '←'}</span>
|
||||
<div className={`p-1 rounded ${getNodeColor(relatedNode.type)}`}>
|
||||
{getNodeIcon(relatedNode.type)}
|
||||
</div>
|
||||
<div className={`p-1 rounded ${getNodeColor(relatedNode.type)}`}>{getNodeIcon(relatedNode.type)}</div>
|
||||
<span className="text-sm text-slate-300">{relatedNode.name}</span>
|
||||
<Badge variant="outline" className="ml-auto border-slate-700 text-slate-400 text-xs">
|
||||
{edge.label}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="ml-auto border-slate-700 text-slate-400 text-xs">{edge.label}</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).length === 0 && <p className="text-xs text-slate-600">无关联连接</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
查看资源
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useThemeStore, useTabStore, useNotificationStore, useUserStore } from '@/store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
Bell,
|
||||
Menu,
|
||||
X,
|
||||
Plus,
|
||||
Settings,
|
||||
User,
|
||||
LogOut,
|
||||
@@ -27,10 +25,6 @@ import {
|
||||
AlertTriangle,
|
||||
Info,
|
||||
CheckCircle,
|
||||
Terminal,
|
||||
Rocket,
|
||||
Server,
|
||||
Cloud,
|
||||
Palette,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
@@ -98,17 +92,6 @@ export function Header() {
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-slate-400 hover:text-slate-100">
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span className="hidden lg:inline">WebShell</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-slate-400 hover:text-slate-100">
|
||||
<Rocket className="h-4 w-4" />
|
||||
<span className="hidden lg:inline">发布</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
@@ -146,35 +129,6 @@ export function Header() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Create New */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
|
||||
<DropdownMenuLabel className="text-slate-300">快速创建</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
添加服务器
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Cloud className="h-4 w-4 mr-2" />
|
||||
添加K8s集群
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Rocket className="h-4 w-4 mr-2" />
|
||||
创建发布
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
创建工单
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Notifications */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
FileCode,
|
||||
Shield,
|
||||
Users,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Box,
|
||||
@@ -41,7 +40,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
@@ -307,38 +305,13 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
{/* Menu */}
|
||||
<ScrollArea className="h-[calc(100vh-3.5rem-4rem)]">
|
||||
<ScrollArea className="h-[calc(100vh-3.5rem)]">
|
||||
<div className={cn('p-2', sidebarCollapsed && 'px-1')}>
|
||||
{menuItems.map((item) => (
|
||||
<MenuItemComponent key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* User */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3 border-t border-slate-800 bg-slate-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium">
|
||||
管
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-200 truncate">管理员</p>
|
||||
<p className="text-xs text-slate-500 truncate">admin@company.com</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sidebarCollapsed && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-2 border-t border-slate-800 bg-slate-900">
|
||||
<Button variant="ghost" size="icon" className="w-10 h-10 mx-auto text-slate-400 hover:text-slate-100">
|
||||
<LogOut className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -15,448 +14,378 @@ import {
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Layers,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Database,
|
||||
Network,
|
||||
Server,
|
||||
HardDrive,
|
||||
Settings,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Link,
|
||||
Copy,
|
||||
ArrowRight,
|
||||
GitBranch,
|
||||
Pencil,
|
||||
Layers,
|
||||
GripVertical,
|
||||
X,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { resourceModels, modelInstances } from '@/mocks/data';
|
||||
import type { ResourceModel, ModelInstance } from '@/types';
|
||||
import { resourceModelApi } from '@/services/api';
|
||||
|
||||
interface AttrDef {
|
||||
name: string;
|
||||
code: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
unique?: boolean;
|
||||
}
|
||||
|
||||
const attrTypes = [
|
||||
{ value: 'string', label: '字符串' },
|
||||
{ value: 'number', label: '数字' },
|
||||
{ value: 'bool', label: '布尔' },
|
||||
{ value: 'date', label: '日期' },
|
||||
{ value: 'enum', label: '枚举' },
|
||||
];
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
normal: '普通模型',
|
||||
parent: '父模型',
|
||||
};
|
||||
|
||||
export function ResourceModelManager() {
|
||||
const [activeTab, setActiveTab] = useState('models');
|
||||
const [models, setModels] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedModel, setSelectedModel] = useState<ResourceModel | null>(null);
|
||||
const [selectedInstance, setSelectedInstance] = useState<ModelInstance | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<any>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '', code: '', description: '', type: 'normal', icon: '',
|
||||
});
|
||||
const [attrs, setAttrs] = useState<AttrDef[]>([]);
|
||||
|
||||
const filteredModels = resourceModels.filter(
|
||||
(model) =>
|
||||
model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
model.code.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await resourceModelApi.list();
|
||||
const items = (res as any)?.list || [];
|
||||
setModels(items);
|
||||
} catch {
|
||||
setModels([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const filtered = models.filter(m =>
|
||||
m.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
m.code?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const getModelTypeBadge = (type: ResourceModel['type']) => {
|
||||
const configs = {
|
||||
normal: { label: '普通', className: 'border-blue-500/30 text-blue-400 bg-blue-500/10' },
|
||||
parent: { label: '父模型', className: 'border-purple-500/30 text-purple-400 bg-purple-500/10' },
|
||||
child: { label: '子模型', className: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10' },
|
||||
};
|
||||
const config = configs[type];
|
||||
return (
|
||||
<Badge variant="outline" className={config.className}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getAttributeTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return <span className="text-xs text-slate-400">ABC</span>;
|
||||
case 'number':
|
||||
return <span className="text-xs text-slate-400">123</span>;
|
||||
case 'boolean':
|
||||
return <span className="text-xs text-slate-400">T/F</span>;
|
||||
case 'date':
|
||||
return <span className="text-xs text-slate-400">📅</span>;
|
||||
case 'enum':
|
||||
return <span className="text-xs text-slate-400">▼</span>;
|
||||
default:
|
||||
return null;
|
||||
const parseAttrs = (attrsStr: string): AttrDef[] => {
|
||||
if (!attrsStr) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(attrsStr);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingItem(null);
|
||||
setFormData({ name: '', code: '', description: '', type: 'normal', icon: '' });
|
||||
setAttrs([]);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: any) => {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
name: item.name || '', code: item.code || '', description: item.description || '',
|
||||
type: item.type || 'normal', icon: item.icon || '',
|
||||
});
|
||||
setAttrs(parseAttrs(item.attributes));
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const addAttr = () => {
|
||||
setAttrs([...attrs, { name: '', code: '', type: 'string', required: false }]);
|
||||
};
|
||||
|
||||
const updateAttr = (index: number, field: keyof AttrDef, value: any) => {
|
||||
const next = [...attrs];
|
||||
(next[index] as any)[field] = value;
|
||||
setAttrs(next);
|
||||
};
|
||||
|
||||
const removeAttr = (index: number) => {
|
||||
setAttrs(attrs.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim() || !formData.code.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload = { ...formData, attributes: attrs.filter(a => a.name.trim() && a.code.trim()) };
|
||||
if (editingItem) {
|
||||
await resourceModelApi.update(editingItem.id, payload);
|
||||
} else {
|
||||
await resourceModelApi.create(payload);
|
||||
}
|
||||
setDialogOpen(false);
|
||||
loadData();
|
||||
} catch {
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const handleDelete = (id: number) => setDeleteId(id);
|
||||
const confirmDelete = async () => {
|
||||
if (deleteId === null) return;
|
||||
await resourceModelApi.delete(deleteId);
|
||||
if (selectedModel?.id === deleteId) setSelectedModel(null);
|
||||
setDeleteId(null);
|
||||
loadData();
|
||||
};
|
||||
|
||||
const getAttrCount = (item: any) => parseAttrs(item.attributes).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">CMDB资源模型定义与实例管理</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">
|
||||
<Link className="h-4 w-4 mr-2" />
|
||||
关系管理
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建模型
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<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">{resourceModels.length}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
|
||||
<Layers 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">
|
||||
{resourceModels.reduce((acc, m) => acc + m.instanceCount, 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
|
||||
<Database 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-purple-400">
|
||||
{resourceModels.filter((m) => m.type === 'parent').length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
|
||||
<GitBranch 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">
|
||||
{resourceModels.reduce((acc, m) => acc + m.attributes.length, 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
|
||||
<Settings className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-64">
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="relative w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
placeholder="搜索模型..."
|
||||
placeholder="搜索模型名称或编码..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加模型
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="bg-slate-800 border border-slate-700">
|
||||
<TabsTrigger value="models" className="data-[state=active]:bg-slate-700">
|
||||
<Layers className="h-4 w-4 mr-2" />
|
||||
模型列表
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="instances" className="data-[state=active]:bg-slate-700">
|
||||
<Database className="h-4 w-4 mr-2" />
|
||||
实例管理
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="relations" className="data-[state=active]:bg-slate-700">
|
||||
<Link className="h-4 w-4 mr-2" />
|
||||
关系视图
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="models" className="mt-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{filteredModels.map((model) => (
|
||||
<Card key={model.id} className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Layers className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base text-slate-100">{model.name}</CardTitle>
|
||||
<CardDescription className="text-slate-500">{model.code}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getModelTypeBadge(model.type)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
onClick={() => setSelectedModel(model)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* List */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-20 text-slate-500">
|
||||
<Layers className="h-12 w-12 mx-auto mb-3 opacity-40" />
|
||||
<p>暂无资源模型</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{filtered.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="bg-slate-800/30 rounded-lg p-4 hover:bg-slate-800/50 transition-colors group cursor-pointer"
|
||||
onClick={() => setSelectedModel(selectedModel?.id === m.id ? null : m)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-cyan-500/20 flex items-center justify-center">
|
||||
<Layers className="h-5 w-5 text-cyan-400" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-400 mb-3">{model.description}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">属性数量</span>
|
||||
<span className="text-slate-300">{model.attributes.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">实例数量</span>
|
||||
<span className="text-slate-300">{model.instanceCount}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-100">{m.name}</h3>
|
||||
<span className="text-xs text-slate-500 font-mono">{m.code}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-slate-800">
|
||||
<p className="text-xs text-slate-500 mb-2">属性预览</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{model.attributes.slice(0, 5).map((attr) => (
|
||||
<Badge key={attr.id} variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
||||
{getAttributeTypeIcon(attr.type)}
|
||||
<span className="ml-1">{attr.name}</span>
|
||||
{attr.required && <span className="text-red-400 ml-0.5">*</span>}
|
||||
</Badge>
|
||||
))}
|
||||
{model.attributes.length > 5 && (
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
||||
+{model.attributes.length - 5}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="instances" className="mt-4">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-0">
|
||||
<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">关键属性</TableHead>
|
||||
<TableHead className="text-slate-400">关系数</TableHead>
|
||||
<TableHead className="text-slate-400">更新时间</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{modelInstances.map((instance) => (
|
||||
<TableRow key={instance.id} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<span className="text-sm font-medium text-slate-200">{instance.name}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
{resourceModels.find((m) => m.id === instance.modelId)?.name}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(instance.data).slice(0, 3).map(([key, value]) => (
|
||||
<code key={key} className="text-xs text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded">
|
||||
{key}: {String(value).slice(0, 15)}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{instance.relations.length}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
{new Date(instance.updatedAt).toLocaleString()}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
onClick={() => setSelectedInstance(instance)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={(e) => { e.stopPropagation(); openEdit(m); }} className="p-1.5 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); handleDelete(m.id); }} className="p-1.5 rounded hover:bg-slate-700 text-slate-400 hover:text-red-400">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{m.description && (
|
||||
<p className="text-sm text-slate-400 mb-3 line-clamp-2">{m.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-slate-400">
|
||||
{typeLabels[m.type] || m.type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-slate-400">
|
||||
{getAttrCount(m)} 个属性
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="relations" className="mt-4">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardContent className="p-8">
|
||||
<div className="text-center">
|
||||
<Link className="h-12 w-12 mx-auto text-slate-500 mb-4" />
|
||||
<h3 className="text-lg font-medium text-slate-300 mb-2">关系视图</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">可视化展示资源实例之间的关系</p>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
创建关系
|
||||
{/* Expanded attributes */}
|
||||
{selectedModel?.id === m.id && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-700/50 space-y-1.5">
|
||||
{parseAttrs(m.attributes).map((attr, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-300">{attr.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500 font-mono">{attr.code}</span>
|
||||
<span className="text-slate-600">{attr.type}</span>
|
||||
{attr.required && <span className="text-red-400">*</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{getAttrCount(m) === 0 && (
|
||||
<p className="text-xs text-slate-600">暂无属性定义</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingItem ? '编辑资源模型' : '添加资源模型'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2 max-h-[60vh] overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>模型名称 *</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="如:服务器"
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>编码 *</Label>
|
||||
<Input
|
||||
value={formData.code}
|
||||
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||
placeholder="如:server"
|
||||
className="bg-slate-800/50 border-slate-700 font-mono"
|
||||
disabled={!!editingItem}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>模型类型</Label>
|
||||
<Select value={formData.type} onValueChange={(v) => setFormData({ ...formData, type: v })}>
|
||||
<SelectTrigger className="bg-slate-800/50 border-slate-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">普通模型</SelectItem>
|
||||
<SelectItem value="parent">父模型</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>描述</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="模型描述..."
|
||||
className="bg-slate-800/50 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attributes editor */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>属性定义</Label>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addAttr} className="h-7 text-xs gap-1">
|
||||
<Plus className="h-3 w-3" /> 添加属性
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Model Detail Dialog */}
|
||||
<Dialog open={!!selectedModel} onOpenChange={() => setSelectedModel(null)}>
|
||||
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Layers className="h-5 w-5 text-blue-400" />
|
||||
{selectedModel?.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
{selectedModel?.code} · {selectedModel?.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-300 mb-2">属性定义</h4>
|
||||
<div className="border rounded-lg border-slate-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-800">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-400">属性名</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-400">字段名</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-400">类型</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-400">必填</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-slate-400">唯一</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800">
|
||||
{selectedModel?.attributes.map((attr) => (
|
||||
<tr key={attr.id} className="hover:bg-slate-800/50">
|
||||
<td className="px-4 py-2 text-sm text-slate-200">{attr.name}</td>
|
||||
<td className="px-4 py-2 text-sm text-slate-400">{attr.code}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
||||
{attr.type}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-sm text-slate-400">{attr.required ? '是' : '否'}</td>
|
||||
<td className="px-4 py-2 text-sm text-slate-400">{attr.unique ? '是' : '否'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-2">
|
||||
{attrs.map((attr, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-slate-600 shrink-0" />
|
||||
<Input
|
||||
placeholder="属性名"
|
||||
value={attr.name}
|
||||
onChange={(e) => updateAttr(i, 'name', e.target.value)}
|
||||
className="h-8 bg-slate-800/50 border-slate-700 flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="编码"
|
||||
value={attr.code}
|
||||
onChange={(e) => updateAttr(i, 'code', e.target.value)}
|
||||
className="h-8 bg-slate-800/50 border-slate-700 flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Select value={attr.type} onValueChange={(v) => updateAttr(i, 'type', v)}>
|
||||
<SelectTrigger className="h-8 w-28 bg-slate-800/50 border-slate-700">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{attrTypes.map(t => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-8 w-8 shrink-0 ${attr.required ? 'text-red-400' : 'text-slate-500'}`}
|
||||
onClick={() => updateAttr(i, 'required', !attr.required)}
|
||||
title={attr.required ? '必填' : '选填'}
|
||||
>
|
||||
*
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-slate-500 hover:text-red-400"
|
||||
onClick={() => removeAttr(i)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{attrs.length === 0 && (
|
||||
<p className="text-sm text-slate-600 py-2">暂无属性,点击"添加属性"开始定义</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300">
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
复制模型
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
编辑模型
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting || !formData.name.trim() || !formData.code.trim()}>
|
||||
{submitting && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
|
||||
{editingItem ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Instance Detail Dialog */}
|
||||
<Dialog open={!!selectedInstance} onOpenChange={() => setSelectedInstance(null)}>
|
||||
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800">
|
||||
{/* 删除确认 */}
|
||||
<Dialog open={deleteId !== null} onOpenChange={(v) => !v && setDeleteId(null)}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-emerald-400" />
|
||||
{selectedInstance?.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
模型: {resourceModels.find((m) => m.id === selectedInstance?.modelId)?.name}
|
||||
</DialogDescription>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-300 mb-2">属性数据</h4>
|
||||
<div className="grid grid-cols-2 gap-3 p-4 rounded-lg bg-slate-800/50">
|
||||
{selectedInstance && Object.entries(selectedInstance.data).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<p className="text-xs text-slate-500 mb-1">{key}</p>
|
||||
<p className="text-sm text-slate-200">{String(value)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedInstance && selectedInstance.relations.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-300 mb-2">关联关系</h4>
|
||||
<div className="space-y-2">
|
||||
{selectedInstance.relations.map((rel, index) => (
|
||||
<div key={index} className="flex items-center gap-2 p-2 rounded-lg bg-slate-800/50">
|
||||
<span className="text-sm text-slate-300">{rel.relationType}</span>
|
||||
<ArrowRight className="h-4 w-4 text-slate-500" />
|
||||
<Badge variant="outline" className="border-slate-700 text-slate-400">
|
||||
{rel.targetInstanceId}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-400">确定删除此资源模型?此操作不可撤销。</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300">
|
||||
<Link className="h-4 w-4 mr-2" />
|
||||
管理关系
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
编辑实例
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setDeleteId(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>删除</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
@@ -50,9 +51,15 @@ import {
|
||||
History,
|
||||
LogOut,
|
||||
RefreshCw,
|
||||
Upload,
|
||||
Download,
|
||||
Network,
|
||||
Loader2,
|
||||
UserPlus,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { roles, operationLogs } from '@/mocks/data';
|
||||
import { userApi } from '@/services/api';
|
||||
import { userApi, ldapApi } from '@/services/api';
|
||||
import type { Role, OperationLog } from '@/types';
|
||||
|
||||
// 后端用户数据转换为前端兼容格式
|
||||
@@ -63,6 +70,7 @@ function mapUser(u: any) {
|
||||
roles: Array.isArray(u.roles) ? u.roles : u.role ? [u.role] : [],
|
||||
lastLoginAt: u.last_login ?? u.lastLoginAt,
|
||||
createdAt: u.created_at ?? u.createdAt,
|
||||
authSource: u.auth_source ?? u.authSource ?? 'local',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,6 +98,44 @@ export function SystemManager() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({ ...emptyForm });
|
||||
|
||||
// 批量导入弹窗
|
||||
const [importDialogOpen, setImportDialogOpen] = useState<'csv' | 'ldap' | null>(null);
|
||||
const [importPreview, setImportPreview] = useState<any[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<any>(null);
|
||||
|
||||
// LDAP 用户列表
|
||||
const [ldapUsers, setLdapUsers] = useState<any[]>([]);
|
||||
const [ldapSelected, setLdapSelected] = useState<Set<string>>(new Set());
|
||||
const [ldapLoading, setLdapLoading] = useState(false);
|
||||
|
||||
// LDAP 配置
|
||||
const [ldapConfig, setLdapConfig] = useState<any>(null);
|
||||
const [ldapForm, setLdapForm] = useState({
|
||||
ldap_host: '', ldap_port: 389, use_ldaps: false,
|
||||
admin_dn: '', admin_password: '', base_dn: '', search_filter: '',
|
||||
});
|
||||
const [ldapSaving, setLdapSaving] = useState(false);
|
||||
const [ldapTesting, setLdapTesting] = useState(false);
|
||||
const [ldapTestResult, setLdapTestResult] = useState<string | null>(null);
|
||||
const [ldapMgmtOpen, setLdapMgmtOpen] = useState(false);
|
||||
const [importFeedback, setImportFeedback] = useState<string | null>(null);
|
||||
const [importResultOpen, setImportResultOpen] = useState(false);
|
||||
|
||||
// LDAP 账号管理
|
||||
const [ldapOUs, setLdapOUs] = useState<any[]>([]);
|
||||
const [selectedOUDN, setSelectedOUDN] = useState('');
|
||||
const [selectedLDAPUsers, setSelectedLDAPUsers] = useState<string[]>([]);
|
||||
const [createOUOpen, setCreateOUOpen] = useState(false);
|
||||
const [createUserOpen, setCreateUserOpen] = useState(false);
|
||||
const [ouForm, setOuForm] = useState({ ou_name: '', parent_dn: '', description: '' });
|
||||
const [newUser, setNewUser] = useState({ username: '', password: '', display_name: '', email: '', ou_dn: '' });
|
||||
const [ldapUserTestOpen, setLdapUserTestOpen] = useState(false);
|
||||
const [ldapTestUser, setLdapTestUser] = useState('');
|
||||
const [ldapTestPass, setLdapTestPass] = useState('');
|
||||
const [ldapUserTesting, setLdapUserTesting] = useState(false);
|
||||
const [ldapUserTestResult, setLdapUserTestResult] = useState<any>(null);
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -106,6 +152,7 @@ export function SystemManager() {
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
loadLdapConfig();
|
||||
}, []);
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
@@ -206,6 +253,215 @@ export function SystemManager() {
|
||||
}
|
||||
};
|
||||
|
||||
// CSV 文件解析
|
||||
const handleCsvUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string;
|
||||
const lines = text.split('\n').filter((l) => l.trim() && !l.startsWith('\xEF\xBB\xBF'));
|
||||
if (lines.length < 2) return;
|
||||
// Find column indices from header
|
||||
const headers = lines[0].split(',').map((h) => h.trim().toLowerCase());
|
||||
const idxUsername = headers.findIndex((h) => h.includes('用户名') || h.includes('username'));
|
||||
const idxNickname = headers.findIndex((h) => h.includes('昵称') || h.includes('name') || h.includes('显示名'));
|
||||
const idxEmail = headers.findIndex((h) => h.includes('邮箱') || h.includes('email') || h.includes('mail'));
|
||||
const idxPhone = headers.findIndex((h) => h.includes('手机') || h.includes('phone'));
|
||||
const idxRole = headers.findIndex((h) => h.includes('角色') || h.includes('role'));
|
||||
const idxPassword = headers.findIndex((h) => h.includes('密码') || h.includes('password'));
|
||||
|
||||
const parsed: any[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cols = lines[i].split(',').map((c) => c.trim());
|
||||
parsed.push({
|
||||
username: idxUsername >= 0 ? cols[idxUsername] : '',
|
||||
nickname: idxNickname >= 0 ? cols[idxNickname] : '',
|
||||
email: idxEmail >= 0 ? cols[idxEmail] : '',
|
||||
phone: idxPhone >= 0 ? cols[idxPhone] : '',
|
||||
role: idxRole >= 0 ? cols[idxRole] : '',
|
||||
password: idxPassword >= 0 ? cols[idxPassword] : '',
|
||||
});
|
||||
}
|
||||
setImportPreview(parsed);
|
||||
setImportResult(null);
|
||||
};
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
};
|
||||
|
||||
// 执行 CSV 批量导入
|
||||
const handleBatchImport = async () => {
|
||||
setImporting(true);
|
||||
try {
|
||||
const res = await userApi.batchImport({ users: importPreview, skip_existing: true });
|
||||
setImportResult(res);
|
||||
await loadUsers();
|
||||
} catch (error: any) {
|
||||
setImportResult({ code: 500, message: error?.message || '导入失败' });
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载 LDAP 用户列表
|
||||
const loadLdapUsers = async () => {
|
||||
setLdapLoading(true);
|
||||
try {
|
||||
const res = await ldapApi.listUsers();
|
||||
setLdapUsers(Array.isArray(res) ? res : []);
|
||||
setLdapSelected(new Set());
|
||||
} catch {
|
||||
setLdapUsers([]);
|
||||
} finally {
|
||||
setLdapLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 执行 LDAP 导入
|
||||
const handleLdapImport = async () => {
|
||||
let selected: any[] = [];
|
||||
if (selectedLDAPUsers.length > 0) {
|
||||
selected = ldapUsers.filter((u) => selectedLDAPUsers.includes(u.dn));
|
||||
} else {
|
||||
selected = ldapUsers; // 没有选择则导入全部
|
||||
}
|
||||
if (selected.length === 0) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const mapped = selected.map((u) => ({
|
||||
username: u.uid || '',
|
||||
nickname: u.cn || '',
|
||||
email: u.mail || '',
|
||||
}));
|
||||
const res = await ldapApi.batchImport(mapped);
|
||||
setImportFeedback(res?.message || `成功导入 ${selected.length} 个用户`);
|
||||
setImportResultOpen(true);
|
||||
setSelectedLDAPUsers([]);
|
||||
await loadUsers();
|
||||
} catch (error: any) {
|
||||
setImportFeedback('导入失败: ' + (error?.message || '未知错误'));
|
||||
setImportResultOpen(true);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载 LDAP 配置
|
||||
const loadLdapConfig = async () => {
|
||||
try {
|
||||
const res = await ldapApi.getConfig();
|
||||
if (res && res.ldap_host) {
|
||||
setLdapConfig(res);
|
||||
setLdapForm({
|
||||
ldap_host: res.ldap_host || '',
|
||||
ldap_port: res.ldap_port || 389,
|
||||
use_ldaps: res.use_ldaps || false,
|
||||
admin_dn: res.admin_dn || '',
|
||||
admin_password: '',
|
||||
base_dn: res.base_dn || '',
|
||||
search_filter: res.search_filter || '',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// No config yet
|
||||
}
|
||||
};
|
||||
|
||||
// 保存 LDAP 配置
|
||||
const handleLdapSave = async () => {
|
||||
setLdapSaving(true);
|
||||
try {
|
||||
await ldapApi.updateConfig(ldapForm);
|
||||
await loadLdapConfig();
|
||||
} catch (error) {
|
||||
console.error('Failed to save LDAP config:', error);
|
||||
} finally {
|
||||
setLdapSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 测试 LDAP 连接
|
||||
const handleLdapTest = async () => {
|
||||
setLdapTesting(true);
|
||||
setLdapTestResult(null);
|
||||
try {
|
||||
await ldapApi.updateConfig(ldapForm);
|
||||
const res = await ldapApi.testConnection();
|
||||
setLdapTestResult(res?.message || (res?.success ? '连接成功' : '连接失败'));
|
||||
} catch (error: any) {
|
||||
setLdapTestResult(error?.message || '连接失败');
|
||||
} finally {
|
||||
setLdapTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// LDAP 账号管理函数
|
||||
const loadLdapOUs = async () => {
|
||||
try {
|
||||
const ous = await ldapApi.listOUs();
|
||||
setLdapOUs(ous || []);
|
||||
// 同时加载根 DN 下的用户
|
||||
if (!selectedOUDN && ldapConfig?.base_dn) {
|
||||
setSelectedOUDN(ldapConfig.base_dn);
|
||||
await loadLdapOUUsers(ldapConfig.base_dn);
|
||||
}
|
||||
} catch (e) { console.error('loadOUs', e); }
|
||||
};
|
||||
|
||||
const loadLdapOUUsers = async (ouDn?: string) => {
|
||||
try {
|
||||
const users = await ldapApi.listUsers(ouDn);
|
||||
setLdapUsers(users || []);
|
||||
} catch (e) { console.error('loadOUUsers', e); }
|
||||
};
|
||||
|
||||
const handleCreateOU = async () => {
|
||||
setLdapSaving(true);
|
||||
try {
|
||||
await ldapApi.createOU(ouForm);
|
||||
setCreateOUOpen(false);
|
||||
setOuForm({ ou_name: '', parent_dn: '', description: '' });
|
||||
await loadLdapOUs();
|
||||
} catch (e: any) {
|
||||
const msg = e?.message || '';
|
||||
if (msg.includes('Already Exists') || msg.includes('68')) {
|
||||
alert(`OU "${ouForm.ou_name}" 已存在,请使用其他名称`);
|
||||
} else {
|
||||
alert('创建失败: ' + msg);
|
||||
}
|
||||
} finally { setLdapSaving(false); }
|
||||
};
|
||||
|
||||
const handleCreateLDAPUser = async () => {
|
||||
setLdapSaving(true);
|
||||
try {
|
||||
await ldapApi.createUser({ ...newUser, ou_dn: newUser.ou_dn || selectedOUDN || ldapConfig?.base_dn });
|
||||
setCreateUserOpen(false);
|
||||
setNewUser({ username: '', password: '', display_name: '', email: '', ou_dn: '' });
|
||||
await loadLdapOUUsers(selectedOUDN);
|
||||
} catch (e: any) { alert(e?.message || '创建失败'); }
|
||||
finally { setLdapSaving(false); }
|
||||
};
|
||||
|
||||
const handleDeleteLDAPEntry = async (dn: string, silent = false) => {
|
||||
if (!silent && !confirm(`确定删除 ${dn}?`)) return;
|
||||
try {
|
||||
await ldapApi.deleteUser(dn);
|
||||
if (!silent) await loadLdapOUUsers(selectedOUDN);
|
||||
} catch (e: any) { if (!silent) alert(e?.message || '删除失败'); }
|
||||
};
|
||||
|
||||
const handleLdapUserTest = async () => {
|
||||
setLdapUserTesting(true);
|
||||
setLdapUserTestResult(null);
|
||||
try {
|
||||
const res = await ldapApi.testConnection({ test_user: ldapTestUser, test_password: ldapTestPass });
|
||||
setLdapUserTestResult(res);
|
||||
} catch (e: any) {
|
||||
setLdapUserTestResult({ success: false, message: e?.message || '验证失败' });
|
||||
} finally { setLdapUserTesting(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
@@ -249,10 +505,20 @@ export function SystemManager() {
|
||||
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建用户
|
||||
</Button>
|
||||
<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" onClick={() => setImportDialogOpen('csv')}>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
批量导入用户
|
||||
</Button>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('ldap')}>
|
||||
<Network className="h-4 w-4 mr-2" />
|
||||
LDAP 导入
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建用户
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
@@ -265,6 +531,7 @@ export function SystemManager() {
|
||||
<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">状态</TableHead>
|
||||
<TableHead className="text-slate-400">最后登录</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">操作</TableHead>
|
||||
@@ -273,7 +540,7 @@ export function SystemManager() {
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={6} className="text-center py-12">
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="h-6 w-6 border-2 border-slate-700 border-t-blue-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
@@ -281,7 +548,7 @@ export function SystemManager() {
|
||||
</TableRow>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={6} className="text-center py-12 text-sm text-slate-500">
|
||||
<TableCell colSpan={7} className="text-center py-12 text-sm text-slate-500">
|
||||
暂无用户数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -322,6 +589,17 @@ export function SystemManager() {
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.authSource === 'ldap' ? (
|
||||
<Badge variant="outline" className="border-cyan-500/30 text-cyan-400 bg-cyan-500/10">
|
||||
LDAP
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-slate-600/30 text-slate-400 bg-slate-600/10">
|
||||
本地
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{getUserStatusBadge(user.status)}</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">
|
||||
@@ -612,14 +890,6 @@ export function SystemManager() {
|
||||
<span className="text-sm text-slate-400">系统版本</span>
|
||||
<span className="text-sm text-slate-200">v2.5.1</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-400">构建时间</span>
|
||||
<span className="text-sm text-slate-200">2025-03-30</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-400">数据库版本</span>
|
||||
<span className="text-sm text-slate-200">PostgreSQL 15.2</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-400">运行时间</span>
|
||||
<span className="text-sm text-slate-200">30天 12小时</span>
|
||||
@@ -627,6 +897,392 @@ export function SystemManager() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* LDAP 配置 */}
|
||||
<Card className="bg-slate-900 border-slate-800 mt-4">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Network className="h-5 w-5 text-cyan-400" />
|
||||
LDAP / Active Directory
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-500 mt-1">配置 LDAP 服务器,实现统一身份认证</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-slate-500">LDAP 服务器地址</label>
|
||||
<Input
|
||||
value={ldapForm.ldap_host}
|
||||
onChange={(e) => setLdapForm({ ...ldapForm, ldap_host: e.target.value })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600"
|
||||
placeholder="ldap.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-slate-500">端口</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={ldapForm.ldap_port}
|
||||
onChange={(e) => setLdapForm({ ...ldapForm, ldap_port: Number(e.target.value) })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200"
|
||||
placeholder="389"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<label className="text-xs text-slate-500">Base DN(用户搜索基础)</label>
|
||||
<Input
|
||||
value={ldapForm.base_dn}
|
||||
onChange={(e) => setLdapForm({ ...ldapForm, base_dn: e.target.value })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600"
|
||||
placeholder="ou=users,dc=example,dc=com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-slate-500">管理员 Bind DN</label>
|
||||
<Input
|
||||
value={ldapForm.admin_dn}
|
||||
onChange={(e) => setLdapForm({ ...ldapForm, admin_dn: e.target.value })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600"
|
||||
placeholder="cn=admin,dc=example,dc=com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-slate-500">管理员密码 {ldapConfig && '(留空不修改)'}</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={ldapForm.admin_password}
|
||||
onChange={(e) => setLdapForm({ ...ldapForm, admin_password: e.target.value })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600"
|
||||
placeholder="******"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<label className="text-xs text-slate-500">搜索过滤器(可选,用 %s 代表用户名)</label>
|
||||
<Input
|
||||
value={ldapForm.search_filter}
|
||||
onChange={(e) => setLdapForm({ ...ldapForm, search_filter: e.target.value })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600"
|
||||
placeholder="(uid=%s)"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 col-span-2">
|
||||
<Switch
|
||||
checked={ldapForm.use_ldaps}
|
||||
onCheckedChange={(v) => setLdapForm({ ...ldapForm, use_ldaps: v })}
|
||||
className="data-[state=checked]:bg-blue-600"
|
||||
/>
|
||||
<span className="text-sm text-slate-300">启用 LDAPS(SSL 加密)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 测试/保存按钮 */}
|
||||
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-slate-800">
|
||||
{ldapTestResult && (
|
||||
<span className={`text-xs ${ldapTestResult.includes('成功') ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{ldapTestResult}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-slate-700 text-slate-300"
|
||||
onClick={handleLdapTest}
|
||||
disabled={ldapTesting}
|
||||
>
|
||||
{ldapTesting ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-1" />}
|
||||
测试连接
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
onClick={handleLdapSave}
|
||||
disabled={ldapSaving}
|
||||
>
|
||||
{ldapSaving ? '保存中...' : '保存配置'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* LDAP 账号管理 - 可折叠 */}
|
||||
{ldapConfig && (
|
||||
<div className="mt-4">
|
||||
{/* 折叠/展开按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const open = !ldapMgmtOpen;
|
||||
setLdapMgmtOpen(open);
|
||||
if (open && ldapOUs.length === 0) loadLdapOUs();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 bg-slate-900/50 hover:bg-slate-800/50 text-slate-400 text-sm transition-colors"
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${ldapMgmtOpen ? '' : '-rotate-90'}`} />
|
||||
<Users className="h-4 w-4 text-amber-400" />
|
||||
<span className="text-slate-300">LDAP 账号管理</span>
|
||||
</button>
|
||||
|
||||
{ldapMgmtOpen && (
|
||||
<div className="bg-slate-900/30 border-t border-slate-800/50 px-4 py-4 space-y-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* 账号验证测试 */}
|
||||
<Dialog open={ldapUserTestOpen} onOpenChange={setLdapUserTestOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-slate-200">
|
||||
<Key className="h-3.5 w-3.5 mr-1" />
|
||||
账号验证
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md bg-slate-900 border-slate-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100">LDAP 账号验证测试</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">输入 LDAP 用户凭据进行认证测试</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">用户名</label>
|
||||
<Input
|
||||
value={ldapTestUser}
|
||||
onChange={(e) => setLdapTestUser(e.target.value)}
|
||||
placeholder="zhangsan"
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">密码</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={ldapTestPass}
|
||||
onChange={(e) => setLdapTestPass(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLdapUserTest()}
|
||||
placeholder="••••••"
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-600"
|
||||
/>
|
||||
</div>
|
||||
{ldapUserTestResult && (
|
||||
<div className={`p-3 rounded-lg text-sm ${ldapUserTestResult.success ? 'bg-emerald-500/10 text-emerald-300' : 'bg-red-500/10 text-red-300'}`}>
|
||||
{ldapUserTestResult.message}
|
||||
{ldapUserTestResult.user_info && (
|
||||
<pre className="mt-2 text-xs whitespace-pre-wrap">{JSON.stringify(ldapUserTestResult.user_info, null, 2)}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||
onClick={handleLdapUserTest}
|
||||
disabled={ldapUserTesting || !ldapTestUser}
|
||||
>
|
||||
{ldapUserTesting ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : null}
|
||||
验证
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-slate-200" onClick={loadLdapOUs}>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
|
||||
{/* OU 选择 */}
|
||||
<Select value={selectedOUDN} onValueChange={(v) => { setSelectedOUDN(v); loadLdapOUUsers(v); }}>
|
||||
<SelectTrigger className="w-60 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs">
|
||||
<SelectValue placeholder="选择组织单元" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value={ldapConfig.base_dn}>根 ({ldapConfig.base_dn})</SelectItem>
|
||||
{ldapOUs.map((ou: any) => (
|
||||
<SelectItem key={ou.dn} value={ou.dn}>
|
||||
{ou.description ? `${ou.name} (${ou.description})` : ou.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 新建 OU */}
|
||||
<Dialog open={createOUOpen} onOpenChange={setCreateOUOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-slate-200">
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
新建 OU
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md bg-slate-900 border-slate-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100">新建组织单元</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">在 LDAP 目录中创建新的组织单元 (OU)</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">OU 名称 *</label>
|
||||
<Input value={ouForm.ou_name} onChange={(e) => setOuForm({ ...ouForm, ou_name: e.target.value })} placeholder="dev" className="bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">父级 DN</label>
|
||||
<Input value={ouForm.parent_dn} onChange={(e) => setOuForm({ ...ouForm, parent_dn: e.target.value })} placeholder={ldapConfig.base_dn} className="bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">描述</label>
|
||||
<Input value={ouForm.description} onChange={(e) => setOuForm({ ...ouForm, description: e.target.value })} placeholder="开发团队" className="bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
<Button className="w-full bg-blue-600 hover:bg-blue-700 text-white" onClick={handleCreateOU} disabled={!ouForm.ou_name || ldapSaving}>
|
||||
{ldapSaving ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : null}
|
||||
创建
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 添加用户 */}
|
||||
<Dialog open={createUserOpen} onOpenChange={setCreateUserOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-slate-200">
|
||||
<UserPlus className="h-3.5 w-3.5 mr-1" />
|
||||
添加用户
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md bg-slate-900 border-slate-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100">添加 LDAP 用户</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">在 LDAP 目录中创建新的用户账号</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">用户名 *</label>
|
||||
<Input value={newUser.username} onChange={(e) => setNewUser({ ...newUser, username: e.target.value })} placeholder="zhangsan" className="bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">密码 *</label>
|
||||
<Input type="password" value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })} placeholder="••••••" className="bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">显示名称</label>
|
||||
<Input value={newUser.display_name} onChange={(e) => setNewUser({ ...newUser, display_name: e.target.value })} placeholder="张三" className="bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">邮箱</label>
|
||||
<Input value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} placeholder="zs@example.com" className="bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">目标 OU</label>
|
||||
<Input value={newUser.ou_dn || selectedOUDN || ldapConfig.base_dn} onChange={(e) => setNewUser({ ...newUser, ou_dn: e.target.value })} className="bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||
</div>
|
||||
<Button className="w-full bg-blue-600 hover:bg-blue-700 text-white" onClick={handleCreateLDAPUser} disabled={!newUser.username || !newUser.password || ldapSaving}>
|
||||
{ldapSaving ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : null}
|
||||
创建
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 导入到本地 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-blue-600/50 text-blue-400 hover:bg-blue-600/10 ml-auto"
|
||||
onClick={handleLdapImport}
|
||||
disabled={ldapUsers.length === 0 || importing}
|
||||
>
|
||||
{importing ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Download className="h-3.5 w-3.5 mr-1" />}
|
||||
{selectedLDAPUsers.length > 0 ? `导入 (${selectedLDAPUsers.length})` : `导入全部 (${ldapUsers.length})`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{importFeedback && (
|
||||
<Dialog open={importResultOpen} onOpenChange={setImportResultOpen}>
|
||||
<DialogContent className="max-w-sm bg-slate-900 border-slate-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100">导入结果</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">LDAP 用户导入到本地用户库的执行结果</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-3 text-sm text-slate-300">
|
||||
{importFeedback}
|
||||
</div>
|
||||
<Button className="w-full bg-blue-600 hover:bg-blue-700 text-white" onClick={() => setImportResultOpen(false)}>
|
||||
确定
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* 用户表格 */}
|
||||
<div className="border border-slate-800 rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded bg-slate-800 border-slate-600"
|
||||
checked={selectedLDAPUsers.length > 0 && selectedLDAPUsers.length === ldapUsers.length}
|
||||
onChange={(e) => setSelectedLDAPUsers(e.target.checked ? ldapUsers.map((u: any) => u.dn) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-400">用户名</TableHead>
|
||||
<TableHead className="text-slate-400">显示名称</TableHead>
|
||||
<TableHead className="text-slate-400">邮箱</TableHead>
|
||||
<TableHead className="text-slate-400">DN</TableHead>
|
||||
<TableHead className="text-slate-400 w-12">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ldapUsers.length === 0 ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={6} className="text-center text-slate-500 py-8">
|
||||
点击「刷新」加载 LDAP 用户
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
ldapUsers.map((user: any) => (
|
||||
<TableRow key={user.dn} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded bg-slate-800 border-slate-600"
|
||||
checked={selectedLDAPUsers.includes(user.dn)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setSelectedLDAPUsers([...selectedLDAPUsers, user.dn]);
|
||||
else setSelectedLDAPUsers(selectedLDAPUsers.filter((d: string) => d !== user.dn));
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-200 font-medium">{user.uid}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-300">{user.cn}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-400">{user.mail || '-'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs text-slate-500">{user.dn}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => handleDeleteLDAPEntry(user.dn)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -813,6 +1469,214 @@ export function SystemManager() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* CSV 批量导入 Dialog */}
|
||||
<Dialog open={importDialogOpen === 'csv'} onOpenChange={(v) => !v && setImportDialogOpen(null)}>
|
||||
<DialogContent className="bg-slate-900 border-slate-800 max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Upload className="h-5 w-5 text-blue-400" />
|
||||
批量导入用户
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
上传 CSV 文件批量创建用户。请先下载模板,按格式填写后上传。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!importResult ? (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={() => userApi.downloadTemplate()}>
|
||||
<Download className="h-4 w-4 mr-1.5" />
|
||||
下载模板
|
||||
</Button>
|
||||
<label className="inline-flex items-center px-3 py-1.5 rounded-md text-sm bg-slate-800 border border-slate-700 text-slate-200 cursor-pointer hover:bg-slate-700 transition-colors">
|
||||
<Upload className="h-4 w-4 mr-1.5" />
|
||||
选择 CSV 文件
|
||||
<input type="file" accept=".csv" className="hidden" onChange={handleCsvUpload} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{importPreview.length > 0 && (
|
||||
<div className="rounded-lg border border-slate-800 overflow-hidden">
|
||||
<div className="px-3 py-2 bg-slate-800/50 text-xs text-slate-400">
|
||||
预览:{importPreview.length} 条数据
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800">
|
||||
<TableHead className="text-slate-400 text-xs">用户名</TableHead>
|
||||
<TableHead className="text-slate-400 text-xs">昵称</TableHead>
|
||||
<TableHead className="text-slate-400 text-xs">邮箱</TableHead>
|
||||
<TableHead className="text-slate-400 text-xs">手机</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{importPreview.slice(0, 5).map((u, i) => (
|
||||
<TableRow key={i} className="border-slate-800">
|
||||
<TableCell className="text-slate-200 text-xs">{u.username}</TableCell>
|
||||
<TableCell className="text-slate-300 text-xs">{u.nickname}</TableCell>
|
||||
<TableCell className="text-slate-400 text-xs">{u.email}</TableCell>
|
||||
<TableCell className="text-slate-400 text-xs">{u.phone}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{importPreview.length > 5 && (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={4} className="text-slate-500 text-xs text-center">
|
||||
还有 {importPreview.length - 5} 条...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => { setImportDialogOpen(null); setImportPreview([]); }}>
|
||||
取消
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleBatchImport} disabled={importing || importPreview.length === 0}>
|
||||
{importing ? '导入中...' : `确认导入 (${importPreview.length})`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className={`flex items-center gap-3 p-4 rounded-lg ${importResult.code === 200 ? 'bg-emerald-500/10' : 'bg-red-500/10'}`}>
|
||||
{importResult.code === 200 ? (
|
||||
<CheckCircle className="h-6 w-6 text-emerald-400 shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-6 w-6 text-red-400 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm text-slate-200">{importResult.message}</span>
|
||||
</div>
|
||||
{importResult.errors?.length > 0 && (
|
||||
<div className="max-h-32 overflow-auto rounded-lg bg-slate-800/50 p-3 space-y-1">
|
||||
{importResult.errors.map((err: string, i: number) => (
|
||||
<div key={i} className="text-xs text-red-400">{err}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={() => { setImportDialogOpen(null); setImportPreview([]); setImportResult(null); }}>
|
||||
完成
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* LDAP 导入 Dialog */}
|
||||
<Dialog open={importDialogOpen === 'ldap'} onOpenChange={(v) => !v && setImportDialogOpen(null)}>
|
||||
<DialogContent className="bg-slate-900 border-slate-800 max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Network className="h-5 w-5 text-cyan-400" />
|
||||
从 LDAP 导入用户
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
从 LDAP 服务器检索用户列表,选择需要导入的用户
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!importResult ? (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-slate-500">
|
||||
{ldapUsers.length > 0 ? `找到 ${ldapUsers.length} 个用户` : '点击刷新从 LDAP 获取用户列表'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={loadLdapUsers} disabled={ldapLoading}>
|
||||
{ldapLoading ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <RefreshCw className="h-4 w-4 mr-1.5" />}
|
||||
刷新列表
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{ldapUsers.length > 0 && (
|
||||
<div className="max-h-80 overflow-auto rounded-lg border border-slate-800">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 sticky top-0 bg-slate-900">
|
||||
<TableHead className="w-10 text-slate-400 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-blue-600"
|
||||
checked={ldapSelected.size === ldapUsers.length && ldapUsers.length > 0}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setLdapSelected(new Set(ldapUsers.map((u) => u.dn)));
|
||||
} else {
|
||||
setLdapSelected(new Set());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-slate-400 text-xs">用户名</TableHead>
|
||||
<TableHead className="text-slate-400 text-xs">显示名</TableHead>
|
||||
<TableHead className="text-slate-400 text-xs">邮箱</TableHead>
|
||||
<TableHead className="text-slate-400 text-xs">DN</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ldapUsers.map((u) => (
|
||||
<TableRow key={u.dn} className="border-slate-800 hover:bg-slate-800/50">
|
||||
<TableCell>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-blue-600"
|
||||
checked={ldapSelected.has(u.dn)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(ldapSelected);
|
||||
if (e.target.checked) next.add(u.dn);
|
||||
else next.delete(u.dn);
|
||||
setLdapSelected(next);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-200 text-xs">{u.uid}</TableCell>
|
||||
<TableCell className="text-slate-300 text-xs">{u.cn}</TableCell>
|
||||
<TableCell className="text-slate-400 text-xs">{u.mail}</TableCell>
|
||||
<TableCell className="text-slate-500 text-xs font-mono truncate max-w-[200px]">{u.dn}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => { setImportDialogOpen(null); setLdapUsers([]); }}>
|
||||
取消
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleLdapImport} disabled={importing || ldapSelected.size === 0}>
|
||||
{importing ? '导入中...' : `导入选中 (${ldapSelected.size})`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className={`flex items-center gap-3 p-4 rounded-lg ${importResult.code === 200 ? 'bg-emerald-500/10' : 'bg-red-500/10'}`}>
|
||||
{importResult.code === 200 ? (
|
||||
<CheckCircle className="h-6 w-6 text-emerald-400 shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-6 w-6 text-red-400 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm text-slate-200">{importResult.message}</span>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={() => { setImportDialogOpen(null); setLdapUsers([]); setImportResult(null); }}>
|
||||
完成
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// API Service for AIOps Platform
|
||||
// 有后端支持的模块调用真实 API,无后端的模块暂用 mock 数据
|
||||
import { http, type ListResponse } from './http';
|
||||
import { useUserStore } from '@/store';
|
||||
import * as mockData from '@/mocks/data';
|
||||
|
||||
// ==================== 用户认证 API ====================
|
||||
@@ -35,6 +36,45 @@ export const userApi = {
|
||||
deleteUser: async (id: number | string) => {
|
||||
return http.delete(`/admin/users/${id}`);
|
||||
},
|
||||
batchImport: async (data: { users: any[]; skip_existing?: boolean }) => {
|
||||
return http.post('/admin/users/batch-import', data);
|
||||
},
|
||||
downloadTemplate: () => {
|
||||
const token = useUserStore.getState().token;
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1';
|
||||
window.open(`${baseUrl}/admin/users/import-template?token=${token}`, '_blank');
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== LDAP 配置 API ====================
|
||||
export const ldapApi = {
|
||||
getConfig: async () => {
|
||||
return http.get('/admin/ldap-config');
|
||||
},
|
||||
updateConfig: async (data: any) => {
|
||||
return http.put('/admin/ldap-config', data);
|
||||
},
|
||||
testConnection: async (data?: { test_user?: string; test_password?: string }) => {
|
||||
return http.post('/admin/ldap-config/test', data || {});
|
||||
},
|
||||
listUsers: async (ouDn?: string) => {
|
||||
return http.post('/admin/ldap-config/users', { base_dn: ouDn || '' });
|
||||
},
|
||||
batchImport: async (users: any[]) => {
|
||||
return http.post('/admin/ldap-config/import', { users });
|
||||
},
|
||||
listOUs: async () => {
|
||||
return http.post('/admin/ldap-config/ou-list', {});
|
||||
},
|
||||
createOU: async (data: { ou_name: string; parent_dn?: string; description?: string }) => {
|
||||
return http.post('/admin/ldap-config/create-ou', data);
|
||||
},
|
||||
createUser: async (data: { username: string; password: string; display_name?: string; email?: string; ou_dn?: string }) => {
|
||||
return http.post('/admin/ldap-config/create-user', data);
|
||||
},
|
||||
deleteUser: async (dn: string) => {
|
||||
return http.post('/admin/ldap-config/delete-user', { dn });
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 仪表盘 API ====================
|
||||
@@ -347,6 +387,54 @@ export const cloudApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 云平台 API ====================
|
||||
export const platformApi = {
|
||||
list: async () => {
|
||||
return http.get<ListResponse>('/platforms');
|
||||
},
|
||||
create: async (data: any) => {
|
||||
return http.post('/platforms', data);
|
||||
},
|
||||
update: async (id: number | string, data: any) => {
|
||||
return http.put(`/platforms/${id}`, data);
|
||||
},
|
||||
delete: async (id: number | string) => {
|
||||
return http.delete(`/platforms/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 区域管理 API ====================
|
||||
export const regionApi = {
|
||||
list: async () => {
|
||||
return http.get<ListResponse>('/regions');
|
||||
},
|
||||
create: async (data: any) => {
|
||||
return http.post('/regions', data);
|
||||
},
|
||||
update: async (id: number | string, data: any) => {
|
||||
return http.put(`/regions/${id}`, data);
|
||||
},
|
||||
delete: async (id: number | string) => {
|
||||
return http.delete(`/regions/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 资源模型 API ====================
|
||||
export const resourceModelApi = {
|
||||
list: async () => {
|
||||
return http.get<ListResponse>('/resource-models');
|
||||
},
|
||||
create: async (data: any) => {
|
||||
return http.post('/resource-models', data);
|
||||
},
|
||||
update: async (id: number | string, data: any) => {
|
||||
return http.put(`/resource-models/${id}`, data);
|
||||
},
|
||||
delete: async (id: number | string) => {
|
||||
return http.delete(`/resource-models/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 审计日志 API ====================
|
||||
export const auditApi = {
|
||||
getLogs: async (params?: { page?: number; page_size?: number; module?: string; action?: string }) => {
|
||||
|
||||
@@ -63,7 +63,8 @@ async function request<T = any>(
|
||||
throw new Error(json.message || '请求失败');
|
||||
}
|
||||
|
||||
return json.data;
|
||||
// Return json.data for standard responses, or full json for non-standard ones
|
||||
return (json.data !== undefined ? json.data : json) as T;
|
||||
}
|
||||
|
||||
// 导出便捷方法
|
||||
|
||||
Reference in new issue
Block a user