544 lines
18 KiB
Vue
544 lines
18 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">{{ $t('dependencyGraph.addDep') }}</button>
|
|
<button v-if="dependencies.length > 0" class="btn btn-ghost btn-sm" @click="checkCycles">{{ $t('dependencyGraph.checkCycles') }}</button>
|
|
<button class="btn btn-ghost btn-sm" @click="exportPNG">{{ $t('dependencyGraph.exportImage') }}</button>
|
|
<button class="btn btn-ghost btn-sm" @click="fitContent">{{ $t('dependencyGraph.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>
|
|
|
|
<!-- 删除依赖确认弹层 -->
|
|
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" :danger-label="confirmState.dangerLabel" @result="answerConfirm" />
|
|
|
|
<!-- 边删除按钮(HTML 遮罩层):edge hover 时定位到边中点显示,leave 时隐藏。
|
|
用 HTML 而非 X6 port 方案,因为 graph.fromJSON 重建所有 edge 会清掉动态加的 port。 -->
|
|
<button
|
|
v-if="deleteDepBtnVisible"
|
|
class="dep-edge-delete-btn"
|
|
:style="deleteDepBtnStyle"
|
|
@click="onDeleteDepBtnClick"
|
|
type="button"
|
|
:title="$t('fileExplorer.deleteCurrentModule')"
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
|
|
<!-- 添加依赖弹窗 -->
|
|
<div v-if="showAddDep" class="dep-graph__modal-overlay" @click.self="showAddDep = false">
|
|
<div class="dep-graph__modal">
|
|
<h3>{{ $t('dependencyGraph.addDepTitle') }}</h3>
|
|
<div class="dep-graph__modal-field">
|
|
<label>{{ $t('dependencyGraph.sourceLabel') }}</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>{{ $t('dependencyGraph.targetLabel') }}</label>
|
|
<select v-model="depTo" class="dep-graph__modal-input">
|
|
<!-- G6:已存在 from→to 组合或选中自身作为目标时禁用该候选 -->
|
|
<option
|
|
v-for="m in modules"
|
|
:key="m.id"
|
|
:value="m.id"
|
|
:disabled="depFrom === m.id || (depFrom !== '' && existingDepKeys.has(`${depFrom}->${m.id}`))"
|
|
>{{ m.name }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="dep-graph__modal-field">
|
|
<label>{{ $t('dependencyGraph.depTypeLabel') }}</label>
|
|
<select v-model="depType" class="dep-graph__modal-input">
|
|
<option value="library">{{ $t('dependencyGraph.typeLibrary') }}</option>
|
|
<option value="api">{{ $t('dependencyGraph.typeApi') }}</option>
|
|
<option value="mq">{{ $t('dependencyGraph.typeMq') }}</option>
|
|
<option value="shared">{{ $t('dependencyGraph.typeShared') }}</option>
|
|
<option value="custom">{{ $t('dependencyGraph.typeCustom') }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="dep-graph__modal-actions">
|
|
<button class="btn btn-ghost btn-sm" @click="showAddDep = false">{{ $t('common.cancel') }}</button>
|
|
<button class="btn btn-primary btn-sm" :disabled="!depFrom || !depTo || depFrom === depTo || existingDepKeys.has(`${depFrom}->${depTo}`)" @click="onAddDep">{{ $t('dependencyGraph.confirm') }}</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, computed, markRaw } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { Graph, Selection, Snapline, History, Scroller, MiniMap } from '@antv/x6'
|
|
import dagre from 'dagre'
|
|
import { register } from '@antv/x6-vue-shape'
|
|
import '@antv/x6-vue-shape'
|
|
import { Message } from '@arco-design/web-vue'
|
|
import { moduleApi, type ProjectModuleRecord, type ModuleDependencyRecord } from '@/api/module'
|
|
import { useConfirm } from '@/composables/useConfirm'
|
|
import ModuleNode from './ModuleNode.vue'
|
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
|
|
|
// vue-shape 注册:把节点 Vue 组件绑定到 'vue-shape' 形状。
|
|
// 库内 shapeMaps 仅经 register 填充(view.js:48 renderVueComponent 读 shapeMaps[node.shape]),
|
|
// 单纯 import '@antv/x6-vue-shape' 只注册 shape 不绑组件 → 节点渲染空/报错(P0)。
|
|
// 幂等:Graph.registerNode 对同名 shape 重复注册会覆盖,重复进组件也安全。
|
|
register({ shape: 'vue-shape', component: ModuleNode })
|
|
|
|
const props = defineProps<{
|
|
projectId: string
|
|
}>()
|
|
|
|
const containerRef = ref<HTMLElement | null>(null)
|
|
const modules = ref<ProjectModuleRecord[]>([])
|
|
const dependencies = ref<ModuleDependencyRecord[]>([])
|
|
const loading = ref(true)
|
|
const { t } = useI18n()
|
|
const { confirmDialog, confirmState, answerConfirm } = useConfirm()
|
|
let graph: Graph | null = null
|
|
|
|
// 边删除按钮(HTML 遮罩层)
|
|
const deleteDepBtnVisible = ref(false)
|
|
const deleteDepBtnStyle = ref<{ left: string; top: string }>({ left: '0px', top: '0px' })
|
|
let deleteDepTarget: ModuleDependencyRecord | null = null
|
|
|
|
// 添加依赖弹窗
|
|
const showAddDep = ref(false)
|
|
const depFrom = ref('')
|
|
const depTo = ref('')
|
|
const depType = ref('library')
|
|
|
|
// 已存在的依赖组合(from→to),用于弹窗候选禁用重复添加(G6)。
|
|
const existingDepKeys = computed(
|
|
() => new Set(dependencies.value.map(d => `${d.from_module_id}->${d.to_module_id}`)),
|
|
)
|
|
|
|
// 当前选中高亮的节点 id(点击节点组件内高亮,不导航;G4)。
|
|
const selectedId = ref<string | null>(null)
|
|
|
|
async function onAddDep() {
|
|
if (!depFrom.value || !depTo.value || depFrom.value === depTo.value) return
|
|
// 重复依赖拦截(G6):已存在组合,弹窗候选已禁用,此处双保险防直达。
|
|
if (existingDepKeys.value.has(`${depFrom.value}->${depTo.value}`)) return
|
|
// 防环(G7):本地 DFS 校验,新增 source→target 会成环则阻止。
|
|
if (wouldCreateCycle(depFrom.value, depTo.value)) {
|
|
Message.warning(t('dependencyGraph.cycleAddError'))
|
|
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)
|
|
// G8:对齐删除依赖的失败反馈,补用户可见错误提示(common.unknownError 通用兜底)。
|
|
Message.error(t('common.unknownError'))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 本地防环:在现有依赖图上,若 target 已能到达 source(DFS),则新增 source→target 会成环。
|
|
* 复用 detectModuleCycles 的 DFS 思路的最小前端校验(纯本地,不请求后端)。
|
|
*/
|
|
function wouldCreateCycle(sourceId: string, targetId: string): boolean {
|
|
const adj = new Map<string, string[]>()
|
|
for (const d of dependencies.value) {
|
|
const list = adj.get(d.from_module_id) ?? []
|
|
list.push(d.to_module_id)
|
|
adj.set(d.from_module_id, list)
|
|
}
|
|
const visited = new Set<string>()
|
|
const stack = [targetId]
|
|
while (stack.length > 0) {
|
|
const cur = stack.pop()!
|
|
if (cur === sourceId) return true
|
|
if (visited.has(cur)) continue
|
|
visited.add(cur)
|
|
for (const next of adj.get(cur) ?? []) {
|
|
if (!visited.has(next)) stack.push(next)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
async function loadModules() {
|
|
loading.value = true
|
|
// 数据变更后旧环高亮失效,复位待重新检测(否则残留上一批环节点红框,G5)。
|
|
cycleNodes.value = new Set()
|
|
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(opts?: { center?: boolean }) {
|
|
if (!graph) return
|
|
// 点选刷新(节点点击)时跳过 centerContent,避免视图跳动;默认(加载/删除/环检测后)居中。
|
|
const shouldCenter = opts?.center !== false
|
|
|
|
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,
|
|
// 环节点高亮(G2):vue-shape markup 无 body 选择器,attrs.body 渲染无效,
|
|
// 改由 isCycle 注入节点 data,ModuleNode 根元素按类描红框。
|
|
isCycle: cycleNodes.value.has(m.id),
|
|
// 选中高亮(G4):当前点选节点组件内高亮。
|
|
selected: selectedId.value === m.id,
|
|
},
|
|
}
|
|
})
|
|
|
|
// 边数据(module_dependencies 表);edge id 用 dep.id,删除时按 id 反查源/目标
|
|
const edges = dependencies.value.map(d => ({
|
|
id: d.id,
|
|
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 })
|
|
if (shouldCenter) 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 }) => {
|
|
// 组件内选中高亮(G4):点击节点仅高亮,不 emit 不导航(依赖图是查看/编辑关系,
|
|
// 跳工程详情与 Tab 语义冲突,原 emit+router.push 是无意义死代码)。
|
|
selectedId.value = String(node.id)
|
|
renderGraph({ center: false })
|
|
})
|
|
|
|
renderGraph()
|
|
bindEdgeDeleteButtons()
|
|
}
|
|
|
|
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)
|
|
// 收集环节点 id,交给 renderGraph 统一渲染高亮(避免 cell.attr 被 fromJSON 抹除)。
|
|
cycleNodes.value = new Set(cycles)
|
|
renderGraph()
|
|
if (cycles.length > 0) {
|
|
Message.warning(t('dependencyGraph.cycleWarning', { n: cycles.length }))
|
|
} else {
|
|
Message.success(t('dependencyGraph.noCycle'))
|
|
}
|
|
} catch (e) {
|
|
console.error('[DependencyGraph] 环检测失败:', e)
|
|
Message.error(t('dependencyGraph.cycleError'))
|
|
}
|
|
}
|
|
|
|
/** 导出 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)
|
|
}
|
|
}
|
|
|
|
/** 删除依赖(二次确认 + 重绘)。 */
|
|
async function confirmRemoveDep(dep: ModuleDependencyRecord) {
|
|
const fromName = modules.value.find(m => m.id === dep.from_module_id)?.name ?? dep.from_module_id
|
|
const toName = modules.value.find(m => m.id === dep.to_module_id)?.name ?? dep.to_module_id
|
|
const ok = await confirmDialog(t('dependencyGraph.deleteConfirm', { from: fromName, to: toName }), t('common.delete'))
|
|
if (!ok) return
|
|
try {
|
|
await moduleApi.removeModuleDependency(dep.id)
|
|
Message.info(t('dependencyGraph.deleteSuccess'))
|
|
await loadModules()
|
|
renderGraph()
|
|
bindEdgeDeleteButtons()
|
|
} catch (e) {
|
|
console.error('[DependencyGraph] 删除依赖失败:', e)
|
|
Message.error(t('dependencyGraph.deleteError'))
|
|
}
|
|
}
|
|
|
|
/** 点击遮罩层上的删除按钮触发。 */
|
|
function onDeleteDepBtnClick() {
|
|
const dep = deleteDepTarget
|
|
if (dep) void confirmRemoveDep(dep)
|
|
hideEdgeDeleteBtn()
|
|
}
|
|
|
|
/** edge hover 时显示删除按钮(定位到边中点屏幕坐标)。 */
|
|
function showEdgeDeleteBtn(edge: any) {
|
|
const depId = String(edge.id)
|
|
const dep = dependencies.value.find(d => d.id === depId)
|
|
if (!dep || !containerRef.value || !graph) return
|
|
deleteDepTarget = dep
|
|
// 算边中点(view 坐标)→ 容器像素坐标:transform.scale + translate
|
|
const bbox = edge.getBBox()
|
|
const mx = bbox.x + bbox.width / 2
|
|
const my = bbox.y + bbox.height / 2
|
|
const ts = graph.translate()
|
|
const sc = graph.scale()
|
|
const viewX = mx * sc.sx + ts.tx
|
|
const viewY = my * sc.sy + ts.ty
|
|
deleteDepBtnStyle.value = { left: `${viewX - 8}px`, top: `${viewY - 8}px` }
|
|
deleteDepBtnVisible.value = true
|
|
}
|
|
|
|
/** edge leave 时隐藏删除按钮。 */
|
|
function hideEdgeDeleteBtn() {
|
|
deleteDepBtnVisible.value = false
|
|
deleteDepTarget = null
|
|
}
|
|
|
|
/** 给所有 edge 绑 hover 显示删除按钮的监听。 */
|
|
function bindEdgeDeleteButtons() {
|
|
if (!graph) return
|
|
try { graph.off('edge:mouseenter', onEdgeEnter); graph.off('edge:mouseleave', onEdgeLeave) } catch { /* ignore */ }
|
|
function onEdgeEnter(e: any) { showEdgeDeleteBtn(e.edge) }
|
|
function onEdgeLeave() { hideEdgeDeleteBtn() }
|
|
graph.on('edge:mouseenter', onEdgeEnter)
|
|
graph.on('edge:mouseleave', onEdgeLeave)
|
|
}
|
|
|
|
/** 依赖类型 → 边颜色 */
|
|
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 / .btn-ghost / .btn-sm / .btn-primary) 已收敛至 global.css */
|
|
/* 依赖图工具栏紧凑,覆 padding/font-size 避免按钮变大 */
|
|
.btn { padding: 4px 12px; font-size: 12px; }
|
|
|
|
/* 添加依赖弹窗 */
|
|
.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;
|
|
}
|
|
|
|
/* 边删除按钮(HTML 遮罩层,绝对定位在 .dep-graph__canvas 上) */
|
|
.dep-edge-delete-btn {
|
|
position: absolute;
|
|
width: 16px;
|
|
height: 16px;
|
|
border: none;
|
|
border-radius: 50%;
|
|
background: var(--df-danger);
|
|
color: #fff;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 200;
|
|
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
|
transition: background 0.15s;
|
|
pointer-events: auto;
|
|
}
|
|
.dep-edge-delete-btn:hover {
|
|
background: #c24040;
|
|
}
|
|
</style>
|