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:
yujiangping
2026-01-05 10:37:08 +08:00
parent ebd2abbfa0
commit f31341151f
40 changed files with 2165 additions and 64 deletions

View File

@@ -1,8 +1,14 @@
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react';
import { Form, Input, Select, Modal } from 'antd';
import { Form, Input, Select, Modal, Tabs, Switch, Radio, Button,message } from 'antd';
import { useTranslation } from 'react-i18next';
import type { KnowledgeBaseListItem, KnowledgeBaseFormData, CreateModalRef, CreateModalRefProps } from '@/views/KnowledgeBase/types';
import { getModelTypeList, getModelList, createKnowledgeBase, updateKnowledgeBase } from '@/api/knowledgeBase'
import {
getModelTypeList,
getModelList,
createKnowledgeBase,
updateKnowledgeBase,
getKnowledgeGraphEntityTypes
} from '@/api/knowledgeBase'
import RbModal from '@/components/RbModal'
const { TextArea } = Input;
const { confirm } = Modal
@@ -14,22 +20,104 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
refreshTable
}, ref) => {
const { t } = useTranslation();
const [messageApi, contextHolder] = message.useMessage();
const [visible, setVisible] = useState(false);
const [modelTypeList, setModelTypeList] = useState<string[]>([]);
const [modelOptionsByType, setModelOptionsByType] = useState<Record<string, { label: string; value: string }[]>>({});
const [datasets, setDatasets] = useState<KnowledgeBaseListItem | null>(null);
const [currentType, setCurrentType] = useState<'General' | 'Web' | 'Third-party' | 'Folder'>('General');
const [form] = Form.useForm<KnowledgeBaseFormData>();
const [loading, setLoading] = useState(false)
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState('basic');
const [generatingEntityTypes, setGeneratingEntityTypes] = useState(false);
// 监听 parser_config.graphrag 相关字段的变化
const parserConfig = Form.useWatch('parser_config', form);
const graphragConfig = parserConfig?.graphrag;
const enableKnowledgeGraph = graphragConfig?.use_graphrag || false;
const entityTypes = graphragConfig?.entity_types || '';
const entityNormalization = graphragConfig?.resolution || false;
const communityReportGeneration = graphragConfig?.community || false;
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setDatasets(null);
form.resetFields();
setLoading(false)
setLoading(false);
setActiveTab('basic');
setVisible(false);
};
// 生成实体类型的函数
const generateEntityTypes = async () => {
const sceneName = form.getFieldValue(['parser_config', 'graphrag', 'scene_name']);
if (!sceneName) {
// 可以添加提示用户输入场景名称
messageApi.error(t('knowledgeBase.enterScenarioName'));
return;
}
// 检查是否选择了 LLM 模型
const llmId = form.getFieldValue('llm_id');
if (!llmId) {
// 跳转到基础配置页
setActiveTab('basic');
messageApi.error(t('knowledgeBase.pleaseSelectLLMModel'));
return;
}
setGeneratingEntityTypes(true);
try {
// 这里应该调用实际的API接口
// const user = JSON.parse(localStorage.getItem('user') as any);
//datasets?.id || datasets?.parent_id || user?.current_workspace_id,
const params = {
scenario: sceneName,
llm_id: llmId
};
const response = await getKnowledgeGraphEntityTypes(params);
// 模拟API调用
// await new Promise(resolve => setTimeout(resolve, 1000));
// 处理API响应数据
console.log('API Response:', response); // 调试日志
// 检查响应结构 - API直接返回字符串
if (response && typeof response === 'string' && response.trim()) {
// 将逗号分隔的字符串转换为换行分隔的格式以便在TextArea中显示
const entityTypesString = response.replace(/,\s*/g, '\n');
console.log('Converted entity types:', entityTypesString); // 调试日志
const currentGraphrag = form.getFieldValue(['parser_config', 'graphrag']) || {};
const updatedGraphrag = {
...currentGraphrag,
entity_types: entityTypesString
};
console.log('Updating form with:', updatedGraphrag); // 调试日志
// 使用更直接的方式更新表单字段
form.setFieldValue(['parser_config', 'graphrag', 'entity_types'], entityTypesString);
// 强制触发表单重新渲染
form.validateFields([['parser_config', 'graphrag', 'entity_types']]);
// 额外的强制更新机制
setTimeout(() => {
form.setFieldValue(['parser_config', 'graphrag', 'entity_types'], entityTypesString);
}, 100);
messageApi.success(t('knowledgeBase.generateEntityTypesSuccess'));
} else {
messageApi.error(t('knowledgeBase.generateEntityTypesFailed') + '' + t('knowledgeBase.unknownError'));
}
} catch (error) {
console.error(t('knowledgeBase.generateEntityTypesFailed') + ':', error);
} finally {
setGeneratingEntityTypes(false);
}
};
const typeToFieldKey = (type: string): string => {
switch ((type || '').toLowerCase()) {
case 'embedding':
@@ -89,6 +177,30 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
type: type || record.type || currentType,
status: record.status,
};
// 处理 parser_config 配置数据,如果没有则设置默认值
baseValues.parser_config = record.parser_config || {
graphrag: {
use_graphrag: false,
scene_name: '',
entity_types: [] as any,
method: 'general',
resolution: false,
community: false,
}
};
// 如果存在 entity_types转换为换行分隔格式用于 TextArea 显示
if (baseValues.parser_config.graphrag.entity_types) {
if (Array.isArray(baseValues.parser_config.graphrag.entity_types)) {
// 如果是数组格式,转换为换行分隔字符串
(baseValues.parser_config.graphrag as any).entity_types = baseValues.parser_config.graphrag.entity_types.join('\n');
} else if (typeof baseValues.parser_config.graphrag.entity_types === 'string') {
// 如果是逗号分隔字符串格式,转换为换行分隔字符串(兼容旧数据)
(baseValues.parser_config.graphrag as any).entity_types = (baseValues.parser_config.graphrag.entity_types as string).replace(/,\s*/g, '\n');
}
}
form.setFieldsValue(baseValues);
};
@@ -142,12 +254,26 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
.then(() => {
setLoading(true)
const formValues = form.getFieldsValue();
// 处理 entity_types 格式转换:从换行分隔字符串转换为字符串数组
if (formValues.parser_config && formValues.parser_config.graphrag && formValues.parser_config.graphrag.entity_types) {
const entityTypesString = formValues.parser_config.graphrag.entity_types as any as string;
const entityTypesArray = entityTypesString
.split('\n')
.map((item: string) => item.trim())
.filter((item: string) => item.length > 0);
formValues.parser_config.graphrag.entity_types = entityTypesArray;
}
const payload: KnowledgeBaseFormData = {
...formValues,
type: formValues.type || currentType,
permission_id: formValues.permission_id || 'Private',
parent_id: datasets?.parent_id || undefined,
};
console.log('Saving payload:', payload); // 调试日志
const submit = datasets?.id
? updateKnowledgeBase(datasets.id, payload)
: createKnowledgeBase(payload);
@@ -205,6 +331,192 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
const dynamicTypeList = useMemo(() => modelTypeList.filter((tp) => (modelOptionsByType[tp] || []).length), [modelTypeList, modelOptionsByType]);
// 基础配置表单内容
const renderBasicConfig = () => (
<>
{!datasets?.id && (
<Form.Item
name="name"
label={t('knowledgeBase.createForm.name')}
rules={[{ required: true, message: t('knowledgeBase.createForm.nameRequired') }]}
>
<Input placeholder={t('knowledgeBase.createForm.name')} />
</Form.Item>
)}
<Form.Item name="description" label={t('knowledgeBase.createForm.description')}>
<TextArea rows={2} placeholder={t('knowledgeBase.createForm.description')} />
</Form.Item>
{currentType !== 'Folder' && dynamicTypeList.map((tp) => {
const fieldKey = typeToFieldKey(tp);
// 当 tp 为 'llm' 时,合并 llm 和 chat 的选项
const options = tp.toLowerCase() === 'llm'
? [...(modelOptionsByType['llm'] || []), ...(modelOptionsByType['chat'] || [])]
: modelOptionsByType[tp] || [];
return (
<Form.Item
key={tp}
name={fieldKey as keyof KnowledgeBaseFormData}
label={t(`knowledgeBase.createForm.${fieldKey}`) + ' ' + 'model'}
rules={[{ required: true, message: t('knowledgeBase.createForm.modelRequired') }]}
>
<Select
options={options}
placeholder={t(`knowledgeBase.createForm.${fieldKey}`)}
allowClear={false}
showSearch
optionFilterProp="label"
onChange={(value) => handleChange(value, tp)}
/>
</Form.Item>
);
})}
</>
);
// 知识图谱配置表单内容
const renderKnowledgeGraphConfig = () => (
<>
<div className={`rb:flex rb:w-full rb:items-center rb:p-4 rb:border-1 rb:rounded-lg rb:mb-4 ${
enableKnowledgeGraph
? 'rb:border-[#155EEF] rb:bg-[rgba(21,94,239,0.06)]'
: 'rb:border-[#EBEBEB]'
}`}>
<div className='rb:flex rb:flex-col rb:flex-1'>
<div className='rb:text-[#212332] rb:text-base rb:font-medium'>
{t('knowledgeBase.enableKnowledgeGraph')}
</div>
<div className='rb:text-xs rb:text-[#5B6167] rb:mt-2'>
{t('knowledgeBase.enableKnowledgeGraphTips')}
</div>
</div>
<Form.Item
name={['parser_config', 'graphrag', 'use_graphrag']}
label=''
valuePropName="checked"
className='rb:mb-0'
>
<Switch />
</Form.Item>
</div>
{enableKnowledgeGraph && (
<>
<div className='rb:text-[#212332] rb:text-base rb:font-medium rb:mb-4'>
{t('knowledgeBase.graphConfig')}
</div>
{/* 场景名称 */}
<div className='rb:flex rb:items-center rb:gap-2'>
<Form.Item
name={['parser_config', 'graphrag', 'scene_name']}
label={t('knowledgeBase.sceneName')}
className='rb:w-full rb:min-w-[240px]'
rules={[{ required: true, message: t('common.pleaseEnter') + t('knowledgeBase.sceneName') }]}
>
<Input placeholder={t('knowledgeBase.sceneNamePlaceholder')} />
</Form.Item>
<Button
type="primary"
loading={generatingEntityTypes}
onClick={generateEntityTypes}
className='rb:mt-1'
>
{!(entityTypes as any as string) || (entityTypes as any as string).trim() === ''
? t('knowledgeBase.generateEntityTypes')
: t('knowledgeBase.regenerateEntityTypes')
}
</Button>
</div>
{/* 实体类型 */}
<Form.Item
name={['parser_config', 'graphrag', 'entity_types']}
label={t('knowledgeBase.entityTypes')}
>
<TextArea
rows={4}
placeholder={t('knowledgeBase.entityTypesPlaceholder')}
/>
</Form.Item>
{/* 实体归一化 */}
<div className={`rb:flex rb:w-full rb:gap-2 rb:items-center rb:p-4 rb:border-1 rb:rounded-lg rb:mb-4 ${
entityNormalization
? 'rb:border-[#155EEF] rb:bg-[rgba(21,94,239,0.06)]'
: 'rb:border-[#EBEBEB]'
}`}>
<div className='rb:flex rb:flex-col rb:flex-1'>
<div className='rb:text-[#212332] rb:text-base rb:font-medium'>
{t('knowledgeBase.entityNormalization')}
</div>
<div className='rb:text-xs rb:text-[#5B6167] rb:mt-2'>
{t('knowledgeBase.entityNormalizationTips')}
</div>
</div>
<Form.Item
name={['parser_config', 'graphrag', 'resolution']}
valuePropName="checked"
className='rb:mb-0'
>
<Switch />
</Form.Item>
</div>
{/* 实体方法 */}
<Form.Item
name={['parser_config', 'graphrag', 'method']}
label={t('knowledgeBase.entityMethod')}
initialValue="general"
>
<Radio.Group>
<Radio value="general">{t('knowledgeBase.entityMethodGeneral')}</Radio>
<Radio value="light">{t('knowledgeBase.entityMethodLight')}</Radio>
</Radio.Group>
</Form.Item>
{/* 社区报告生成 */}
<div className={`rb:flex rb:w-full rb:gap-2 rb:items-center rb:p-4 rb:border-1 rb:rounded-lg rb:mb-4 ${
communityReportGeneration
? 'rb:border-[#155EEF] rb:bg-[rgba(21,94,239,0.06)]'
: 'rb:border-[#EBEBEB]'
}`}>
<div className='rb:flex rb:flex-col rb:flex-1'>
<div className='rb:text-[#212332] rb:text-base rb:font-medium'>
{t('knowledgeBase.communityReportGeneration')}
</div>
<div className='rb:text-xs rb:text-[#5B6167] rb:mt-2'>
{t('knowledgeBase.communityReportGenerationTips')}
</div>
</div>
<Form.Item
name={['parser_config', 'graphrag', 'community']}
valuePropName="checked"
className='rb:mb-0'
>
<Switch />
</Form.Item>
</div>
</>
)}
</>
);
// Tabs 配置
const tabItems = [
{
key: 'basic',
label: t('knowledgeBase.basicConfig'),
children: renderBasicConfig(),
},
{
key: 'knowledgeGraph',
label: t('knowledgeBase.knowledgeGraph'),
children: renderKnowledgeGraphConfig(),
},
];
return (
<RbModal
title={getTitle()}
@@ -220,48 +532,25 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
initialValues={{
permission_id: 'Private', // 设置 permission_id 的默认值
type: currentType,
parser_config: {
graphrag: {
use_graphrag: false, // 默认不启用知识图谱
scene_name: '', // 场景名称
entity_types: '' as any, // 实体类型(界面上显示为字符串,保存时转为数组)
method: 'general', // 默认使用通用方法
resolution: false, // 默认不启用实体归一化
community: false, // 默认不生成社区报告
}
}
}}
>
{/* <div className="rb:text-[14px] rb:font-medium rb:text-[#5B6167] rb:mb-[16px]">{t('model.basicParameters')}</div> */}
{!datasets?.id && (
<Form.Item
name="name"
label={t('knowledgeBase.createForm.name')}
rules={[{ required: true, message: t('knowledgeBase.createForm.nameRequired') }]}
>
<Input placeholder={t('knowledgeBase.createForm.name')} />
</Form.Item>
)}
<Form.Item name="description" label={t('knowledgeBase.createForm.description')}>
<TextArea rows={2} placeholder={t('knowledgeBase.createForm.description')} />
</Form.Item>
{currentType !== 'Folder' && dynamicTypeList.map((tp) => {
const fieldKey = typeToFieldKey(tp);
// 当 tp 为 'llm' 时,合并 llm 和 chat 的选项
const options = tp.toLowerCase() === 'llm'
? [...(modelOptionsByType['llm'] || []), ...(modelOptionsByType['chat'] || [])]
: modelOptionsByType[tp] || [];
return (
<Form.Item
key={tp}
name={fieldKey as keyof KnowledgeBaseFormData}
label={t(`knowledgeBase.createForm.${fieldKey}`) + ' ' + 'model'}
rules={[{ required: true, message: t('knowledgeBase.createForm.modelRequired') }]}
>
<Select
options={options}
placeholder={t(`knowledgeBase.createForm.${fieldKey}`)}
allowClear={false}
showSearch
optionFilterProp="label"
onChange={(value) => handleChange(value, tp)}
/>
</Form.Item>
);
})}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={tabItems}
/>
</Form>
{contextHolder}
</RbModal>
);
});

