feat(kg): 实现 Phase 3.1 前端图谱浏览器

核心功能:
- G6 v5 力导向图,支持交互式缩放、平移、拖拽
- 5 种布局模式:force, circular, grid, radial, concentric
- 双击展开节点邻居到图中(增量探索)
- 全文搜索,类型过滤,结果高亮(变暗/高亮状态)
- 节点详情抽屉:实体属性、别名、置信度、关系列表(可导航)
- 关系详情抽屉:类型、源/目标、权重、置信度、属性
- 查询构建器:最短路径/全路径查询,可配置 maxDepth/maxPaths
- 基于 UUID 的图加载(输入或 URL 参数 ?graphId=...)
- 大图性能优化(200 节点阈值,超过时禁用动画)

新增文件(13 个):
- knowledge-graph.model.ts - TypeScript 接口,匹配 Java DTOs
- knowledge-graph.api.ts - API 服务,包含所有 KG REST 端点
- knowledge-graph.const.ts - 实体类型颜色、关系类型标签、中文显示名称
- graphTransform.ts - 后端数据 → G6 节点/边格式转换 + 合并工具
- graphConfig.ts - G6 v5 图配置(节点/边样式、行为、布局)
- hooks/useGraphData.ts - 数据钩子:加载子图、展开节点、搜索、合并
- hooks/useGraphLayout.ts - 布局钩子:5 种布局类型
- components/GraphCanvas.tsx - G6 v5 画布,力导向布局,缩放/平移/拖拽
- components/SearchPanel.tsx - 全文实体搜索,类型过滤
- components/NodeDetail.tsx - 实体详情抽屉
- components/RelationDetail.tsx - 关系详情抽屉
- components/QueryBuilder.tsx - 路径查询构建器
- Home/KnowledgeGraphPage.tsx - 主页面,整合所有组件

修改文件(5 个):
- package.json - 添加 @antv/g6 v5 依赖
- vite.config.ts - 添加 /knowledge-graph 代理规则
- auth/permissions.ts - 添加 knowledgeGraphRead/knowledgeGraphWrite
- pages/Layout/menu.tsx - 添加知识图谱菜单项(Network 图标)
- routes/routes.ts - 添加 /data/knowledge-graph 路由

新增文档(10 个):
- docs/knowledge-graph/ - 完整的知识图谱设计文档

Bug 修复(Codex 审查后修复):
- P1: 详情抽屉状态与选中状态不一致(显示旧数据)
- P1: 查询构建器未实现(最短路径/多路径查询)
- P2: 实体类型映射 Organization → Org(匹配后端)
- P2: getSubgraph depth 参数无效(改用正确端点)
- P2: AllPathsVO 字段名不一致(totalPaths → pathCount)
- P2: 搜索取消逻辑无效(传递 AbortController.signal)
- P2: 大图性能优化(动画降级)
- P3: 移除未使用的类型导入

构建验证:
- tsc --noEmit  clean
- eslint  0 errors/warnings
- vite build  successful
This commit is contained in:
2026-02-20 19:13:46 +08:00
parent 9b6ff59a11
commit afcb8783aa
29 changed files with 6472 additions and 37 deletions

View File

