宽度调整

This commit is contained in:
2026-06-16 15:32:49 +08:00
parent 7b30435d5a
commit 5cc164479f
6 changed files with 513 additions and 12 deletions
+320
View File
@@ -0,0 +1,320 @@
<template>
<div class="knowledge-graph" ref="containerRef">
<svg class="graph-svg" :width="svgWidth" :height="svgHeight">
<!-- 连线 -->
<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>
</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>
.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%);
border: 1px solid var(--border-color);
border-radius: 16px;
overflow: hidden;
min-height: 300px;
padding: 16px;
}
.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>