ForcePilot/web/src/components/KnowledgeGraphSection.vue
Wenjie Zhang 3d63418adf feat: 实现统一图谱适配器及前端组件重构
重构图谱系统架构,引入适配器模式支持多种图数据库类型。主要变更包括:

1. 新增 GraphAdapter 基类及 LightRAG/Upload 适配器实现
2. 重构前端组件结构,新增 GraphDetailPanel 等可复用组件
3. 实现 useGraph 组合式函数统一管理图谱状态
4. 优化 Neo4j 节点/边数据标准化处理
5. 新增统一图谱 API 接口及对应测试用例
6. 改进前端交互体验,增加节点详情展示等功能

前端组件库重构为模块化结构,提升代码复用性。适配器模式使系统可灵活支持不同图数据源,为后续扩展奠定基础。

Next:上传图谱文件的时候,支持属性解析
2025-12-15 23:25:56 +08:00

269 lines
6.4 KiB
Vue

<template>
<div class="graph-section" v-if="isGraphSupported">
<div class="graph-container-compact">
<div v-if="!isGraphSupported" class="graph-disabled">
<div class="disabled-content">
<h4>知识图谱不可用</h4>
<p>当前知识库类型 "{{ kbTypeLabel }}" 不支持知识图谱功能。</p>
<p>只有 LightRAG 类型的知识库支持知识图谱。</p>
</div>
</div>
<div v-else class="graph-wrapper">
<GraphCanvas
ref="graphRef"
:graph-data="graph.graphData"
@node-click="graph.handleNodeClick"
@edge-click="graph.handleEdgeClick"
@canvas-click="graph.handleCanvasClick"
>
<template #top>
<div class="compact-actions">
<a-input-search
v-model:value="searchInput"
placeholder="搜索实体"
style="width: 200px"
@search="onSearch"
allow-clear
/>
<a-button
type="text"
:icon="h(ReloadOutlined)"
:loading="graph.fetching"
@click="loadGraph"
title="刷新"
/>
<a-button
type="text"
:icon="h(SettingOutlined)"
@click="showSettings = true"
title="设置"
/>
</div>
</template>
</GraphCanvas>
<!-- 详情浮动卡片 -->
<GraphDetailPanel
:visible="graph.showDetailDrawer"
:item="graph.selectedItem"
:type="graph.selectedItemType"
@close="graph.handleCanvasClick"
style="top: 50px; right: 10px;"
/>
</div>
</div>
<!-- 设置模态框 -->
<a-modal
v-model:open="showSettings"
title="图谱设置"
:footer="null"
width="300px"
>
<div class="settings-form">
<a-form layout="vertical">
<a-form-item label="最大节点数 (limit)">
<a-input-number
v-model:value="graphLimit"
:min="10"
:max="1000"
:step="10"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="搜索深度 (depth)">
<a-input-number
v-model:value="graphDepth"
:min="1"
:max="5"
:step="1"
style="width: 100%"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="applySettings" style="width: 100%">
应用
</a-button>
</a-form-item>
</a-form>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onUnmounted, reactive, h } from 'vue';
import { useDatabaseStore } from '@/stores/database';
import { ReloadOutlined, SettingOutlined } from '@ant-design/icons-vue';
import GraphCanvas from '@/components/GraphCanvas.vue';
import GraphDetailPanel from '@/components/GraphDetailPanel.vue';
import { getKbTypeLabel } from '@/utils/kb_utils';
import { unifiedApi } from '@/apis/graph_api';
import { message } from 'ant-design-vue';
import { useGraph } from '@/composables/useGraph';
const props = defineProps({
active: {
type: Boolean,
default: false,
},
});
const store = useDatabaseStore();
const databaseId = computed(() => store.databaseId);
const kbType = computed(() => store.database.kb_type);
const kbTypeLabel = computed(() => getKbTypeLabel(kbType.value || 'lightrag'));
const graphRef = ref(null);
const showSettings = ref(false);
const graphLimit = ref(50);
const graphDepth = ref(2);
const searchInput = ref('');
const graph = reactive(useGraph(graphRef));
// 计算属性:是否支持知识图谱
const isGraphSupported = computed(() => {
const type = kbType.value?.toLowerCase();
return type === 'lightrag';
});
let pendingLoadTimer = null;
const loadGraph = async () => {
if (!databaseId.value || !isGraphSupported.value) return;
graph.fetching = true;
try {
const res = await unifiedApi.getSubgraph({
db_id: databaseId.value,
node_label: searchInput.value || '*',
max_nodes: graphLimit.value,
max_depth: graphDepth.value
});
if (res.success && res.data) {
graph.updateGraphData(res.data.nodes, res.data.edges);
}
} catch (e) {
console.error('Failed to load graph:', e);
message.error('加载图谱失败');
} finally {
graph.fetching = false;
}
};
const applySettings = () => {
showSettings.value = false;
loadGraph();
};
const onSearch = () => {
loadGraph();
}
const scheduleGraphLoad = (delay = 200) => {
// 确保组件激活且数据库支持图谱功能
if (!props.active || !isGraphSupported.value || !databaseId.value) {
return;
}
if (pendingLoadTimer) {
clearTimeout(pendingLoadTimer);
}
pendingLoadTimer = setTimeout(async () => {
await nextTick();
if (props.active && isGraphSupported.value && databaseId.value) {
await loadGraph();
}
}, delay);
};
watch(
() => props.active,
(active) => {
if (active) {
scheduleGraphLoad();
}
},
{ immediate: true }
);
watch(databaseId, () => {
graph.clearGraph();
if (isGraphSupported.value) {
scheduleGraphLoad(300);
}
});
watch(isGraphSupported, (supported) => {
if (!supported) {
graph.clearGraph();
return;
}
scheduleGraphLoad(200);
});
onUnmounted(() => {
if (pendingLoadTimer) {
clearTimeout(pendingLoadTimer);
pendingLoadTimer = null;
}
});
</script>
<style scoped lang="less">
.graph-section {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.graph-container-compact {
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
}
.graph-wrapper {
height: 100%;
width: 100%;
position: relative;
}
.compact-actions {
position: absolute;
top: 10px;
left: 10px;
display: flex;
align-items: center;
gap: 8px;
background: var(--color-trans-light);
backdrop-filter: blur(4px);
padding: 6px;
border-radius: 8px;
box-shadow: 0 0px 4px var(--shadow-3);
}
.graph-disabled {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.disabled-content {
text-align: center;
color: var(--gray-400);
h4 {
margin-bottom: 8px;
}
}
</style>