@@ -0,0 +1,182 @@
import { useEffect, useRef, useCallback, memo } from "react";
import { Graph } from "@antv/g6";
import { Spin } from "antd";
import type { G6GraphData } from "../graphTransform";
import { createGraphOptions, LARGE_GRAPH_THRESHOLD } from "../graphConfig";
import type { LayoutType } from "../hooks/useGraphLayout";
interface GraphCanvasProps {
data: G6GraphData;
loading?: boolean;
layoutType: LayoutType;
highlightedNodeIds?: Set<string>;
onNodeClick?: (nodeId: string) => void;
onEdgeClick?: (edgeId: string) => void;
onNodeDoubleClick?: (nodeId: string) => void;
onCanvasClick?: () => void;
}
function GraphCanvas({
data,
loading = false,
layoutType,
highlightedNodeIds,
onNodeClick,
onEdgeClick,
onNodeDoubleClick,
onCanvasClick,
}: GraphCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const graphRef = useRef<Graph | null>(null);
// Initialize graph
useEffect(() => {
if (!containerRef.current) return;
const options = createGraphOptions(containerRef.current);
const graph = new Graph(options);
graphRef.current = graph;
graph.render();
return () => {
graphRef.current = null;
graph.destroy();
};
}, []);
// Update data (with large-graph performance optimization)
useEffect(() => {
const graph = graphRef.current;
if (!graph) return;
const isLargeGraph = data.nodes.length >= LARGE_GRAPH_THRESHOLD;
if (isLargeGraph) {
graph.setOptions({ animation: false });
}
if (data.nodes.length === 0 && data.edges.length === 0) {
graph.setData({ nodes: [], edges: [] });
graph.render();
return;
}
graph.setData(data);
graph.render();
}, [data]);
// Update layout
useEffect(() => {
const graph = graphRef.current;
if (!graph) return;
const layoutConfigs: Record<string, Record<string, unknown>> = {
"d3-force": {
type: "d3-force",
preventOverlap: true,
link: { distance: 180 },
charge: { strength: -400 },
collide: { radius: 50 },
},
circular: { type: "circular", radius: 250 },
grid: { type: "grid" },
radial: { type: "radial", unitRadius: 120, preventOverlap: true, nodeSpacing: 30 },
concentric: { type: "concentric", preventOverlap: true, nodeSpacing: 30 },
};
graph.setLayout(layoutConfigs[layoutType] ?? layoutConfigs["d3-force"]);
graph.layout();
}, [layoutType]);
// Highlight nodes
useEffect(() => {
const graph = graphRef.current;
if (!graph || !highlightedNodeIds) return;
const allNodeIds = data.nodes.map((n) => n.id);
if (highlightedNodeIds.size === 0) {
// Clear all states
allNodeIds.forEach((id) => {
graph.setElementState(id, []);
});
data.edges.forEach((e) => {
graph.setElementState(e.id, []);
});
return;
}
allNodeIds.forEach((id) => {
if (highlightedNodeIds.has(id)) {
graph.setElementState(id, ["highlighted"]);
} else {
graph.setElementState(id, ["dimmed"]);
}
});
data.edges.forEach((e) => {
if (highlightedNodeIds.has(e.source) || highlightedNodeIds.has(e.target)) {
graph.setElementState(e.id, []);
} else {
graph.setElementState(e.id, ["dimmed"]);
}
});
}, [highlightedNodeIds, data]);
// Bind events
useEffect(() => {
const graph = graphRef.current;
if (!graph) return;
const handleNodeClick = (event: { target: { id: string } }) => {
onNodeClick?.(event.target.id);
};
const handleEdgeClick = (event: { target: { id: string } }) => {
onEdgeClick?.(event.target.id);
};
const handleNodeDblClick = (event: { target: { id: string } }) => {
onNodeDoubleClick?.(event.target.id);
};
const handleCanvasClick = () => {
onCanvasClick?.();
};
graph.on("node:click", handleNodeClick);
graph.on("edge:click", handleEdgeClick);
graph.on("node:dblclick", handleNodeDblClick);
graph.on("canvas:click", handleCanvasClick);
return () => {
graph.off("node:click", handleNodeClick);
graph.off("edge:click", handleEdgeClick);
graph.off("node:dblclick", handleNodeDblClick);
graph.off("canvas:click", handleCanvasClick);
};
}, [onNodeClick, onEdgeClick, onNodeDoubleClick, onCanvasClick]);
// Fit view helper
const handleFitView = useCallback(() => {
graphRef.current?.fitView();
}, []);
return (
<div className="relative w-full h-full">
<Spin spinning={loading} tip="加载中...">
<div ref={containerRef} className="w-full h-full min-h-[500px]" />
</Spin>
<div className="absolute bottom-4 right-4 flex gap-2">
<button
onClick={handleFitView}
className="px-3 py-1.5 bg-white border border-gray-300 rounded shadow-sm text-xs hover:bg-gray-50"
>
</button>
<button
onClick={() => graphRef.current?.zoomTo(1)}
className="px-3 py-1.5 bg-white border border-gray-300 rounded shadow-sm text-xs hover:bg-gray-50"
>
</button>
</div>
</div>
);
}
export default memo(GraphCanvas);

