refactor(web): 前端工程化 — 组件拆分 + 名称缓存统一
- useHealthStore 新增 batchResolvePatientNames/batchResolveDoctorNames 批量解析方法(去重 → 过滤已缓存 → 5 并发批次加载) - PointsOrderList 移除局部 nameCache,改用 useHealthStore 全局缓存 - PluginCRUDPage (871L) 拆分为 usePluginData + DetailDrawer + ImportModal + PluginCRUDPageInner,原文件改为 re-export - PluginGraphPage (765L) 拆分为 useGraphData + useGraphCanvas hooks - StatisticsDashboard (580L) 拆分为 useStatsData + HealthDataCenter
This commit is contained in:
250
apps/web/src/pages/PluginGraphPage/useGraphCanvas.ts
Normal file
250
apps/web/src/pages/PluginGraphPage/useGraphCanvas.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useRef, useState, useCallback, useEffect } from 'react';
|
||||
import type { GlobalToken } from 'antd/es/theme/interface';
|
||||
import type { GraphNode, GraphEdge, NodePosition, HoverState } from '../graph/graphTypes';
|
||||
import { computeCircularLayout } from '../graph/graphLayout';
|
||||
import {
|
||||
getEdgeColor,
|
||||
NODE_HOVER_SCALE,
|
||||
getRelColor,
|
||||
getEdgeTypeLabel,
|
||||
getNodeDegree,
|
||||
degreeToRadius,
|
||||
drawCurvedEdge,
|
||||
drawNode,
|
||||
drawEdgeLabel,
|
||||
drawNodeLabel,
|
||||
} from '../graph/graphRenderer';
|
||||
|
||||
interface UseGraphCanvasParams {
|
||||
token: GlobalToken;
|
||||
canvasSize: { width: number; height: number };
|
||||
selectedCenter: string | null;
|
||||
visibleNodes: GraphNode[];
|
||||
visibleEdges: GraphEdge[];
|
||||
}
|
||||
|
||||
export function useGraphCanvas({
|
||||
token,
|
||||
canvasSize,
|
||||
selectedCenter,
|
||||
visibleNodes,
|
||||
visibleEdges,
|
||||
}: UseGraphCanvasParams) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const nodePositionsRef = useRef<Map<string, NodePosition>>(new Map());
|
||||
const visibleNodesRef = useRef<GraphNode[]>(visibleNodes);
|
||||
const visibleEdgesRef = useRef<GraphEdge[]>(visibleEdges);
|
||||
const [hoverState, setHoverState] = useState<HoverState>({ nodeId: null, x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
visibleNodesRef.current = visibleNodes;
|
||||
visibleEdgesRef.current = visibleEdges;
|
||||
}, [visibleNodes, visibleEdges]);
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
|
||||
const textColor = token.colorText;
|
||||
const bgColor = token.colorBgContainer;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
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;
|
||||
|
||||
const degreeMap = new Map<string, number>();
|
||||
for (const node of nodes) {
|
||||
degreeMap.set(node.id, getNodeDegree(node.id, edges));
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
let nodeColorBase = '#2563eb';
|
||||
let nodeColorLight = '#60a5fa';
|
||||
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 pick = getEdgeColor(`_node_${idx}`);
|
||||
nodeColorBase = pick.base;
|
||||
nodeColorLight = pick.light;
|
||||
nodeColorGlow = pick.glow;
|
||||
}
|
||||
|
||||
const nodeAlpha = hoverState.nodeId
|
||||
? (isHovered || 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);
|
||||
}
|
||||
|
||||
if (hoverState.nodeId) {
|
||||
const hoveredNode = nodes.find((n) => n.id === hoverState.nodeId);
|
||||
if (hoveredNode) {
|
||||
const degree = degreeMap.get(hoveredNode.id) || 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]);
|
||||
|
||||
useEffect(() => { drawGraph(); }, [drawGraph]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const observer = new ResizeObserver(() => { drawGraph(); });
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [drawGraph]);
|
||||
|
||||
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) =>
|
||||
prev.nodeId === foundId ? prev : { 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) {
|
||||
return { clicked: node.id };
|
||||
}
|
||||
}
|
||||
return { clicked: null };
|
||||
},
|
||||
[selectedCenter],
|
||||
);
|
||||
|
||||
return {
|
||||
canvasRef,
|
||||
containerRef,
|
||||
hoverState,
|
||||
drawGraph,
|
||||
handleCanvasMouseMove,
|
||||
handleCanvasMouseLeave,
|
||||
handleCanvasClick,
|
||||
};
|
||||
}
|
||||
117
apps/web/src/pages/PluginGraphPage/useGraphData.ts
Normal file
117
apps/web/src/pages/PluginGraphPage/useGraphData.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { listPluginData } from '../../api/pluginData';
|
||||
import {
|
||||
getPluginSchema,
|
||||
type PluginFieldSchema,
|
||||
type PluginSchemaResponse,
|
||||
} from '../../api/plugins';
|
||||
import type { GraphNode, GraphEdge, GraphConfig } from '../graph/graphTypes';
|
||||
|
||||
export function useGraphData(pluginId?: string, entityName?: string) {
|
||||
const [customers, setCustomers] = useState<GraphNode[]>([]);
|
||||
const [relationships, setRelationships] = useState<GraphEdge[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [graphConfig, setGraphConfig] = useState<GraphConfig | null>(null);
|
||||
const [fields, setFields] = useState<PluginFieldSchema[]>([]);
|
||||
const [relTypes, setRelTypes] = useState<string[]>([]);
|
||||
|
||||
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]);
|
||||
|
||||
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]);
|
||||
|
||||
return { customers, relationships, loading, fields, graphConfig, relTypes };
|
||||
}
|
||||
Reference in New Issue
Block a user