feat(index): add homepage with dashboard cards and knowledge graph support
- Add new Index view with dashboard layout and quick action cards - Create TopCardList component to display core data management options - Add GuideCard and VersionCard components for user guidance - Add QuickActions component for common operations - Create KnowledgeGraph and KnowledgeGraphCard components for knowledge base visualization - Add comprehensive SVG assets for index page (apps, arrows, management icons) - Add common API module for shared request utilities - Extend knowledgeBase API with knowledge graph endpoints (getKnowledgeGraph, getKnowledgeGraphEntityTypes) - Update i18n translations for English and Chinese with new index page strings - Update routing configuration to include new Index route - Update menu configuration to reflect new navigation structure - Update KnowledgeBase CreateModal and Private view components - Add TypeScript types for Index page components - Improve overall UI/UX with new dashboard-style homepage
This commit is contained in:
451
web/src/views/KnowledgeBase/components/KnowledgeGraph.tsx
Normal file
451
web/src/views/KnowledgeBase/components/KnowledgeGraph.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
import React, { type FC, useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Col } from 'antd'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import ReactEcharts from 'echarts-for-react'
|
||||
import zoom from '@/assets/images/userMemory/zoom.svg'
|
||||
import drag from '@/assets/images/userMemory/drag.svg'
|
||||
import pointer from '@/assets/images/userMemory/pointer.svg'
|
||||
import empty from '@/assets/images/userMemory/empty.svg'
|
||||
import Empty from '@/components/Empty'
|
||||
|
||||
// 知识图谱数据类型定义
|
||||
export interface KnowledgeNode {
|
||||
id: string
|
||||
entity_name: string
|
||||
entity_type: string
|
||||
description: string
|
||||
pagerank: number
|
||||
source_id: string[]
|
||||
// ECharts 需要的属性
|
||||
name: string
|
||||
category: number
|
||||
symbolSize: number
|
||||
itemStyle: {
|
||||
color: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface KnowledgeEdge {
|
||||
src_id: string
|
||||
tgt_id: string
|
||||
description: string
|
||||
keywords: string[]
|
||||
weight: number
|
||||
source_id: string[]
|
||||
source: string
|
||||
target: string
|
||||
// ECharts 需要的属性
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface KnowledgeGraphData {
|
||||
directed: boolean
|
||||
multigraph: boolean
|
||||
graph: {
|
||||
source_id: string[]
|
||||
}
|
||||
nodes: KnowledgeNode[]
|
||||
edges: KnowledgeEdge[]
|
||||
}
|
||||
|
||||
export interface KnowledgeGraphResponse {
|
||||
graph: KnowledgeGraphData
|
||||
mind_map: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface KnowledgeGraphProps {
|
||||
data?: KnowledgeGraphResponse
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const operations = [
|
||||
{ name: 'click', icon: pointer },
|
||||
{ name: 'drag', icon: drag },
|
||||
{ name: 'zoom', icon: zoom },
|
||||
]
|
||||
|
||||
// 预定义的颜色调色板
|
||||
const colorPalette = [
|
||||
'#155EEF', '#4DA8FF', '#9C6FFF', '#8BAEF7', '#369F21',
|
||||
'#FF5D34', '#FF8A4C', '#FFB048', '#E74C3C', '#9B59B6',
|
||||
'#3498DB', '#1ABC9C', '#F39C12', '#D35400', '#C0392B',
|
||||
'#8E44AD', '#2980B9', '#16A085', '#F1C40F', '#E67E22'
|
||||
]
|
||||
|
||||
// 动态生成实体类型颜色映射
|
||||
const generateEntityTypeColors = (entityTypes: string[]): Record<string, string> => {
|
||||
const colorMap: Record<string, string> = {}
|
||||
entityTypes.forEach((type, index) => {
|
||||
colorMap[type] = colorPalette[index % colorPalette.length]
|
||||
})
|
||||
return colorMap
|
||||
}
|
||||
|
||||
const KnowledgeGraph: FC<KnowledgeGraphProps> = ({ data, loading = false }) => {
|
||||
const { t } = useTranslation()
|
||||
const chartRef = useRef<ReactEcharts>(null)
|
||||
const resizeScheduledRef = useRef(false)
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
const [nodes, setNodes] = useState<KnowledgeNode[]>([])
|
||||
const [links, setLinks] = useState<KnowledgeEdge[]>([])
|
||||
const [categories, setCategories] = useState<{ name: string }[]>([])
|
||||
const [selectedNode, setSelectedNode] = useState<KnowledgeNode | null>(null)
|
||||
const [entityTypeColors, setEntityTypeColors] = useState<Record<string, string>>({})
|
||||
|
||||
// 弹框拖动相关状态
|
||||
const [modalPosition, setModalPosition] = useState({ x: 20, y: 20 })
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
||||
|
||||
// 拖动处理函数
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
setIsDragging(true)
|
||||
setDragStart({
|
||||
x: e.clientX - modalPosition.x,
|
||||
y: e.clientY - modalPosition.y
|
||||
})
|
||||
}, [modalPosition])
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
|
||||
const newX = e.clientX - dragStart.x
|
||||
const newY = e.clientY - dragStart.y
|
||||
|
||||
// 限制拖动范围,确保弹框不会超出容器
|
||||
const container = chartRef.current?.getEchartsInstance().getDom().parentElement
|
||||
if (container && modalRef.current) {
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const modalRect = modalRef.current.getBoundingClientRect()
|
||||
|
||||
const maxX = containerRect.width - modalRect.width
|
||||
const maxY = containerRect.height - modalRect.height
|
||||
|
||||
setModalPosition({
|
||||
x: Math.max(0, Math.min(newX, maxX)),
|
||||
y: Math.max(0, Math.min(newY, maxY))
|
||||
})
|
||||
}
|
||||
}, [isDragging, dragStart])
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
// 添加全局鼠标事件监听
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}
|
||||
}, [isDragging, handleMouseMove, handleMouseUp])
|
||||
|
||||
// 关闭弹框
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setSelectedNode(null)
|
||||
}, [])
|
||||
|
||||
// 处理知识图谱数据
|
||||
const processGraphData = useCallback(() => {
|
||||
if (!data?.graph) {
|
||||
setNodes([])
|
||||
setLinks([])
|
||||
setCategories([])
|
||||
setSelectedNode(null)
|
||||
return
|
||||
}
|
||||
|
||||
const { nodes: rawNodes, edges: rawEdges } = data.graph
|
||||
const processedNodes: KnowledgeNode[] = []
|
||||
const processedEdges: KnowledgeEdge[] = []
|
||||
|
||||
// 获取所有实体类型
|
||||
const entityTypes = [...new Set(rawNodes.map(node => node.entity_type))]
|
||||
const categoryMap = entityTypes.reduce((acc, type, index) => {
|
||||
acc[type] = index
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
|
||||
// 动态生成实体类型颜色映射
|
||||
const dynamicEntityTypeColors = generateEntityTypeColors(entityTypes)
|
||||
setEntityTypeColors(dynamicEntityTypeColors)
|
||||
|
||||
// 计算每个节点的连接数
|
||||
const connectionCount: Record<string, number> = {}
|
||||
rawEdges.forEach(edge => {
|
||||
// 使用 src_id 和 tgt_id 计算连接数
|
||||
connectionCount[edge.src_id] = (connectionCount[edge.src_id] || 0) + 1
|
||||
connectionCount[edge.tgt_id] = (connectionCount[edge.tgt_id] || 0) + 1
|
||||
})
|
||||
|
||||
// 处理节点数据
|
||||
rawNodes.forEach(node => {
|
||||
const connections = connectionCount[node.id] || 0
|
||||
const categoryIndex = categoryMap[node.entity_type] || 0
|
||||
|
||||
// 根据 pagerank 和连接数计算节点大小
|
||||
let symbolSize = Math.max(10, Math.min(50, node.pagerank * 200 + connections * 2))
|
||||
|
||||
processedNodes.push({
|
||||
...node,
|
||||
name: node.entity_name,
|
||||
category: categoryIndex,
|
||||
symbolSize,
|
||||
itemStyle: {
|
||||
color: dynamicEntityTypeColors[node.entity_type] || colorPalette[0]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 处理边数据
|
||||
rawEdges.forEach(edge => {
|
||||
// 注意:根据数据结构,source 和 target 字段可能与 src_id 和 tgt_id 相反
|
||||
// 我们使用 src_id 和 tgt_id 作为正确的连接关系
|
||||
processedEdges.push({
|
||||
...edge, // 保留所有原始字段
|
||||
source: edge.src_id, // 使用 src_id 作为源节点
|
||||
target: edge.tgt_id, // 使用 tgt_id 作为目标节点
|
||||
value: edge.weight || 1
|
||||
})
|
||||
})
|
||||
|
||||
// 验证节点ID和边的连接
|
||||
const nodeIds = new Set(processedNodes.map(n => n.id))
|
||||
const validEdges = processedEdges.filter(edge => {
|
||||
const sourceExists = nodeIds.has(edge.source)
|
||||
const targetExists = nodeIds.has(edge.target)
|
||||
if (!sourceExists || !targetExists) {
|
||||
console.warn('Invalid edge:', edge, 'Source exists:', sourceExists, 'Target exists:', targetExists)
|
||||
}
|
||||
return sourceExists && targetExists
|
||||
})
|
||||
|
||||
// 调试信息
|
||||
console.log('Total nodes:', processedNodes.length)
|
||||
console.log('Total edges:', processedEdges.length)
|
||||
console.log('Valid edges:', validEdges.length)
|
||||
console.log('Node IDs:', Array.from(nodeIds).slice(0, 5))
|
||||
console.log('Edge sample:', validEdges.slice(0, 3))
|
||||
|
||||
// 设置分类
|
||||
const processedCategories = entityTypes.map(type => ({ name: type }))
|
||||
|
||||
setNodes(processedNodes)
|
||||
setLinks(validEdges) // 只使用有效的边
|
||||
setCategories(processedCategories)
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
processGraphData()
|
||||
}, [processGraphData])
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (chartRef.current && !resizeScheduledRef.current) {
|
||||
resizeScheduledRef.current = true
|
||||
requestAnimationFrame(() => {
|
||||
chartRef.current?.getEchartsInstance().resize()
|
||||
resizeScheduledRef.current = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(handleResize)
|
||||
const chartElement = chartRef.current?.getEchartsInstance().getDom().parentElement
|
||||
if (chartElement) {
|
||||
resizeObserver.observe(chartElement)
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [nodes])
|
||||
|
||||
return (
|
||||
<Col span={24}>
|
||||
<RbCard
|
||||
title={t('knowledgeBase.knowledgeGraph')}
|
||||
headerType="borderless"
|
||||
headerClassName="rb:text-[18px]! rb:leading-[24px]"
|
||||
>
|
||||
<div className="rb:h-124 rb:relative">
|
||||
{loading ? (
|
||||
<div className="rb:h-full rb:flex rb:items-center rb:justify-center">
|
||||
<div className="rb:text-[#5B6167]">加载中...</div>
|
||||
</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<Empty className="rb:h-full" />
|
||||
) : (
|
||||
<>
|
||||
<ReactEcharts
|
||||
ref={chartRef}
|
||||
option={{
|
||||
colors: Object.values(entityTypeColors),
|
||||
tooltip: {
|
||||
show: true,
|
||||
formatter: (params: any) => {
|
||||
if (params.dataType === 'node') {
|
||||
const node = params.data as KnowledgeNode
|
||||
return `
|
||||
<div>
|
||||
<div><strong>${node.entity_name}</strong></div>
|
||||
<div>类型: ${node.entity_type}</div>
|
||||
<div>重要度: ${(node.pagerank * 100).toFixed(2)}%</div>
|
||||
</div>
|
||||
`
|
||||
} else if (params.dataType === 'edge') {
|
||||
const edge = params.data as KnowledgeEdge
|
||||
return `
|
||||
<div>
|
||||
<div><strong>关系</strong></div>
|
||||
<div>权重: ${edge.weight}</div>
|
||||
<div>${edge.description}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: categories.map(cat => cat.name),
|
||||
orient: 'vertical',
|
||||
left: 'right',
|
||||
top: 'center'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'graph',
|
||||
layout: 'force',
|
||||
data: nodes,
|
||||
links: links,
|
||||
categories: categories,
|
||||
roam: true,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
formatter: '{b}',
|
||||
fontSize: 12
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#5B6167',
|
||||
curveness: 0.3,
|
||||
width: 2, // 固定线宽,避免函数问题
|
||||
opacity: 0.8
|
||||
},
|
||||
force: {
|
||||
repulsion: 300,
|
||||
edgeLength: 150,
|
||||
gravity: 0.1,
|
||||
layoutAnimation: true,
|
||||
preventOverlap: true
|
||||
},
|
||||
selectedMode: 'single',
|
||||
draggable: true,
|
||||
animationDurationUpdate: 0,
|
||||
select: {
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff',
|
||||
shadowBlur: 10,
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'adjacency',
|
||||
lineStyle: {
|
||||
width: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}}
|
||||
style={{ height: '496px', width: '100%' }}
|
||||
notMerge={false}
|
||||
lazyUpdate={true}
|
||||
onEvents={{
|
||||
click: (params: { dataType: string; data: KnowledgeNode }) => {
|
||||
if (params.dataType === 'node') {
|
||||
console.log('Knowledge node clicked:', params.data)
|
||||
setSelectedNode(params.data)
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 实体详情弹框 */}
|
||||
{selectedNode && (
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="rb:absolute rb:bg-white rb:border rb:border-[#EBEBEB] rb:rounded-[12px] rb:shadow-lg rb:p-4 rb:w-80 rb:z-10"
|
||||
style={{
|
||||
left: modalPosition.x,
|
||||
top: modalPosition.y,
|
||||
cursor: isDragging ? 'grabbing' : 'grab'
|
||||
}}
|
||||
>
|
||||
{/* 弹框头部 - 可拖动区域 */}
|
||||
<div
|
||||
className="rb:flex rb:items-center rb:justify-between rb:mb-3 rb:pb-2 rb:border-b rb:border-[#EBEBEB] rb:cursor-grab"
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
|
||||
>
|
||||
<div className="rb:text-[16px] rb:font-medium rb:text-[#1A1A1A]">
|
||||
{t('knowledgeBase.entityDetails')}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCloseModal}
|
||||
className="rb:w-6 rb:h-6 rb:flex rb:items-center rb:justify-center rb:text-[#5B6167] hover:rb:text-[#1A1A1A] hover:rb:bg-[#F0F3F8] rb:rounded rb:transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 弹框内容 */}
|
||||
<div>
|
||||
<div className="rb:font-medium rb:mb-4">
|
||||
<div className="rb:text-[16px] rb:mb-2">{selectedNode.entity_name}</div>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mb-2">
|
||||
<span className="rb:inline-block rb:px-2 rb:py-1 rb:bg-[#F0F3F8] rb:rounded rb:mr-2">
|
||||
{selectedNode.entity_type}
|
||||
</span>
|
||||
<span>重要度: {(selectedNode.pagerank * 100).toFixed(2)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rb:font-medium rb:mb-4">
|
||||
{t('knowledgeBase.entityDescription')}
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-2 rb:leading-5">
|
||||
{selectedNode.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rb:font-medium rb:mb-2">
|
||||
{t('knowledgeBase.sourceDocuments')}
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-2">
|
||||
{selectedNode.source_id.length} 个文档
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="rb:bg-[#F0F3F8] rb:flex rb:items-center rb:gap-6 rb:rounded-[0px_0px_12px_12px] rb:p-[14px_40px] rb:m-[0_-20px_-16px_-16px]">
|
||||
{operations.map((item) => (
|
||||
<div key={item.name} className="rb:flex rb:items-center rb:text-[#5B6167] rb:leading-5">
|
||||
<img src={item.icon} className="rb:w-5 rb:h-5 rb:mr-1" />
|
||||
{t(`userMemory.${item.name}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</RbCard>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(KnowledgeGraph)
|
||||
Reference in New Issue
Block a user