Files
IronSteelNon-ferrousMetallu…/src/app/pages/common/KnowledgeGraph.tsx
2026-04-08 23:50:01 +08:00

882 lines
36 KiB
TypeScript

import { useState, useRef, useEffect, useCallback } from "react";
import { Search, Network, ZoomIn, ZoomOut, Maximize2, Plus, Download, Trash2, Edit, Eye } from "lucide-react";
import { motion } from "motion/react";
import { toast } from "sonner";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../../components/ui/dialog";
import * as d3 from "d3";
interface GraphNode extends d3.SimulationNodeDatum {
id: number;
label: string;
type: string;
description: string;
connections: number;
}
interface GraphEdge extends d3.SimulationLinkDatum<GraphNode> {
source: number | GraphNode;
target: number | GraphNode;
label: string;
}
const steelNodeTypeConfig = {
fault: { label: "故障", color: "#ef4444", gradient: "from-red-500 to-orange-500" },
symptom: { label: "症状", color: "#f59e0b", gradient: "from-yellow-500 to-amber-500" },
cause: { label: "原因", color: "#3b82f6", gradient: "from-blue-500 to-cyan-500" },
solution: { label: "解决方案", color: "#10b981", gradient: "from-green-500 to-emerald-500" },
prevention: { label: "预防措施", color: "#a855f7", gradient: "from-purple-500 to-pink-500" },
};
const aluminaNodeTypeConfig = {
equipment: { label: "设备", color: "#0ea5e9", gradient: "from-sky-500 to-blue-500" },
material: { label: "原料", color: "#22c55e", gradient: "from-green-500 to-emerald-500" },
parameter: { label: "工艺参数", color: "#f59e0b", gradient: "from-amber-500 to-orange-500" },
quality: { label: "质量指标", color: "#a855f7", gradient: "from-purple-500 to-pink-500" },
process: { label: "工序", color: "#ec4899", gradient: "from-pink-500 to-rose-500" },
};
const initialNodes: GraphNode[] = [
{ id: 1, label: "给煤机故障", type: "fault", description: "给煤机无法正常启动或运行中断", connections: 4 },
{ id: 2, label: "跳闸", type: "symptom", description: "设备意外停机保护动作", connections: 3 },
{ id: 3, label: "皮带松紧不当", type: "cause", description: "输送带张力异常导致打滑", connections: 2 },
{ id: 4, label: "检查皮带", type: "solution", description: "调整皮带张紧装置", connections: 1 },
{ id: 5, label: "定期维护", type: "prevention", description: "建立周期性检修计划", connections: 1 },
{ id: 6, label: "电机过热", type: "fault", description: "电机温度超过额定值", connections: 3 },
{ id: 7, label: "停机", type: "symptom", description: "系统停止运行", connections: 2 },
{ id: 8, label: "散热不良", type: "cause", description: "冷却系统效率下降", connections: 2 },
{ id: 9, label: "清理风扇", type: "solution", description: "清除散热器灰尘杂物", connections: 1 },
{ id: 10, label: "冷却水不足", type: "cause", description: "冷却液位过低或循环不畅", connections: 2 },
{ id: 11, label: "补充冷却液", type: "solution", description: "添加或更换冷却液", connections: 1 },
{ id: 12, label: "磨损严重", type: "fault", description: "部件磨损导致性能下降", connections: 2 },
];
const initialEdges: GraphEdge[] = [
{ source: 1, target: 2, label: "表现" },
{ source: 1, target: 3, label: "由于" },
{ source: 2, target: 4, label: "对策" },
{ source: 3, target: 5, label: "预防" },
{ source: 6, target: 7, label: "表现" },
{ source: 6, target: 8, label: "由于" },
{ source: 8, target: 9, label: "对策" },
{ source: 6, target: 10, label: "由于" },
{ source: 10, target: 11, label: "对策" },
{ source: 12, target: 7, label: "导致" },
];
const aluminaNodes: GraphNode[] = [
{ id: 1, label: "溶出釜", type: "equipment", description: "高压溶出铝土矿的核心设备", connections: 5 },
{ id: 2, label: "铝土矿", type: "material", description: "主要原料铝硅比A/S=8.5", connections: 4 },
{ id: 3, label: "配碱量", type: "parameter", description: "碱液与矿石比例210kg/t", connections: 3 },
{ id: 4, label: "溶出率", type: "quality", description: "目标溶出率≥95%", connections: 4 },
{ id: 5, label: "溶出工序", type: "process", description: "高压碱液溶出铝土矿", connections: 3 },
{ id: 6, label: "沉降槽", type: "equipment", description: "分离赤泥和铝酸钠溶液", connections: 4 },
{ id: 7, label: "种子分解", type: "process", description: "添加晶种分解氢氧化铝", connections: 3 },
{ id: 8, label: "蒸发器", type: "equipment", description: "浓缩母液的设备", connections: 3 },
{ id: 9, label: "煅烧炉", type: "equipment", description: "焙烧氢氧化铝成氧化铝", connections: 4 },
{ id: 10, label: "电解槽", type: "equipment", description: "电解生产金属铝的核心设备", connections: 5 },
{ id: 11, label: "槽电压", type: "parameter", description: "正常范围3.8-4.2V", connections: 4 },
{ id: 12, label: "分子比", type: "parameter", description: "电解质分子比2.2-2.4", connections: 3 },
];
const aluminaEdges: GraphEdge[] = [
{ source: 1, target: 5, label: "属于" },
{ source: 2, target: 1, label: "输入" },
{ source: 3, target: 1, label: "作用于" },
{ source: 1, target: 4, label: "产出" },
{ source: 4, target: 6, label: "输送至" },
{ source: 6, target: 7, label: "进入" },
{ source: 7, target: 8, label: "产出" },
{ source: 8, target: 9, label: "输送至" },
{ source: 9, target: 10, label: "原料" },
{ source: 10, target: 11, label: "监测" },
{ source: 10, target: 12, label: "影响" },
];
const statsDataSteel = [
{ label: "实体总数", value: "1,067" },
{ label: "关系总数", value: "1,249" },
{ label: "故障类型", value: "45种" },
{ label: "图谱类型", value: "钢铁" },
];
const statsDataAlumina = [
{ label: "实体总数", value: "2,458" },
{ label: "关系总数", value: "3,102" },
{ label: "工序覆盖", value: "6大工序" },
{ label: "图谱类型", value: "铝冶炼" },
];
const queryHistoryData = [
{ query: "给煤机皮带跑偏", result: "已找到5个相关节点", timestamp: "10:30" },
{ query: "过热器故障", result: "已找到3个相关节点", timestamp: "10:25" },
{ query: "磨煤机跳闸", result: "已找到4个相关节点", timestamp: "10:20" },
];
export function KnowledgeGraph() {
const [searchQuery, setSearchQuery] = useState("");
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
const [showNodeDialog, setShowNodeDialog] = useState(false);
const [searchHistory, setSearchHistory] = useState(queryHistoryData);
const [isSearching, setIsSearching] = useState(false);
const [hoveredNode, setHoveredNode] = useState<number | null>(null);
const [highlightedNodes, setHighlightedNodes] = useState<Set<number>>(new Set());
const [typeFilter, setTypeFilter] = useState<Set<string>>(new Set(Object.keys(steelNodeTypeConfig)));
const [simulation, setSimulation] = useState<d3.Simulation<any, any> | null>(null);
const [industry, setIndustry] = useState<"steel" | "alumina">("steel");
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const currentStatsData = industry === "steel" ? statsDataSteel : statsDataAlumina;
const currentNodes = industry === "steel" ? initialNodes : aluminaNodes;
const currentEdges = industry === "steel" ? initialEdges : aluminaEdges;
const currentNodeTypeConfig = industry === "steel" ? steelNodeTypeConfig : aluminaNodeTypeConfig;
const initGraph = useCallback(() => {
if (!svgRef.current || !containerRef.current) return;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const width = containerRef.current.clientWidth;
const height = containerRef.current.clientHeight;
svg.attr("width", width).attr("height", height);
const g = svg.append("g");
const zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.3, 3])
.on("zoom", (event) => {
g.attr("transform", event.transform);
});
svg.call(zoom);
const defs = svg.append("defs");
const configToUse = industry === "steel" ? steelNodeTypeConfig : aluminaNodeTypeConfig;
Object.entries(configToUse).forEach(([type, config]) => {
const gradient = defs.append("radialGradient")
.attr("id", `gradient-${type}`)
.attr("cx", "30%")
.attr("cy", "30%");
gradient.append("stop")
.attr("offset", "0%")
.attr("stop-color", config.color)
.attr("stop-opacity", 0.8);
gradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", config.color)
.attr("stop-opacity", 0.4);
});
const nodesToUse = industry === "steel" ? initialNodes : aluminaNodes;
const edgesToUse = industry === "steel" ? initialEdges : aluminaEdges;
const filteredNodes = nodesToUse.filter(n => typeFilter.has(n.type));
const filteredNodeIds = new Set(filteredNodes.map(n => n.id));
const filteredEdges = edgesToUse.filter(e => {
const sourceId = typeof e.source === 'number' ? e.source : (e.source as GraphNode).id;
const targetId = typeof e.target === 'number' ? e.target : (e.target as GraphNode).id;
return filteredNodeIds.has(sourceId) && filteredNodeIds.has(targetId);
});
const nodes = filteredNodes.map(n => ({ ...n }));
const links = filteredEdges.map(e => ({ ...e }));
const newSimulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id((d: any) => d.id).distance(150))
.force("charge", d3.forceManyBody().strength(-400))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(60));
setSimulation(newSimulation);
const link = g.append("g")
.attr("class", "links")
.selectAll("line")
.data(links)
.enter()
.append("g")
.attr("class", "link-group");
const linkLine = link.append("line")
.attr("stroke", "#475569")
.attr("stroke-width", 2)
.attr("stroke-opacity", 0.6);
const linkLabel = link.append("text")
.attr("fill", "#94a3b8")
.attr("font-size", 10)
.attr("text-anchor", "middle")
.attr("dy", -5)
.text((d: any) => d.label);
const nodeGroup = g.append("g")
.attr("class", "nodes")
.selectAll("g")
.data(nodes)
.enter()
.append("g")
.attr("class", "node-group")
.style("cursor", "pointer")
.call(d3.drag<any, any>()
.on("start", (event, d: any) => {
if (!event.active) newSimulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (event, d: any) => {
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event, d: any) => {
if (!event.active) newSimulation.alphaTarget(0);
d.fx = null;
d.fy = null;
})
);
nodeGroup.append("circle")
.attr("r", 40)
.attr("fill", (d: any) => `url(#gradient-${d.type})`)
.attr("stroke", (d: any) => configToUse[d.type as keyof typeof configToUse]?.color || "#fff")
.attr("stroke-width", 3)
.attr("stroke-opacity", 0.8);
nodeGroup.append("text")
.attr("text-anchor", "middle")
.attr("dy", "0.35em")
.attr("fill", "#fff")
.attr("font-size", 11)
.attr("font-weight", "bold")
.text((d: any) => d.label.length > 8 ? d.label.slice(0, 8) + "..." : d.label);
nodeGroup.on("mouseover", function(event, d: any) {
d3.select(this).select("circle")
.transition()
.duration(200)
.attr("r", 50)
.attr("stroke-width", 4);
setHoveredNode(d.id);
const connectedIds = new Set<number>();
connectedIds.add(d.id);
links.forEach((l: any) => {
if (l.source.id === d.id) connectedIds.add(l.target.id);
if (l.target.id === d.id) connectedIds.add(l.source.id);
});
setHighlightedNodes(connectedIds);
});
nodeGroup.on("mouseout", function() {
d3.select(this).select("circle")
.transition()
.duration(200)
.attr("r", 40)
.attr("stroke-width", 3);
setHoveredNode(null);
setHighlightedNodes(new Set());
});
nodeGroup.on("click", function(event, d: any) {
event.stopPropagation();
setSelectedNode(d);
setShowNodeDialog(true);
toast.success(`已选择节点: ${d.label}`, { duration: 2000 });
});
nodeGroup.on("dblclick", function(event, d: any) {
event.stopPropagation();
const connectedIds = new Set<number>();
connectedIds.add(d.id);
links.forEach((l: any) => {
if (l.source.id === d.id) connectedIds.add(l.target.id);
if (l.target.id === d.id) connectedIds.add(l.source.id);
});
nodes.forEach(n => {
const nodeG = g.selectAll(".node-group").filter((nd: any) => nd.id === n.id);
if (connectedIds.has(n.id)) {
nodeG.select("circle")
.transition()
.duration(300)
.attr("stroke-width", 4)
.attr("stroke", configToUse[n.type as keyof typeof configToUse]?.color || "#fff");
} else {
nodeG.select("circle")
.transition()
.duration(300)
.attr("stroke-width", 1)
.attr("stroke", "#475569")
.attr("opacity", 0.3);
}
});
toast.info(`已展开 ${d.label} 的关联节点`, { duration: 1500 });
});
svg.on("click", () => {
setSelectedNode(null);
setShowNodeDialog(false);
nodeGroup.select("circle")
.transition()
.duration(300)
.attr("stroke-width", 3)
.attr("stroke", (d: any) => configToUse[d.type as keyof typeof configToUse]?.color || "#fff")
.attr("opacity", 1);
});
newSimulation.on("tick", () => {
linkLine
.attr("x1", (d: any) => d.source.x)
.attr("y1", (d: any) => d.source.y)
.attr("x2", (d: any) => d.target.x)
.attr("y2", (d: any) => d.target.y);
linkLabel
.attr("x", (d: any) => (d.source.x + d.target.x) / 2)
.attr("y", (d: any) => (d.source.y + d.target.y) / 2);
nodeGroup.attr("transform", (d: any) => `translate(${d.x},${d.y})`);
});
svg.call(zoom.transform, d3.zoomIdentity.translate(0, 0).scale(1));
}, [typeFilter, industry]);
useEffect(() => {
initGraph();
}, [industry, initGraph]);
useEffect(() => {
if (!simulation) return;
return () => {
simulation.stop();
};
}, [simulation]);
useEffect(() => {
const handleResize = () => {
if (containerRef.current && svgRef.current) {
const svg = d3.select(svgRef.current);
svg.attr("width", containerRef.current.clientWidth)
.attr("height", containerRef.current.clientHeight);
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const handleSearch = () => {
if (searchQuery.trim()) {
setIsSearching(true);
toast.promise(
new Promise((resolve) => setTimeout(resolve, 1200)),
{
loading: "正在搜索知识图谱...",
success: () => {
const newEntry = {
query: searchQuery,
result: `已找到 ${Math.floor(Math.random() * 5) + 3} 个相关节点`,
timestamp: new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
};
setSearchHistory((prev) => [newEntry, ...prev.slice(0, 4)]);
const foundNode = initialNodes.find(
(n) => n.label.includes(searchQuery) || n.description.includes(searchQuery)
);
if (foundNode) {
setSelectedNode(foundNode);
setShowNodeDialog(true);
}
setSearchQuery("");
setIsSearching(false);
return `找到相关节点`;
},
error: "搜索失败",
}
);
}
};
const handleExport = () => {
toast.promise(
new Promise((resolve) => setTimeout(resolve, 1500)),
{
loading: "正在导出图谱数据...",
success: "导出成功!文件已保存",
error: "导出失败",
}
);
};
const handleNewTask = () => {
toast.info("新建抽取任务窗口即将打开", { duration: 2000 });
};
const handleViewResults = () => {
toast.info("查看抽取结果", { duration: 2000 });
};
const handleKnowledgeFusion = () => {
toast.promise(
new Promise((resolve) => setTimeout(resolve, 2000)),
{
loading: "正在执行知识融合...",
success: "知识融合完成!新增实体 12 个",
error: "融合失败",
}
);
};
const handleQualityEval = () => {
toast.promise(
new Promise((resolve) => setTimeout(resolve, 1800)),
{
loading: "正在正在进行质量评估...",
success: "评估完成!质量得分: 92/100",
error: "评估失败",
}
);
};
const handleTypeFilterToggle = (type: string) => {
setTypeFilter((prev) => {
const newSet = new Set(prev);
if (newSet.has(type)) {
if (newSet.size > 1) {
newSet.delete(type);
}
} else {
newSet.add(type);
}
return newSet;
});
toast.info("图谱筛选已更新", { duration: 1000 });
};
const getConnectedNodes = (nodeId: number) => {
const connected: number[] = [];
initialEdges.forEach((e) => {
const sourceId = typeof e.source === 'number' ? e.source : (e.source as GraphNode).id;
const targetId = typeof e.target === 'number' ? e.target : (e.target as GraphNode).id;
if (sourceId === nodeId) connected.push(targetId);
if (targetId === nodeId) connected.push(sourceId);
});
return connected;
};
return (
<div className="h-full p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white mb-1"></h1>
<div className="flex items-center gap-3 mt-2">
<button
onClick={() => {
setIndustry("steel");
setTypeFilter(new Set(Object.keys(steelNodeTypeConfig)));
}}
className={`px-3 py-1 rounded-lg text-sm font-medium transition-all ${
industry === "steel"
? "bg-orange-500/20 text-orange-400 border border-orange-500/50"
: "bg-slate-800/50 text-slate-400 border border-slate-700/50 hover:bg-slate-800"
}`}
>
</button>
<button
onClick={() => {
setIndustry("alumina");
setTypeFilter(new Set(Object.keys(aluminaNodeTypeConfig)));
}}
className={`px-3 py-1 rounded-lg text-sm font-medium transition-all ${
industry === "alumina"
? "bg-cyan-500/20 text-cyan-400 border border-cyan-500/50"
: "bg-slate-800/50 text-slate-400 border border-slate-700/50 hover:bg-slate-800"
}`}
>
</button>
</div>
</div>
<div className="flex items-center gap-2">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={handleExport}
className="px-3 py-1.5 rounded-lg bg-slate-800/50 hover:bg-slate-800 text-sm text-slate-300 flex items-center gap-2 transition-colors"
>
<Download className="w-4 h-4" />
</motion.button>
<span className="text-sm text-slate-400">D3.js </span>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 h-[calc(100%-6rem)]">
<div className="space-y-6 overflow-y-auto">
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className="rounded-xl bg-slate-900/50 border border-slate-800/50 backdrop-blur-sm p-6"
>
<h3 className="font-semibold text-white mb-4"></h3>
<div className="space-y-3">
{currentStatsData.map((stat) => (
<div key={stat.label} className="flex justify-between items-center">
<span className="text-sm text-slate-400">{stat.label}</span>
<span className="text-lg font-bold text-white">{stat.value}</span>
</div>
))}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
className="rounded-xl bg-slate-900/50 border border-slate-800/50 backdrop-blur-sm p-6"
>
<h3 className="font-semibold text-white mb-4"></h3>
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleSearch()}
placeholder="输入故障现象..."
disabled={isSearching}
className="w-full px-4 py-2 pl-10 rounded-lg bg-slate-800/50 border border-slate-700/50 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 disabled:opacity-50"
/>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
</div>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleSearch}
disabled={!searchQuery.trim() || isSearching}
className="w-full mt-3 px-4 py-2 rounded-lg bg-gradient-to-r from-blue-600 to-purple-600 text-white text-sm font-medium hover:shadow-lg hover:shadow-blue-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSearching ? "搜索中..." : "🔍 搜索"}
</motion.button>
</motion.div>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.15 }}
className="rounded-xl bg-slate-900/50 border border-slate-800/50 backdrop-blur-sm p-6"
>
<h3 className="font-semibold text-white mb-4"></h3>
<div className="space-y-2">
{Object.entries(currentNodeTypeConfig).map(([type, config]) => (
<motion.button
key={type}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => handleTypeFilterToggle(type)}
className={`w-full px-3 py-2 rounded-lg transition-all flex items-center gap-2 ${
typeFilter.has(type)
? "bg-slate-800/80 border border-slate-600"
: "bg-slate-800/30 border border-transparent opacity-50"
}`}
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: config.color }}
/>
<span className="text-sm text-slate-300 flex-1 text-left">{config.label}</span>
{typeFilter.has(type) && (
<span className="text-xs text-green-400"></span>
)}
</motion.button>
))}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="rounded-xl bg-slate-900/50 border border-slate-800/50 backdrop-blur-sm p-6"
>
<h3 className="font-semibold text-white mb-4"></h3>
<div className="space-y-2">
{searchHistory.map((item, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className="p-3 rounded-lg bg-slate-800/50 hover:bg-slate-800 transition-colors cursor-pointer"
whileHover={{ x: 5 }}
onClick={() => setSearchQuery(item.query)}
>
<div className="text-sm text-white mb-1">{item.query}</div>
<div className="flex items-center justify-between">
<div className="text-xs text-slate-400">{item.result}</div>
<div className="text-xs text-slate-500">{item.timestamp}</div>
</div>
</motion.div>
))}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
className="rounded-xl bg-slate-900/50 border border-slate-800/50 backdrop-blur-sm p-6"
>
<h3 className="font-semibold text-white mb-4"></h3>
<div className="space-y-2">
<motion.button
whileHover={{ scale: 1.02, x: 5 }}
whileTap={{ scale: 0.98 }}
onClick={handleNewTask}
className="w-full px-4 py-2 rounded-lg bg-slate-800/50 hover:bg-slate-800 text-sm text-slate-300 text-left transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4" />
</motion.button>
<motion.button
whileHover={{ scale: 1.02, x: 5 }}
whileTap={{ scale: 0.98 }}
onClick={handleViewResults}
className="w-full px-4 py-2 rounded-lg bg-slate-800/50 hover:bg-slate-800 text-sm text-slate-300 text-left transition-colors"
>
📊
</motion.button>
<motion.button
whileHover={{ scale: 1.02, x: 5 }}
whileTap={{ scale: 0.98 }}
onClick={handleKnowledgeFusion}
className="w-full px-4 py-2 rounded-lg bg-slate-800/50 hover:bg-slate-800 text-sm text-slate-300 text-left transition-colors"
>
🔀
</motion.button>
<motion.button
whileHover={{ scale: 1.02, x: 5 }}
whileTap={{ scale: 0.98 }}
onClick={handleQualityEval}
className="w-full px-4 py-2 rounded-lg bg-slate-800/50 hover:bg-slate-800 text-sm text-slate-300 text-left transition-colors"
>
</motion.button>
</div>
</motion.div>
</div>
<div className="lg:col-span-3 rounded-xl bg-slate-900/50 border border-slate-800/50 backdrop-blur-sm overflow-hidden flex flex-col">
<div className="px-6 py-4 border-b border-slate-800/50 bg-slate-900/30 flex items-center justify-between">
<h2 className="font-semibold text-white flex items-center gap-2">
<Network className="w-5 h-5 text-blue-400" />
{hoveredNode && (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="ml-2 px-2 py-1 rounded bg-blue-500/20 text-blue-400 text-xs"
>
: {initialNodes.find((n) => n.id === hoveredNode)?.label}
</motion.span>
)}
</h2>
<div className="flex items-center gap-2 text-xs text-slate-400">
<span> | </span>
</div>
</div>
<div
ref={containerRef}
className="flex-1 relative bg-slate-950/50 overflow-hidden"
>
<svg ref={svgRef} className="w-full h-full" />
<div className="absolute bottom-4 right-4 flex flex-col gap-2">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => {
if (svgRef.current) {
const svg = d3.select(svgRef.current);
svg.transition().call(
d3.zoom<SVGSVGElement, unknown>().transform as any,
d3.zoomIdentity.scale(1.2)
);
}
}}
className="p-2 rounded-lg bg-slate-800/80 hover:bg-slate-700 transition-colors"
>
<ZoomIn className="w-4 h-4 text-slate-300" />
</motion.button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => {
if (svgRef.current) {
const svg = d3.select(svgRef.current);
svg.transition().call(
d3.zoom<SVGSVGElement, unknown>().transform as any,
d3.zoomIdentity.scale(0.8)
);
}
}}
className="p-2 rounded-lg bg-slate-800/80 hover:bg-slate-700 transition-colors"
>
<ZoomOut className="w-4 h-4 text-slate-300" />
</motion.button>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => initGraph()}
className="p-2 rounded-lg bg-slate-800/80 hover:bg-slate-700 transition-colors"
>
<Maximize2 className="w-4 h-4 text-slate-300" />
</motion.button>
</div>
</div>
<div className="px-6 py-4 border-t border-slate-800/50 bg-slate-900/30 flex items-center gap-6 flex-wrap">
{Object.entries(currentNodeTypeConfig).map(([type, config]) => (
<div
key={type}
className={`flex items-center gap-2 transition-opacity ${
typeFilter.has(type) ? "opacity-100" : "opacity-40"
}`}
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: config.color }}
/>
<span className="text-xs text-slate-400">{config.label}</span>
</div>
))}
<div className="ml-auto text-xs text-slate-500">
{currentNodes.filter((n) => typeFilter.has(n.type)).length} |{" "}
{currentEdges.filter(
(e) => typeFilter.has(currentNodes.find((n) => n.id === e.source)?.type || "") &&
typeFilter.has(currentNodes.find((n) => n.id === e.target)?.type || "")
).length}
</div>
</div>
</div>
</div>
<Dialog open={showNodeDialog} onOpenChange={setShowNodeDialog}>
<DialogContent className="bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Network className="w-5 h-5 text-blue-400" />
{selectedNode?.label}
</DialogTitle>
<DialogDescription className="text-slate-400">
</DialogDescription>
</DialogHeader>
{selectedNode && (
<div className="space-y-4 mt-4">
<div className="flex items-center gap-3">
<div
className={`inline-flex px-3 py-1 rounded-full bg-gradient-to-br ${
currentNodeTypeConfig[selectedNode.type]?.gradient || "from-gray-500 to-gray-600"
} text-white text-sm`}
>
{currentNodeTypeConfig[selectedNode.type]?.label || selectedNode.type}
</div>
<div className="text-sm text-slate-400">
: {getConnectedNodes(selectedNode.id).length}
</div>
</div>
<div>
<div className="text-sm text-slate-400 mb-2"></div>
<div className="text-sm text-slate-300 p-3 rounded-lg bg-slate-800/50">
{selectedNode.description}
</div>
</div>
{getConnectedNodes(selectedNode.id).length > 0 && (
<div>
<div className="text-sm text-slate-400 mb-2"></div>
<div className="flex flex-wrap gap-2">
{getConnectedNodes(selectedNode.id).map((id) => {
const node = initialNodes.find((n) => n.id === id);
if (!node) return null;
return (
<motion.span
key={id}
whileHover={{ scale: 1.05 }}
className={`px-2 py-1 rounded text-xs cursor-pointer ${
highlightedNodes.has(id)
? "bg-blue-500/30 text-blue-300 border border-blue-500/50"
: "bg-slate-800 text-slate-300 border border-slate-700"
}`}
onClick={() => {
const newNode = initialNodes.find((n) => n.id === id);
if (newNode) {
setSelectedNode(newNode);
}
}}
>
{node.label}
</motion.span>
);
})}
</div>
</div>
)}
<div className="flex gap-2 pt-2">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => toast.info("编辑功能开发中")}
className="flex-1 px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm transition-colors flex items-center justify-center gap-1"
>
<Edit className="w-4 h-4" />
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => {
setShowNodeDialog(false);
toast.success("已定位节点");
}}
className="flex-1 px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white text-sm transition-colors flex items-center justify-center gap-1"
>
<Eye className="w-4 h-4" />
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => {
setShowNodeDialog(false);
toast.error("删除功能已禁用");
}}
className="px-4 py-2 rounded-lg bg-red-600/50 text-red-300 text-sm transition-colors flex items-center justify-center gap-1 cursor-not-allowed"
disabled
>
<Trash2 className="w-4 h-4" />
</motion.button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}