refactor(web): 前端工程化 — 组件拆分 + 名称缓存统一
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 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:
iven
2026-04-27 20:56:27 +08:00
parent fdceed7284
commit 41af241238
13 changed files with 1624 additions and 1841 deletions

View 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,
};
}

View 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 };
}