import { useEffect, useState, useRef, useCallback } from 'react'; import { useParams } from 'react-router-dom'; import { Card, Select, Space, Empty, Spin, Statistic, Row, Col, Tag, Tooltip, message, theme, Typography, Divider, Badge, Flex, } from 'antd'; import { ApartmentOutlined, TeamOutlined, NodeIndexOutlined, AimOutlined, InfoCircleOutlined, ReloadOutlined, } from '@ant-design/icons'; import { listPluginData } from '../api/pluginData'; import { getPluginSchema, type PluginFieldSchema, type PluginSchemaResponse, } from '../api/plugins'; import type { GraphNode, GraphEdge, GraphConfig, NodePosition, HoverState } from './graph/graphTypes'; import { computeCircularLayout } from './graph/graphLayout'; import { RELATIONSHIP_COLORS, NODE_HOVER_SCALE, getRelColor, getEdgeTypeLabel, getNodeDegree, degreeToRadius, drawCurvedEdge, drawNode, drawEdgeLabel, drawNodeLabel, } from './graph/graphRenderer'; const { Text } = Typography; /** * 插件关系图谱页面 — 通过路由参数自加载 schema * 路由: /plugins/:pluginId/graph/:entityName */ export function PluginGraphPage() { const { pluginId, entityName } = useParams<{ pluginId: string; entityName: string }>(); const { token } = theme.useToken(); const canvasRef = useRef(null); const containerRef = useRef(null); const animFrameRef = useRef(0); const nodePositionsRef = useRef>(new Map()); const visibleNodesRef = useRef([]); const visibleEdgesRef = useRef([]); const [customers, setCustomers] = useState([]); const [relationships, setRelationships] = useState([]); const [loading, setLoading] = useState(false); const [selectedCenter, setSelectedCenter] = useState(null); const [relTypes, setRelTypes] = useState([]); const [relFilter, setRelFilter] = useState(); const [graphConfig, setGraphConfig] = useState(null); const [fields, setFields] = useState([]); const [hoverState, setHoverState] = useState({ nodeId: null, x: 0, y: 0 }); const [canvasSize, setCanvasSize] = useState({ width: 800, height: 600 }); // ── Computed stats ── const filteredRels = relFilter ? relationships.filter((r) => r.label === relFilter) : relationships; const visibleEdges = selectedCenter ? filteredRels.filter((r) => r.source === selectedCenter || r.target === selectedCenter) : filteredRels; const visibleNodeIds = new Set(); if (selectedCenter) { visibleNodeIds.add(selectedCenter); for (const e of visibleEdges) { visibleNodeIds.add(e.source); visibleNodeIds.add(e.target); } } const visibleNodes = selectedCenter ? customers.filter((n) => visibleNodeIds.has(n.id)) : customers; const centerNode = customers.find((c) => c.id === selectedCenter); const centerDegree = selectedCenter ? getNodeDegree(selectedCenter, visibleEdges) : 0; // ── Schema loading ── useEffect(() => { if (!pluginId || !entityName) return; const abortController = new AbortController(); async function loadSchema() { try { const schema: PluginSchemaResponse = await getPluginSchema(pluginId!); if (abortController.signal.aborted) return; const pages = schema.ui?.pages || []; const graphPage = pages.find( (p): p is typeof p & GraphConfig & { type: 'graph' } => p.type === 'graph' && p.entity === entityName, ); if (graphPage) { setGraphConfig({ entity: graphPage.entity, relationshipEntity: graphPage.relationship_entity, sourceField: graphPage.source_field, targetField: graphPage.target_field, edgeLabelField: graphPage.edge_label_field, nodeLabelField: graphPage.node_label_field, }); } const entity = schema.entities?.find((e) => e.name === entityName); if (entity) setFields(entity.fields); } catch { message.warning('Schema 加载失败,部分功能不可用'); } } loadSchema(); return () => abortController.abort(); }, [pluginId, entityName]); // ── Data loading ── useEffect(() => { if (!pluginId || !graphConfig) return; const abortController = new AbortController(); const gc = graphConfig; const labelField = fields.find((f) => f.name === gc.nodeLabelField)?.name || fields[1]?.name || 'name'; async function loadData() { setLoading(true); try { let allCustomers: GraphNode[] = []; let page = 1; let hasMore = true; while (hasMore) { if (abortController.signal.aborted) return; const result = await listPluginData(pluginId!, gc.entity, page, 100); allCustomers = [ ...allCustomers, ...result.data.map((r) => ({ id: r.id, label: String(r.data[labelField] || '未命名'), data: r.data, })), ]; hasMore = result.data.length === 100 && allCustomers.length < result.total; page++; } if (abortController.signal.aborted) return; setCustomers(allCustomers); let allRels: GraphEdge[] = []; page = 1; hasMore = true; const types = new Set(); while (hasMore) { if (abortController.signal.aborted) return; const result = await listPluginData(pluginId!, gc.relationshipEntity, page, 100); for (const r of result.data) { const relType = String(r.data[gc.edgeLabelField] || ''); types.add(relType); allRels.push({ source: String(r.data[gc.sourceField] || ''), target: String(r.data[gc.targetField] || ''), label: relType, }); } hasMore = result.data.length === 100 && allRels.length < result.total; page++; } if (abortController.signal.aborted) return; setRelationships(allRels); setRelTypes(Array.from(types)); } catch { message.warning('数据加载失败'); } if (!abortController.signal.aborted) setLoading(false); } loadData(); return () => abortController.abort(); }, [pluginId, graphConfig, fields]); // ── Canvas resize observer ── useEffect(() => { const container = containerRef.current; if (!container) return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { const { width } = entry.contentRect; if (width > 0) { setCanvasSize({ width, height: Math.max(500, Math.min(700, width * 0.65)) }); } } }); observer.observe(container); return () => observer.disconnect(); }, []); // ── Update refs for animation loop ── useEffect(() => { visibleNodesRef.current = visibleNodes; visibleEdgesRef.current = visibleEdges; }, [visibleNodes, visibleEdges]); // ── Main canvas drawing with requestAnimationFrame ── const drawGraph = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const nodes = visibleNodesRef.current; const edges = visibleEdgesRef.current; const width = canvasSize.width; const height = canvasSize.height; // High DPI support const dpr = window.devicePixelRatio || 1; canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; ctx.scale(dpr, dpr); // Theme-aware colors const textColor = token.colorText; const bgColor = token.colorBgContainer; // Clear canvas ctx.clearRect(0, 0, width, height); // Background ctx.fillStyle = bgColor; ctx.fillRect(0, 0, width, height); if (nodes.length === 0) return; // Compute layout const centerX = width / 2; const centerY = height / 2; const radius = Math.min(width, height) * 0.36; const positions = computeCircularLayout(nodes, centerX, centerY, radius); nodePositionsRef.current = positions; // Precompute degrees for node sizing const degreeMap = new Map(); for (const node of nodes) { degreeMap.set(node.id, getNodeDegree(node.id, edges)); } // ── Draw edges first (behind nodes) ── for (const edge of edges) { const from = positions.get(edge.source); const to = positions.get(edge.target); if (!from || !to) continue; const colors = getRelColor(edge.label); const isHighlighted = hoverState.nodeId === edge.source || hoverState.nodeId === edge.target; const alpha = hoverState.nodeId ? (isHighlighted ? 1 : 0.15) : 0.7; const lw = isHighlighted ? 2.5 : 1.5; const labelPos = drawCurvedEdge( ctx, from.x, from.y, to.x, to.y, colors.base, lw, isHighlighted, alpha, ); // Edge label if (edge.label && labelPos) { const labelAlpha = hoverState.nodeId ? (isHighlighted ? 1 : 0.1) : 0.9; drawEdgeLabel(ctx, labelPos.labelX, labelPos.labelY - 10, edge.label, colors.base, labelAlpha); } } // ── Draw nodes ── for (const node of nodes) { const pos = positions.get(node.id); if (!pos) continue; const isCenter = node.id === selectedCenter; const isHovered = node.id === hoverState.nodeId; const degree = degreeMap.get(node.id) || 0; const r = degreeToRadius(degree, isCenter); // Determine node color from its most common edge type, or default palette let nodeColorBase = '#4F46E5'; let nodeColorLight = '#818CF8'; let nodeColorGlow = 'rgba(79,70,229,0.3)'; if (isCenter) { const firstEdge = edges.find((e) => e.source === node.id || e.target === node.id); if (firstEdge) { const rc = getRelColor(firstEdge.label); nodeColorBase = rc.base; nodeColorLight = rc.light; nodeColorGlow = rc.glow; } } else { const idx = nodes.indexOf(node); const palette = Object.values(RELATIONSHIP_COLORS); const pick = palette[idx % palette.length]; nodeColorBase = pick.base; nodeColorLight = pick.light; nodeColorGlow = pick.glow; } const nodeAlpha = hoverState.nodeId ? (isHovered || (hoverState.nodeId && edges.some( (e) => (e.source === hoverState.nodeId && e.target === node.id) || (e.target === hoverState.nodeId && e.source === node.id), )) ? 1 : 0.2) : 1; drawNode(ctx, pos.x, pos.y, r, nodeColorBase, nodeColorLight, nodeColorGlow, isCenter, isHovered, nodeAlpha); drawNodeLabel(ctx, pos.x, pos.y, r, node.label, textColor, isCenter, isHovered); } // ── Hover tooltip ── if (hoverState.nodeId) { const hoveredNode = nodes.find((n) => n.id === hoverState.nodeId); if (hoveredNode) { const degree = degreeMap.get(hoverState.nodeId) || 0; const tooltipText = `${hoveredNode.label} (${degree} 条关系)`; ctx.save(); ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; const metrics = ctx.measureText(tooltipText); const tw = metrics.width + 16; const th = 28; const tx = hoverState.x - tw / 2; const ty = hoverState.y - 40; ctx.fillStyle = token.colorBgElevated; ctx.shadowColor = 'rgba(0,0,0,0.15)'; ctx.shadowBlur = 8; ctx.beginPath(); ctx.roundRect(tx, ty, tw, th, 6); ctx.fill(); ctx.shadowBlur = 0; ctx.fillStyle = token.colorText; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(tooltipText, hoverState.x, ty + th / 2); ctx.restore(); } } }, [canvasSize, selectedCenter, hoverState, token]); // ── Animation loop ── useEffect(() => { const animate = () => { drawGraph(); animFrameRef.current = requestAnimationFrame(animate); }; animFrameRef.current = requestAnimationFrame(animate); return () => cancelAnimationFrame(animFrameRef.current); }, [drawGraph]); // ── Mouse interaction handlers ── const handleCanvasMouseMove = useCallback( (e: React.MouseEvent) => { const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const positions = nodePositionsRef.current; const nodes = visibleNodesRef.current; const edges = visibleEdgesRef.current; let foundId: string | null = null; for (const node of nodes) { const pos = positions.get(node.id); if (!pos) continue; const degree = getNodeDegree(node.id, edges); const r = degreeToRadius(degree, node.id === selectedCenter) * NODE_HOVER_SCALE; const dx = x - pos.x; const dy = y - pos.y; if (dx * dx + dy * dy < r * r) { foundId = node.id; break; } } canvas.style.cursor = foundId ? 'pointer' : 'default'; setHoverState((prev) => { if (prev.nodeId === foundId) return prev; return { nodeId: foundId, x, y }; }); if (foundId) { setHoverState({ nodeId: foundId, x, y }); } }, [selectedCenter], ); const handleCanvasMouseLeave = useCallback(() => { setHoverState({ nodeId: null, x: 0, y: 0 }); }, []); const handleCanvasClick = useCallback( (e: React.MouseEvent) => { const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const positions = nodePositionsRef.current; const nodes = visibleNodesRef.current; const edges = visibleEdgesRef.current; for (const node of nodes) { const pos = positions.get(node.id); if (!pos) continue; const degree = getNodeDegree(node.id, edges); const r = degreeToRadius(degree, node.id === selectedCenter); const dx = x - pos.x; const dy = y - pos.y; if (dx * dx + dy * dy < r * r) { setSelectedCenter((prev) => (prev === node.id ? null : node.id)); return; } } }, [selectedCenter], ); // ── Legend data ── const legendItems = relTypes.map((type) => ({ label: getEdgeTypeLabel(type), rawLabel: type, color: getRelColor(type).base, count: relationships.filter((r) => r.label === type).length, })); // ── Render ── if (loading) { return (
); } return (
{/* Stats Row */} 客户总数 } value={customers.length} valueStyle={{ color: token.colorPrimary, fontWeight: 600 }} /> 关系总数 } value={relationships.length} valueStyle={{ color: token.colorSuccess, fontWeight: 600 }} /> 当前中心 } value={centerNode?.label || '未选择'} valueStyle={{ fontSize: 20, color: centerNode ? token.colorWarning : token.colorTextDisabled, fontWeight: 600, }} /> {selectedCenter && ( {centerDegree} 条直接关系 )} {/* Main Graph Card */} 客户关系图谱 {relFilter && ( setRelFilter(undefined)} > {getEdgeTypeLabel(relFilter)} )} } size="small" extra={ ({ label: c.label, value: c.id, }))} onChange={(v) => setSelectedCenter(v || null)} /> } > {customers.length === 0 ? ( ) : (
{/* Legend overlay */} {legendItems.length > 0 && (
关系类型图例 {legendItems.map((item) => ( { setRelFilter((prev) => prev === item.rawLabel ? undefined : item.rawLabel, ); }} > {item.label} ({item.count}) ))}
)} {/* Info overlay */} {hoverState.nodeId && (
{visibleNodes.find((n) => n.id === hoverState.nodeId)?.label} 点击节点设为中心 / 再次点击取消
)}
)}
{/* Selected node detail panel */} {selectedCenter && centerNode && ( {centerNode.label} — 详细信息 } extra={ setSelectedCenter(null)} > 重置视图 } > {Object.entries(centerNode.data).map(([key, value]) => { if (value == null || value === '') return null; const fieldSchema = fields.find((f) => f.name === key); const displayName = fieldSchema?.display_name || key; return ( {displayName} {String(value)} ); })} 直接关系: {centerDegree} 条 — 显示 {visibleNodes.length} 个节点、{visibleEdges.length} 条边 )}
); }