fix(用户管理): 修复用户列表页面加载失败问题
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

修复用户列表页面加载失败导致测试超时的问题,确保页面元素正确渲染
This commit is contained in:
iven
2026-04-19 08:46:28 +08:00
parent 0ee9d22634
commit 841766b168
174 changed files with 26366 additions and 675 deletions

View File

@@ -0,0 +1,33 @@
/**
* Graph 常量定义 — 通用调色板、尺寸参数
* 关系类型颜色和标签由 options 动态派生,不硬编码
*/
// 通用边调色板
const EDGE_PALETTE: Array<{ base: string; light: string; glow: string }> = [
{ base: '#4F46E5', light: '#818CF8', glow: 'rgba(79,70,229,0.3)' },
{ base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' },
{ base: '#D97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' },
{ base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' },
{ base: '#DC2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' },
{ base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' },
{ base: '#EA580C', light: '#FB923C', glow: 'rgba(234,88,12,0.3)' },
{ base: '#DB2777', light: '#F472B6', glow: 'rgba(219,39,119,0.3)' },
];
export const DEFAULT_REL_COLOR = { base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' };
const edgeColorCache = new Map<string, { base: string; light: string; glow: string }>();
export function getEdgeColor(relationType: string): { base: string; light: string; glow: string } {
const cached = edgeColorCache.get(relationType);
if (cached) return cached;
const color = EDGE_PALETTE[edgeColorCache.size % EDGE_PALETTE.length];
edgeColorCache.set(relationType, color);
return color;
}
export const NODE_BASE_RADIUS = 18;
export const NODE_CENTER_RADIUS = 26;
export const NODE_HOVER_SCALE = 1.3;
export const LABEL_MAX_LENGTH = 8;

View File

@@ -0,0 +1,81 @@
/**
* Graph 交互处理 — 鼠标悬停/点击/拖拽事件
*/
import type { GraphNode, GraphEdge, NodePosition, HoverState } from './graphTypes';
import { getNodeDegree, degreeToRadius } from './graphLayout';
import { NODE_HOVER_SCALE } from './graphConstants';
/** 鼠标移动 — 检测 hover 节点 */
export function handleCanvasMouseMove(
e: React.MouseEvent<HTMLCanvasElement>,
canvas: HTMLCanvasElement,
positions: Map<string, NodePosition>,
nodes: GraphNode[],
edges: GraphEdge[],
selectedCenter: string | null,
setHoverState: (state: HoverState | ((prev: HoverState) => HoverState)) => void,
) {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
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 });
}
}
/** 鼠标离开 — 清除 hover 状态 */
export function handleCanvasMouseLeave(
setHoverState: (state: HoverState) => void,
) {
setHoverState({ nodeId: null, x: 0, y: 0 });
}
/** 鼠标点击 — 选择/取消中心节点 */
export function handleCanvasClick(
e: React.MouseEvent<HTMLCanvasElement>,
canvas: HTMLCanvasElement,
positions: Map<string, NodePosition>,
nodes: GraphNode[],
edges: GraphEdge[],
selectedCenter: string | null,
setSelectedCenter: (fn: (prev: string | null) => string | null) => void,
) {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
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;
}
}
}

View File

@@ -0,0 +1,47 @@
/**
* Graph 布局算法 — 圆形布局 + 度数计算
*/
import type { GraphNode, GraphEdge, NodePosition } from './graphTypes';
import { NODE_BASE_RADIUS, NODE_CENTER_RADIUS } from './graphConstants';
/** 计算节点的连接度 (degree) */
export function getNodeDegree(nodeId: string, edges: GraphEdge[]): number {
return edges.filter((e) => e.source === nodeId || e.target === nodeId).length;
}
/** 根据度数映射节点半径 — 连接越多越大 */
export 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;
}
/** 计算圆形布局位置 */
export function computeCircularLayout(
nodes: GraphNode[],
centerX: number,
centerY: number,
radius: number,
): Map<string, NodePosition> {
const positions = new Map<string, NodePosition>();
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;
}

View File