View File

@@ -0,0 +1,187 @@
import { useEffect, useState } from "react";
import { Drawer, Descriptions, Tag, List, Button, Spin, Empty, message } from "antd";
import { Expand } from "lucide-react";
import type { GraphEntity, RelationVO, PagedResponse } from "../knowledge-graph.model";
import {
ENTITY_TYPE_LABELS,
ENTITY_TYPE_COLORS,
DEFAULT_ENTITY_COLOR,
RELATION_TYPE_LABELS,
} from "../knowledge-graph.const";
import * as api from "../knowledge-graph.api";
interface NodeDetailProps {
graphId: string;
entityId: string | null;
open: boolean;
onClose: () => void;
onExpandNode: (entityId: string) => void;
onRelationClick: (relationId: string) => void;
onEntityNavigate: (entityId: string) => void;
}
export default function NodeDetail({
graphId,
entityId,
open,
onClose,
onExpandNode,
onRelationClick,
onEntityNavigate,
}: NodeDetailProps) {
const [entity, setEntity] = useState<GraphEntity | null>(null);
const [relations, setRelations] = useState<RelationVO[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!entityId || !graphId) {
setEntity(null);
setRelations([]);
return;
}
if (!open) return;
setLoading(true);
Promise.all([
api.getEntity(graphId, entityId),
api.listEntityRelations(graphId, entityId, { page: 0, size: 50 }),
])
.then(([entityData, relData]: [GraphEntity, PagedResponse<RelationVO>]) => {
setEntity(entityData);
setRelations(relData.content);
})
.catch(() => {
message.error("加载实体详情失败");
})
.finally(() => {
setLoading(false);
});
}, [graphId, entityId, open]);
return (
<Drawer
title={
<div className="flex items-center gap-2">
<span></span>
{entity && (
<Tag color={ENTITY_TYPE_COLORS[entity.type] ?? DEFAULT_ENTITY_COLOR}>
{ENTITY_TYPE_LABELS[entity.type] ?? entity.type}
</Tag>
)}
</div>
}
open={open}
onClose={onClose}
width={420}
extra={
entityId && (
<Button
type="primary"
size="small"
icon={<Expand className="w-3 h-3" />}
onClick={() => onExpandNode(entityId)}
>
</Button>
)
}
>
<Spin spinning={loading}>
{entity ? (
<div className="flex flex-col gap-4">
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="名称">{entity.name}</Descriptions.Item>
<Descriptions.Item label="类型">
{ENTITY_TYPE_LABELS[entity.type] ?? entity.type}
</Descriptions.Item>
{entity.description && (
<Descriptions.Item label="描述">{entity.description}</Descriptions.Item>
)}
{entity.aliases && entity.aliases.length > 0 && (
<Descriptions.Item label="别名">
{entity.aliases.map((a) => (
<Tag key={a}>{a}</Tag>
))}
</Descriptions.Item>
)}
{entity.confidence != null && (
<Descriptions.Item label="置信度">
{(entity.confidence * 100).toFixed(0)}%
</Descriptions.Item>
)}
{entity.sourceType && (
<Descriptions.Item label="来源">{entity.sourceType}</Descriptions.Item>
)}
{entity.createdAt && (
<Descriptions.Item label="创建时间">{entity.createdAt}</Descriptions.Item>
)}
</Descriptions>
{entity.properties && Object.keys(entity.properties).length > 0 && (
<>
<h4 className="font-medium text-sm"></h4>
<Descriptions column={1} size="small" bordered>
{Object.entries(entity.properties).map(([key, value]) => (
<Descriptions.Item key={key} label={key}>
{String(value)}
</Descriptions.Item>
))}
</Descriptions>
</>
)}
<h4 className="font-medium text-sm"> ({relations.length})</h4>
{relations.length > 0 ? (
<List
size="small"
dataSource={relations}
renderItem={(rel) => {
const isSource = rel.sourceEntityId === entityId;
const otherName = isSource ? rel.targetEntityName : rel.sourceEntityName;
const otherType = isSource ? rel.targetEntityType : rel.sourceEntityType;
const otherId = isSource ? rel.targetEntityId : rel.sourceEntityId;
const direction = isSource ? "→" : "←";
return (
<List.Item
className="cursor-pointer hover:bg-gray-50 !px-2"
onClick={() => onRelationClick(rel.id)}
>
<div className="flex items-center gap-1.5 w-full min-w-0 text-sm">
<span className="text-gray-400">{direction}</span>
<Tag
className="shrink-0"
color={ENTITY_TYPE_COLORS[otherType] ?? DEFAULT_ENTITY_COLOR}
>
{ENTITY_TYPE_LABELS[otherType] ?? otherType}
</Tag>
<Button
type="link"
size="small"
className="!p-0 truncate"
onClick={(e) => {
e.stopPropagation();
onEntityNavigate(otherId);
}}
>
{otherName}
</Button>
<span className="ml-auto text-xs text-gray-400 shrink-0">
{RELATION_TYPE_LABELS[rel.relationType] ?? rel.relationType}
</span>
</div>
</List.Item>
);
}}
/>
) : (
<Empty description="暂无关系" image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</div>
) : !loading ? (
<Empty description="选择一个节点查看详情" />
) : null}
</Spin>
</Drawer>
);
}

View File

@@ -0,0 +1,173 @@
import { useState, useCallback } from "react";
import { Input, Button, Select, InputNumber, List, Tag, Empty, message, Spin } from "antd";
import type { PathVO, AllPathsVO, EntitySummaryVO, EdgeSummaryVO } from "../knowledge-graph.model";
import {
ENTITY_TYPE_LABELS,
ENTITY_TYPE_COLORS,
DEFAULT_ENTITY_COLOR,
RELATION_TYPE_LABELS,
} from "../knowledge-graph.const";
import * as api from "../knowledge-graph.api";
type QueryType = "shortest-path" | "all-paths";
interface QueryBuilderProps {
graphId: string;
onPathResult: (nodes: EntitySummaryVO[], edges: EdgeSummaryVO[]) => void;
}
export default function QueryBuilder({ graphId, onPathResult }: QueryBuilderProps) {
const [queryType, setQueryType] = useState<QueryType>("shortest-path");
const [sourceId, setSourceId] = useState("");
const [targetId, setTargetId] = useState("");
const [maxDepth, setMaxDepth] = useState(5);
const [maxPaths, setMaxPaths] = useState(3);
const [loading, setLoading] = useState(false);
const [pathResults, setPathResults] = useState<PathVO[]>([]);
const handleQuery = useCallback(async () => {
if (!sourceId.trim() || !targetId.trim()) {
message.warning("请输入源实体和目标实体 ID");
return;
}
setLoading(true);
setPathResults([]);
try {
if (queryType === "shortest-path") {
const path: PathVO = await api.getShortestPath(graphId, {
sourceId: sourceId.trim(),
targetId: targetId.trim(),
maxDepth,
});
setPathResults([path]);
onPathResult(path.nodes, path.edges);
} else {
const result: AllPathsVO = await api.getAllPaths(graphId, {
sourceId: sourceId.trim(),
targetId: targetId.trim(),
maxDepth,
maxPaths,
});
setPathResults(result.paths);
if (result.paths.length > 0) {
const allNodes = result.paths.flatMap((p) => p.nodes);
const allEdges = result.paths.flatMap((p) => p.edges);
onPathResult(allNodes, allEdges);
}
}
} catch {
message.error("路径查询失败");
} finally {
setLoading(false);
}
}, [graphId, queryType, sourceId, targetId, maxDepth, maxPaths, onPathResult]);
const handleClear = useCallback(() => {
setPathResults([]);
setSourceId("");
setTargetId("");
onPathResult([], []);
}, [onPathResult]);
return (
<div className="flex flex-col gap-3">
<Select
value={queryType}
onChange={setQueryType}
className="w-full"
options={[
{ label: "最短路径", value: "shortest-path" },
{ label: "所有路径", value: "all-paths" },
]}
/>
<Input
placeholder="源实体 ID"
value={sourceId}
onChange={(e) => setSourceId(e.target.value)}
allowClear
/>
<Input
placeholder="目标实体 ID"
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
allowClear
/>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 shrink-0"></span>
<InputNumber
min={1}
max={10}
value={maxDepth}
onChange={(v) => setMaxDepth(v ?? 5)}
size="small"
className="flex-1"
/>
</div>
{queryType === "all-paths" && (
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 shrink-0"></span>
<InputNumber
min={1}
max={20}
value={maxPaths}
onChange={(v) => setMaxPaths(v ?? 3)}
size="small"
className="flex-1"
/>
</div>
)}
<div className="flex gap-2">
<Button type="primary" onClick={handleQuery} loading={loading} className="flex-1">
</Button>
<Button onClick={handleClear}></Button>
</div>
<Spin spinning={loading}>
{pathResults.length > 0 ? (
<List
size="small"
dataSource={pathResults}
renderItem={(path, index) => (
<List.Item className="!px-2">
<div className="flex flex-col gap-1 w-full">
<div className="text-xs font-medium text-gray-600">
{index + 1}{path.pathLength}
</div>
<div className="flex items-center gap-1 flex-wrap">
{path.nodes.map((node, ni) => (
<span key={node.id} className="flex items-center gap-1">
{ni > 0 && (
<span className="text-xs text-gray-400">
{path.edges[ni - 1]
? RELATION_TYPE_LABELS[path.edges[ni - 1].relationType] ??
path.edges[ni - 1].relationType
: "→"}
</span>
)}
<Tag
color={ENTITY_TYPE_COLORS[node.type] ?? DEFAULT_ENTITY_COLOR}
className="!m-0"
>
{ENTITY_TYPE_LABELS[node.type] ?? node.type}
</Tag>
<span className="text-xs">{node.name}</span>
</span>
))}
</div>
</div>
</List.Item>
)}
/>
) : !loading && sourceId && targetId ? (
<Empty description="暂无结果" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : null}
</Spin>
</div>
);
}

View File

@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import { Drawer, Descriptions, Tag, Spin, Empty, message } from "antd";
import type { RelationVO } from "../knowledge-graph.model";
import {
ENTITY_TYPE_LABELS,
ENTITY_TYPE_COLORS,
DEFAULT_ENTITY_COLOR,
RELATION_TYPE_LABELS,
} from "../knowledge-graph.const";
import * as api from "../knowledge-graph.api";
interface RelationDetailProps {
graphId: string;
relationId: string | null;
open: boolean;
onClose: () => void;
onEntityNavigate: (entityId: string) => void;
}
export default function RelationDetail({
graphId,
relationId,
open,
onClose,
onEntityNavigate,
}: RelationDetailProps) {
const [relation, setRelation] = useState<RelationVO | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!relationId || !graphId) {
setRelation(null);
return;
}
if (!open) return;
setLoading(true);
api
.getRelation(graphId, relationId)
.then((data) => setRelation(data))
.catch(() => message.error("加载关系详情失败"))
.finally(() => setLoading(false));
}, [graphId, relationId, open]);
return (
<Drawer title="关系详情" open={open} onClose={onClose} width={400}>
<Spin spinning={loading}>
{relation ? (
<div className="flex flex-col gap-4">
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="关系类型">
<Tag color="blue">
{RELATION_TYPE_LABELS[relation.relationType] ?? relation.relationType}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="源实体">
<div className="flex items-center gap-1.5">
<Tag
color={
ENTITY_TYPE_COLORS[relation.sourceEntityType] ?? DEFAULT_ENTITY_COLOR
}
>
{ENTITY_TYPE_LABELS[relation.sourceEntityType] ?? relation.sourceEntityType}
</Tag>
<a
className="text-blue-500 cursor-pointer hover:underline"
onClick={() => onEntityNavigate(relation.sourceEntityId)}
>
{relation.sourceEntityName}
</a>
</div>
</Descriptions.Item>
<Descriptions.Item label="目标实体">
<div className="flex items-center gap-1.5">
<Tag
color={
ENTITY_TYPE_COLORS[relation.targetEntityType] ?? DEFAULT_ENTITY_COLOR
}
>
{ENTITY_TYPE_LABELS[relation.targetEntityType] ?? relation.targetEntityType}
</Tag>
<a
className="text-blue-500 cursor-pointer hover:underline"
onClick={() => onEntityNavigate(relation.targetEntityId)}
>
{relation.targetEntityName}
</a>
</div>
</Descriptions.Item>
{relation.weight != null && (
<Descriptions.Item label="权重">{relation.weight}</Descriptions.Item>
)}
{relation.confidence != null && (
<Descriptions.Item label="置信度">
{(relation.confidence * 100).toFixed(0)}%
</Descriptions.Item>
)}
{relation.createdAt && (
<Descriptions.Item label="创建时间">{relation.createdAt}</Descriptions.Item>
)}
</Descriptions>
{relation.properties && Object.keys(relation.properties).length > 0 && (
<>
<h4 className="font-medium text-sm"></h4>
<Descriptions column={1} size="small" bordered>
{Object.entries(relation.properties).map(([key, value]) => (
<Descriptions.Item key={key} label={key}>
{String(value)}
</Descriptions.Item>
))}
</Descriptions>
</>
)}
</div>
) : !loading ? (
<Empty description="选择一条边查看详情" />
) : null}
</Spin>
</Drawer>
);
}

