You've already forked DataMate
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:
1444
frontend/package-lock.json
generated
1444
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,8 @@
|
||||
"react-dom": "^18.1.1",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.8.0",
|
||||
"recharts": "2.15.0"
|
||||
"recharts": "2.15.0",
|
||||
"@antv/g6": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
|
||||
@@ -22,6 +22,8 @@ export const PermissionCodes = {
|
||||
taskCoordinationAssign: "module:task-coordination:assign",
|
||||
contentGenerationUse: "module:content-generation:use",
|
||||
agentUse: "module:agent:use",
|
||||
knowledgeGraphRead: "module:knowledge-graph:read",
|
||||
knowledgeGraphWrite: "module:knowledge-graph:write",
|
||||
userManage: "system:user:manage",
|
||||
roleManage: "system:role:manage",
|
||||
permissionManage: "system:permission:manage",
|
||||
@@ -39,6 +41,7 @@ const routePermissionRules: Array<{ prefix: string; permission: string }> = [
|
||||
{ prefix: "/data/orchestration", permission: PermissionCodes.orchestrationRead },
|
||||
{ prefix: "/data/task-coordination", permission: PermissionCodes.taskCoordinationRead },
|
||||
{ prefix: "/data/content-generation", permission: PermissionCodes.contentGenerationUse },
|
||||
{ prefix: "/data/knowledge-graph", permission: PermissionCodes.knowledgeGraphRead },
|
||||
{ prefix: "/chat", permission: PermissionCodes.agentUse },
|
||||
];
|
||||
|
||||
|
||||
274
frontend/src/pages/KnowledgeGraph/Home/KnowledgeGraphPage.tsx
Normal file
274
frontend/src/pages/KnowledgeGraph/Home/KnowledgeGraphPage.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Card, Input, Select, Button, Tag, Space, Empty, Tabs, message } from "antd";
|
||||
import { Network, RotateCcw } from "lucide-react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import GraphCanvas from "../components/GraphCanvas";
|
||||
import SearchPanel from "../components/SearchPanel";
|
||||
import QueryBuilder from "../components/QueryBuilder";
|
||||
import NodeDetail from "../components/NodeDetail";
|
||||
import RelationDetail from "../components/RelationDetail";
|
||||
import useGraphData from "../hooks/useGraphData";
|
||||
import useGraphLayout, { LAYOUT_OPTIONS } from "../hooks/useGraphLayout";
|
||||
import {
|
||||
ENTITY_TYPE_COLORS,
|
||||
DEFAULT_ENTITY_COLOR,
|
||||
ENTITY_TYPE_LABELS,
|
||||
} from "../knowledge-graph.const";
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export default function KnowledgeGraphPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [graphId, setGraphId] = useState(() => params.get("graphId") ?? "");
|
||||
const [graphIdInput, setGraphIdInput] = useState(() => params.get("graphId") ?? "");
|
||||
|
||||
const {
|
||||
graphData,
|
||||
loading,
|
||||
searchResults,
|
||||
searchLoading,
|
||||
highlightedNodeIds,
|
||||
loadInitialData,
|
||||
expandNode,
|
||||
searchEntities,
|
||||
mergePathData,
|
||||
clearGraph,
|
||||
clearSearch,
|
||||
} = useGraphData();
|
||||
|
||||
const { layoutType, setLayoutType } = useGraphLayout();
|
||||
|
||||
// Detail panel state
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
const [nodeDetailOpen, setNodeDetailOpen] = useState(false);
|
||||
const [relationDetailOpen, setRelationDetailOpen] = useState(false);
|
||||
|
||||
// Load graph when graphId changes
|
||||
useEffect(() => {
|
||||
if (graphId && UUID_REGEX.test(graphId)) {
|
||||
clearGraph();
|
||||
loadInitialData(graphId);
|
||||
}
|
||||
}, [graphId, loadInitialData, clearGraph]);
|
||||
|
||||
const handleLoadGraph = useCallback(() => {
|
||||
if (!UUID_REGEX.test(graphIdInput)) {
|
||||
message.warning("请输入有效的图谱 ID(UUID 格式)");
|
||||
return;
|
||||
}
|
||||
setGraphId(graphIdInput);
|
||||
setParams({ graphId: graphIdInput });
|
||||
}, [graphIdInput, setParams]);
|
||||
|
||||
const handleNodeClick = useCallback((nodeId: string) => {
|
||||
setSelectedNodeId(nodeId);
|
||||
setSelectedEdgeId(null);
|
||||
setNodeDetailOpen(true);
|
||||
setRelationDetailOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleEdgeClick = useCallback((edgeId: string) => {
|
||||
setSelectedEdgeId(edgeId);
|
||||
setSelectedNodeId(null);
|
||||
setRelationDetailOpen(true);
|
||||
setNodeDetailOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleNodeDoubleClick = useCallback(
|
||||
(nodeId: string) => {
|
||||
if (!graphId) return;
|
||||
expandNode(graphId, nodeId);
|
||||
},
|
||||
[graphId, expandNode]
|
||||
);
|
||||
|
||||
const handleCanvasClick = useCallback(() => {
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
setNodeDetailOpen(false);
|
||||
setRelationDetailOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleExpandNode = useCallback(
|
||||
(entityId: string) => {
|
||||
if (!graphId) return;
|
||||
expandNode(graphId, entityId);
|
||||
},
|
||||
[graphId, expandNode]
|
||||
);
|
||||
|
||||
const handleEntityNavigate = useCallback(
|
||||
(entityId: string) => {
|
||||
setSelectedNodeId(entityId);
|
||||
setNodeDetailOpen(true);
|
||||
setRelationDetailOpen(false);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchResultClick = useCallback(
|
||||
(entityId: string) => {
|
||||
handleNodeClick(entityId);
|
||||
// If the entity is not in the current graph, expand it
|
||||
if (!graphData.nodes.find((n) => n.id === entityId) && graphId) {
|
||||
expandNode(graphId, entityId);
|
||||
}
|
||||
},
|
||||
[handleNodeClick, graphData.nodes, graphId, expandNode]
|
||||
);
|
||||
|
||||
const handleRelationClick = useCallback((relationId: string) => {
|
||||
setSelectedEdgeId(relationId);
|
||||
setRelationDetailOpen(true);
|
||||
setNodeDetailOpen(false);
|
||||
}, []);
|
||||
|
||||
const hasGraph = graphId && UUID_REGEX.test(graphId);
|
||||
const nodeCount = graphData.nodes.length;
|
||||
const edgeCount = graphData.edges.length;
|
||||
|
||||
// Collect unique entity types in current graph for legend
|
||||
const entityTypes = [...new Set(graphData.nodes.map((n) => n.data.type))].sort();
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
<Network className="w-5 h-5" />
|
||||
知识图谱浏览器
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Graph ID Input + Controls */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Space.Compact className="w-[420px]">
|
||||
<Input
|
||||
placeholder="输入图谱 ID (UUID)..."
|
||||
value={graphIdInput}
|
||||
onChange={(e) => setGraphIdInput(e.target.value)}
|
||||
onPressEnter={handleLoadGraph}
|
||||
allowClear
|
||||
/>
|
||||
<Button type="primary" onClick={handleLoadGraph}>
|
||||
加载
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
<Select
|
||||
value={layoutType}
|
||||
onChange={setLayoutType}
|
||||
options={LAYOUT_OPTIONS}
|
||||
className="w-28"
|
||||
/>
|
||||
|
||||
{hasGraph && (
|
||||
<>
|
||||
<Button
|
||||
icon={<RotateCcw className="w-3.5 h-3.5" />}
|
||||
onClick={() => loadInitialData(graphId)}
|
||||
>
|
||||
重新加载
|
||||
</Button>
|
||||
<span className="text-sm text-gray-500">
|
||||
节点: {nodeCount} | 边: {edgeCount}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
{entityTypes.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-gray-500">图例:</span>
|
||||
{entityTypes.map((type) => (
|
||||
<Tag key={type} color={ENTITY_TYPE_COLORS[type] ?? DEFAULT_ENTITY_COLOR}>
|
||||
{ENTITY_TYPE_LABELS[type] ?? type}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex gap-4 min-h-0">
|
||||
{/* Sidebar with tabs */}
|
||||
{hasGraph && (
|
||||
<Card className="w-72 shrink-0 overflow-auto" size="small" bodyStyle={{ padding: 0 }}>
|
||||
<Tabs
|
||||
size="small"
|
||||
className="px-3"
|
||||
items={[
|
||||
{
|
||||
key: "search",
|
||||
label: "搜索",
|
||||
children: (
|
||||
<SearchPanel
|
||||
graphId={graphId}
|
||||
results={searchResults}
|
||||
loading={searchLoading}
|
||||
onSearch={searchEntities}
|
||||
onResultClick={handleSearchResultClick}
|
||||
onClear={clearSearch}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "query",
|
||||
label: "路径查询",
|
||||
children: (
|
||||
<QueryBuilder
|
||||
graphId={graphId}
|
||||
onPathResult={mergePathData}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Canvas */}
|
||||
<Card className="flex-1 min-w-0" bodyStyle={{ height: "100%", padding: 0 }}>
|
||||
{hasGraph ? (
|
||||
<GraphCanvas
|
||||
data={graphData}
|
||||
loading={loading}
|
||||
layoutType={layoutType}
|
||||
highlightedNodeIds={highlightedNodeIds}
|
||||
onNodeClick={handleNodeClick}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onCanvasClick={handleCanvasClick}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Empty
|
||||
description="请输入图谱 ID 加载知识图谱"
|
||||
image={<Network className="w-16 h-16 text-gray-300 mx-auto" />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Detail drawers */}
|
||||
<NodeDetail
|
||||
graphId={graphId}
|
||||
entityId={selectedNodeId}
|
||||
open={nodeDetailOpen}
|
||||
onClose={() => setNodeDetailOpen(false)}
|
||||
onExpandNode={handleExpandNode}
|
||||
onRelationClick={handleRelationClick}
|
||||
onEntityNavigate={handleEntityNavigate}
|
||||
/>
|
||||
<RelationDetail
|
||||
graphId={graphId}
|
||||
relationId={selectedEdgeId}
|
||||
open={relationDetailOpen}
|
||||
onClose={() => setRelationDetailOpen(false)}
|
||||
onEntityNavigate={handleEntityNavigate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
frontend/src/pages/KnowledgeGraph/components/GraphCanvas.tsx
Normal file
182
frontend/src/pages/KnowledgeGraph/components/GraphCanvas.tsx
Normal 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);
|
||||
187
frontend/src/pages/KnowledgeGraph/components/NodeDetail.tsx
Normal file
187
frontend/src/pages/KnowledgeGraph/components/NodeDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
frontend/src/pages/KnowledgeGraph/components/QueryBuilder.tsx
Normal file
173
frontend/src/pages/KnowledgeGraph/components/QueryBuilder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
frontend/src/pages/KnowledgeGraph/components/RelationDetail.tsx
Normal file
122
frontend/src/pages/KnowledgeGraph/components/RelationDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
frontend/src/pages/KnowledgeGraph/components/SearchPanel.tsx
Normal file
102
frontend/src/pages/KnowledgeGraph/components/SearchPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
frontend/src/pages/KnowledgeGraph/graphConfig.ts
Normal file
106
frontend/src/pages/KnowledgeGraph/graphConfig.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { ENTITY_TYPE_COLORS, DEFAULT_ENTITY_COLOR } from "./knowledge-graph.const";
|
||||
|
||||
/** Node count threshold above which performance optimizations kick in. */
|
||||
export const LARGE_GRAPH_THRESHOLD = 200;
|
||||
|
||||
/** Create the G6 v5 graph options. */
|
||||
export function createGraphOptions(container: HTMLElement) {
|
||||
return {
|
||||
container,
|
||||
autoFit: "view" as const,
|
||||
padding: 40,
|
||||
animation: true,
|
||||
layout: {
|
||||
type: "d3-force" as const,
|
||||
preventOverlap: true,
|
||||
link: {
|
||||
distance: 180,
|
||||
},
|
||||
charge: {
|
||||
strength: -400,
|
||||
},
|
||||
collide: {
|
||||
radius: 50,
|
||||
},
|
||||
},
|
||||
node: {
|
||||
type: "circle" as const,
|
||||
style: {
|
||||
size: (d: { data?: { type?: string } }) => {
|
||||
return d?.data?.type === "Dataset" ? 40 : 32;
|
||||
},
|
||||
fill: (d: { data?: { type?: string } }) => {
|
||||
const type = d?.data?.type ?? "";
|
||||
return ENTITY_TYPE_COLORS[type] ?? DEFAULT_ENTITY_COLOR;
|
||||
},
|
||||
stroke: "#fff",
|
||||
lineWidth: 2,
|
||||
labelText: (d: { data?: { label?: string } }) => d?.data?.label ?? "",
|
||||
labelFontSize: 11,
|
||||
labelFill: "#333",
|
||||
labelPlacement: "bottom" as const,
|
||||
labelOffsetY: 4,
|
||||
labelMaxWidth: 100,
|
||||
labelWordWrap: true,
|
||||
labelWordWrapWidth: 100,
|
||||
cursor: "pointer",
|
||||
},
|
||||
state: {
|
||||
selected: {
|
||||
stroke: "#1677ff",
|
||||
lineWidth: 3,
|
||||
shadowColor: "rgba(22, 119, 255, 0.4)",
|
||||
shadowBlur: 10,
|
||||
labelVisibility: "visible" as const,
|
||||
},
|
||||
highlighted: {
|
||||
stroke: "#faad14",
|
||||
lineWidth: 3,
|
||||
labelVisibility: "visible" as const,
|
||||
},
|
||||
dimmed: {
|
||||
opacity: 0.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
edge: {
|
||||
type: "line" as const,
|
||||
style: {
|
||||
stroke: "#C2C8D5",
|
||||
lineWidth: 1,
|
||||
endArrow: true,
|
||||
endArrowSize: 6,
|
||||
labelText: (d: { data?: { label?: string } }) => d?.data?.label ?? "",
|
||||
labelFontSize: 10,
|
||||
labelFill: "#999",
|
||||
labelBackground: true,
|
||||
labelBackgroundFill: "#fff",
|
||||
labelBackgroundOpacity: 0.85,
|
||||
labelPadding: [2, 4],
|
||||
cursor: "pointer",
|
||||
},
|
||||
state: {
|
||||
selected: {
|
||||
stroke: "#1677ff",
|
||||
lineWidth: 2,
|
||||
},
|
||||
highlighted: {
|
||||
stroke: "#faad14",
|
||||
lineWidth: 2,
|
||||
},
|
||||
dimmed: {
|
||||
opacity: 0.15,
|
||||
},
|
||||
},
|
||||
},
|
||||
behaviors: [
|
||||
"drag-canvas",
|
||||
"zoom-canvas",
|
||||
"drag-element",
|
||||
{
|
||||
type: "click-select" as const,
|
||||
multiple: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
77
frontend/src/pages/KnowledgeGraph/graphTransform.ts
Normal file
77
frontend/src/pages/KnowledgeGraph/graphTransform.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { EntitySummaryVO, EdgeSummaryVO, SubgraphVO } from "./knowledge-graph.model";
|
||||
import { ENTITY_TYPE_COLORS, DEFAULT_ENTITY_COLOR, RELATION_TYPE_LABELS } from "./knowledge-graph.const";
|
||||
|
||||
export interface G6NodeData {
|
||||
id: string;
|
||||
data: {
|
||||
label: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
};
|
||||
style?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface G6EdgeData {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
data: {
|
||||
label: string;
|
||||
relationType: string;
|
||||
weight?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface G6GraphData {
|
||||
nodes: G6NodeData[];
|
||||
edges: G6EdgeData[];
|
||||
}
|
||||
|
||||
export function entityToG6Node(entity: EntitySummaryVO): G6NodeData {
|
||||
return {
|
||||
id: entity.id,
|
||||
data: {
|
||||
label: entity.name,
|
||||
type: entity.type,
|
||||
description: entity.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function edgeToG6Edge(edge: EdgeSummaryVO): G6EdgeData {
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.sourceEntityId,
|
||||
target: edge.targetEntityId,
|
||||
data: {
|
||||
label: RELATION_TYPE_LABELS[edge.relationType] ?? edge.relationType,
|
||||
relationType: edge.relationType,
|
||||
weight: edge.weight,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function subgraphToG6Data(subgraph: SubgraphVO): G6GraphData {
|
||||
return {
|
||||
nodes: subgraph.nodes.map(entityToG6Node),
|
||||
edges: subgraph.edges.map(edgeToG6Edge),
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge new subgraph data into existing graph data, avoiding duplicates. */
|
||||
export function mergeG6Data(existing: G6GraphData, incoming: G6GraphData): G6GraphData {
|
||||
const nodeIds = new Set(existing.nodes.map((n) => n.id));
|
||||
const edgeIds = new Set(existing.edges.map((e) => e.id));
|
||||
|
||||
const newNodes = incoming.nodes.filter((n) => !nodeIds.has(n.id));
|
||||
const newEdges = incoming.edges.filter((e) => !edgeIds.has(e.id));
|
||||
|
||||
return {
|
||||
nodes: [...existing.nodes, ...newNodes],
|
||||
edges: [...existing.edges, ...newEdges],
|
||||
};
|
||||
}
|
||||
|
||||
export function getEntityColor(type: string): string {
|
||||
return ENTITY_TYPE_COLORS[type] ?? DEFAULT_ENTITY_COLOR;
|
||||
}
|
||||
141
frontend/src/pages/KnowledgeGraph/hooks/useGraphData.ts
Normal file
141
frontend/src/pages/KnowledgeGraph/hooks/useGraphData.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { message } from "antd";
|
||||
import type { SubgraphVO, SearchHitVO, EntitySummaryVO, EdgeSummaryVO } from "../knowledge-graph.model";
|
||||
import type { G6GraphData } from "../graphTransform";
|
||||
import { subgraphToG6Data, mergeG6Data } from "../graphTransform";
|
||||
import * as api from "../knowledge-graph.api";
|
||||
|
||||
export interface UseGraphDataReturn {
|
||||
graphData: G6GraphData;
|
||||
loading: boolean;
|
||||
searchResults: SearchHitVO[];
|
||||
searchLoading: boolean;
|
||||
highlightedNodeIds: Set<string>;
|
||||
loadSubgraph: (graphId: string, entityIds: string[], depth?: number) => Promise<void>;
|
||||
expandNode: (graphId: string, entityId: string, depth?: number) => Promise<void>;
|
||||
searchEntities: (graphId: string, query: string) => Promise<void>;
|
||||
loadInitialData: (graphId: string) => Promise<void>;
|
||||
mergePathData: (nodes: EntitySummaryVO[], edges: EdgeSummaryVO[]) => void;
|
||||
clearGraph: () => void;
|
||||
clearSearch: () => void;
|
||||
}
|
||||
|
||||
export default function useGraphData(): UseGraphDataReturn {
|
||||
const [graphData, setGraphData] = useState<G6GraphData>({ nodes: [], edges: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchHitVO[]>([]);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [highlightedNodeIds, setHighlightedNodeIds] = useState<Set<string>>(new Set());
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const loadInitialData = useCallback(async (graphId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const entities = await api.listEntitiesPaged(graphId, { page: 0, size: 100 });
|
||||
const entityIds = entities.content.map((e) => e.id);
|
||||
if (entityIds.length === 0) {
|
||||
setGraphData({ nodes: [], edges: [] });
|
||||
return;
|
||||
}
|
||||
const subgraph: SubgraphVO = await api.getSubgraph(graphId, { entityIds }, { depth: 1 });
|
||||
setGraphData(subgraphToG6Data(subgraph));
|
||||
} catch {
|
||||
message.error("加载图谱数据失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSubgraph = useCallback(async (graphId: string, entityIds: string[], depth = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const subgraph = await api.getSubgraph(graphId, { entityIds }, { depth });
|
||||
setGraphData(subgraphToG6Data(subgraph));
|
||||
} catch {
|
||||
message.error("加载子图失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const expandNode = useCallback(
|
||||
async (graphId: string, entityId: string, depth = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const subgraph = await api.getNeighborSubgraph(graphId, entityId, { depth, limit: 50 });
|
||||
const incoming = subgraphToG6Data(subgraph);
|
||||
setGraphData((prev) => mergeG6Data(prev, incoming));
|
||||
} catch {
|
||||
message.error("展开节点失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const searchEntitiesFn = useCallback(async (graphId: string, query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
setHighlightedNodeIds(new Set());
|
||||
return;
|
||||
}
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
const result = await api.searchEntities(graphId, { q: query, size: 20 }, { signal: controller.signal });
|
||||
setSearchResults(result.content);
|
||||
setHighlightedNodeIds(new Set(result.content.map((h) => h.id)));
|
||||
} catch {
|
||||
// ignore abort errors
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearGraph = useCallback(() => {
|
||||
setGraphData({ nodes: [], edges: [] });
|
||||
setSearchResults([]);
|
||||
setHighlightedNodeIds(new Set());
|
||||
}, []);
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
setSearchResults([]);
|
||||
setHighlightedNodeIds(new Set());
|
||||
}, []);
|
||||
|
||||
const mergePathData = useCallback(
|
||||
(nodes: EntitySummaryVO[], edges: EdgeSummaryVO[]) => {
|
||||
if (nodes.length === 0) {
|
||||
setHighlightedNodeIds(new Set());
|
||||
return;
|
||||
}
|
||||
const pathData = subgraphToG6Data({
|
||||
nodes,
|
||||
edges,
|
||||
nodeCount: nodes.length,
|
||||
edgeCount: edges.length,
|
||||
});
|
||||
setGraphData((prev) => mergeG6Data(prev, pathData));
|
||||
setHighlightedNodeIds(new Set(nodes.map((n) => n.id)));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
graphData,
|
||||
loading,
|
||||
searchResults,
|
||||
searchLoading,
|
||||
highlightedNodeIds,
|
||||
loadSubgraph,
|
||||
expandNode,
|
||||
searchEntities: searchEntitiesFn,
|
||||
loadInitialData,
|
||||
mergePathData,
|
||||
clearGraph,
|
||||
clearSearch,
|
||||
};
|
||||
}
|
||||
61
frontend/src/pages/KnowledgeGraph/hooks/useGraphLayout.ts
Normal file
61
frontend/src/pages/KnowledgeGraph/hooks/useGraphLayout.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
export type LayoutType = "d3-force" | "circular" | "grid" | "radial" | "concentric";
|
||||
|
||||
interface LayoutConfig {
|
||||
type: LayoutType;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const LAYOUT_CONFIGS: Record<LayoutType, LayoutConfig> = {
|
||||
"d3-force": {
|
||||
type: "d3-force",
|
||||
preventOverlap: true,
|
||||
link: { distance: 180 },
|
||||
charge: { strength: -400 },
|
||||
collide: { radius: 50 },
|
||||
},
|
||||
circular: {
|
||||
type: "circular",
|
||||
radius: 250,
|
||||
},
|
||||
grid: {
|
||||
type: "grid",
|
||||
rows: undefined,
|
||||
cols: undefined,
|
||||
sortBy: "type",
|
||||
},
|
||||
radial: {
|
||||
type: "radial",
|
||||
unitRadius: 120,
|
||||
preventOverlap: true,
|
||||
nodeSpacing: 30,
|
||||
},
|
||||
concentric: {
|
||||
type: "concentric",
|
||||
preventOverlap: true,
|
||||
nodeSpacing: 30,
|
||||
},
|
||||
};
|
||||
|
||||
export const LAYOUT_OPTIONS: { label: string; value: LayoutType }[] = [
|
||||
{ label: "力导向", value: "d3-force" },
|
||||
{ label: "环形", value: "circular" },
|
||||
{ label: "网格", value: "grid" },
|
||||
{ label: "径向", value: "radial" },
|
||||
{ label: "同心圆", value: "concentric" },
|
||||
];
|
||||
|
||||
export default function useGraphLayout() {
|
||||
const [layoutType, setLayoutType] = useState<LayoutType>("d3-force");
|
||||
|
||||
const getLayoutConfig = useCallback((): LayoutConfig => {
|
||||
return LAYOUT_CONFIGS[layoutType] ?? LAYOUT_CONFIGS["d3-force"];
|
||||
}, [layoutType]);
|
||||
|
||||
return {
|
||||
layoutType,
|
||||
setLayoutType,
|
||||
getLayoutConfig,
|
||||
};
|
||||
}
|
||||
148
frontend/src/pages/KnowledgeGraph/knowledge-graph.api.ts
Normal file
148
frontend/src/pages/KnowledgeGraph/knowledge-graph.api.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { get, post, del, put } from "@/utils/request";
|
||||
import type {
|
||||
GraphEntity,
|
||||
SubgraphVO,
|
||||
RelationVO,
|
||||
SearchHitVO,
|
||||
PagedResponse,
|
||||
PathVO,
|
||||
AllPathsVO,
|
||||
} from "./knowledge-graph.model";
|
||||
|
||||
const BASE = "/knowledge-graph";
|
||||
|
||||
// ---- Entity ----
|
||||
|
||||
export function getEntity(graphId: string, entityId: string): Promise<GraphEntity> {
|
||||
return get(`${BASE}/${graphId}/entities/${entityId}`);
|
||||
}
|
||||
|
||||
export function listEntities(
|
||||
graphId: string,
|
||||
params?: { type?: string; keyword?: string }
|
||||
): Promise<GraphEntity[]> {
|
||||
return get(`${BASE}/${graphId}/entities`, params ?? null);
|
||||
}
|
||||
|
||||
export function listEntitiesPaged(
|
||||
graphId: string,
|
||||
params: { type?: string; keyword?: string; page?: number; size?: number }
|
||||
): Promise<PagedResponse<GraphEntity>> {
|
||||
return get(`${BASE}/${graphId}/entities`, params);
|
||||
}
|
||||
|
||||
export function createEntity(
|
||||
graphId: string,
|
||||
data: { name: string; type: string; description?: string; properties?: Record<string, unknown> }
|
||||
): Promise<GraphEntity> {
|
||||
return post(`${BASE}/${graphId}/entities`, data);
|
||||
}
|
||||
|
||||
export function updateEntity(
|
||||
graphId: string,
|
||||
entityId: string,
|
||||
data: { name?: string; type?: string; description?: string; properties?: Record<string, unknown> }
|
||||
): Promise<GraphEntity> {
|
||||
return put(`${BASE}/${graphId}/entities/${entityId}`, data);
|
||||
}
|
||||
|
||||
export function deleteEntity(graphId: string, entityId: string): Promise<void> {
|
||||
return del(`${BASE}/${graphId}/entities/${entityId}`);
|
||||
}
|
||||
|
||||
// ---- Relation ----
|
||||
|
||||
export function getRelation(graphId: string, relationId: string): Promise<RelationVO> {
|
||||
return get(`${BASE}/${graphId}/relations/${relationId}`);
|
||||
}
|
||||
|
||||
export function listRelations(
|
||||
graphId: string,
|
||||
params?: { type?: string; page?: number; size?: number }
|
||||
): Promise<PagedResponse<RelationVO>> {
|
||||
return get(`${BASE}/${graphId}/relations`, params ?? null);
|
||||
}
|
||||
|
||||
export function createRelation(
|
||||
graphId: string,
|
||||
data: {
|
||||
sourceEntityId: string;
|
||||
targetEntityId: string;
|
||||
relationType: string;
|
||||
properties?: Record<string, unknown>;
|
||||
weight?: number;
|
||||
confidence?: number;
|
||||
}
|
||||
): Promise<RelationVO> {
|
||||
return post(`${BASE}/${graphId}/relations`, data);
|
||||
}
|
||||
|
||||
export function updateRelation(
|
||||
graphId: string,
|
||||
relationId: string,
|
||||
data: { relationType?: string; properties?: Record<string, unknown>; weight?: number; confidence?: number }
|
||||
): Promise<RelationVO> {
|
||||
return put(`${BASE}/${graphId}/relations/${relationId}`, data);
|
||||
}
|
||||
|
||||
export function deleteRelation(graphId: string, relationId: string): Promise<void> {
|
||||
return del(`${BASE}/${graphId}/relations/${relationId}`);
|
||||
}
|
||||
|
||||
export function listEntityRelations(
|
||||
graphId: string,
|
||||
entityId: string,
|
||||
params?: { direction?: string; type?: string; page?: number; size?: number }
|
||||
): Promise<PagedResponse<RelationVO>> {
|
||||
return get(`${BASE}/${graphId}/entities/${entityId}/relations`, params ?? null);
|
||||
}
|
||||
|
||||
// ---- Query ----
|
||||
|
||||
export function getNeighborSubgraph(
|
||||
graphId: string,
|
||||
entityId: string,
|
||||
params?: { depth?: number; limit?: number }
|
||||
): Promise<SubgraphVO> {
|
||||
return get(`${BASE}/${graphId}/query/neighbors/${entityId}`, params ?? null);
|
||||
}
|
||||
|
||||
export function getSubgraph(
|
||||
graphId: string,
|
||||
data: { entityIds: string[] },
|
||||
params?: { depth?: number }
|
||||
): Promise<SubgraphVO> {
|
||||
return post(`${BASE}/${graphId}/query/subgraph/export?depth=${params?.depth ?? 1}`, data);
|
||||
}
|
||||
|
||||
export function getShortestPath(
|
||||
graphId: string,
|
||||
params: { sourceId: string; targetId: string; maxDepth?: number }
|
||||
): Promise<PathVO> {
|
||||
return get(`${BASE}/${graphId}/query/shortest-path`, params);
|
||||
}
|
||||
|
||||
export function getAllPaths(
|
||||
graphId: string,
|
||||
params: { sourceId: string; targetId: string; maxDepth?: number; maxPaths?: number }
|
||||
): Promise<AllPathsVO> {
|
||||
return get(`${BASE}/${graphId}/query/all-paths`, params);
|
||||
}
|
||||
|
||||
export function searchEntities(
|
||||
graphId: string,
|
||||
params: { q: string; page?: number; size?: number },
|
||||
options?: { signal?: AbortSignal }
|
||||
): Promise<PagedResponse<SearchHitVO>> {
|
||||
return get(`${BASE}/${graphId}/query/search`, params, options);
|
||||
}
|
||||
|
||||
// ---- Neighbors (entity controller) ----
|
||||
|
||||
export function getEntityNeighbors(
|
||||
graphId: string,
|
||||
entityId: string,
|
||||
params?: { depth?: number; limit?: number }
|
||||
): Promise<GraphEntity[]> {
|
||||
return get(`${BASE}/${graphId}/entities/${entityId}/neighbors`, params ?? null);
|
||||
}
|
||||
46
frontend/src/pages/KnowledgeGraph/knowledge-graph.const.ts
Normal file
46
frontend/src/pages/KnowledgeGraph/knowledge-graph.const.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/** Entity type -> display color mapping */
|
||||
export const ENTITY_TYPE_COLORS: Record<string, string> = {
|
||||
Dataset: "#5B8FF9",
|
||||
Field: "#5AD8A6",
|
||||
User: "#F6BD16",
|
||||
Org: "#E86452",
|
||||
Workflow: "#6DC8EC",
|
||||
Job: "#945FB9",
|
||||
LabelTask: "#FF9845",
|
||||
KnowledgeSet: "#1E9493",
|
||||
};
|
||||
|
||||
/** Default color for unknown entity types */
|
||||
export const DEFAULT_ENTITY_COLOR = "#9CA3AF";
|
||||
|
||||
/** Relation type -> Chinese label mapping */
|
||||
export const RELATION_TYPE_LABELS: Record<string, string> = {
|
||||
HAS_FIELD: "包含字段",
|
||||
DERIVED_FROM: "来源于",
|
||||
USES_DATASET: "使用数据集",
|
||||
PRODUCES: "产出",
|
||||
ASSIGNED_TO: "分配给",
|
||||
BELONGS_TO: "属于",
|
||||
TRIGGERS: "触发",
|
||||
DEPENDS_ON: "依赖",
|
||||
IMPACTS: "影响",
|
||||
SOURCED_FROM: "知识来源",
|
||||
};
|
||||
|
||||
/** Entity type -> Chinese label mapping */
|
||||
export const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
Dataset: "数据集",
|
||||
Field: "字段",
|
||||
User: "用户",
|
||||
Org: "组织",
|
||||
Workflow: "工作流",
|
||||
Job: "作业",
|
||||
LabelTask: "标注任务",
|
||||
KnowledgeSet: "知识集",
|
||||
};
|
||||
|
||||
/** Available entity types for filtering */
|
||||
export const ENTITY_TYPES = Object.keys(ENTITY_TYPE_LABELS);
|
||||
|
||||
/** Available relation types for filtering */
|
||||
export const RELATION_TYPES = Object.keys(RELATION_TYPE_LABELS);
|
||||
81
frontend/src/pages/KnowledgeGraph/knowledge-graph.model.ts
Normal file
81
frontend/src/pages/KnowledgeGraph/knowledge-graph.model.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export interface GraphEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
labels?: string[];
|
||||
aliases?: string[];
|
||||
properties?: Record<string, unknown>;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
graphId: string;
|
||||
confidence?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface EntitySummaryVO {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface EdgeSummaryVO {
|
||||
id: string;
|
||||
sourceEntityId: string;
|
||||
targetEntityId: string;
|
||||
relationType: string;
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
export interface SubgraphVO {
|
||||
nodes: EntitySummaryVO[];
|
||||
edges: EdgeSummaryVO[];
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
}
|
||||
|
||||
export interface RelationVO {
|
||||
id: string;
|
||||
sourceEntityId: string;
|
||||
sourceEntityName: string;
|
||||
sourceEntityType: string;
|
||||
targetEntityId: string;
|
||||
targetEntityName: string;
|
||||
targetEntityType: string;
|
||||
relationType: string;
|
||||
properties?: Record<string, unknown>;
|
||||
weight?: number;
|
||||
confidence?: number;
|
||||
sourceId?: string;
|
||||
graphId: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface SearchHitVO {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface PagedResponse<T> {
|
||||
page: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
content: T[];
|
||||
}
|
||||
|
||||
export interface PathVO {
|
||||
nodes: EntitySummaryVO[];
|
||||
edges: EdgeSummaryVO[];
|
||||
pathLength: number;
|
||||
}
|
||||
|
||||
export interface AllPathsVO {
|
||||
paths: PathVO[];
|
||||
pathCount: number;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Shield,
|
||||
Sparkles,
|
||||
ListChecks,
|
||||
Network,
|
||||
// Database,
|
||||
// Store,
|
||||
// Merge,
|
||||
@@ -56,6 +57,14 @@ export const menuItems = [
|
||||
description: "管理知识集与知识条目",
|
||||
color: "bg-indigo-500",
|
||||
},
|
||||
{
|
||||
id: "knowledge-graph",
|
||||
title: "知识图谱",
|
||||
icon: Network,
|
||||
permissionCode: PermissionCodes.knowledgeGraphRead,
|
||||
description: "知识图谱浏览与探索",
|
||||
color: "bg-teal-500",
|
||||
},
|
||||
{
|
||||
id: "task-coordination",
|
||||
title: "任务协调",
|
||||
|
||||
@@ -55,6 +55,7 @@ import ContentGenerationPage from "@/pages/ContentGeneration/ContentGenerationPa
|
||||
import LoginPage from "@/pages/Login/LoginPage";
|
||||
import ProtectedRoute from "@/components/ProtectedRoute";
|
||||
import ForbiddenPage from "@/pages/Forbidden/ForbiddenPage";
|
||||
import KnowledgeGraphPage from "@/pages/KnowledgeGraph/Home/KnowledgeGraphPage";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -287,6 +288,10 @@ const router = createBrowserRouter([
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "knowledge-graph",
|
||||
Component: withErrorBoundary(KnowledgeGraphPage),
|
||||
},
|
||||
{
|
||||
path: "task-coordination",
|
||||
children: [
|
||||
|
||||
@@ -18,6 +18,11 @@ export default defineConfig({
|
||||
// "Origin, X-Requested-With, Content-Type, Accept",
|
||||
// },
|
||||
proxy: {
|
||||
"^/knowledge-graph": {
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
"^/api": {
|
||||
target: "http://localhost:8080", // 本地后端服务地址
|
||||
changeOrigin: true,
|
||||
|
||||
Reference in New Issue
Block a user