Files
X-Financial/web/src/components/logs/KnowledgeIngestGraphView.vue
caoxiaozhu 2dcc72102d style: 全局 UI 主题皮肤重构与样式模块化
引入 Element Plus 主题定制和主题皮肤 composable,将全局
样式拆分为组件级独立 CSS 文件(侧边栏、顶栏、工作台等),
统一色彩变量和间距规范,重构所有视图和组件样式以适配新
主题系统,优化图表和知识图谱组件视觉表现,提取审计和差
旅报销相关子组件。
2026-05-27 09:17:57 +08:00

644 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>