修复: 项目详情批量(任务列表联动刷新/工作流降级标注/中文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()
+71 -11
View File
@@ -84,9 +84,18 @@
<!-- 文件树视图 -->
<div v-show="viewMode === 'tree'" class="explorer-tree">
<div v-if="!currentModuleId" class="tree-loading">
<!-- B4[PD-P2-8P2-10]:区分加载中 vs 零工程currentModuleId 为空且已加载完工程列表
说明无工程,显示空态引导(添加工程 CTA,对齐概览模块空态),而非无限 spinner -->
<div v-if="!currentModuleId && !mLoaded" class="tree-loading">
<span class="spinner"></span>
</div>
<div v-else-if="!currentModuleId" class="module-empty">
<div class="module-empty-icon">📦</div>
<p class="module-empty-hint">{{ $t('projectDetail.modulesEmptyHint') }}</p>
<div class="module-empty-actions">
<button class="btn btn-primary btn-sm" type="button" @click="openAddModule">{{ $t('fileExplorer.addModule') }}</button>
</div>
</div>
<FileTree
v-else
:module-id="currentModuleId"
@@ -106,6 +115,7 @@
<GitChanges
v-if="currentModuleId"
:module-id="currentModuleId"
:refresh-key="gitStatusRefreshKey"
@select-file="onChangeFileSelect"
/>
</div>
@@ -198,7 +208,7 @@
* 父组件(ProjectDetail)传入 projectId,本组件按 id 拉工程列表(moduleApi.listProjectModules),
* 单工程自动选中;多工程默认选首个 + 提供下拉切换。
*/
import { ref, computed, watch, reactive, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch, reactive, onMounted, onUnmounted, onActivated, onDeactivated } from 'vue'
import { useI18n } from 'vue-i18n'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { moduleApi, type ProjectModuleRecord, type FileTreeEntry, type GitStatusResult } from '@/api/module'
@@ -217,6 +227,9 @@ const modules = ref<ProjectModuleRecord[]>([])
const currentModuleId = ref<string>('')
const refreshing = ref(false)
const moduleDropdownOpen = ref(false)
// B4[PD-P2-8→P2-10]:工程列表是否已加载完(首次拉取 resolve)。currentModuleId 为空时用它
// 区分「还在加载中(转圈)」vs「确实无工程(空态引导)」,避免无工程时无限 spinner。
const mLoaded = ref(false)
// 工程 CRUD 弹窗
const showModuleModal = ref(false)
@@ -277,6 +290,9 @@ async function loadModules() {
} catch (e) {
console.error('[FileExplorer] 加载工程列表失败', e)
modules.value = []
} finally {
// B4[PD-P2-8→P2-10]:标记加载完成(成功/失败都算),供空态判定
mLoaded.value = true
}
}
@@ -364,6 +380,15 @@ function onDetach() {
// ── 视图模式切换:文件树 / Git 变更 ──
const viewMode = ref<'tree' | 'changes'>('tree')
const gitStatusData = ref<GitStatusResult | null>(null)
// B2[PD-P1-5]:git 状态刷新信号(自增)。AI 写文件后(下)传 GitChanges 触发重拉,
// 替代旧的「tree→changes 切换触发 watch」hack;同时自身重拉 gitStatusData 保徽标计数。
const gitStatusRefreshKey = ref(0)
// 本组件是否可见(KeepAlive 保活下 onDeactivated 置 false,onActivated 置 true)。
// 保活后组件在切走 overview 时仍存活,其 df-data-changed 监听会在后台触发;
// 用此守卫避免「用户切到 overview 时文件 tab 后台偷偷切到 changes 视图」。
const viewVisible = ref(true)
onActivated(() => { viewVisible.value = true })
onDeactivated(() => { viewVisible.value = false })
const changedCount = computed(() => gitStatusData.value?.changed_files.length ?? 0)
@@ -396,16 +421,22 @@ onUnmounted(() => document.removeEventListener('click', closeDropdown))
// AI 工具写入文件后(df-data-changed entity=file)自动跳转变更视图 + 刷新
let _unlistenDataChanged: UnlistenFn | null = null
onMounted(async () => {
_unlistenDataChanged = await listen<{ entity: string; action: string }>('df-data-changed', (e) => {
_unlistenDataChanged = await listen<{ entity: string; action: string }>('df-data-changed', async (e) => {
if (e.payload.entity !== 'file') return
// 刷新 git 状态缓存,使变更计数角标即时更新
gitStatusData.value = null
if (viewMode.value === 'changes') {
// 已在变更视图,触发 GitChanges 重新拉取(切一次 viewMode 触发 watch)
viewMode.value = 'tree'
viewMode.value = 'changes'
} else {
// 不在变更视图,自动切换到变更视图,让用户看到 AI 写入的内容
if (!currentModuleId.value) return
// B2[PD-P1-5]:重拉 git 状态而非置 null —— 置 null 会让 changedCount 徽标瞬闪为 0,
// 且 switchToChanges 短路 !gitStatusData,事件后依赖旧缓存不再重新拉取。
try {
gitStatusData.value = await moduleApi.getModuleGitStatus(currentModuleId.value)
} catch {
gitStatusData.value = null
}
// 通知 GitChanges 重拉(已在 changes 视图立即生效;未挂载时挂载即拉最新)。
// 后端 git status 有 5s TTL 缓存(module.rs GIT_STATUS_CACHE_TTL),写后立即重拉可能拿到
// 缓存旧值,5s 内随用户操作/再次刷新自然更新;跨层绕过缓存留待设计(不强行改事件协议)。
gitStatusRefreshKey.value++
if (viewMode.value !== 'changes' && viewVisible.value) {
// 不在变更视图且本组件可见:自动切换到变更视图,让用户看到 AI 写入的内容
viewMode.value = 'changes'
}
})
@@ -757,6 +788,35 @@ async function onRemoveModule() {
height: 100%;
}
/* B4[PD-P2-8→P2-10]:零工程空态引导(对齐概览模块空态视觉) */
.module-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 32px 16px;
text-align: center;
height: 100%;
justify-content: center;
}
.module-empty-icon {
font-size: 28px;
opacity: 0.6;
line-height: 1;
}
.module-empty-hint {
margin: 0;
font-size: 12px;
color: var(--df-text-dim);
line-height: 1.6;
max-width: 300px;
}
.module-empty-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.tree-loading .spinner {
width: 20px;
height: 20px;
+23 -2
View File
@@ -39,6 +39,12 @@
<span class="expand-icon">{{ expandedPaths.has(entry.path) ? '▾' : '▸' }}</span>
<span class="file-icon">📁</span>
<span class="file-name">{{ entry.name }}</span>
<!-- B3[PD-P1-6]:展开失败 行内 (title 显原因),点击重试;不设整树 error,不炸其他已展开节点 -->
<span
v-if="toggleErrors.has(entry.path)"
class="dir-toggle-error"
:title="$t('fileExplorer.loadFailed') + ': ' + toggleErrors.get(entry.path)"
></span>
</div>
<!-- 文件:点击通知父组件加载预览 -->
@@ -93,7 +99,7 @@
* 这样设计的好处:刷新(refresh)只需 FileExplorer 清空 loadedChildren 重拉根,
* 所有展开的子树因 expandedPaths 被清而卸载,下次展开重新拉取,无脏数据。
*/
import { ref, watch, onMounted } from 'vue'
import { ref, reactive, watch, onMounted } from 'vue'
import { moduleApi, type FileTreeEntry } from '@/api/module'
const props = withDefaults(
@@ -126,6 +132,10 @@ const emit = defineEmits<{
const entries = ref<FileTreeEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// B3[PD-P1-6]:目录展开失败的行内错误态(路径 → 错误信息)。之前把 toggleDir 失败设到 error.value,
// 会让整个子树渲染分支(v-else-if="error")替换成错误文案,一次子目录失败就炸掉已展开的其他节点。
// 改为按 path 记录,条目行内显 ⚠ + title 原因,点击重试;成功加载即清除。
const toggleErrors = reactive(new Map<string, string>())
/** 拉取当前 subPath 的条目列表。 */
async function loadEntries() {
@@ -170,8 +180,11 @@ async function toggleDir(entry: FileTreeEntry) {
loading.value = true
const res = await moduleApi.getModuleFileTree(props.moduleId, entry.path)
props.loadedChildren.set(entry.path, res.entries)
toggleErrors.delete(entry.path)
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
// B3[PD-P1-6]:行内错误态(该目录 ⚠ + title 原因),不设整树 error;不展开,点击可重试
toggleErrors.set(entry.path, e instanceof Error ? e.message : String(e))
return
} finally {
loading.value = false
}
@@ -335,6 +348,14 @@ watch(
white-space: nowrap;
}
/* B3[PD-P1-6]:目录展开失败的行内 ⚠(flex-shrink 防挤压,危险色) */
.dir-toggle-error {
flex-shrink: 0;
font-size: 11px;
color: #e05050;
cursor: pointer;
}
/* Git 状态徽章 */
.git-badge {
flex-shrink: 0;
+9
View File
@@ -183,6 +183,8 @@ const { t } = useI18n()
const props = defineProps<{
moduleId: string
/** B2[PD-P1-5]:外部(FileExplorer df-data-changed)自增刷新信号,变化时重拉 git 状态。 */
refreshKey?: number
}>()
const emit = defineEmits<{
@@ -531,6 +533,13 @@ function showCommitFileDiff(path: string) {
}
watch(() => props.moduleId, loadStatus, { immediate: true })
// B2[PD-P1-5]:外部刷新信号(AI 写文件后 FileExplorer 自增)变化 → 重拉 git 状态。
// 需保留当前历史 Tab/详情展示(只刷 gitStatus/commits,不动 showHistory/selectedCommit)。
watch(() => props.refreshKey, () => {
if (props.refreshKey === undefined) return
loadStatus()
})
</script>
<style scoped>
+24 -12
View File
@@ -1,5 +1,5 @@
<template>
<div class="module-node" :class="{ active: selected }">
<div class="module-node" :class="{ active: isSelected, 'module-node--cycle': data.isCycle }">
<div class="module-node__header">
<span class="module-node__icon">📦</span>
<span class="module-node__name">{{ data.name }}</span>
@@ -16,39 +16,45 @@ import { computed } from 'vue'
import { parseJsonArray } from '@/utils/json'
const props = defineProps<{
data: {
name: string
path: string
stack: string | null
gitUrl: string | null
}
// x6-vue-shape props node + graph(view.js:48 h(component, { node, graph })),
// node.getData() , data propdata prop x6
node?: any
graph?: any
data?: Record<string, unknown>
selected?: boolean
}>()
// : node.getData()(vue-shape ), props.data()
// data { name, path, stack, gitUrl } + { isCycle, selected }
const data = computed<Record<string, any>>(() => props.node?.getData?.() ?? props.data ?? {})
// :data.selected(vue-shape ) selected prop
const isSelected = computed(() => !!props.selected || !!data.value.selected)
// :(/)+,
// '...' + slice(-29) ,( C:/a D:/b )
// :(/)+ + ( 32 )
const truncatedPath = computed(() => {
const p = props.data?.path || ''
const p = data.value?.path || ''
if (p.length <= 32) return p
// :(/ \,)( C: )
const sepMatch = p.match(/[\\/]/)
if (!sepMatch) {
// ,退
return '...' + p.slice(-29)
return '' + p.slice(-29)
}
const sepIdx = sepMatch.index! + 1
const head = p.slice(0, sepIdx)
const tailBudget = 32 - head.length - 1 // -1 ( )
const tailBudget = 32 - head.length - 1 // -1
if (tailBudget < 4) {
// ,退()
return '...' + p.slice(-29)
return '' + p.slice(-29)
}
const tail = p.slice(-tailBudget)
return head + '…' + tail
})
const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
const stackList = computed(() => parseJsonArray(data.value?.stack).slice(0, 3))
</script>
<style scoped>
@@ -70,6 +76,12 @@ const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
border-color: #5378e8;
box-shadow: 0 0 0 2px rgba(83, 120, 232, 0.3);
}
/* (G2):vue-shape markup body , attrs.body ,
改由依赖图把 isCycle 注入节点 data,组件根元素按类描边 */
.module-node--cycle {
border-color: #e05050;
box-shadow: 0 0 0 2px rgba(224, 80, 80, 0.3);
}
.module-node__header {
display: flex;
align-items: center;