325 lines
7.7 KiB
Vue
325 lines
7.7 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">
|
|
<div class="actions-left">
|
|
<a-input
|
|
v-model:value="searchInput"
|
|
placeholder="搜索实体"
|
|
style="width: 240px"
|
|
@keydown.enter="onSearch"
|
|
allow-clear
|
|
>
|
|
<template #suffix>
|
|
<component
|
|
:is="graph.fetching ? LoadingOutlined : SearchOutlined"
|
|
@click="onSearch"
|
|
/>
|
|
</template>
|
|
</a-input>
|
|
<a-button
|
|
class="action-btn"
|
|
:icon="h(ReloadOutlined)"
|
|
:loading="graph.fetching"
|
|
@click="loadGraph"
|
|
title="刷新"
|
|
/>
|
|
</div>
|
|
<div class="actions-right">
|
|
<a-button
|
|
class="action-btn"
|
|
:icon="h(SettingOutlined)"
|
|
@click="showSettings = true"
|
|
title="设置"
|
|
/>
|
|
</div>
|
|
</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,
|
|
SearchOutlined,
|
|
LoadingOutlined
|
|
} 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;
|
|
right: 10px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
pointer-events: none; /* Let clicks pass through empty areas */
|
|
|
|
.actions-left,
|
|
.actions-right {
|
|
pointer-events: auto; /* Re-enable clicks for buttons/inputs */
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
background: var(--color-trans-light);
|
|
backdrop-filter: blur(4px);
|
|
padding: 2px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 0 4px 0px var(--shadow-2);
|
|
}
|
|
|
|
:deep(.ant-input-affix-wrapper) {
|
|
padding: 4px 11px;
|
|
border-radius: 6px;
|
|
border-color: transparent;
|
|
box-shadow: none;
|
|
background: var(--color-trans-light);
|
|
|
|
&:hover,
|
|
&:focus,
|
|
&-focused {
|
|
background: var(--main-0);
|
|
border-color: var(--primary-color);
|
|
}
|
|
|
|
input {
|
|
background: transparent;
|
|
}
|
|
}
|
|
|
|
.action-btn {
|
|
width: 32px;
|
|
height: 32px;
|
|
padding: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--gray-600);
|
|
border-radius: 6px;
|
|
box-shadow: none;
|
|
|
|
&:hover {
|
|
background: rgba(0, 0, 0, 0.05);
|
|
color: var(--primary-color);
|
|
}
|
|
}
|
|
}
|
|
|
|
.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>
|