@@ -0,0 +1,358 @@
/**
* Graph Canvas 渲染 — 边/节点/标签绘制函数
*/
import type { NodePosition } from './graphTypes';
import { degreeToRadius } from './graphLayout';
import {
getEdgeColor,
DEFAULT_REL_COLOR,
NODE_HOVER_SCALE,
LABEL_MAX_LENGTH,
} from './graphConstants';
// ── Helper 函数 ──
export function getRelColor(label: string) {
return getEdgeColor(label) || DEFAULT_REL_COLOR;
}
export function getEdgeTypeLabel(label: string): string {
return label;
}
function truncateLabel(label: string): string {
return label.length > LABEL_MAX_LENGTH
? label.slice(0, LABEL_MAX_LENGTH) + '...'
: label;
}
// ── 渲染函数 ──
/** 绘制贝塞尔曲线边 + 箭头 */
export 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;
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();
// 箭头
const t = 0.95;
const tangentX = 2 * (1 - t) * (cpX - fromX) + 2 * t * (toX - cpX);
const tangentY = 2 * (1 - t) * (cpY - fromY) + 2 * t * (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;
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 { labelX: cpX, labelY: cpY };
}
/** 绘制渐变填充的节点圆 + 阴影 */
export 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;
if (isCenter || isHovered) {
ctx.shadowColor = glowColor;
ctx.shadowBlur = isCenter ? 20 : 14;
} else {
ctx.shadowColor = 'rgba(0,0,0,0.12)';
ctx.shadowBlur = 6;
}
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();
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.strokeStyle = isCenter ? color : lightColor;
ctx.lineWidth = isCenter ? 3 : 1.5;
if (isHovered) {
ctx.lineWidth += 1;
}
ctx.stroke();
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();
}
/** 绘制边标签 (背景药丸形状) */
export function drawEdgeLabel(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
label: string,
color: string,
_textColor: 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;
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();
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(display, x, y);
ctx.restore();
}
/** 绘制节点标签 */
export 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();
}
/** 绘制完整图谱 (edges + nodes + hover tooltip) */
export function drawFullGraph(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
positions: Map<string, NodePosition>,
nodes: { id: string; label: string; data: Record<string, unknown> }[],
edges: { source: string; target: string; label: string }[],
selectedCenter: string | null,
hoverState: { nodeId: string | null; x: number; y: number },
token: {
colorText: string;
colorBgContainer: string;
colorBgElevated: string;
colorBorderSecondary: string;
},
degreeMap: Map<string, number>,
) {
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;
// 绘制边 (在节点下方)
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, textColor, 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 = '#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 pick = getEdgeColor(`_node_${idx}`);
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(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();
}
}
}

View File

@@ -0,0 +1,37 @@
/**
* Graph 页面共享类型定义
*/
export interface GraphNode {
id: string;
label: string;
data: Record<string, unknown>;
}
export interface GraphEdge {
source: string;
target: string;
label: string;
}
export interface GraphConfig {
entity: string;
relationshipEntity: string;
sourceField: string;
targetField: string;
edgeLabelField: string;
nodeLabelField: string;
}
export interface NodePosition {
x: number;
y: number;
vx: number;
vy: number;
}
export interface HoverState {
nodeId: string | null;
x: number;
y: number;
}

View File

@@ -0,0 +1,42 @@
/**
* Graph 模块 barrel export
*/
export type {
GraphNode,
GraphEdge,
GraphConfig,
NodePosition,
HoverState,
} from './graphTypes';
export {
getEdgeColor,
DEFAULT_REL_COLOR,
NODE_BASE_RADIUS,
NODE_CENTER_RADIUS,
NODE_HOVER_SCALE,
LABEL_MAX_LENGTH,
} from './graphConstants';
export {
getNodeDegree,
degreeToRadius,
computeCircularLayout,
} from './graphLayout';
export {
getRelColor,
getEdgeTypeLabel,
drawCurvedEdge,
drawNode,
drawEdgeLabel,
drawNodeLabel,
drawFullGraph,
} from './graphRenderer';
export {
handleCanvasMouseMove,
handleCanvasMouseLeave,
handleCanvasClick,
} from './graphInteraction';