From 4df91ed155d671293c784f9728872c458978a95e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 9 Aug 2026 21:35:13 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E6=89=B9=E9=87=8F(=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E8=81=94=E5=8A=A8=E5=88=B7=E6=96=B0/?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=99=8D=E7=BA=A7=E6=A0=87=E6=B3=A8?= =?UTF-8?q?/=E4=B8=AD=E6=96=87git=E4=B9=B1=E7=A0=81/=E5=AD=90=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E9=94=99=E8=AF=AF=E8=A1=8C=E5=86=85/=E9=9B=B6?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B=E7=A9=BA=E6=80=81/=E5=88=87tab=E4=BF=9D?= =?UTF-8?q?=E6=B4=BB)=20+=20=E4=BE=9D=E8=B5=96=E5=9B=BE=E6=89=B9=E9=87=8F(?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E6=B8=B2=E6=9F=93register/=E7=8E=AF=E9=AB=98?= =?UTF-8?q?=E4=BA=AE/=E9=98=B2=E7=8E=AF=E9=98=B2=E9=87=8D/=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E9=80=89=E4=B8=AD)=20+=20=E9=94=80=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/todo.md | 23 ++-- src-tauri/src/commands/module.rs | 44 ++++++- src/components/project/DependencyGraph.vue | 93 +++++++++++--- src/components/project/FileExplorer.vue | 82 ++++++++++-- src/components/project/FileTree.vue | 25 +++- src/components/project/GitChanges.vue | 9 ++ src/components/project/ModuleNode.vue | 36 ++++-- src/constants/project.ts | 8 ++ src/i18n/en/dependencyGraph.ts | 1 + src/i18n/en/projectDetail.ts | 2 + src/i18n/en/projects.ts | 5 + src/i18n/zh-CN/dependencyGraph.ts | 1 + src/i18n/zh-CN/projectDetail.ts | 2 + src/i18n/zh-CN/projects.ts | 5 + src/views/ProjectDetail.vue | 143 ++++++++++++++++++--- 15 files changed, 402 insertions(+), 77 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index 65f8101..4cb1681 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -587,22 +587,23 @@ graph TD > 用户要求「检查项目详情(概览、文件、依赖图)存在的问题」并「先检查做记录,不直接改」。详单见 [项目详情页走查-2026-08-09.md](./05-代码审查/项目详情页走查-2026-08-09.md)。共 **60 项**(P0×1 / P1×6 / P2×14 / P3×27)。守 session-role-diagnose-only:仅走查登记,未实施代码。 **P0(核心不可用)**: -- [ ] **PD-P0-1** — 依赖图节点 Vue 组件未注册到 x6-vue-shape → **节点根本不渲染**(DependencyGraph.vue:82 只 import 未 register,`component` 字段库内不读;ModuleNode 读 props.data 但 vue-shape 传 {node,graph}) +- [x] **PD-P0-1** — ✅ 已修(2026-08-09 e6760e8):DependencyGraph 调 `register({shape:'vue-shape',component:ModuleNode})` + ModuleNode 改 `props.node?.getData?.()` 双兜底(vue-shape 传 {node,graph} 非 data,主代核 x6 源码确认) **P1(数据正确性/一致性·6 项)**: -- [ ] **PD-P1-1** — 中文文件名 git status 八进制转义未还原(core.quotepath 默认)→ 变更视图乱码/打不开/树无徽标(module.rs:1081-1091) -- [ ] **PD-P1-2** — 删除参与依赖的工程必失败(FK 约束无级联清理)→ remove_project_module 报错+前端误导(module.rs:249-260) -- [ ] **PD-P1-3** — 工作流日志/DAG/进度读全局 liveEvents 无项目隔离 → 跨项目数据错配(ProjectDetail.vue:676-709) -- [ ] **PD-P1-4** — projectTasks 无 df-data-changed 本地监听 → AI/其它视图改任务后概览列表陈旧(TaskDetail 已做此处漏做,:534-542) -- [ ] **PD-P1-5** — AI 写文件后自动切变更视图不刷新 GitChanges + 徽标计数清零(v-show 不重挂载,:399-411) -- [ ] **PD-P1-6** — 子目录展开失败把父级整棵树替换为错误态且无法自愈(FileTree:157-181) +- [x] **PD-P1-1** — ✅ 已修(a9cb802):module.rs git status 加 `-c core.quotepath=false` 一行根治中文乱码 +- [x] **PD-P1-2** — ✅ 已修(e6760e8):remove_project_module 删工程前级联清 module_dependencies(FK 约束) +- [~] **PD-P1-3** — 🟡 已降级标注(2026-08-09 a9cb802):WorkflowEventPayload 无 project 维度无法按项目过滤 → 实时进度面板默认隐藏(wfProgressEnabled)+ 日志默认折叠+「全局事件」标注,不改事件协议(跨层大改留设计) +- [x] **PD-P1-4** — ✅ 已修(a9cb802):projectTasks 加 df-data-changed 本地监听(entity=task,200ms 防抖,overview 活跃才刷) +- [x] **PD-P1-5** — ✅ 已修(a9cb802):df-data-changed 写文件后重拉 gitStatus + GitChanges refreshKey prop(修徽标清零+不刷新) +- [x] **PD-P1-6** — ✅ 已修(a9cb802):FileTree toggleDir 错误改行内态(toggleErrors 按 path),不炸整树 **P2(体验/打磨·14 项)**: -- [ ] **PD-P2-1** 概览工程列表与 FileExplorer 双向不同步(:548-555) / **PD-P2-2** 快速切项目列表竞态(无 seq 守卫,:866-871) / **PD-P2-3** DAG 边条件编辑 emit 父级未监听→失效回退 / **PD-P2-4** onUnmounted stopEventListener 停全局监听波及他视图 / **PD-P2-5** 折叠态跨项目残留 -- [ ] **PD-P2-6** 概览任务无 loading/分页 / **PD-P2-7** 项目状态 in_progress/testing 无 i18n / **PD-P2-8** 环高亮 attrs.body 选择器 vue-shape 无 body 元素(UX-P0-4 未真正修) / **PD-P2-9** 节点点击 emit 无人监听+路由重复导航(死代码) / **PD-P2-10** 零工程文件 tab 永久转圈无空态 -- [ ] **PD-P2-11** 打开文件 tab 即全量 git status+log 扫描(纯浏览也扫) / **PD-P2-12** 切 tab v-if 反复销毁重建 FileExplorer(状态全丢+重复扫描) / **PD-P2-13** cycleNodes 跨数据变更不重置 / **PD-P2-14** 重复依赖可重复添加(前后端无去重) +- [x] **PD-P2-1** ✅ 已修(a9cb802):watch(activeTab) 切回 overview 重拉 modules / **PD-P2-2** ✅ 已修:loadProjectTasks/loadModules 加 seq 序号守卫 / **PD-P2-3** 🟡 DAG 边条件编辑父级未监听(a9cb802 概览进度面板默认隐藏,编辑入口随隐藏;TaskDetail 场景不受影响,待评估) / **PD-P2-4** 🟡 stopEventListener 波及他视图(共享单例,留设计) / **PD-P2-5** ✅ 已修:watch(projectId) 重置折叠态 +- [x] **PD-P2-6** 🟡 概览任务无 loading/分页(未做,列表上限 500 通常够,待评估) / **PD-P2-7** ✅ 已修:constants/project.ts 补全 5 状态 + i18n / **PD-P2-8** ✅ 已修(e6760e8):环高亮改 isCycle 注入 data + ModuleNode 红框 / **PD-P2-9** ✅ 已修:节点点击改组件内 selectedId 选中高亮(移除死代码 emit+push) / **PD-P2-10** ✅ 已修(a9cb802):零工程空态引导 CTA +- [x] **PD-P2-11** 🟡 打开文件 tab 全量 git 扫描(后端 5s 缓存已缓解,懒加载留设计) / **PD-P2-12** ✅ 已修:FileExplorer/DependencyGraph 用 KeepAlive 保活(onActivated 守卫) / **PD-P2-13** ✅ 已修(e6760e8):loadModules 重置 cycleNodes / **PD-P2-14** ✅ 已修:前端禁用已存在组合 + 后端幂等 -**P3(边缘/打磨·27 项)**: 见详单文档(概览 O10-18 / 文件 F13-24 / 依赖图 G14-18) +**P3(边缘/打磨·27 项)**: 见详单文档(概览 O10-18 / 文件 F13-24 / 依赖图 G14-18) — 部分已随批修(见下) +> 已随批修 P3:O7 sourceScores clamp / O8 descCollapsible DOM 测量 / O9 handleSync 补模块+灵感 / O10 parseStack 缓存 / 依赖图 G10 防环文案 cycleAddError / G14 后端 from!=to 校验 / G16 省略号统一。其余待后续批。 > **优先级建议**:P0 G1 节点不渲染 → P1 F3 中文乱码 + G3 删依赖必失败 + O2 跨项目串数据 + O1 列表陈旧 + F1 变更不刷新 → P2 分批。 > **memory 指针**:[[devflow-project-detail-walkthrough]] diff --git a/src-tauri/src/commands/module.rs b/src-tauri/src/commands/module.rs index 0c005c9..1e6af46 100644 --- a/src-tauri/src/commands/module.rs +++ b/src-tauri/src/commands/module.rs @@ -255,6 +255,25 @@ pub async fn remove_project_module(state: State<'_, AppState>, id: String) -> Re if let Ok(Some(module)) = state.project_modules.get_by_id(&id).await { invalidate_git_status_cache(&module.path); } + // G3:FK 级联 — module_dependencies 的 from/to REFERENCES project_modules(id),参与依赖的 + // 工程直接删必 FK 违例失败(foreign_keys=ON)。删工程前先清所有参与该工程的依赖边 + // (从/到任一端都算参与),顺序参照 purge_with_descendants(子表先于父表删)。 + for dep in state + .module_dependencies + .list_by_field("from_module_id", &id) + .await + .map_err(err_str)? + { + state.module_dependencies.delete(&dep.id).await.map_err(err_str)?; + } + for dep in state + .module_dependencies + .list_by_field("to_module_id", &id) + .await + .map_err(err_str)? + { + state.module_dependencies.delete(&dep.id).await.map_err(err_str)?; + } state.project_modules.delete(&id).await.map_err(err_str)?; Ok(()) } @@ -1065,7 +1084,14 @@ fn collect_git_status_map(dir: &str) -> HashMap { return map; } let timeout = std::time::Duration::from_secs(10); - let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) else { + // B1[PD-P1-1]:-c core.quotepath=false 让 git status 直接输出 UTF-8 原名(中文/非 ASCII + // 路径不再八进制转义 \xxx),否则 GitChanges/文件树展示一串转义码。其余特殊字符 + // (空格/引号)仍按 C 风格加引号,由下方 strip_prefix 逻辑剥除兜底。 + let Some(out) = run_git_cmd( + path, + &["-c", "core.quotepath=false", "status", "--porcelain"], + timeout, + ) else { return map; }; for line in out.lines() { @@ -1302,6 +1328,22 @@ pub async fn add_module_dependency( if project_id.is_empty() || from_module_id.is_empty() || to_module_id.is_empty() { return Err("project_id / from_module_id / to_module_id 不能为空".to_string()); } + // G9:依赖两端不能是同一工程(自环无意义)。 + if from_module_id == to_module_id { + return Err("依赖两端不能是同一工程".to_string()); + } + // G6:幂等 — 同 project_id + from + to 已存在则直接返回已存在记录,不重复插入。 + let existing = state + .module_dependencies + .list_by_field("project_id", &project_id) + .await + .map_err(err_str)?; + if let Some(found) = existing + .iter() + .find(|d| d.from_module_id == from_module_id && d.to_module_id == to_module_id) + { + return Ok(found.clone()); + } let dep_type = dep_type.unwrap_or_else(|| "library".to_string()); let record = ModuleDependencyRecord { id: new_id(), diff --git a/src/components/project/DependencyGraph.vue b/src/components/project/DependencyGraph.vue index 7679e39..da598b2 100644 --- a/src/components/project/DependencyGraph.vue +++ b/src/components/project/DependencyGraph.vue @@ -46,7 +46,13 @@
@@ -61,7 +67,7 @@
- +
@@ -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(null) const modules = ref([]) const dependencies = ref([]) @@ -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(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() + 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() + 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() diff --git a/src/components/project/FileExplorer.vue b/src/components/project/FileExplorer.vue index a601fd1..3db1c58 100644 --- a/src/components/project/FileExplorer.vue +++ b/src/components/project/FileExplorer.vue @@ -84,9 +84,18 @@
-
+ +
+
+
📦
+

{{ $t('projectDetail.modulesEmptyHint') }}

+
+ +
+
@@ -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([]) const currentModuleId = ref('') 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(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; diff --git a/src/components/project/FileTree.vue b/src/components/project/FileTree.vue index fec7e48..903ba0a 100644 --- a/src/components/project/FileTree.vue +++ b/src/components/project/FileTree.vue @@ -39,6 +39,12 @@ {{ expandedPaths.has(entry.path) ? '▾' : '▸' }} 📁 {{ entry.name }} + +
@@ -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([]) const loading = ref(false) const error = ref(null) +// B3[PD-P1-6]:目录展开失败的行内错误态(路径 → 错误信息)。之前把 toggleDir 失败设到 error.value, +// 会让整个子树渲染分支(v-else-if="error")替换成错误文案,一次子目录失败就炸掉已展开的其他节点。 +// 改为按 path 记录,条目行内显 ⚠ + title 原因,点击重试;成功加载即清除。 +const toggleErrors = reactive(new Map()) /** 拉取当前 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; diff --git a/src/components/project/GitChanges.vue b/src/components/project/GitChanges.vue index b7d8705..b8fcef6 100644 --- a/src/components/project/GitChanges.vue +++ b/src/components/project/GitChanges.vue @@ -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() +})