Files
X-Financial/web/src/components/logs/KnowledgeIngestGraphView.vue

644 lines
14 KiB
Vue
Raw Normal View History

<template>
<section class="knowledge-graph-space">
<header class="graph-head">
<div>
<span class="graph-eyebrow">LightRAG 知识图谱</span>
<h4>实体关系空间</h4>
</div>
<div class="graph-toolbar">
<label class="graph-search">
<i class="mdi mdi-magnify"></i>
<input
v-model.trim="graphQuery"
type="search"
placeholder="搜索实体"
@keydown.enter.prevent="focusMatchedNode"
/>
</label>
<button type="button" title="定位匹配实体" @click="focusMatchedNode">
<i class="mdi mdi-crosshairs-gps"></i>
</button>
<button type="button" title="缩小" @click="zoomGraph(0.86)">
<i class="mdi mdi-magnify-minus-outline"></i>
</button>
<button type="button" title="放大" @click="zoomGraph(1.16)">
<i class="mdi mdi-magnify-plus-outline"></i>
</button>
<button type="button" title="适配画布" @click="fitGraph">
<i class="mdi mdi-fit-to-page-outline"></i>
</button>
</div>
</header>
<div class="graph-stats">
<span>{{ graphSummary.entityCount }} 实体</span>
<span>{{ graphSummary.relationCount }} 关系</span>
<span>{{ graphSummary.visibleNodeCount }} 节点可见</span>
<span>{{ graphSummary.visibleEdgeCount }} 连线可见</span>
</div>
<div class="graph-body" :class="{ empty: !graphData.nodes.length }">
<div class="graph-theater">
<div v-if="graphData.nodes.length" ref="graphContainer" class="g6-canvas"></div>
<div v-else class="graph-empty">
<strong>暂无图谱数据</strong>
<span>本次归集还没有返回可展示的实体关系</span>
</div>
<div v-if="graphData.nodes.length" class="graph-hud">
<span>G6 Force</span>
<span>拖拽节点</span>
<span>滚轮缩放</span>
<span>点击查看详情</span>
</div>
</div>
<aside class="graph-inspector">
<template v-if="selectedNode">
<div class="inspector-title">
<span>节点详情</span>
<h5>{{ selectedNode.name }}</h5>
</div>
<div class="node-facts">
<div>
<span>关系数</span>
<strong>{{ selectedNode.degree }}</strong>
</div>
<div>
<span>入边</span>
<strong>{{ selectedNodeIncoming.length }}</strong>
</div>
<div>
<span>出边</span>
<strong>{{ selectedNodeOutgoing.length }}</strong>
</div>
</div>
<div class="node-meta">
<span>类型</span>
<strong>{{ selectedNode.type || '实体' }}</strong>
</div>
<KnowledgeIngestNodeDetails
:node="selectedNode"
:incoming="selectedNodeIncoming"
:outgoing="selectedNodeOutgoing"
:relations="selectedNodeRelations"
@focus-peer="focusRelationPeer"
/>
</template>
<div v-else class="inspector-empty">
<strong>选择一个实体</strong>
<span>点击图谱中的节点查看它的入边出边和关系明细</span>
</div>
</aside>
</div>
</section>
</template>
<script setup>
import { Graph, NodeEvent } from '@antv/g6'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import KnowledgeIngestNodeDetails from './KnowledgeIngestNodeDetails.vue'
import { useKnowledgeIngestGraph } from './useKnowledgeIngestGraph.js'
const props = defineProps({
graph: {
type: Object,
default: () => ({})
},
documents: {
type: Array,
default: () => []
}
})
const graphContainer = ref(null)
let graphInstance = null
let resizeObserver = null
const {
activeNodeId,
graphData,
graphQuery,
graphSummary,
selectNodeById,
focusFirstMatch,
selectRelationPeer,
selectedNode,
selectedNodeIncoming,
selectedNodeOutgoing,
selectedNodeRelations
} = useKnowledgeIngestGraph(props)
onMounted(() => {
initGraph()
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
graphInstance?.destroy()
resizeObserver = null
graphInstance = null
})
watch(
graphData,
() => {
renderGraph()
},
{ deep: true }
)
watch(activeNodeId, (nodeId) => {
syncGraphSelection(nodeId)
})
async function initGraph() {
if (!graphData.value.nodes.length) return
await nextTick()
if (!graphContainer.value || graphInstance) return
graphInstance = new Graph(buildGraphOptions())
graphInstance.on(NodeEvent.CLICK, async (event) => {
const nodeId = String(event?.target?.id || '').trim()
if (!nodeId) return
selectNodeById(nodeId)
await focusGraphNode(nodeId, { focus: false })
})
await graphInstance.render()
await syncGraphSelection(activeNodeId.value)
observeResize()
await fitGraph()
}
function buildGraphOptions() {
return {
container: graphContainer.value,
autoResize: true,
background: 'transparent',
zoomRange: [0.22, 2.8],
autoFit: {
type: 'view',
options: { padding: [86, 122, 86, 122] },
animation: { duration: 520, easing: 'ease-in-out' }
},
data: graphData.value,
layout: {
type: 'd3-force',
preventOverlap: true,
centerStrength: 0.34,
linkDistance: (edge) => (edge?.data?.weight > 2 ? 155 : 210),
edgeStrength: 0.22,
nodeStrength: (node) => (node?.data?.degree >= 5 ? -520 : -360),
collide: {
radius: (node) => 40 + Math.sqrt(Math.max(node?.data?.degree || 1, 1)) * 12,
strength: 0.88,
iterations: 3
},
manyBody: {
strength: (node) => (node?.data?.degree >= 5 ? -620 : -420),
distanceMax: 620
},
center: {
strength: 0.24
},
alpha: 0.88,
alphaDecay: 0.035,
velocityDecay: 0.32,
iterations: 360
},
node: {
type: 'circle',
style: (datum) => datum.style,
state: {
selected: {
lineWidth: 4,
stroke: '#1d4ed8',
halo: true,
haloStroke: '#60a5fa',
haloLineWidth: 24,
haloStrokeOpacity: 0.24,
shadowBlur: 22
},
active: {
lineWidth: 3,
halo: true,
haloLineWidth: 16,
haloStrokeOpacity: 0.28
},
inactive: {
opacity: 0.2
},
dimmed: {
opacity: 0.18
}
}
},
edge: {
type: 'line',
style: (datum) => datum.style,
state: {
selected: {
stroke: '#2563eb',
lineWidth: 2.8,
opacity: 0.96,
halo: true,
haloStroke: '#93c5fd',
haloLineWidth: 8,
haloStrokeOpacity: 0.2
},
active: {
stroke: '#60a5fa',
lineWidth: 2.4,
opacity: 0.9
},
inactive: {
opacity: 0.14
}
}
},
behaviors: [
{ type: 'drag-canvas', key: 'drag-canvas' },
{ type: 'zoom-canvas', key: 'zoom-canvas', sensitivity: 1.15 },
{ type: 'drag-element-force', key: 'drag-element-force', fixed: true }
],
animation: { duration: 420, easing: 'ease-out' }
}
}
async function renderGraph() {
if (!graphData.value.nodes.length) return
if (!graphInstance) {
await initGraph()
return
}
graphInstance.setData(graphData.value)
await graphInstance.render()
await syncGraphSelection(activeNodeId.value)
await fitGraph()
}
async function syncGraphSelection(nodeId) {
if (!graphInstance || graphInstance.destroyed || !nodeId) return
const stateMap = {}
for (const node of graphData.value.nodes) {
if (node.id === nodeId) {
stateMap[node.id] = ['selected']
} else if (node.states?.includes('dimmed')) {
stateMap[node.id] = ['dimmed']
} else {
stateMap[node.id] = []
}
}
for (const edge of graphData.value.edges) {
stateMap[edge.id] = edge.source === nodeId || edge.target === nodeId ? ['selected'] : ['inactive']
}
await graphInstance.setElementState(stateMap, false)
}
async function fitGraph() {
if (!graphInstance || graphInstance.destroyed) return
await graphInstance.fitView(
{ padding: [84, 118, 84, 118] },
{ duration: 420, easing: 'ease-in-out' }
)
}
async function zoomGraph(ratio) {
if (!graphInstance || graphInstance.destroyed) return
await graphInstance.zoomBy(ratio, { duration: 220, easing: 'ease-out' })
}
async function focusMatchedNode() {
const nodeId = focusFirstMatch()
if (nodeId) await focusGraphNode(nodeId)
}
async function focusRelationPeer(relation) {
const nodeId = selectRelationPeer(relation)
if (nodeId) await focusGraphNode(nodeId)
}
async function focusGraphNode(nodeId, options = {}) {
if (!graphInstance || graphInstance.destroyed) return
await syncGraphSelection(nodeId)
if (options.focus === false) return
await graphInstance.focusElement(nodeId, { duration: 360, easing: 'ease-in-out' })
}
function observeResize() {
if (!graphContainer.value || typeof ResizeObserver === 'undefined') return
resizeObserver = new ResizeObserver(() => {
if (!graphInstance || graphInstance.destroyed) return
graphInstance.setSize(graphContainer.value.clientWidth, graphContainer.value.clientHeight)
})
resizeObserver.observe(graphContainer.value)
}
</script>
<style scoped>
.knowledge-graph-space {
min-height: 1080px;
display: grid;
grid-template-rows: auto auto minmax(1020px, 1fr);
gap: 12px;
overflow: hidden;
}
.graph-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.graph-eyebrow {
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 850;
}
.graph-head h4 {
margin: 4px 0 0;
color: #0f172a;
font-size: 15px;
}
.graph-toolbar,
.graph-stats {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.graph-search {
min-width: 230px;
min-height: 32px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 9px;
border: 1px solid #dbe6ef;
border-radius: 8px;
background: #fff;
color: #64748b;
}
.graph-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: #0f172a;
font-size: 12px;
}
.graph-toolbar button {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid #dbe6ef;
border-radius: 8px;
background: #fff;
color: #475569;
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.graph-toolbar button:hover {
border-color: #2563eb;
color: #2563eb;
transform: translateY(-1px);
}
.graph-stats {
justify-content: flex-start;
}
.graph-stats span {
min-height: 26px;
display: inline-flex;
align-items: center;
padding: 0 9px;
border: 1px solid #dbe6ef;
border-radius: 999px;
background: #fff;
color: #475569;
font-size: 12px;
font-weight: 750;
}
.graph-body {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 14px;
overflow: hidden;
}
.graph-theater {
position: relative;
min-height: 1020px;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 8px;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 54%, #eef4fb 100%);
box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.82);
}
.graph-theater::before {
position: absolute;
inset: 0;
content: '';
pointer-events: none;
background:
linear-gradient(rgba(148, 163, 184, 0.18) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, 0.18) 1px, transparent 1px);
background-size: 34px 34px;
mask-image: radial-gradient(circle at center, black 0%, black 58%, transparent 100%);
}
.g6-canvas {
position: absolute;
inset: 0;
z-index: 1;
cursor: grab;
}
.g6-canvas:active {
cursor: grabbing;
}
.graph-hud {
position: absolute;
left: 14px;
right: 14px;
bottom: 12px;
z-index: 2;
display: flex;
flex-wrap: wrap;
gap: 8px;
pointer-events: none;
}
.graph-hud span {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border: 1px solid rgba(203, 213, 225, 0.86);
border-radius: 999px;
background: rgba(255, 255, 255, 0.76);
color: #334155;
font-size: 11px;
font-weight: 750;
backdrop-filter: blur(10px);
}
.graph-inspector {
min-height: 0;
display: grid;
align-content: start;
gap: 12px;
overflow: auto;
padding: 12px;
border: 1px solid #dbe6ef;
border-radius: 8px;
background: #fff;
}
.inspector-title span {
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 850;
}
.inspector-title h5 {
margin: 4px 0 0;
color: #0f172a;
font-size: 15px;
line-height: 1.35;
word-break: break-word;
}
.node-facts {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.node-facts div,
.node-meta {
min-width: 0;
display: grid;
gap: 4px;
padding: 9px;
border-radius: 8px;
background: #f8fafc;
}
.node-meta {
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
}
.node-facts span,
.node-meta span {
color: #64748b;
font-size: 12px;
}
.node-facts strong,
.node-meta strong {
min-width: 0;
overflow: hidden;
color: #0f172a;
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.graph-empty,
.inspector-empty {
min-height: 120px;
display: grid;
place-items: center;
align-content: center;
gap: 6px;
color: #64748b;
text-align: center;
}
.graph-empty {
position: absolute;
inset: 0;
z-index: 1;
}
.graph-empty strong,
.inspector-empty strong {
color: #0f172a;
font-size: 14px;
}
.inspector-empty strong {
color: #0f172a;
}
.graph-empty span,
.inspector-empty span {
font-size: 12px;
}
@media (prefers-reduced-motion: reduce) {
.graph-toolbar button {
transition: none;
}
}
@media (max-width: 980px) {
.knowledge-graph-space {
min-height: 980px;
grid-template-rows: auto auto minmax(900px, 1fr);
}
.graph-head {
flex-direction: column;
}
.graph-toolbar {
justify-content: flex-start;
}
.graph-body {
grid-template-columns: 1fr;
grid-template-rows: minmax(780px, 1fr) minmax(220px, auto);
}
.graph-theater {
min-height: 780px;
}
.graph-inspector {
max-height: 240px;
}
}
@media (max-width: 620px) {
.graph-search {
min-width: 100%;
}
.graph-toolbar {
width: 100%;
}
}
</style>