引入 Element Plus 主题定制和主题皮肤 composable,将全局 样式拆分为组件级独立 CSS 文件(侧边栏、顶栏、工作台等), 统一色彩变量和间距规范,重构所有视图和组件样式以适配新 主题系统,优化图表和知识图谱组件视觉表现,提取审计和差 旅报销相关子组件。
644 lines
14 KiB
Vue
644 lines
14 KiB
Vue
<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>
|