- 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
451 lines
16 KiB
TypeScript
451 lines
16 KiB
TypeScript
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) |