Files
erp/apps/web/src/pages/PluginGraphPage.tsx
iven 0a57cd7030 refactor(web): 拆分 PluginGraphPage 为 graph 子模块 — 每个文件 < 800 行
- graphTypes.ts (39 行) — GraphNode/GraphEdge/GraphConfig/NodePosition/HoverState
- graphLayout.ts (41 行) — computeCircularLayout 环形布局算法
- graphRenderer.ts (293 行) — Canvas 绘制函数 + 常量 + helper
- PluginGraphPage.tsx (758 行) — 组件壳:state/effects/event handlers/JSX
2026-04-17 12:51:32 +08:00

759 lines
24 KiB
TypeScript

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<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const animFrameRef = useRef<number>(0);
const nodePositionsRef = useRef<Map<string, NodePosition>>(new Map());
const visibleNodesRef = useRef<GraphNode[]>([]);
const visibleEdgesRef = useRef<GraphEdge[]>([]);
const [customers, setCustomers] = useState<GraphNode[]>([]);
const [relationships, setRelationships] = useState<GraphEdge[]>([]);
const [loading, setLoading] = useState(false);
const [selectedCenter, setSelectedCenter] = useState<string | null>(null);
const [relTypes, setRelTypes] = useState<string[]>([]);
const [relFilter, setRelFilter] = useState<string | undefined>();
const [graphConfig, setGraphConfig] = useState<GraphConfig | null>(null);
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
const [hoverState, setHoverState] = useState<HoverState>({ 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<string>();
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<string>();
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<string, number>();
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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 (
<div style={{ padding: 24, textAlign: 'center' }}>
<Spin size="large" tip="加载图谱数据中..." />
</div>
);
}
return (
<div style={{ padding: 24 }}>
{/* Stats Row */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Card
size="small"
style={{ borderLeft: `3px solid ${token.colorPrimary}` }}
>
<Statistic
title={
<Text type="secondary" style={{ fontSize: 12 }}>
<TeamOutlined style={{ marginRight: 4 }} />
</Text>
}
value={customers.length}
valueStyle={{ color: token.colorPrimary, fontWeight: 600 }}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card
size="small"
style={{ borderLeft: `3px solid ${token.colorSuccess}` }}
>
<Statistic
title={
<Text type="secondary" style={{ fontSize: 12 }}>
<NodeIndexOutlined style={{ marginRight: 4 }} />
</Text>
}
value={relationships.length}
valueStyle={{ color: token.colorSuccess, fontWeight: 600 }}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card
size="small"
style={{ borderLeft: `3px solid ${token.colorWarning}` }}
>
<Statistic
title={
<Text type="secondary" style={{ fontSize: 12 }}>
<AimOutlined style={{ marginRight: 4 }} />
</Text>
}
value={centerNode?.label || '未选择'}
valueStyle={{
fontSize: 20,
color: centerNode ? token.colorWarning : token.colorTextDisabled,
fontWeight: 600,
}}
/>
{selectedCenter && (
<Text type="secondary" style={{ fontSize: 11 }}>
{centerDegree}
</Text>
)}
</Card>
</Col>
</Row>
{/* Main Graph Card */}
<Card
title={
<Space>
<ApartmentOutlined />
<span></span>
{relFilter && (
<Tag
color="blue"
closable
onClose={() => setRelFilter(undefined)}
>
{getEdgeTypeLabel(relFilter)}
</Tag>
)}
</Space>
}
size="small"
extra={
<Space wrap>
<Select
placeholder="筛选关系类型"
allowClear
style={{ width: 150 }}
value={relFilter}
options={relTypes.map((t) => ({
label: (
<Space>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
backgroundColor: getRelColor(t).base,
}}
/>
{getEdgeTypeLabel(t)}
<Text type="secondary" style={{ fontSize: 11 }}>
({relationships.filter((r) => r.label === t).length})
</Text>
</Space>
),
value: t,
}))}
onChange={(v) => setRelFilter(v)}
/>
<Select
placeholder="选择中心客户"
allowClear
showSearch
style={{ width: 200 }}
optionFilterProp="label"
value={selectedCenter || undefined}
options={customers.map((c) => ({
label: c.label,
value: c.id,
}))}
onChange={(v) => setSelectedCenter(v || null)}
/>
</Space>
}
>
{customers.length === 0 ? (
<Empty
description="暂无客户数据"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<div ref={containerRef} style={{ position: 'relative' }}>
<canvas
ref={canvasRef}
onMouseMove={handleCanvasMouseMove}
onMouseLeave={handleCanvasMouseLeave}
onClick={handleCanvasClick}
style={{
width: '100%',
height: canvasSize.height,
borderRadius: 8,
border: `1px solid ${token.colorBorderSecondary}`,
display: 'block',
}}
/>
{/* Legend overlay */}
{legendItems.length > 0 && (
<div
style={{
position: 'absolute',
bottom: 12,
left: 12,
background: token.colorBgElevated,
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: 8,
padding: '8px 12px',
boxShadow: token.boxShadowSecondary,
maxWidth: 220,
}}
>
<Text
strong
style={{ fontSize: 11, color: token.colorTextSecondary, display: 'block', marginBottom: 4 }}
>
</Text>
<Flex wrap="wrap" gap={6}>
{legendItems.map((item) => (
<Tag
key={item.rawLabel}
color={item.color}
style={{
margin: 0,
fontSize: 11,
cursor: relFilter === item.rawLabel ? 'default' : 'pointer',
opacity: relFilter && relFilter !== item.rawLabel ? 0.4 : 1,
}}
onClick={() => {
setRelFilter((prev) =>
prev === item.rawLabel ? undefined : item.rawLabel,
);
}}
>
{item.label} ({item.count})
</Tag>
))}
</Flex>
</div>
)}
{/* Info overlay */}
{hoverState.nodeId && (
<div
style={{
position: 'absolute',
top: 12,
right: 12,
background: token.colorBgElevated,
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: 8,
padding: '8px 12px',
boxShadow: token.boxShadowSecondary,
maxWidth: 280,
transition: 'opacity 0.15s ease',
}}
>
<Space direction="vertical" size={4}>
<Text strong>
{visibleNodes.find((n) => n.id === hoverState.nodeId)?.label}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
<InfoCircleOutlined style={{ marginRight: 4 }} />
/
</Text>
</Space>
</div>
)}
</div>
)}
</Card>
{/* Selected node detail panel */}
{selectedCenter && centerNode && (
<Card
size="small"
style={{ marginTop: 16 }}
title={
<Space>
<Badge color={token.colorPrimary} />
<Text strong>{centerNode.label}</Text>
<Text type="secondary"> </Text>
</Space>
}
extra={
<Tooltip title="取消选中">
<Text
type="secondary"
style={{ cursor: 'pointer', fontSize: 12 }}
onClick={() => setSelectedCenter(null)}
>
<ReloadOutlined style={{ marginRight: 4 }} />
</Text>
</Tooltip>
}
>
<Row gutter={[16, 12]}>
{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 (
<Col xs={12} sm={8} md={6} key={key}>
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}>
{displayName}
</Text>
<Text style={{ fontSize: 13 }}>{String(value)}</Text>
</Col>
);
})}
</Row>
<Divider style={{ margin: '12px 0 8px' }} />
<Text type="secondary" style={{ fontSize: 12 }}>
: {centerDegree}
{visibleNodes.length} {visibleEdges.length}
</Text>
</Card>
)}
</div>
);
}