View File

@@ -0,0 +1,102 @@
import { useState, useCallback } from "react";
import { Input, List, Tag, Select, Empty } from "antd";
import { Search } from "lucide-react";
import type { SearchHitVO } from "../knowledge-graph.model";
import {
ENTITY_TYPES,
ENTITY_TYPE_LABELS,
ENTITY_TYPE_COLORS,
DEFAULT_ENTITY_COLOR,
} from "../knowledge-graph.const";
interface SearchPanelProps {
graphId: string;
results: SearchHitVO[];
loading: boolean;
onSearch: (graphId: string, query: string) => void;
onResultClick: (entityId: string) => void;
onClear: () => void;
}
export default function SearchPanel({
graphId,
results,
loading,
onSearch,
onResultClick,
onClear,
}: SearchPanelProps) {
const [query, setQuery] = useState("");
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
const handleSearch = useCallback(
(value: string) => {
setQuery(value);
if (!value.trim()) {
onClear();
return;
}
onSearch(graphId, value);
},
[graphId, onSearch, onClear]
);
const filteredResults = typeFilter
? results.filter((r) => r.type === typeFilter)
: results;
return (
<div className="flex flex-col gap-3">
<Input.Search
placeholder="搜索实体名称..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onSearch={handleSearch}
allowClear
onClear={() => {
setQuery("");
onClear();
}}
prefix={<Search className="w-4 h-4 text-gray-400" />}
loading={loading}
/>
<Select
allowClear
placeholder="按类型筛选"
value={typeFilter}
onChange={setTypeFilter}
className="w-full"
options={ENTITY_TYPES.map((t) => ({
label: ENTITY_TYPE_LABELS[t] ?? t,
value: t,
}))}
/>
{filteredResults.length > 0 ? (
<List
size="small"
dataSource={filteredResults}
renderItem={(item) => (
<List.Item
className="cursor-pointer hover:bg-gray-50 !px-2"
onClick={() => onResultClick(item.id)}
>
<div className="flex items-center gap-2 w-full min-w-0">
<Tag color={ENTITY_TYPE_COLORS[item.type] ?? DEFAULT_ENTITY_COLOR}>
{ENTITY_TYPE_LABELS[item.type] ?? item.type}
</Tag>
<span className="truncate font-medium text-sm">{item.name}</span>
<span className="ml-auto text-xs text-gray-400 shrink-0">
{item.score.toFixed(2)}
</span>
</div>
</List.Item>
)}
/>
) : query && !loading ? (
<Empty description="未找到匹配实体" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : null}
</div>
);
}