既然你已经有了工程文件的结构数据(比如是从 project.config.json 读取出来的树形结构),现在的核心任务是:验证这些路径在磁盘上是否真的存在,并根据结果更新 UI。
这是一个典型的 “元数据” vs “物理数据” 的校验场景。
为了保证性能(避免成百上千次 IPC 通信),我们应该采用 “批量校验” 的策略。
1. 后端:批量路径校验 (src/main/ipc/fs-ipc.ts)
不要一个一个文件去调 fs.existsSync,那样 UI 会卡顿。我们要提供一个接口,前端传一个路径数组,后端返回一个状态 Map。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| import { ipcMain } from 'electron' import fs from 'fs-extra'
export function setupFsIPC() {
ipcMain.handle('fs-check-paths-batch', async (_, paths: string[]) => { const result: Record<string, boolean> = {} await Promise.all(paths.map(async (p) => { try { await fs.access(p) result[p] = true } catch { result[p] = false } })) return result }) }
|
2. Preload 暴露 API (src/preload/index.ts)
1 2 3 4 5 6
| const fsAPI = { checkPathsBatch: (paths: string[]) => ipcRenderer.invoke('fs-check-paths-batch', paths) }
|
3. 前端:递归组件实现 (FileTreeItem.vue)
我们需要修改组件,接收一个全局的 fileStatusMap(记录谁存在,谁不存在)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108
| <!-- src/components/explorer/FileTreeItem.vue --> <template> <div class="tree-node"> <div class="item-label" :class="{ 'selected': isSelected, 'missing': isMissing // 🔴 关键样式:不存在标红 }" :style="{ paddingLeft: `${depth * 16 + 8}px` }" @click.stop="handleClick" @contextmenu.stop="handleRightClick" > <!-- 图标:如果丢失显示警告,否则显示正常的文件夹/文件图标 --> <span class="icon"> <template v-if="isMissing">⚠️</template> <template v-else> {{ node.type === 'directory' ? (isOpen ? '📂' : '📁') : '📄' }} </template> </span> <span class="name">{{ node.name }}</span> <span v-if="isMissing" class="missing-tag">(已删除)</span> </div>
<!-- 递归渲染 --> <div v-if="node.type === 'directory' && isOpen"> <FileTreeItem v-for="child in node.children" :key="child.path" :node="child" :depth="depth + 1" :selectedPath="selectedPath" :statusMap="statusMap" @select="$emit('select', $event)" /> </div> </div> </template>
<script setup lang="ts"> import { ref, computed } from 'vue'
const props = defineProps<{ node: any depth: number selectedPath: string // 接收父组件传下来的状态表 { "path": true/false } statusMap: Record<string, boolean> }>()
const emit = defineEmits(['select'])
const isOpen = ref(false)
const isSelected = computed(() => props.selectedPath === props.node.path)
// ⚠️ 核心判断:如果 map 里显式记为 false,则视为丢失 // (如果 map 里没有这个 key,通常视作还没加载完,暂不标红,或者默认为 true) const isMissing = computed(() => { return props.statusMap[props.node.path] === false })
const handleClick = () => { // 🚫 核心逻辑:不存在则禁止点击(或者只能触发特定逻辑) if (isMissing.value) { console.warn('文件不存在,无法打开') return }
if (props.node.type === 'directory') { isOpen.value = !isOpen.value } emit('select', props.node) }
const handleRightClick = () => { // 即使文件不存在,通常也允许右键,以便用户选择 "从列表中删除" } </script>
<style scoped> .item-label { display: flex; align-items: center; cursor: pointer; padding: 4px 0; color: #ccc; user-select: none; }
/* 正常状态 hover */ .item-label:hover:not(.missing) { background-color: #2a2d2e; } .item-label.selected { background-color: #37373d; color: white; }
/* 🔴 丢失文件样式 */ .item-label.missing { color: #e51400; /* 鲜艳的红 */ opacity: 0.8; cursor: not-allowed; /* 禁止手势 */ } .item-label.missing:hover { background-color: rgba(229, 20, 0, 0.1); /* 红色背景微光 */ }
.icon { margin-right: 6px; width: 16px; text-align: center; } .missing-tag { font-size: 10px; margin-left: 8px; opacity: 0.7; } </style>
|
4. 前端:父组件逻辑 (ProjectExplorer.vue)
在这里我们将树形数据拍平,批量发送给后端校验。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
| <!-- src/components/explorer/ProjectExplorer.vue --> <template> <div class="explorer"> <div class="toolbar"> <button @click="validateFiles">🔄 刷新状态</button> </div> <div class="tree-container"> <FileTreeItem v-for="node in projectData" :key="node.path" :node="node" :depth="0" :selectedPath="selectedPath" :statusMap="fileStatusMap" @select="handleSelect" /> </div> </div> </template>
<script setup lang="ts"> import { ref, onMounted, watch } from 'vue' import FileTreeItem from './FileTreeItem.vue'
// 假设这是你从 project.config.json 读出来的原始数据 const projectData = ref<any[]>([ // 示例数据 { name: 'src', path: 'D:/project/src', type: 'directory', children: [ { name: 'main.ts', path: 'D:/project/src/main.ts', type: 'file' }, { name: 'deleted.ts', path: 'D:/project/src/deleted.ts', type: 'file' } // 假设这个文件被删了 ] } ])
const selectedPath = ref('') const fileStatusMap = ref<Record<string, boolean>>({})
// === 核心工具:拍平树结构获取所有路径 === const getAllPaths = (nodes: any[]): string[] => { let paths: string[] = [] for (const node of nodes) { paths.push(node.path) if (node.children) { paths = paths.concat(getAllPaths(node.children)) } } return paths }
// === 核心逻辑:批量校验 === const validateFiles = async () => { if (projectData.value.length === 0) return
// 1. 提取所有路径 const allPaths = getAllPaths(projectData.value) // 2. 发送给主进程批量检查 console.log('正在校验文件数量:', allPaths.length) const result = await window.fsAPI.checkPathsBatch(allPaths) // 3. 更新状态 Map fileStatusMap.value = result }
// 初始化时校验一次 onMounted(() => { validateFiles() })
// 监听数据变化(如果工程文件变了,重新校验) watch(projectData, () => { validateFiles() }, { deep: true })
const handleSelect = (node: any) => { selectedPath.value = node.path // 打开文件逻辑... } </script>
|
方案总结
- 性能优化:通过
getAllPaths 将树拍平,只需 一次 IPC 调用 即可验证整个工程成百上千个文件的状态,不会卡顿。
- 状态驱动 UI:使用
fileStatusMap 传递给所有子组件。Vue 的响应式系统会自动把状态为 false 的节点渲染成红色。
- 交互控制:在子组件内部拦截
handleClick,如果 isMissing 为真,直接 return,实现了“不能点击”。
这完全符合你的需求。你可以直接把这段样式和逻辑贴进你现有的左侧菜单组件中。
Prev: 文件菜单显示
Next: electron-vite IDE左侧读取工程文件