View 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)

View File

@@ -0,0 +1,66 @@
/*
* @Description:
* @Version: 0.0.1
* @Author: yujiangping
* @Date: 2025-12-30 15:07:37
* @LastEditors: yujiangping
* @LastEditTime: 2026-01-04 20:15:12
*/
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next';
import { Row } from 'antd'
import KnowledgeGraph, { type KnowledgeGraphResponse } from './KnowledgeGraph'
import { getKnowledgeGraph } from '@/api/knowledgeBase';
interface KnowledgeGraphCardProps {
knowledgeBaseId?: string;
}
const KnowledgeGraphCard: React.FC<KnowledgeGraphCardProps> = ({ knowledgeBaseId }) => {
const { t } = useTranslation();
const [data, setData] = useState<KnowledgeGraphResponse | undefined>()
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
if (!knowledgeBaseId) {
setLoading(false)
return
}
setLoading(true)
try {
const res = await getKnowledgeGraph(knowledgeBaseId)
setData(res as KnowledgeGraphResponse)
} catch (error) {
console.error('获取知识图谱数据失败:', error)
} finally {
setLoading(false)
}
}
fetchData()
}, [knowledgeBaseId])
return (
<div className='rb:flex rb:flex-col'>
<div className='rb:flex rb:flex-col rb:p-4'>
<div className='rb:w-full rb:text-lg rb:font-medium rb:text-[#212332] rb:leading-6'>
{t('knowledgeBase.graphTitle')}
</div>
<div className='rb:w-full rb:text-xs rb:text-[#5B6167] rb:leading-4 rb:mt-2'>
{t('knowledgeBase.graphTips')}
</div>
<div className='rb:flex rb:items-center rb:justify-between'>
</div>
</div>
<div className='rb:p-4'>
<KnowledgeGraph data={data} loading={loading} />
</div>
</div>
)
}
export default KnowledgeGraphCard

