新增: 依赖关系图(AntV X6 v3 + Vue 自定义节点)
- DependencyGraph.vue: X6 画布初始化 + 插件(框选/对齐线/撤销/滚动) + 工程节点渲染 - ModuleNode.vue: Vue 自定义节点组件(工程名/路径/技术栈标签) - 项目详情新增依赖图Tab,与概览/文件并列 - 网格布局排列工程节点,支持缩放/拖拽/框选 - Phase 0 验证 demo(X6Demo.vue)保留在 /dev/x6 路由
This commit is contained in:
185
src/components/dev/X6Demo.vue
Normal file
185
src/components/dev/X6Demo.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="x6-demo">
|
||||
<div class="x6-demo__header">
|
||||
<h2>AntV X6 v3 Phase 0 验证</h2>
|
||||
<div class="x6-demo__status">
|
||||
<span :class="['x6-demo__badge', status]">{{ statusText }}</span>
|
||||
<a-button size="mini" @click="rebuild">重建画布</a-button>
|
||||
<a-button size="mini" @click="exportData">导出 JSON</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="containerRef" class="x6-demo__canvas" />
|
||||
<div class="x6-demo__log">
|
||||
<div v-for="(log, i) in logs" :key="i" class="x6-demo__log-line">[{{ log.t }}] {{ log.msg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, ref, reactive } from 'vue'
|
||||
import { Graph } from '@antv/x6'
|
||||
// v3: 插件全部从核心包内置导出(不再需要 @antv/x6-plugin-* 独立包)
|
||||
import { Selection, Snapline, History, MiniMap, Scroller } from '@antv/x6'
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const status = ref<'pending' | 'ok' | 'fail'>('pending')
|
||||
const statusText = ref('初始化中...')
|
||||
const logs = reactive<{ t: string; msg: string }[]>([])
|
||||
|
||||
let graph: Graph | null = null
|
||||
|
||||
function log(msg: string) {
|
||||
const now = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
logs.unshift({ t: now, msg })
|
||||
if (logs.length > 20) logs.pop()
|
||||
}
|
||||
|
||||
function buildGraph() {
|
||||
if (!containerRef.value) return
|
||||
log('开始创建 Graph 实例 (X6 v3)')
|
||||
|
||||
graph = new Graph({
|
||||
container: containerRef.value,
|
||||
background: { color: '#F2F7FA' },
|
||||
grid: { visible: true, size: 10 },
|
||||
mousewheel: { enabled: true, modifiers: ['ctrl'], minScale: 0.5, maxScale: 2 },
|
||||
})
|
||||
|
||||
// v3: 插件通过 graph.use() 注册,类从核心包导入
|
||||
graph.use(new Selection({ enabled: true, rubberband: true }))
|
||||
log('✅ Selection 插件(框选)注册成功')
|
||||
|
||||
graph.use(new Snapline({ enabled: true }))
|
||||
log('✅ Snapline 插件(对齐线)注册成功')
|
||||
|
||||
graph.use(new History({ enabled: true }))
|
||||
log('✅ History 插件(撤销重做)注册成功')
|
||||
|
||||
graph.use(new Scroller({ enabled: true, pannable: true }))
|
||||
log('✅ Scroller 插件(画布滚动)注册成功')
|
||||
|
||||
graph.use(new MiniMap({ width: 200, height: 120 }))
|
||||
log('✅ MiniMap 插件(小地图)注册成功')
|
||||
|
||||
// 渲染节点 + 边(模拟 DevFlow 多工程依赖图)
|
||||
graph.fromJSON({
|
||||
nodes: [
|
||||
{ id: 'devflow', shape: 'rect', x: 80, y: 80, width: 140, height: 48,
|
||||
label: 'DevFlow\n(Tauri+Vue3)', attrs: { body: { fill: '#e8f3ff', stroke: '#165dff' } } },
|
||||
{ id: 'df-types', shape: 'rect', x: 320, y: 40, width: 120, height: 40,
|
||||
label: 'df-types', attrs: { body: { fill: '#fff7e8', stroke: '#ff7d00' } } },
|
||||
{ id: 'df-storage', shape: 'rect', x: 320, y: 120, width: 120, height: 40,
|
||||
label: 'df-storage', attrs: { body: { fill: '#fff7e8', stroke: '#ff7d00' } } },
|
||||
{ id: 'df-ai', shape: 'rect', x: 540, y: 80, width: 120, height: 40,
|
||||
label: 'df-ai', attrs: { body: { fill: '#e8ffea', stroke: '#00b42a' } } },
|
||||
],
|
||||
edges: [
|
||||
{ source: 'devflow', target: 'df-types', labels: [{ text: '类库' }] },
|
||||
{ source: 'devflow', target: 'df-storage', labels: [{ text: '类库' }] },
|
||||
{ source: 'df-storage', target: 'df-ai', labels: [{ text: '依赖' }] },
|
||||
],
|
||||
})
|
||||
log('✅ 渲染 4 节点 + 3 边(DevFlow 依赖图 mock)')
|
||||
|
||||
// 事件验证
|
||||
graph.on('node:click', ({ node }) => {
|
||||
log(`🖱️ 节点点击: ${node.id}`)
|
||||
})
|
||||
graph.on('blank:click', () => {
|
||||
log('🖱️ 空白处点击')
|
||||
})
|
||||
|
||||
status.value = 'ok'
|
||||
statusText.value = '渲染成功'
|
||||
log('🎉 Phase 0 验证通过:X6 v3 在当前环境正常工作')
|
||||
}
|
||||
|
||||
function rebuild() {
|
||||
if (graph) {
|
||||
graph.dispose()
|
||||
log('画布已销毁,重建中...')
|
||||
}
|
||||
status.value = 'pending'
|
||||
statusText.value = '重建中'
|
||||
setTimeout(buildGraph, 100)
|
||||
}
|
||||
|
||||
function exportData() {
|
||||
if (!graph) return
|
||||
const json = graph.toJSON()
|
||||
log(`📤 导出 JSON: ${JSON.stringify(json).slice(0, 80)}...`)
|
||||
console.log('X6 graph JSON:', json)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
buildGraph()
|
||||
} catch (e) {
|
||||
status.value = 'fail'
|
||||
statusText.value = '验证失败'
|
||||
log(`❌ 错误: ${e instanceof Error ? e.message : String(e)}`)
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (graph) {
|
||||
graph.dispose()
|
||||
graph = null
|
||||
log('画布已销毁(组件卸载)')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.x6-demo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
.x6-demo__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.x6-demo__header h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.x6-demo__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.x6-demo__badge {
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.x6-demo__badge.pending { background: #fff7e8; color: #ff7d00; }
|
||||
.x6-demo__badge.ok { background: #e8ffea; color: #00b42a; }
|
||||
.x6-demo__badge.fail { background: #ffece8; color: #f53f3f; }
|
||||
.x6-demo__canvas {
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.x6-demo__log {
|
||||
height: 120px;
|
||||
overflow-y: auto;
|
||||
background: #1d2129;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-family: 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.x6-demo__log-line {
|
||||
color: #c9cdd4;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
224
src/components/project/DependencyGraph.vue
Normal file
224
src/components/project/DependencyGraph.vue
Normal 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>
|
||||
98
src/components/project/ModuleNode.vue
Normal file
98
src/components/project/ModuleNode.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="module-node" :class="{ active: selected }">
|
||||
<div class="module-node__header">
|
||||
<span class="module-node__icon">📦</span>
|
||||
<span class="module-node__name">{{ data.name }}</span>
|
||||
</div>
|
||||
<div class="module-node__path">{{ shortPath }}</div>
|
||||
<div v-if="stackList.length" class="module-node__tags">
|
||||
<span v-for="s in stackList" :key="s" class="module-node__tag">{{ s }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
data: {
|
||||
name: string
|
||||
path: string
|
||||
stack: string | null
|
||||
gitUrl: string | null
|
||||
}
|
||||
selected?: boolean
|
||||
}>()
|
||||
|
||||
const shortPath = computed(() => {
|
||||
const p = props.data?.path || ''
|
||||
if (p.length <= 32) return p
|
||||
return '...' + p.slice(-29)
|
||||
})
|
||||
|
||||
const stackList = computed(() => {
|
||||
if (!props.data?.stack) return []
|
||||
try {
|
||||
const parsed = JSON.parse(props.data.stack)
|
||||
return Array.isArray(parsed) ? parsed.slice(0, 3) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.module-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #16213e;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.module-node.active {
|
||||
border-color: #5378e8;
|
||||
box-shadow: 0 0 0 2px rgba(83, 120, 232, 0.3);
|
||||
}
|
||||
.module-node__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.module-node__icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
.module-node__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.module-node__path {
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.module-node__tags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.module-node__tag {
|
||||
font-size: 9px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(83, 120, 232, 0.15);
|
||||
color: #7c9aff;
|
||||
}
|
||||
</style>
|
||||
7
src/i18n/en/dependencyGraph.ts
Normal file
7
src/i18n/en/dependencyGraph.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
dependencyGraph: {
|
||||
title: 'Dependency Graph',
|
||||
empty: 'No modules yet, please add a module first',
|
||||
tabTitle: '🔗 Dependencies',
|
||||
},
|
||||
}
|
||||
7
src/i18n/zh-CN/dependencyGraph.ts
Normal file
7
src/i18n/zh-CN/dependencyGraph.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
dependencyGraph: {
|
||||
title: '依赖关系图',
|
||||
empty: '暂无工程,请先添加工程',
|
||||
tabTitle: '🔗 依赖图',
|
||||
},
|
||||
}
|
||||
@@ -65,6 +65,12 @@ const routes = [
|
||||
component: () => import('../views/AuditLog.vue'),
|
||||
meta: { title: '审批历史' },
|
||||
},
|
||||
{
|
||||
path: '/dev/x6',
|
||||
name: 'X6Demo',
|
||||
component: () => import('../components/dev/X6Demo.vue'),
|
||||
meta: { title: 'X6 v3 验证' },
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
|
||||
@@ -58,6 +58,14 @@
|
||||
>
|
||||
{{ $t('fileExplorer.tabTitle') }}
|
||||
</button>
|
||||
<button
|
||||
class="tab-btn"
|
||||
:class="{ 'tab-active': activeTab === 'graph' }"
|
||||
type="button"
|
||||
@click="activeTab = 'graph'"
|
||||
>
|
||||
{{ $t('dependencyGraph.tabTitle') }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 文件浏览器 Tab -->
|
||||
@@ -65,6 +73,11 @@
|
||||
<FileExplorer :project-id="projectId" />
|
||||
</section>
|
||||
|
||||
<!-- 依赖图 Tab -->
|
||||
<section v-else-if="activeTab === 'graph'" class="file-explorer-wrap">
|
||||
<DependencyGraph :project-id="projectId" />
|
||||
</section>
|
||||
|
||||
<!-- 概览 Tab:原有主体两栏 -->
|
||||
<div v-else class="detail-grid">
|
||||
<!-- 左栏:项目信息 -->
|
||||
@@ -258,6 +271,7 @@ import { projectStatusLabel, taskStatusLabel, taskStatusClass } from '../constan
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import ApprovalDialog from '@/components/project/ApprovalDialog.vue'
|
||||
import FileExplorer from '@/components/project/FileExplorer.vue'
|
||||
import DependencyGraph from '@/components/project/DependencyGraph.vue'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useRendered } from '@/composables/useMarkdown'
|
||||
|
||||
@@ -270,7 +284,7 @@ const showApprovalDialog = ref(false)
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
// Tab 导航:概览(项目信息/任务/工作流日志) vs 文件浏览器(Batch 10)。
|
||||
const activeTab = ref<'overview' | 'files'>('overview')
|
||||
const activeTab = ref<'overview' | 'files' | 'graph'>('overview')
|
||||
|
||||
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
|
||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||
|
||||
Reference in New Issue
Block a user