修复: 项目详情批量(任务列表联动刷新/工作流降级标注/中文git乱码/子目录错误行内/零工程空态/切tab保活) + 依赖图批量(节点渲染register/环高亮/防环防重/点击选中) + 销账

This commit is contained in:
lxy
2026-08-09 21:35:13 +08:00
parent 4a9f77872e
commit 4df91ed155
15 changed files with 402 additions and 77 deletions
+73 -20
View File
@@ -46,7 +46,13 @@
<div class="dep-graph__modal-field">
<label>{{ $t('dependencyGraph.targetLabel') }}</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>
<!-- G6:已存在 fromto 组合或选中自身作为目标时禁用该候选 -->
<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">
@@ -61,7 +67,7 @@
</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" @click="onAddDep">{{ $t('dependencyGraph.confirm') }}</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>
@@ -74,11 +80,11 @@
*
* 节点用 Vue 组件(ModuleNode.vue)渲染,通过 @antv/x6-vue-shape 注册。
*/
import { onMounted, onBeforeUnmount, ref, watch, markRaw } from 'vue'
import { useRouter } from 'vue-router'
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'
@@ -86,16 +92,16 @@ 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 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[]>([])
@@ -115,8 +121,23 @@ 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
@@ -127,11 +148,40 @@ async function onAddDep() {
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),
@@ -147,8 +197,10 @@ async function loadModules() {
}
}
function renderGraph() {
function renderGraph(opts?: { center?: boolean }) {
if (!graph) return
// 点选刷新(节点点击)时跳过 centerContent,避免视图跳动;默认(加载/删除/环检测后)居中。
const shouldCenter = opts?.center !== false
const nodeWidth = 200
const nodeHeight = 80
@@ -168,7 +220,6 @@ function renderGraph() {
// 映射回 X6 节点格式
const nodes = modules.value.map(m => {
const pos = g.node(m.id)
const isCycle = cycleNodes.value.has(m.id)
return {
id: m.id,
shape: 'vue-shape',
@@ -182,12 +233,12 @@ function renderGraph() {
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,
},
// 环节点高亮(红框);renderGraph 是画布唯一渲染入口,高亮必须在这里注入,
// 否则会被 graph.fromJSON 整体替换抹除。
attrs: isCycle ? {
body: { stroke: '#e05050', strokeWidth: 3 },
} : undefined,
}
})
@@ -207,7 +258,7 @@ function renderGraph() {
}))
graph.fromJSON({ nodes, edges })
graph.centerContent()
if (shouldCenter) graph.centerContent()
}
function buildGraph() {
@@ -228,8 +279,10 @@ function buildGraph() {
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}`)
// 组件内选中高亮(G4):点击节点仅高亮,不 emit 不导航(依赖图是查看/编辑关系,
// 跳工程详情与 Tab 语义冲突,原 emit+router.push 是无意义死代码)。
selectedId.value = String(node.id)
renderGraph({ center: false })
})
renderGraph()