View File

@@ -0,0 +1,163 @@
# KnowledgeGraph 组件
基于 ECharts 的知识图谱可视化组件,用于展示知识库中实体之间的关系网络。
## 功能特性
- 🎯 **交互式图谱**: 支持节点点击、拖拽、缩放等交互操作
- 🎨 **实体分类**: 根据实体类型自动分配颜色和图例
- 📊 **智能布局**: 基于力导向算法的自动布局
- 🔍 **详情展示**: 点击节点查看实体详细信息
- 📱 **响应式设计**: 自适应容器大小变化
- 🌐 **国际化支持**: 支持中英文切换
## 数据结构
### KnowledgeGraphResponse
```typescript
interface KnowledgeGraphResponse {
code: number
msg: string
data: {
graph: KnowledgeGraphData
mind_map: Record<string, unknown>
}
error: string
time: number
}
```
### KnowledgeNode
```typescript
interface KnowledgeNode {
id: string // 节点唯一标识
entity_name: string // 实体名称
entity_type: string // 实体类型 (ORGANIZATION, PERSON, EVENT, CATEGORY, etc.)
description: string // 实体描述
pagerank: number // PageRank 重要度分数
source_id: string[] // 来源文档ID列表
}
```
### KnowledgeEdge
```typescript
interface KnowledgeEdge {
src_id: string // 源节点ID
tgt_id: string // 目标节点ID
description: string // 关系描述
keywords: string[] // 关键词
weight: number // 关系权重
source_id: string[] // 来源文档ID列表
source: string // 源节点名称
target: string // 目标节点名称
}
```
## 使用方法
### 基础用法
```tsx
import KnowledgeGraph from './components/KnowledgeGraph'
const MyComponent = () => {
const [graphData, setGraphData] = useState<KnowledgeGraphResponse>()
const [loading, setLoading] = useState(false)
return (
<KnowledgeGraph
data={graphData}
loading={loading}
/>
)
}
```
### 完整示例
```tsx
import React, { useState, useEffect } from 'react'
import { Row } from 'antd'
import KnowledgeGraph from './components/KnowledgeGraph'
const KnowledgeBasePage = () => {
const [data, setData] = useState()
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchKnowledgeGraph = async () => {
setLoading(true)
try {
const response = await api.getKnowledgeGraph(knowledgeBaseId)
setData(response)
} catch (error) {
console.error('Failed to fetch knowledge graph:', error)
} finally {
setLoading(false)
}
}
fetchKnowledgeGraph()
}, [knowledgeBaseId])
return (
<Row gutter={[16, 16]}>
<KnowledgeGraph data={data} loading={loading} />
</Row>
)
}
```
## 组件属性
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| data | KnowledgeGraphResponse | undefined | 知识图谱数据 |
| loading | boolean | false | 加载状态 |
## 实体类型颜色
组件内置了以下实体类型的颜色映射:
- `ORGANIZATION`: #155EEF (蓝色)
- `PERSON`: #4DA8FF (浅蓝色)
- `EVENT`: #9C6FFF (紫色)
- `CATEGORY`: #8BAEF7 (淡蓝色)
- `LOCATION`: #369F21 (绿色)
- `TIME`: #FF5D34 (橙红色)
- `CONCEPT`: #FF8A4C (橙色)
- `OTHER`: #FFB048 (黄色)
## 交互功能
1. **节点点击**: 点击节点查看实体详细信息
2. **拖拽**: 拖拽节点调整位置
3. **缩放**: 鼠标滚轮缩放图谱
4. **悬停**: 悬停显示节点和边的详细信息
5. **高亮**: 点击节点高亮相邻节点和边
## 国际化
组件使用以下翻译键:
- `knowledgeBase.knowledgeGraph`: 知识图谱标题
- `knowledgeBase.entityDetails`: 实体详情标题
- `knowledgeBase.entityDetailEmpty`: 空状态提示
- `knowledgeBase.entityDetailEmptyDesc`: 空状态描述
- `knowledgeBase.entityDescription`: 实体描述标签
- `knowledgeBase.sourceDocuments`: 来源文档标签
- `userMemory.click/drag/zoom`: 操作说明
## 性能优化
- 使用 `React.memo` 避免不必要的重渲染
- 使用 `ResizeObserver` 监听容器大小变化
- 使用 `requestAnimationFrame` 优化图表重绘
- 延迟更新和懒加载提升大数据集性能
## 注意事项
1. 确保传入的数据结构符合 `KnowledgeGraphResponse` 接口
2. 节点的 `pagerank` 值用于计算节点大小,建议范围在 0-1 之间
3. 边的 `weight` 值用于计算连线粗细,建议使用正数
4. 大数据集可能影响渲染性能,建议进行数据分页或过滤