882 lines
36 KiB
TypeScript
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>
|
|
);
|
|
}
|