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'; const { Text } = Typography; // ── Types ── interface GraphNode { id: string; label: string; data: Record; } interface GraphEdge { source: string; target: string; label: string; } interface GraphConfig { entity: string; relationshipEntity: string; sourceField: string; targetField: string; edgeLabelField: string; nodeLabelField: string; } interface NodePosition { x: number; y: number; vx: number; vy: number; } interface HoverState { nodeId: string | null; x: number; y: number; } // ── Constants ── const RELATIONSHIP_COLORS: Record = { parent_child: { base: '#4F46E5', light: '#818CF8', glow: 'rgba(79,70,229,0.3)' }, sibling: { base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' }, partner: { base: '#D97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' }, supplier: { base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' }, competitor: { base: '#DC2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' }, }; const DEFAULT_REL_COLOR = { base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' }; const REL_LABEL_MAP: Record = { parent_child: '母子', sibling: '兄弟', partner: '伙伴', supplier: '供应商', competitor: '竞争', }; const NODE_BASE_RADIUS = 18; const NODE_CENTER_RADIUS = 26; const NODE_HOVER_SCALE = 1.3; const LABEL_MAX_LENGTH = 8; // ── Helpers ── function getRelColor(label: string) { return RELATIONSHIP_COLORS[label] || DEFAULT_REL_COLOR; } function getEdgeTypeLabel(label: string): string { return REL_LABEL_MAP[label] || label; } function truncateLabel(label: string): string { return label.length > LABEL_MAX_LENGTH ? label.slice(0, LABEL_MAX_LENGTH) + '...' : label; } /** Compute color for a node based on its connection count (degree). */ function getNodeDegree(nodeId: string, edges: GraphEdge[]): number { return edges.filter((e) => e.source === nodeId || e.target === nodeId).length; } /** Map a degree value to a node radius — more connections = larger node. */ function degreeToRadius(degree: number, isCenter: boolean): number { const base = isCenter ? NODE_CENTER_RADIUS : NODE_BASE_RADIUS; const bonus = Math.min(degree * 1.2, 10); return base + bonus; } /** Calculate circular layout positions. */ function computeCircularLayout( nodes: GraphNode[], centerX: number, centerY: number, radius: number, ): Map { const positions = new Map(); const count = nodes.length; if (count === 0) return positions; if (count === 1) { positions.set(nodes[0].id, { x: centerX, y: centerY, vx: 0, vy: 0 }); return positions; } nodes.forEach((node, i) => { const angle = (2 * Math.PI * i) / count - Math.PI / 2; positions.set(node.id, { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle), vx: 0, vy: 0, }); }); return positions; } /** Draw a quadratic bezier curved edge with an arrowhead. */ function drawCurvedEdge( ctx: CanvasRenderingContext2D, fromX: number, fromY: number, toX: number, toY: number, color: string, lineWidth: number, highlighted: boolean, alpha: number, ) { const dx = toX - fromX; const dy = toY - fromY; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < 1) return; // Control point offset perpendicular to the edge midpoint const curvature = Math.min(dist * 0.15, 30); const midX = (fromX + toX) / 2; const midY = (fromY + toY) / 2; const nx = -dy / dist; const ny = dx / dist; const cpX = midX + nx * curvature; const cpY = midY + ny * curvature; ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = color; ctx.lineWidth = highlighted ? lineWidth + 1 : lineWidth; if (highlighted) { ctx.shadowColor = color; ctx.shadowBlur = 8; } ctx.beginPath(); ctx.moveTo(fromX, fromY); ctx.quadraticCurveTo(cpX, cpY, toX, toY); ctx.stroke(); // Arrowhead at target end const t = 0.95; const arrowT = t; const tangentX = 2 * (1 - arrowT) * (cpX - fromX) + 2 * arrowT * (toX - cpX); const tangentY = 2 * (1 - arrowT) * (cpY - fromY) + 2 * arrowT * (toY - cpY); const tangentLen = Math.sqrt(tangentX * tangentX + tangentY * tangentY); if (tangentLen < 1) { ctx.restore(); return; } const arrowSize = highlighted ? 10 : 7; const ax = tangentX / tangentLen; const ay = tangentY / tangentLen; // Point slightly before target to avoid overlapping the node circle const endX = toX - ax * 4; const endY = toY - ay * 4; ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(endX, endY); ctx.lineTo(endX - arrowSize * ax + arrowSize * 0.4 * ay, endY - arrowSize * ay - arrowSize * 0.4 * ax); ctx.lineTo(endX - arrowSize * ax - arrowSize * 0.4 * ay, endY - arrowSize * ay + arrowSize * 0.4 * ax); ctx.closePath(); ctx.fill(); ctx.restore(); // Return the midpoint for label placement return { labelX: cpX, labelY: cpY }; } /** Draw a gradient-filled node circle with shadow. */ function drawNode( ctx: CanvasRenderingContext2D, x: number, y: number, radius: number, color: string, lightColor: string, glowColor: string, isCenter: boolean, isHovered: boolean, alpha: number, ) { ctx.save(); ctx.globalAlpha = alpha; const r = isHovered ? radius * NODE_HOVER_SCALE : radius; // Outer glow / shadow if (isCenter || isHovered) { ctx.shadowColor = glowColor; ctx.shadowBlur = isCenter ? 20 : 14; } else { ctx.shadowColor = 'rgba(0,0,0,0.12)'; ctx.shadowBlur = 6; } // Gradient fill const gradient = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, 0, x, y, r); if (isCenter) { gradient.addColorStop(0, lightColor); gradient.addColorStop(1, color); } else { gradient.addColorStop(0, 'rgba(255,255,255,0.9)'); gradient.addColorStop(1, 'rgba(255,255,255,0.4)'); } ctx.beginPath(); ctx.arc(x, y, r, 0, 2 * Math.PI); ctx.fillStyle = gradient; ctx.fill(); // Border ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; ctx.strokeStyle = isCenter ? color : lightColor; ctx.lineWidth = isCenter ? 3 : 1.5; if (isHovered) { ctx.lineWidth += 1; } ctx.stroke(); // Inner highlight ring for center node if (isCenter) { ctx.beginPath(); ctx.arc(x, y, r + 4, 0, 2 * Math.PI); ctx.strokeStyle = glowColor; ctx.lineWidth = 2; ctx.setLineDash([4, 4]); ctx.stroke(); ctx.setLineDash([]); } ctx.restore(); } /** Draw the edge label with background pill. */ function drawEdgeLabel( ctx: CanvasRenderingContext2D, x: number, y: number, label: string, color: string, alpha: number, ) { ctx.save(); ctx.globalAlpha = alpha * 0.85; const display = getEdgeTypeLabel(label); ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; const metrics = ctx.measureText(display); const textWidth = metrics.width; const padding = 6; const pillHeight = 18; const pillRadius = 9; // Background pill const pillX = x - textWidth / 2 - padding; const pillY = y - pillHeight / 2; ctx.fillStyle = color + '18'; ctx.beginPath(); ctx.roundRect(pillX, pillY, textWidth + padding * 2, pillHeight, pillRadius); ctx.fill(); ctx.strokeStyle = color + '40'; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(pillX, pillY, textWidth + padding * 2, pillHeight, pillRadius); ctx.stroke(); // Text ctx.fillStyle = color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(display, x, y); ctx.restore(); } /** Draw a node label below the node. */ function drawNodeLabel( ctx: CanvasRenderingContext2D, x: number, y: number, radius: number, label: string, textColor: string, isCenter: boolean, isHovered: boolean, ) { ctx.save(); const display = truncateLabel(label); const r = isHovered ? radius * NODE_HOVER_SCALE : radius; const fontSize = isCenter ? 13 : isHovered ? 12 : 11; const fontWeight = isCenter ? '600' : isHovered ? '500' : '400'; ctx.font = `${fontWeight} ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = textColor; if (isCenter || isHovered) { ctx.globalAlpha = 1; } else { ctx.globalAlpha = 0.7; } ctx.fillText(display, x, y + r + 8); ctx.restore(); } /** * 插件关系图谱页面 — 通过路由参数自加载 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) { // Use the first connected edge color for center node 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 { // Assign color based on index for non-center nodes 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} 条边 )}
); }