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,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>
|
||||
|
||||
Reference in new issue
Block a user