新增: 依赖关系图(AntV X6 v3 + Vue 自定义节点)

- DependencyGraph.vue: X6 画布初始化 + 插件(框选/对齐线/撤销/滚动) + 工程节点渲染
- ModuleNode.vue: Vue 自定义节点组件(工程名/路径/技术栈标签)
- 项目详情新增依赖图Tab,与概览/文件并列
- 网格布局排列工程节点,支持缩放/拖拽/框选
- Phase 0 验证 demo(X6Demo.vue)保留在 /dev/x6 路由
This commit is contained in:
2026-06-30 23:57:43 +08:00
parent 4dac9fe5f1
commit 976ce29ad9
12 changed files with 1030 additions and 2 deletions

View File

@@ -0,0 +1,224 @@
<template>
<div class="dep-graph">
<div class="dep-graph__toolbar">
<span class="dep-graph__title">{{ $t('dependencyGraph.title') }}</span>
<div class="dep-graph__actions">
<button class="btn btn-ghost btn-sm" @click="fitContent">适应内容</button>
<button class="btn btn-ghost btn-sm" @click="zoomIn">+</button>
<button class="btn btn-ghost btn-sm" @click="zoomOut">-</button>
</div>
</div>
<div ref="containerRef" class="dep-graph__canvas" />
<div v-if="modules.length === 0 && !loading" class="dep-graph__empty">
<div class="empty-icon">📦</div>
<div>{{ $t('dependencyGraph.empty') }}</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* 依赖关系图组件 — 基于 AntV X6 v3 + vue-shape。
*
* 节点用 Vue 组件(ModuleNode.vue)渲染,通过 @antv/x6-vue-shape 注册。
*/
import { onMounted, onBeforeUnmount, ref, watch, markRaw } from 'vue'
import { Graph, Selection, Snapline, History, Scroller } from '@antv/x6'
import '@antv/x6-vue-shape'
import { moduleApi, type ProjectModuleRecord } from '@/api/module'
import ModuleNode from './ModuleNode.vue'
const props = defineProps<{
projectId: string
}>()
const emit = defineEmits<{
(e: 'select-module', moduleId: string): void
}>()
const containerRef = ref<HTMLElement | null>(null)
const modules = ref<ProjectModuleRecord[]>([])
const loading = ref(true)
let graph: Graph | null = null
async function loadModules() {
loading.value = true
try {
modules.value = await moduleApi.listProjectModules(props.projectId)
} catch {
modules.value = []
} finally {
loading.value = false
}
}
function renderGraph() {
if (!graph) return
const cols = 3
const nodeWidth = 200
const nodeHeight = 80
const gapX = 50
const gapY = 40
const nodes = modules.value.map((m, i) => {
const col = i % cols
const row = Math.floor(i / cols)
return {
id: m.id,
shape: 'vue-shape',
x: col * (nodeWidth + gapX) + 40,
y: row * (nodeHeight + gapY) + 40,
width: nodeWidth,
height: nodeHeight,
component: markRaw(ModuleNode),
data: {
name: m.name,
path: m.path,
stack: m.stack,
gitUrl: m.git_url,
},
}
})
// 边暂为空(module_dependencies 表未建)
graph.fromJSON({ nodes, edges: [] })
graph.centerContent()
}
function buildGraph() {
if (!containerRef.value) return
graph = new Graph({
container: containerRef.value,
background: { color: '#1a1a2e' },
grid: { visible: true, size: 10, type: 'dot', args: { color: '#2a2a4e' } },
mousewheel: { enabled: true, modifiers: ['ctrl'], minScale: 0.3, maxScale: 3 },
interacting: { nodeMovable: true },
})
graph.use(new Selection({ enabled: true, rubberband: true }))
graph.use(new Snapline({ enabled: true }))
graph.use(new History({ enabled: true }))
graph.use(new Scroller({ enabled: true, pannable: true }))
graph.on('node:click', ({ node }) => {
emit('select-module', String(node.id))
})
renderGraph()
}
function fitContent() {
graph?.zoomToFit({ padding: 20, maxScale: 1.5 })
}
function zoomIn() {
graph?.zoom(0.1)
}
function zoomOut() {
graph?.zoom(-0.1)
}
onMounted(async () => {
await loadModules()
buildGraph()
})
watch(() => props.projectId, async () => {
await loadModules()
renderGraph()
})
onBeforeUnmount(() => {
if (graph) {
graph.dispose()
graph = null
}
})
</script>
<style scoped>
.dep-graph {
display: flex;
flex-direction: column;
height: 100%;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg, 8px);
overflow: hidden;
position: relative;
}
.dep-graph__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 14px;
border-bottom: 0.5px solid var(--df-border);
background: var(--df-bg-card);
flex-shrink: 0;
}
.dep-graph__title {
font-size: 14px;
font-weight: 500;
color: var(--df-text);
}
.dep-graph__actions {
display: flex;
gap: 6px;
}
.dep-graph__canvas {
flex: 1;
min-height: 300px;
overflow: hidden;
}
.dep-graph__empty {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: var(--df-text-dim);
font-size: 13px;
pointer-events: none;
}
.empty-icon {
font-size: 32px;
opacity: 0.4;
}
.btn {
padding: 4px 12px;
border: none;
border-radius: var(--df-radius-sm, 4px);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.btn-ghost {
background: transparent;
color: var(--df-text-secondary);
border: 0.5px solid var(--df-border);
}
.btn-ghost:hover {
background: var(--df-bg);
color: var(--df-text);
}
.btn-sm {
padding: 4px 10px;
font-size: 11px;
}
</style>