2026-06-16 15:32:49 +08:00
|
|
|
|
<template>
|
2026-06-16 21:09:21 +08:00
|
|
|
|
<div class="kg-wrapper">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="knowledge-graph"
|
|
|
|
|
|
ref="containerRef"
|
|
|
|
|
|
:style="{ width: svgWidth + 'px', height: svgHeight + 'px' }"
|
|
|
|
|
|
>
|
|
|
|
|
|
<svg class="graph-svg" :width="svgWidth" :height="svgHeight">
|
2026-06-16 15:32:49 +08:00
|
|
|
|
<!-- 连线 -->
|
|
|
|
|
|
<path
|
|
|
|
|
|
v-for="(edge, i) in layoutEdges"
|
|
|
|
|
|
:key="'e' + i"
|
|
|
|
|
|
:d="edge.path"
|
|
|
|
|
|
class="graph-edge"
|
|
|
|
|
|
:class="{ highlighted: highlightedNode && (edge.source === highlightedNode || edge.target === highlightedNode) }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<!-- 连线标签 -->
|
|
|
|
|
|
<text
|
|
|
|
|
|
v-for="(edge, i) in layoutEdges"
|
|
|
|
|
|
:key="'el' + i"
|
|
|
|
|
|
:x="edge.labelX"
|
|
|
|
|
|
:y="edge.labelY"
|
|
|
|
|
|
class="edge-label"
|
|
|
|
|
|
:class="{ highlighted: highlightedNode && (edge.source === highlightedNode || edge.target === highlightedNode) }"
|
|
|
|
|
|
>{{ edge.label }}</text>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 结点 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="node in layoutNodes"
|
|
|
|
|
|
:key="node.id"
|
|
|
|
|
|
class="graph-node"
|
|
|
|
|
|
:class="{ highlighted: highlightedNode === node.id }"
|
|
|
|
|
|
:style="{
|
|
|
|
|
|
left: node.x + 'px',
|
|
|
|
|
|
top: node.y + 'px',
|
|
|
|
|
|
'--node-hue': node.hue
|
|
|
|
|
|
}"
|
|
|
|
|
|
@mouseenter="highlightedNode = node.id"
|
|
|
|
|
|
@mouseleave="highlightedNode = null"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="node-label">{{ node.label }}</div>
|
|
|
|
|
|
<div class="node-desc">{{ node.desc }}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-06-16 21:09:21 +08:00
|
|
|
|
</div>
|
2026-06-16 15:32:49 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
import { ref, computed, onMounted, watch } from 'vue'
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
|
|
nodes: { type: Array, default: () => [] },
|
|
|
|
|
|
edges: { type: Array, default: () => [] }
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const containerRef = ref(null)
|
|
|
|
|
|
const highlightedNode = ref(null)
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 布局计算 ----
|
|
|
|
|
|
const NODE_W = 160
|
|
|
|
|
|
const NODE_H = 56
|
|
|
|
|
|
const LEVEL_GAP = 160
|
|
|
|
|
|
const SIBLING_GAP = 24
|
|
|
|
|
|
const PADDING = 20
|
|
|
|
|
|
|
|
|
|
|
|
const layoutNodes = computed(() => {
|
|
|
|
|
|
if (!props.nodes.length) return []
|
|
|
|
|
|
|
|
|
|
|
|
// 建立邻接表
|
|
|
|
|
|
const children = {}
|
|
|
|
|
|
const parent = {}
|
|
|
|
|
|
const indeg = {}
|
|
|
|
|
|
for (const n of props.nodes) { indeg[n.id] = 0; children[n.id] = [] }
|
|
|
|
|
|
for (const e of props.edges) {
|
|
|
|
|
|
if (children[e.source]) children[e.source].push(e.target)
|
|
|
|
|
|
parent[e.target] = e.source
|
|
|
|
|
|
indeg[e.target] = (indeg[e.target] || 0) + 1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 找根结点(入度为0)
|
|
|
|
|
|
const roots = props.nodes.filter(n => (indeg[n.id] || 0) === 0).map(n => n.id)
|
|
|
|
|
|
const rootId = roots[0] || props.nodes[0].id
|
|
|
|
|
|
|
|
|
|
|
|
// 计算层级(BFS)
|
|
|
|
|
|
const level = {}
|
|
|
|
|
|
const queue = [rootId]
|
|
|
|
|
|
level[rootId] = 0
|
|
|
|
|
|
let maxLevel = 0
|
|
|
|
|
|
const seen = new Set([rootId])
|
|
|
|
|
|
while (queue.length) {
|
|
|
|
|
|
const cur = queue.shift()
|
|
|
|
|
|
for (const ch of children[cur] || []) {
|
|
|
|
|
|
if (!seen.has(ch)) {
|
|
|
|
|
|
seen.add(ch)
|
|
|
|
|
|
level[ch] = level[cur] + 1
|
|
|
|
|
|
if (level[ch] > maxLevel) maxLevel = level[ch]
|
|
|
|
|
|
queue.push(ch)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 按层级分组
|
|
|
|
|
|
const byLevel = {}
|
|
|
|
|
|
for (const n of props.nodes) {
|
|
|
|
|
|
const lv = level[n.id] !== undefined ? level[n.id] : maxLevel
|
|
|
|
|
|
if (!byLevel[lv]) byLevel[lv] = []
|
|
|
|
|
|
byLevel[lv].push(n.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 计算每个结点 X 位置(同层均匀分布)
|
|
|
|
|
|
const xPos = {}
|
|
|
|
|
|
for (const lv of Object.keys(byLevel).map(Number).sort((a, b) => a - b)) {
|
|
|
|
|
|
const ids = byLevel[lv]
|
|
|
|
|
|
const totalW = ids.length * NODE_W + (ids.length - 1) * SIBLING_GAP
|
|
|
|
|
|
ids.forEach((id, i) => {
|
|
|
|
|
|
xPos[id] = PADDING + i * (NODE_W + SIBLING_GAP) + NODE_W / 2
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 计算 Y 位置
|
|
|
|
|
|
const yPos = {}
|
|
|
|
|
|
for (const n of props.nodes) {
|
|
|
|
|
|
const lv = level[n.id] !== undefined ? level[n.id] : maxLevel
|
|
|
|
|
|
yPos[n.id] = PADDING + lv * (NODE_H + LEVEL_GAP) + NODE_H / 2
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 分配颜色
|
|
|
|
|
|
const hues = [210, 260, 180, 30, 330, 160]
|
|
|
|
|
|
const nodeColors = {}
|
|
|
|
|
|
for (const n of props.nodes) {
|
|
|
|
|
|
const lv = level[n.id] !== undefined ? level[n.id] : 0
|
|
|
|
|
|
nodeColors[n.id] = hues[lv % hues.length]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return props.nodes.map(n => ({
|
|
|
|
|
|
...n,
|
|
|
|
|
|
x: xPos[n.id] - NODE_W / 2,
|
|
|
|
|
|
y: yPos[n.id] - NODE_H / 2,
|
|
|
|
|
|
cx: xPos[n.id],
|
|
|
|
|
|
cy: yPos[n.id],
|
|
|
|
|
|
hue: nodeColors[n.id]
|
|
|
|
|
|
}))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const layoutEdges = computed(() => {
|
|
|
|
|
|
if (!props.edges.length || !layoutNodes.value.length) return []
|
|
|
|
|
|
const nodeMap = {}
|
|
|
|
|
|
for (const n of layoutNodes.value) nodeMap[n.id] = n
|
|
|
|
|
|
|
|
|
|
|
|
return props.edges.map(e => {
|
|
|
|
|
|
const src = nodeMap[e.source]
|
|
|
|
|
|
const tgt = nodeMap[e.target]
|
|
|
|
|
|
if (!src || !tgt) return null
|
|
|
|
|
|
|
|
|
|
|
|
const x1 = src.cx, y1 = src.cy + NODE_H / 2
|
|
|
|
|
|
const x2 = tgt.cx, y2 = tgt.cy - NODE_H / 2
|
|
|
|
|
|
const cy = (y1 + y2) / 2
|
|
|
|
|
|
|
|
|
|
|
|
// 三次贝塞尔曲线
|
|
|
|
|
|
const path = `M ${x1} ${y1} C ${x1} ${cy}, ${x2} ${cy}, ${x2} ${y2}`
|
|
|
|
|
|
|
|
|
|
|
|
// 标签位置(曲线中点偏左)
|
|
|
|
|
|
const labelX = (x1 + x2) / 2
|
|
|
|
|
|
const labelY = (y1 + y2) / 2 - 6
|
|
|
|
|
|
|
|
|
|
|
|
return { source: e.source, target: e.target, label: e.label, path, labelX, labelY }
|
|
|
|
|
|
}).filter(Boolean)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// SVG 尺寸
|
|
|
|
|
|
const svgWidth = computed(() => {
|
|
|
|
|
|
if (!layoutNodes.value.length) return 400
|
|
|
|
|
|
const maxX = Math.max(...layoutNodes.value.map(n => n.x + NODE_W))
|
|
|
|
|
|
return maxX + PADDING
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const svgHeight = computed(() => {
|
|
|
|
|
|
if (!layoutNodes.value.length) return 300
|
|
|
|
|
|
const maxY = Math.max(...layoutNodes.value.map(n => n.y + NODE_H))
|
|
|
|
|
|
return maxY + PADDING
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-06-16 21:09:21 +08:00
|
|
|
|
.kg-wrapper {
|
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
|
overflow-y: visible;
|
|
|
|
|
|
-webkit-overflow-scrolling: touch;
|
|
|
|
|
|
border: 1px solid var(--border-color);
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 15:32:49 +08:00
|
|
|
|
.knowledge-graph {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
background:
|
|
|
|
|
|
radial-gradient(circle at 20% 30%, rgba(59, 130, 246, 0.03), transparent 60%),
|
|
|
|
|
|
radial-gradient(circle at 80% 70%, rgba(139, 92, 246, 0.03), transparent 60%);
|
2026-06-16 21:09:21 +08:00
|
|
|
|
overflow: visible;
|
2026-06-16 15:32:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.graph-svg {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
overflow: visible;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.graph-edge {
|
|
|
|
|
|
fill: none;
|
|
|
|
|
|
stroke: var(--border-color);
|
|
|
|
|
|
stroke-width: 1.5;
|
|
|
|
|
|
stroke-linecap: round;
|
|
|
|
|
|
transition: stroke 0.3s, stroke-width 0.3s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.graph-edge.highlighted {
|
|
|
|
|
|
stroke: var(--primary-color);
|
|
|
|
|
|
stroke-width: 2.5;
|
|
|
|
|
|
filter: drop-shadow(0 0 4px rgba(59, 130, 246, 0.3));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.edge-label {
|
|
|
|
|
|
fill: var(--text-tertiary);
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
text-anchor: middle;
|
|
|
|
|
|
font-family: var(--sans);
|
|
|
|
|
|
transition: fill 0.3s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.edge-label.highlighted {
|
|
|
|
|
|
fill: var(--primary-color);
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---- 结点 ---- */
|
|
|
|
|
|
.graph-node {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
width: 160px;
|
|
|
|
|
|
min-height: 48px;
|
|
|
|
|
|
background: var(--card-bg);
|
|
|
|
|
|
border: 1.5px solid var(--border-color);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
padding: 8px 12px;
|
|
|
|
|
|
cursor: default;
|
|
|
|
|
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
|
|
|
|
|
z-index: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.graph-node::before {
|
|
|
|
|
|
content: '';
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
inset: -2px;
|
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
|
background: linear-gradient(135deg,
|
|
|
|
|
|
hsla(var(--node-hue, 210), 70%, 55%, 0.2),
|
|
|
|
|
|
hsla(calc(var(--node-hue, 210) + 30), 70%, 55%, 0.05));
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transition: opacity 0.3s;
|
|
|
|
|
|
z-index: -1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.graph-node:hover,
|
|
|
|
|
|
.graph-node.highlighted {
|
|
|
|
|
|
transform: translateY(-3px) scale(1.03);
|
|
|
|
|
|
border-color: hsl(var(--node-hue, 210), 70%, 55%);
|
|
|
|
|
|
box-shadow:
|
|
|
|
|
|
0 8px 24px rgba(0,0,0,0.1),
|
|
|
|
|
|
0 0 0 1px hsla(var(--node-hue, 210), 70%, 55%, 0.15);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.graph-node:hover::before,
|
|
|
|
|
|
.graph-node.highlighted::before {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.node-label {
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
|
line-height: 1.3;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.node-desc {
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
color: var(--text-tertiary);
|
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
|
line-height: 1.3;
|
|
|
|
|
|
display: -webkit-box;
|
|
|
|
|
|
-webkit-line-clamp: 2;
|
|
|
|
|
|
-webkit-box-orient: vertical;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 深色模式适配 */
|
|
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
|
|
|
|
.graph-node {
|
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
|
|
|
|
}
|
|
|
|
|
|
.graph-node:hover,
|
|
|
|
|
|
.graph-node.highlighted {
|
|
|
|
|
|
box-shadow:
|
|
|
|
|
|
0 8px 24px rgba(0,0,0,0.3),
|
|
|
|
|
|
0 0 0 1px hsla(var(--node-hue, 210), 70%, 55%, 0.15);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 响应式 */
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.graph-node {
|
|
|
|
|
|
width: 120px;
|
|
|
|
|
|
min-height: 40px;
|
|
|
|
|
|
padding: 6px 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.node-label {
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.node-desc {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|