Files
DevFlow/src/components/project/DependencyGraph.vue
绝尘 36ea090d9b 新增: 依赖图环形检测 + PNG 导出 + 任务列表真实总数
- 后端 detect_module_cycles IPC(DFS 三色标记法检测环形依赖)
- 前端环检测按钮:高亮参与环的节点(红色边框)
- 图导出 PNG(X6 toPNG 回调模式)
- 后端 count_tasks IPC + 前端 taskApi.count()
- 任务列表所有筛选/搜索/排序/翻页均拉真实 total
2026-07-01 11:30:19 +08:00

402 lines
11 KiB
Vue

<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 v-if="modules.length >= 2" class="btn btn-ghost btn-sm" @click="showAddDep = true">+ 依赖</button>
<button v-if="dependencies.length > 0" class="btn btn-ghost btn-sm" @click="checkCycles">🔍 环检测</button>
<button class="btn btn-ghost btn-sm" @click="exportPNG">📷 导出</button>
<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 v-if="showAddDep" class="dep-graph__modal-overlay" @click.self="showAddDep = false">
<div class="dep-graph__modal">
<h3>添加工程依赖</h3>
<div class="dep-graph__modal-field">
<label>源工程(依赖方)</label>
<select v-model="depFrom" class="dep-graph__modal-input">
<option v-for="m in modules" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
</div>
<div class="dep-graph__modal-field">
<label>目标工程(被依赖)</label>
<select v-model="depTo" class="dep-graph__modal-input">
<option v-for="m in modules" :key="m.id" :value="m.id">{{ m.name }}</option>
</select>
</div>
<div class="dep-graph__modal-field">
<label>依赖类型</label>
<select v-model="depType" class="dep-graph__modal-input">
<option value="library">类库</option>
<option value="api">API 调用</option>
<option value="mq">消息队列</option>
<option value="shared">共享资源</option>
<option value="custom">自定义</option>
</select>
</div>
<div class="dep-graph__modal-actions">
<button class="btn btn-ghost btn-sm" @click="showAddDep = false">取消</button>
<button class="btn btn-primary btn-sm" :disabled="!depFrom || !depTo || depFrom === depTo" @click="onAddDep">确认</button>
</div>
</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 { useRouter } from 'vue-router'
import { Graph, Selection, Snapline, History, Scroller, MiniMap } from '@antv/x6'
import dagre from 'dagre'
import '@antv/x6-vue-shape'
import { moduleApi, type ProjectModuleRecord, type ModuleDependencyRecord } from '@/api/module'
import ModuleNode from './ModuleNode.vue'
const props = defineProps<{
projectId: string
}>()
const router = useRouter()
const emit = defineEmits<{
(e: 'select-module', moduleId: string): void
}>()
const containerRef = ref<HTMLElement | null>(null)
const modules = ref<ProjectModuleRecord[]>([])
const dependencies = ref<ModuleDependencyRecord[]>([])
const loading = ref(true)
let graph: Graph | null = null
// 添加依赖弹窗
const showAddDep = ref(false)
const depFrom = ref('')
const depTo = ref('')
const depType = ref('library')
async function onAddDep() {
if (!depFrom.value || !depTo.value || depFrom.value === depTo.value) return
try {
await moduleApi.addModuleDependency(props.projectId, depFrom.value, depTo.value, depType.value)
showAddDep.value = false
depFrom.value = ''
depTo.value = ''
depType.value = 'library'
await loadModules()
renderGraph()
} catch (e) {
console.error('[DependencyGraph] 添加依赖失败:', e)
}
}
async function loadModules() {
loading.value = true
try {
const [mods, deps] = await Promise.all([
moduleApi.listProjectModules(props.projectId),
moduleApi.listModuleDependencies(props.projectId),
])
modules.value = mods
dependencies.value = deps
} catch {
modules.value = []
dependencies.value = []
} finally {
loading.value = false
}
}
function renderGraph() {
if (!graph) return
const nodeWidth = 200
const nodeHeight = 80
// dagre 层次布局
const g = new dagre.graphlib.Graph()
g.setGraph({ rankdir: 'LR', nodesep: 30, ranksep: 60 })
g.setDefaultEdgeLabel(() => ({}))
for (const m of modules.value) {
g.setNode(m.id, { width: nodeWidth, height: nodeHeight })
}
for (const d of dependencies.value) {
g.setEdge(d.from_module_id, d.to_module_id)
}
dagre.layout(g)
// 映射回 X6 节点格式
const nodes = modules.value.map(m => {
const pos = g.node(m.id)
return {
id: m.id,
shape: 'vue-shape',
x: (pos?.x ?? 0) - nodeWidth / 2,
y: (pos?.y ?? 0) - nodeHeight / 2,
width: nodeWidth,
height: nodeHeight,
component: markRaw(ModuleNode),
data: {
name: m.name,
path: m.path,
stack: m.stack,
gitUrl: m.git_url,
},
}
})
// 边数据(module_dependencies 表)
const edges = dependencies.value.map(d => ({
source: d.from_module_id,
target: d.to_module_id,
labels: d.label ? [{ text: d.label }] : [{ text: d.dep_type }],
attrs: {
line: {
stroke: depTypeColor(d.dep_type),
strokeWidth: 1.5,
targetMarker: { name: 'block', width: 8, height: 6 },
},
},
}))
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.use(new MiniMap({ width: 200, height: 120, padding: 10 }))
graph.on('node:click', ({ node }) => {
emit('select-module', String(node.id))
router.push(`/projects/${props.projectId}`)
})
renderGraph()
}
function fitContent() {
graph?.zoomToFit({ padding: 20, maxScale: 1.5 })
}
function zoomIn() {
graph?.zoom(0.1)
}
function zoomOut() {
graph?.zoom(-0.1)
}
/** 环形依赖检测。 */
const cycleNodes = ref<Set<string>>(new Set())
async function checkCycles() {
try {
const cycles = await moduleApi.detectModuleCycles(props.projectId)
cycleNodes.value = new Set(cycles)
if (cycles.length > 0) {
// 高亮环节点(加红色边框)
for (const id of cycles) {
const cell = graph?.getCellById(id)
if (cell) {
cell.attr('body/stroke', '#e05050')
cell.attr('body/strokeWidth', 3)
}
}
}
renderGraph()
} catch (e) {
console.error('[DependencyGraph] 环检测失败:', e)
}
}
/** 导出 PNG。 */
async function exportPNG() {
if (!graph) return
try {
graph.toPNG((dataUri: string) => {
const a = document.createElement('a')
a.href = dataUri
a.download = `dependency-graph-${props.projectId}.png`
a.click()
})
} catch (e) {
console.error('[DependencyGraph] 导出 PNG 失败:', e)
}
}
/** 依赖类型 → 边颜色 */
function depTypeColor(depType: string): string {
switch (depType) {
case 'api': return '#5378e8'
case 'mq': return '#e8a053'
case 'shared': return '#53e8a0'
case 'custom': return '#e853a0'
default: return '#5c6b8a' // library
}
}
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;
}
/* 添加依赖弹窗 */
.dep-graph__modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dep-graph__modal {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: 20px;
min-width: 360px;
}
.dep-graph__modal h3 { font-size: 16px; color: var(--df-text); margin-bottom: 16px; }
.dep-graph__modal-field { margin-bottom: 12px; }
.dep-graph__modal-field label {
display: block; font-size: 12px; color: var(--df-text-secondary); margin-bottom: 4px;
}
.dep-graph__modal-input {
width: 100%; box-sizing: border-box;
padding: 6px 10px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg); color: var(--df-text);
font-size: 13px;
}
.dep-graph__modal-actions {
display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px;
}
.btn-primary { background: var(--df-accent); color: #fff; }
.btn-primary:hover { filter: brightness(1.1); }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
</style>