从零构建跨平台桌面文件管理器 | 支持 Windows + macOS
Explorer Pro - 功能完善的跨平台文件管理器
包含文件浏览、搜索、预览、标签管理、云同步等核心功能
本教程将构建一个现代化的文件管理工具,具备以下技术特点:
极速的热重载、高效的构建性能、优化的开发体验,支持原生 ES 模块
更灵活的组件逻辑复用、更清晰的代码组织、性能提升 30%
真正的桌面应用体验、系统级 API 访问、原生窗口管理
我们将采用 Electron 推荐的“主进程 + 渲染进程”模式,结合 Vite 的高效构建能力和 Vue3 的响应式框架,实现高性能的桌面应用。整体架构如下所示:
架构说明:
首先,请确保您的开发环境满足以下要求,并安装必要的工具:
您可以通过以下命令检查 Node.js 和 npm 的版本:
# 检查 Node.js 版本
node -v
# 检查 npm 版本
npm -v
# (可选) 如果没有安装pnpm,建议安装:
npm install -g pnpm
接下来,我们将初始化项目并安装核心依赖:
# 1. 创建项目目录并进入
mkdir explorer-pro
cd explorer-pro
# 2. 初始化 npm 项目
pnpm init
# 3. 创建基础目录结构 (可根据 1.3 节的目录结构手动创建或使用脚本)
mkdir -p src/main src/renderer/{components,views,stores,router,utils,assets} src/shared/{services,utils,types} public
# 4. 安装核心依赖
pnpm add electron vue@next vue-router pinia # 生产依赖
pnpm add -D vite @vitejs/plugin-vue electron-builder concurrently typescript @types/node # 开发依赖
# 5. 安装一些推荐的辅助库(根据实际需求)
pnpm add axios localforage # 用于HTTP请求和本地存储
package.json 配置配置 package.json 文件,定义项目脚本和依赖:
{
"name": "explorer-pro",
"version": "1.0.0",
"description": "A modern file manager built with Electron + Vite + Vue3",
"main": "dist/main/index.js",
"private": true,
"scripts": {
"dev": "concurrently \"pnpm run dev:renderer\" \"pnpm run dev:electron\"",
"dev:renderer": "vite",
"dev:electron": "wait-on http://localhost:5173 && electron .",
"build": "pnpm run build:renderer && pnpm run build:main",
"build:renderer": "vite build",
"build:main": "vite build -c vite.main.config.js",
"preview": "vite preview",
"package": "pnpm run build && electron-builder",
"dist": "pnpm run build && electron-builder --publish=never",
"dist:win": "pnpm run build && electron-builder --win --publish=never",
"dist:mac": "pnpm run build && electron-builder --mac --publish=never"
},
"devDependencies": {
"electron": "^29.0.0",
"electron-builder": "^24.9.1",
"vite": "^5.1.4",
"concurrently": "^8.2.2",
"wait-on": "^7.2.0",
"@vitejs/plugin-vue": "^5.0.4",
"vue": "^3.4.19",
"vue-router": "^4.3.0",
"pinia": "^2.1.7",
"typescript": "^5.2.2",
"@types/node": "^20.11.20"
},
"dependencies": {
"axios": "^1.6.7",
"localforage": "^1.10.0"
},
"keywords": [
"Electron",
"Vite",
"Vue3",
"File Manager",
"Desktop App"
]
}
dev:renderer: 启动 Vite 开发服务器,用于渲染进程的热重载。dev:electron: 等待 Vite 服务器启动后,启动 Electron 应用。build:renderer: 构建渲染进程的生产代码。build:main: 构建主进程的生产代码 (使用单独的 Vite 配置)。package: 构建完整的 Electron 安装包。vite.config.js)渲染进程的 Vite 配置相对简单,主要用于 Vue3 应用的打包。注意这里的 base 配置,对于 Electron 应用,通常使用相对路径。
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue()],
base: './', // 确保在 Electron 中资源路径正确
build: {
outDir: 'dist/renderer', // 输出到 dist/renderer 目录
emptyOutDir: true,
// 配置 Rollup 选项
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
}
}
},
server: {
port: 5173, // Vite 默认端口
strictPort: true, // 确保端口不被占用
},
resolve: {
alias: {
'@': resolve(__dirname, 'src/renderer') // 别名配置
}
}
});
vite.main.config.js)主进程也使用 Vite 进行打包,但需要将其配置为 Node.js 环境,并排除 Electron 和 Node.js 内置模块。
// vite.main.config.js
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
build: {
outDir: 'dist/main', // 输出到 dist/main 目录
emptyOutDir: true,
lib: {
entry: resolve(__dirname, 'src/main/index.js'),
formats: ['cjs'], // 主进程使用 CommonJS 格式
fileName: 'index'
},
rollupOptions: {
// 外部化 Electron 和 Node.js 内置模块
external: [
'electron',
...require('node:module').builtinModules,
'path',
'fs',
'os',
'child_process' // 明确排除
]
}
}
});
src/renderer/main.js)Vue3 应用的启动文件:
// src/renderer/main.js
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import router from './router';
import App from './App.vue';
// 引入全局样式
import './assets/styles/index.css';
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');
// 渲染进程中的 Electron API 调用示例
// window.electronAPI.invoke('system:getInfo').then(info => {
// console.log('Renderer received system info:', info);
// });
index.html 仍然是 Vue 应用的入口,无需过多修改,只需引入 main.js:
<!-- src/renderer/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Explorer Pro</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/renderer/main.js"></script>
</body>
</html>
src/main/index.js)Electron 主进程文件,负责窗口管理、IPC 设置和应用生命周期。
// src/main/index.js
const { app, BrowserWindow, ipcMain, Menu, shell, globalShortcut, dialog } = require('electron');
const path = require('path');
const url = require('url');
const isDev = process.env.NODE_ENV === 'development';
class ExplorerPro {
constructor() {
this.mainWindow = null;
this.init();
}
init() {
// 确保应用程序在单实例模式下运行
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
return;
}
app.on('second-instance', (event, commandLine, workingDirectory) => {
// 当运行第二个实例时,聚焦到主窗口
if (this.mainWindow) {
if (this.mainWindow.isMinimized()) this.mainWindow.restore();
this.mainWindow.focus();
}
});
// 应用准备就绪时创建窗口
app.whenReady().then(() => {
this.createWindow();
this.setupIpcHandlers();
this.setupMenu();
this.setupGlobalShortcuts();
// macOS 特有的应用激活处理
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
this.createWindow();
}
});
});
// 所有窗口关闭时退出应用 (macOS 除外)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// 应用程序退出前注销所有快捷方式
app.on('will-quit', () => {
globalShortcut.unregisterAll();
});
// macOS: 处理文件关联打开
app.on('open-file', (event, filePath) => {
event.preventDefault();
if (this.mainWindow) {
this.mainWindow.webContents.send('file:open-external', filePath);
}
});
}
createWindow() {
this.mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
title: "Explorer Pro",
icon: path.join(__dirname, '../../public/icon.png'), // 应用图标
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false, // 禁用 Node.js
contextIsolation: true, // 启用上下文隔离
enableRemoteModule: false, // 禁用 remote 模块
sandbox: true // 启用沙箱
},
show: false, // 初始不显示窗口,等待内容加载
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', // macOS 风格标题栏
backgroundColor: '#ffffff' // 初始背景色
});
// 加载渲染进程的 URL 或文件
if (isDev) {
this.mainWindow.loadURL('http://localhost:5173');
this.mainWindow.webContents.openDevTools();
} else {
// 生产环境加载打包后的 HTML 文件
this.mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
}
// 窗口内容加载完成后显示
this.mainWindow.once('ready-to-show', () => {
this.mainWindow.show();
});
// 处理外部链接,使用系统浏览器打开
this.mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
}
setupIpcHandlers() {
const fileService = require(path.join(__dirname, '../shared/services/fileService'));
const searchService = require(path.join(__dirname, '../shared/services/searchService'));
ipcMain.handle('file:readDir', async (event, dirPath) => {
try {
const result = await fileService.getDirectoryContents(dirPath);
return { success: true, data: result };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('file:readFile', async (event, filePath, encoding = 'utf8') => {
try {
const result = await fileService.readFile(filePath, encoding);
return { success: true, data: result.content };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('file:writeFile', async (event, filePath, content, encoding = 'utf8') => {
try {
await fileService.createFile(filePath, content, encoding);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('file:deleteItem', async (event, itemPath) => {
try {
await fileService.deleteItem(itemPath);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('file:renameItem', async (event, oldPath, newName) => {
try {
const newPath = await fileService.renameItem(oldPath, newName);
return { success: true, data: newPath };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('file:createDirectory', async (event, parentPath, dirName) => {
try {
const newPath = await fileService.createDirectory(parentPath, dirName);
return { success: true, data: newPath };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('search:files', async (event, rootPath, query, options) => {
try {
const results = await searchService.searchFiles(rootPath, query, options);
return { success: true, data: results };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('system:getHomeDir', () => {
return require('os').homedir();
});
ipcMain.handle('system:getPlatform', () => {
return process.platform;
});
ipcMain.handle('dialog:showOpenDialog', async (event, options) => {
const result = await dialog.showOpenDialog(this.mainWindow, options);
return result;
});
ipcMain.handle('shell:openPath', async (event, filePath) => {
try {
const result = await shell.openPath(filePath);
return { success: !result, error: result }; // shell.openPath 返回空字符串表示成功
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('notification:show', (event, title, body) => {
const { Notification } = require('electron');
if (Notification.isSupported()) {
new Notification({ title, body }).show();
}
});
}
setupMenu() {
const isMac = process.platform === 'darwin';
const template = [
...(isMac ? [{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ label: 'Preferences...', accelerator: 'CommandOrControl+,', click: () => this.mainWindow.webContents.send('app:open-settings') },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideothers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' }
]
}] : []),
{
label: 'File',
submenu: [
{ label: 'New Folder', accelerator: 'CommandOrControl+Shift+N', click: () => this.mainWindow.webContents.send('app:new-folder') },
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' }
]
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'delete' },
{ type: 'separator' },
{ role: 'selectAll' }
]
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' }
]
},
{
label: 'Help',
submenu: [
{
label: 'Learn More',
click: async () => {
const { shell } = require('electron');
await shell.openExternal('https://www.electronjs.org');
}
}
]
}
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
setupGlobalShortcuts() {
globalShortcut.register('CommandOrControl+Shift+I', () => {
if (this.mainWindow && this.mainWindow.isFocused()) {
this.mainWindow.webContents.toggleDevTools();
}
});
globalShortcut.register('CommandOrControl+R', () => {
if (this.mainWindow && this.mainWindow.isFocused()) {
this.mainWindow.reload();
}
});
}
}
// 启动应用
new ExplorerPro();
Electron 应用的安全性至关重要,特别是渲染进程:
nodeIntegration: false - 在渲染进程中禁用 Node.js API,防止恶意代码执行。contextIsolation: true - 启用上下文隔离,阻止渲染进程的代码直接访问 Electron/Node.js 全局对象。enableRemoteModule: false - 禁用 remote 模块,避免渲染进程直接调用主进程 API。preload 脚本 - 唯一安全地将主进程功能暴露给渲染进程的方式。sandbox: true - 启用 Chromium 的沙箱机制,提供额外的安全层。这些配置共同构成了 Electron 应用的基础安全防线。
src/main/preload.js)预加载脚本是连接主进程和渲染进程的桥梁,它运行在隔离的上下文中,安全地暴露主进程 IPC 方法给渲染进程。
// src/main/preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
// 渲染进程向主进程发送请求并等待响应 (invoke)
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
// 渲染进程接收主进程发送的通知 (on)
on: (channel, callback) => {
const subscription = (event, ...args) => callback(...args);
ipcRenderer.on(channel, subscription);
return () => ipcRenderer.removeListener(channel, subscription);
},
// (可选) 渲染进程向主进程发送单向消息 (send)
send: (channel, ...args) => ipcRenderer.send(channel, ...args)
});
我们主要使用 ipcRenderer.invoke 和 ipcMain.handle 进行双向通信,这是 Electron 推荐的安全通信方式。
src/shared/services/fileService.js)封装 Node.js 的 fs/promises 模块,提供异步文件操作接口。
// src/shared/services/fileService.js
const { readdir, stat, readFile, writeFile, mkdir, rm, rename } = require('fs/promises');
const { join, basename, extname, dirname } = require('path');
const { homedir } = require('os');
class FileService {
constructor() {
this.homeDirectory = homedir();
}
/**
* 获取指定目录的内容 (文件和文件夹)
* @param {string} directoryPath - 要读取的目录路径
* @returns {Promise>} - 包含文件/文件夹信息的数组
*/
async getDirectoryContents(directoryPath) {
try {
const items = await readdir(directoryPath);
const contents = [];
for (const item of items) {
const itemPath = join(directoryPath, item);
try {
const stats = await stat(itemPath);
contents.push({
name: item,
path: itemPath,
type: stats.isDirectory() ? 'directory' : 'file',
size: stats.size,
modified: stats.mtimeMs, // 修改时间戳
created: stats.birthtimeMs, // 创建时间戳
extension: stats.isFile() ? extname(item).toLowerCase() : '',
isHidden: item.startsWith('.') && process.platform !== 'win32' // Unix-like系统隐藏文件
});
} catch (innerError) {
// 忽略无法访问的文件/目录,继续处理其他项
console.warn(`无法获取 ${itemPath} 的信息: ${innerError.message}`);
}
}
// 排序: 目录优先,然后按名称排序
return contents.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
});
} catch (error) {
console.error(`读取目录 ${directoryPath} 失败:`, error);
throw new Error(`无法读取目录: ${error.message}`);
}
}
/**
* 读取文件内容
* @param {string} filePath - 文件路径
* @param {string} encoding - 编码格式,默认为 utf8
* @returns {Promise
src/renderer/stores/fileStore.js)使用 Pinia 管理文件浏览器状态,并通过 window.electronAPI.invoke 调用主进程的文件服务。
// src/renderer/stores/fileStore.js
import { defineStore } from 'pinia';
export const useFileStore = defineStore('fileStore', {
state: () => ({
currentPath: '',
homePath: '',
directoryContents: [], // 当前目录下的文件和文件夹
selectedItems: [],
loading: false,
error: null,
}),
getters: {
// 获取当前路径的父目录
parentPath: (state) => {
if (!state.currentPath || state.currentPath === state.homePath) return null;
const parts = state.currentPath.split(/[/\\]/);
return parts.slice(0, -1).join('/') || '/'; // 返回 / 或父目录
},
// 当前目录的层级路径
breadcrumbs: (state) => {
if (!state.currentPath) return [];
const parts = state.currentPath.split(/[/\\]/).filter(Boolean);
let accumulatedPath = '';
return parts.map((part, index) => {
accumulatedPath += (index === 0 ? '' : '/') + part;
return { name: part, path: accumulatedPath };
});
}
},
actions: {
async initialize() {
this.loading = true;
try {
const homeDir = await window.electronAPI.invoke('system:getHomeDir');
this.homePath = homeDir;
this.currentPath = homeDir;
await this.loadDirectory(homeDir);
} catch (err) {
this.error = err.message;
console.error('初始化文件存储失败:', err);
} finally {
this.loading = false;
}
},
async loadDirectory(path) {
this.loading = true;
this.error = null;
try {
const response = await window.electronAPI.invoke('file:readDir', path);
if (response.success) {
this.directoryContents = response.data;
this.currentPath = path;
this.selectedItems = []; // 清空选中项
} else {
throw new Error(response.error);
}
} catch (err) {
this.error = err.message;
console.error('加载目录失败:', err);
throw err; // 抛出错误以便组件处理
} finally {
this.loading = false;
}
},
async createNewFolder(parentPath, folderName) {
this.loading = true;
this.error = null;
try {
const response = await window.electronAPI.invoke('file:createDirectory', parentPath, folderName);
if (response.success) {
await this.loadDirectory(parentPath); // 刷新当前目录
} else {
throw new Error(response.error);
}
} catch (err) {
this.error = err.message;
console.error('创建文件夹失败:', err);
throw err;
} finally {
this.loading = false;
}
},
async createNewFile(parentPath, fileName, content = '') {
this.loading = true;
this.error = null;
try {
const response = await window.electronAPI.invoke('file:writeFile', parentPath, fileName, content);
if (response.success) {
await this.loadDirectory(parentPath); // 刷新当前目录
} else {
throw new Error(response.error);
}
} catch (err) {
this.error = err.message;
console.error('创建文件失败:', err);
throw err;
} finally {
this.loading = false;
}
},
async deleteItem(itemPath) {
this.loading = true;
this.error = null;
try {
const response = await window.electronAPI.invoke('file:deleteItem', itemPath);
if (response.success) {
await this.loadDirectory(this.currentPath); // 刷新当前目录
} else {
throw new Error(response.error);
}
} catch (err) {
this.error = err.message;
console.error('删除项失败:', err);
throw err;
} finally {
this.loading = false;
}
},
async renameItem(oldPath, newName) {
this.loading = true;
this.error = null;
try {
const response = await window.electronAPI.invoke('file:renameItem', oldPath, newName);
if (response.success) {
await this.loadDirectory(this.currentPath); // 刷新当前目录
} else {
throw new Error(response.error);
}
} catch (err) {
this.error = err.message;
console.error('重命名失败:', err);
throw err;
} finally {
this.loading = false;
}
},
toggleSelectItem(itemPath) {
const index = this.selectedItems.indexOf(itemPath);
if (index > -1) {
this.selectedItems.splice(index, 1);
} else {
this.selectedItems.push(itemPath);
}
},
clearSelection() {
this.selectedItems = [];
}
}
});
src/renderer/components/FileBrowser.vue)这是一个核心的 Vue 组件,用于显示当前目录下的文件和文件夹,并处理用户交互。
<!-- src/renderer/components/FileBrowser.vue -->
<template>
<div class="file-browser">
<div v-if="fileStore.loading" class="loading-overlay">
<div class="spinner"></div>
<p>加载中...</p>
</div>
<div v-else-if="fileStore.error" class="error-message">
<p>{{ fileStore.error }}</p>
<button @click="fileStore.loadDirectory(fileStore.currentPath)">重试</button>
</div>
<div v-else :class="['file-list-container', viewMode]">
<div
v-for="item in fileStore.directoryContents"
:key="item.path"
:class="['file-item', { 'selected': fileStore.selectedItems.includes(item.path) }]"
@click.stop="handleClick(item, $event)"
@dblclick="handleDoubleClick(item)"
@contextmenu.prevent="showContextMenu($event, item)"
>
<!-- 图标 -->
<div class="item-icon">{{ getFileIcon(item) }}</div>
<!-- 名称 -->
<div class="item-name">{{ item.name }}</div>
<!-- 额外信息 (仅列表模式显示) -->
<div v-if="viewMode === 'list'" class="item-meta">
<span class="item-size">{{ item.type === 'file' ? formatFileSize(item.size) : '' }}</span>
<span class="item-date">{{ formatDate(item.modified) }}</span>
</div>
</div>
<div v-if="fileStore.directoryContents.length === 0 && !fileStore.loading" class="empty-folder">
<p>此文件夹为空。</p>
</div>
</div>
<!-- 右键菜单 -->
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
>
<div class="menu-item" @click="handleDoubleClick(contextMenu.item)">打开</div>
<div class="menu-item" @click="renameItem(contextMenu.item)">重命名</div>
<div class="menu-item" @click="deleteItem(contextMenu.item)">删除</div>
<div class="menu-item" @click="copyItem(contextMenu.item)">复制</div>
<div class="menu-item" @click="cutItem(contextMenu.item)">剪切</div>
<div class="menu-item" @click="openInSystemExplorer(contextMenu.item)">在系统文件管理器中打开</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useFileStore } from '../stores/fileStore';
import { formatFileSize, formatDate } from '../utils/helpers'; // 辅助函数
const props = defineProps({
viewMode: {
type: String,
default: 'list' // 'list' 或 'grid'
}
});
const emit = defineEmits(['file-selected', 'folder-opened']);
const fileStore = useFileStore();
const contextMenu = ref({
visible: false,
x: 0,
y: 0,
item: null
});
// 计算属性和方法
const getFileIcon = (item) => {
if (item.type === 'directory') return '📁';
const extension = item.extension.toLowerCase();
const iconMap = {
'.js': '📄', '.ts': '📄', '.vue': '⚛️', '.html': '🌐', '.css': '🎨',
'.json': '📋', '.md': '📝', '.txt': '📄', '.log': '📜',
'.png': '🖼️', '.jpg': '🖼️', '.jpeg': '🖼️', '.gif': '🖼️', '.svg': '🖼️',
'.mp4': '🎥', '.mp3': '🎵', '.zip': '🗜️', '.rar': '🗜️', '.7z': '🗜️',
'.pdf': '📚', '.doc': '📃', '.docx': '📃', '.xls': '📊', '.xlsx': '📊',
'.ppt': '📈', '.pptx': '📈', '.exe': '⚙️', '.dmg': '📦'
};
return iconMap[extension] || '❓'; // 默认未知文件图标
};
const handleClick = (item, event) => {
fileStore.toggleSelectItem(item.path);
emit('file-selected', item); // 向上级组件发出事件
};
const handleDoubleClick = async (item) => {
if (item.type === 'directory') {
await fileStore.loadDirectory(item.path);
emit('folder-opened', item); // 向上级组件发出事件
} else {
// 调用主进程打开文件
const response = await window.electronAPI.invoke('shell:openPath', item.path);
if (!response.success) {
console.error('无法打开文件:', response.error);
alert(`无法打开文件: ${response.error}`);
}
}
hideContextMenu();
};
const showContextMenu = (event, item) => {
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
item: item
};
};
const hideContextMenu = () => {
contextMenu.value.visible = false;
contextMenu.value.item = null;
};
const renameItem = async (item) => {
const newName = prompt(`重命名 ${item.name} 为:`, item.name);
if (newName && newName.trim() !== item.name) {
try {
await fileStore.renameItem(item.path, newName.trim());
await fileStore.loadDirectory(fileStore.currentPath); // 刷新
} catch (error) {
alert(`重命名失败: ${error.message}`);
}
}
hideContextMenu();
};
const deleteItem = async (item) => {
if (confirm(`确定要删除 ${item.name} 吗?此操作不可逆!`)) {
try {
await fileStore.deleteItem(item.path);
await fileStore.loadDirectory(fileStore.currentPath); // 刷新
} catch (error) {
alert(`删除失败: ${error.message}`);
}
}
hideContextMenu();
};
const copyItem = (item) => {
console.log('复制:', item.path);
// 实现复制逻辑,可能需要跨进程通信进行文件操作
hideContextMenu();
};
const cutItem = (item) => {
console.log('剪切:', item.path);
// 实现剪切逻辑
hideContextMenu();
};
const openInSystemExplorer = async (item) => {
const response = await window.electronAPI.invoke('shell:openPath', item.path); // 使用 shell.openPath 打开父目录
if (!response.success) {
console.error('在系统文件管理器中打开失败:', response.error);
alert(`在系统文件管理器中打开失败: ${response.error}`);
}
hideContextMenu();
};
// 全局点击事件,用于关闭右键菜单和清空选中项
const handleGlobalClick = (event) => {
if (contextMenu.value.visible && !event.target.closest('.context-menu')) {
hideContextMenu();
}
// 如果点击的不是文件项,则清空选中项
if (!event.target.closest('.file-item')) {
fileStore.clearSelection();
}
};
onMounted(() => {
document.addEventListener('click', handleGlobalClick);
document.addEventListener('contextmenu', handleGlobalClick); // 监听右键点击,清除菜单
});
onUnmounted(() => {
document.removeEventListener('click', handleGlobalClick);
document.removeEventListener('contextmenu', handleGlobalClick);
});
</script>
<style scoped>
.file-browser {
flex: 1; /* 占据可用空间 */
overflow: hidden; /* 防止内容溢出 */
position: relative;
background: var(--bg-primary);
}
.loading-overlay, .error-message {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
color: #555;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin-bottom: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-message p {
color: #e74c3c;
margin-bottom: 10px;
}
.error-message button {
padding: 8px 15px;
background: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.file-list-container {
height: 100%;
overflow-y: auto; /* 允许滚动 */
padding: 10px;
display: flex;
flex-wrap: wrap; /* 允许网格布局换行 */
align-content: flex-start; /* 网格内容从顶部开始 */
}
.file-list-container.list {
flex-direction: column; /* 列表模式 */
}
.file-list-container.grid {
flex-direction: row; /* 网格模式 */
gap: 10px; /* 网格间距 */
}
.file-item {
display: flex;
align-items: center;
padding: 8px;
margin-bottom: 2px;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s;
user-select: none; /* 防止文本被选中 */
width: 100%; /* 列表模式下占据整行 */
}
.file-list-container.grid .file-item {
flex-direction: column;
justify-content: center;
text-align: center;
width: 120px; /* 网格项固定宽度 */
height: 100px; /* 网格项固定高度 */
margin: 5px; /* 网格间距 */
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
border: 1px solid var(--border-color);
}
.file-item:hover {
background: var(--hover-bg);
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}
.file-item.selected {
background: var(--accent-bg);
color: var(--accent-color);
box-shadow: 0 0 0 2px var(--accent-color) inset;
}
.file-list-container.grid .file-item.selected {
box-shadow: 0 0 0 2px var(--accent-color) inset;
background: var(--accent-bg);
color: var(--accent-color);
}
.item-icon {
width: 30px;
font-size: 1.2em;
text-align: center;
flex-shrink: 0;
margin-right: 10px;
}
.file-list-container.grid .item-icon {
margin: 0;
margin-bottom: 5px;
width: auto;
}
.item-name {
flex: 1;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-list-container.grid .item-name {
flex: none;
width: 100%;
font-size: 0.9em;
white-space: normal;
word-break: break-all;
height: 3em; /* 限制两行 */
line-height: 1.5em;
overflow: hidden;
}
.item-meta {
font-size: 0.8em;
opacity: 0.7;
display: flex;
gap: 10px;
flex-shrink: 0;
}
.empty-folder {
text-align: center;
width: 100%;
padding: 50px;
color: #888;
}
/* 右键菜单样式 */
.context-menu {
position: fixed;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;
min-width: 150px;
}
.menu-item {
padding: 8px 16px;
cursor: pointer;
border-bottom: 1px solid #eee;
color: #333;
}
.menu-item:last-child {
border-bottom: none;
}
.menu-item:hover {
background: #f0f0f0;
}
</style>
这个文件浏览器组件采用了 Vue3 组合式 API 设计模式,具有以下特点:
list) 和网格 (grid) 两种视图模式。src/shared/services/searchService.js)实现高效的文件搜索系统,支持文件名和内容搜索。
// src/shared/services/searchService.js
const { readdir, stat, createReadStream } = require('fs/promises');
const { join, basename, extname } = require('path');
const { EventEmitter } = require('events');
class SearchService extends EventEmitter {
constructor() {
super();
this.searchHistory = [];
this.maxHistorySize = 50;
this.isSearching = false;
this.abortController = null;
}
/**
* 递归搜索文件和文件夹
* @param {string} rootPath - 搜索的根目录
* @param {string} query - 搜索关键词
* @param {Object} options - 搜索选项
* @param {boolean} [options.searchInContent=false] - 是否搜索文件内容
* @param {Array} [options.fileTypes=[]] - 过滤文件类型 (e.g., ['.txt', '.md'])
* @param {boolean} [options.caseSensitive=false] - 是否区分大小写
* @param {boolean} [options.regexSearch=false] - 是否使用正则表达式搜索
* @returns {Promise>} - 搜索结果数组
*/
async searchFiles(rootPath, query, options = {}) {
if (this.isSearching) {
this.abortController.abort(); // 取消上一次搜索
}
this.isSearching = true;
this.abortController = new AbortController();
const { signal } = this.abortController;
const {
searchInContent = false,
fileTypes = [],
caseSensitive = false,
regexSearch = false
} = options;
const results = [];
const searchQuery = caseSensitive ? query : query.toLowerCase();
const fileTypeSet = new Set(fileTypes.map(ext => ext.toLowerCase()));
const traverse = async (dirPath) => {
if (signal.aborted) return;
try {
const items = await readdir(dirPath, { withFileTypes: true });
for (const item of items) {
if (signal.aborted) return;
const itemPath = join(dirPath, item.name);
try {
const stats = await stat(itemPath);
let matchFound = false;
// 1. 文件名匹配
const fileName = basename(item.name);
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
if (regexSearch) {
const regex = new RegExp(query, caseSensitive ? '' : 'i');
if (regex.test(fileName)) {
matchFound = true;
}
} else if (searchName.includes(searchQuery)) {
matchFound = true;
}
// 2. 文件类型过滤
if (matchFound && fileTypeSet.size > 0 && stats.isFile()) {
const ext = extname(fileName).toLowerCase();
matchFound = fileTypeSet.has(ext);
}
// 3. 内容搜索 (如果文件名匹配失败且需要内容搜索)
if (!matchFound && searchInContent && stats.isFile()) {
matchFound = await this._searchInContent(itemPath, searchQuery, caseSensitive, signal);
}
if (matchFound) {
results.push({
path: itemPath,
name: fileName,
size: stats.size,
modified: stats.mtimeMs,
type: stats.isDirectory() ? 'directory' : 'file',
extension: stats.isFile() ? extname(fileName).toLowerCase() : ''
});
this.emit('progress', results.length); // 发送进度事件
}
if (item.isDirectory()) {
await traverse(itemPath);
}
} catch (innerError) {
// 忽略权限错误或无法访问的路径
console.warn(`无法访问 ${itemPath}: ${innerError.message}`);
}
}
} catch (error) {
// 忽略父目录无法读取的错误
console.warn(`读取目录 ${dirPath} 失败: ${error.message}`);
}
};
try {
await traverse(rootPath);
this.addToHistory(query);
return results;
} finally {
this.isSearching = false;
this.abortController = null;
}
}
/**
* 在文件内容中搜索关键词
* @private
* @param {string} filePath - 文件路径
* @param {string} searchQuery - 搜索关键词 (已处理大小写)
* @param {boolean} caseSensitive - 是否区分大小写
* @param {AbortSignal} signal - AbortController 信号
* @returns {Promise} - 是否找到匹配项
*/
async _searchInContent(filePath, searchQuery, caseSensitive, signal) {
return new Promise((resolve) => {
if (signal.aborted) return resolve(false);
const stream = createReadStream(filePath, { encoding: 'utf8', signal });
let found = false;
stream.on('data', (chunk) => {
if (signal.aborted) {
stream.destroy();
return;
}
const content = caseSensitive ? chunk : chunk.toLowerCase();
if (content.includes(searchQuery)) {
found = true;
stream.destroy(); // 找到后立即停止读取
}
});
stream.on('end', () => resolve(found));
stream.on('error', (err) => {
if (err.name === 'AbortError') {
console.log('Search in content aborted for:', filePath);
} else {
console.error(`读取文件内容 ${filePath} 失败:`, err.message);
}
resolve(false);
});
});
}
/**
* 添加搜索关键词到历史记录
* @param {string} query
*/
addToHistory(query) {
const trimmedQuery = query.trim();
if (trimmedQuery && !this.searchHistory.includes(trimmedQuery)) {
this.searchHistory.unshift(trimmedQuery);
if (this.searchHistory.length > this.maxHistorySize) {
this.searchHistory = this.searchHistory.slice(0, this.maxHistorySize);
}
}
}
/**
* 获取搜索历史记录
* @returns {Array}
*/
getSearchHistory() {
return this.searchHistory;
}
}
module.exports = new SearchService();
src/renderer/components/FilePreview.vue)支持多种文件类型的预览,包括文本、图片、Markdown 和 JSON。
<!-- src/renderer/components/FilePreview.vue -->
<template>
<div class="file-preview">
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<p>正在加载预览...</p>
</div>
<div v-else-if="error" class="error-state">
<p>预览加载失败: {{ error }}</p>
<button @click="loadPreview">重试</button>
</div>
<div v-else-if="previewContent || imageUrl || pdfUrl" class="preview-content-wrapper">
<!-- 文本文件预览 -->
<pre v-if="isTextFile || isJsonFile" class="text-preview">{{ formattedContent }}</pre>
<!-- 图片预览 -->
<div v-else-if="isImageFile" class="image-preview">
<img :src="imageUrl" :alt="file.name" @load="onMediaLoad" @error="onMediaError">
</div>
<!-- PDF 预览 -->
<div v-else-if="isPdfFile" class="pdf-preview">
<iframe :src="pdfUrl" frameborder="0"></iframe>
</div>
<!-- Markdown 预览 -->
<div v-else-if="isMarkdownFile" class="markdown-preview" v-html="renderedMarkdown"></div>
</div>
<div v-else class="unsupported-preview">
<p>此文件类型暂不支持预览或文件过大。</p>
<div class="file-info-summary">
<p><strong>文件名:</strong> {{ file.name }}</p>
<p><strong>大小:</strong> {{ formatFileSize(file.size) }}</p>
<p><strong>类型:</strong> {{ file.extension || '未知' }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { formatFileSize } from '../utils/helpers';
// 假设有一个简单的 markdown-it 库或类似功能
// import MarkdownIt from 'markdown-it';
// const md = new MarkdownIt();
const props = defineProps({
file: {
type: Object,
required: true,
validator: (val) => val && typeof val.path === 'string' && typeof val.name === 'string'
}
});
const loading = ref(false);
const error = ref('');
const previewContent = ref('');
const imageUrl = ref('');
const pdfUrl = ref('');
const renderedMarkdown = ref('');
// 文件类型判断 (使用计算属性,更清晰)
const fileExtension = computed(() => props.file.extension.toLowerCase());
const isTextFile = computed(() => ['.txt', '.log', '.js', '.ts', '.vue', '.html', '.css', '.scss', '.less', '.xml', '.csv'].includes(fileExtension.value) && props.file.size < 10 * 1024 * 1024); // 10MB
const isImageFile = computed(() => ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.bmp', '.webp', '.ico'].includes(fileExtension.value) && props.file.size < 50 * 1024 * 1024); // 50MB
const isPdfFile = computed(() => fileExtension.value === '.pdf' && props.file.size < 100 * 1024 * 1024); // 100MB
const isMarkdownFile = computed(() => fileExtension.value === '.md' && props.file.size < 5 * 1024 * 1024); // 5MB
const isJsonFile = computed(() => fileExtension.value === '.json' && props.file.size < 10 * 1024 * 1024); // 10MB
const formattedContent = computed(() => {
if (isJsonFile.value) {
try {
return JSON.stringify(JSON.parse(previewContent.value), null, 2);
} catch {
return previewContent.value;
}
}
return previewContent.value;
});
const loadPreview = async () => {
if (!props.file || !props.file.path) return;
loading.value = true;
error.value = '';
previewContent.value = '';
imageUrl.value = '';
pdfUrl.value = '';
renderedMarkdown.value = '';
try {
if (isTextFile.value || isMarkdownFile.value || isJsonFile.value) {
const response = await window.electronAPI.invoke('file:readFile', props.file.path);
if (response.success) {
previewContent.value = response.data;
if (isMarkdownFile.value) {
// 简单的Markdown渲染,实际项目会用库如 'markdown-it'
renderedMarkdown.value = previewContent.value
.replace(/^# (.*$)/gim, '$1
')
.replace(/^## (.*$)/gim, '$1
')
.replace(/^### (.*$)/gim, '$1
')
.replace(/\*\*(.*?)\*\*/gim, '$1')
.replace(/\*(.*?)\*/gim, '$1')
.replace(/`(.*?)`/gim, '$1');
// .replace(/!\[(.*?)\]\((.*?)\)/gim, '
'); // 支持图片
}
} else {
throw new Error(response.error);
}
} else if (isImageFile.value) {
imageUrl.value = `file://${props.file.path}`;
} else if (isPdfFile.value) {
pdfUrl.value = `file://${props.file.path}`;
} else {
// 不支持预览的类型
}
} catch (err) {
error.value = `无法加载文件预览: ${err.message}`;
console.error(error.value);
} finally {
loading.value = false;
}
};
const onMediaLoad = () => {
// 图片或视频加载成功
};
const onMediaError = () => {
error.value = '媒体文件加载失败或格式不支持。';
};
// 监听文件变化,重新加载预览
watch(() => props.file.path, loadPreview, { immediate: true });
onMounted(() => {
loadPreview();
});
</script>
<style scoped>
.file-preview {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
}
.loading-state, .error-state, .unsupported-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
text-align: center;
gap: 15px;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid var(--accent-color);
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-state p {
color: #e74c3c;
}
.error-state button, .unsupported-preview button {
padding: 8px 15px;
background: var(--accent-color);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.error-state button:hover, .unsupported-preview button:hover {
background: var(--accent-dark);
}
.preview-content-wrapper {
flex: 1;
overflow: auto;
padding: 20px;
}
.text-preview, .json-preview {
background: var(--code-bg);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 15px;
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 0.9em;
line-height: 1.4;
white-space: pre-wrap; /* 文本自动换行 */
word-break: break-all; /* 防止长单词溢出 */
color: var(--code-text);
}
.image-preview {
text-align: center;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
.image-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain; /* 保持图片比例 */
border-radius: 4px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.pdf-preview {
width: 100%;
height: 100%;
}
.pdf-preview iframe {
width: 100%;
height: 100%;
border: none;
border-radius: 4px;
}
.markdown-preview {
background: var(--bg-secondary);
padding: 20px;
line-height: 1.6;
border-radius: 6px;
color: var(--text-primary);
}
.markdown-preview h1, .markdown-preview h2, .markdown-preview h3 {
color: var(--accent-color);
margin: 20px 0 10px 0;
border-bottom: 1px dashed var(--border-color);
padding-bottom: 5px;
}
.markdown-preview strong { color: var(--accent-color); }
.markdown-preview em { color: #e74c3c; }
.markdown-preview code {
background: var(--code-inline-bg);
padding: 2px 4px;
border-radius: 3px;
font-family: 'Fira Code', monospace;
color: var(--code-inline-text);
}
.file-info-summary {
margin-top: 20px;
padding: 20px;
background: var(--bg-secondary);
border-radius: 8px;
text-align: left;
min-width: 300px;
box-shadow: 0 1px 4px rgba(0,0,0,0.05);
color: var(--text-primary);
}
.file-info-summary p {
margin: 5px 0;
}
</style>
通过预加载脚本暴露的 electronAPI,渲染进程可以安全地调用主进程的 IPC 方法,实现文件系统、系统通知等功能。
// src/renderer/main.js (部分代码 - 如何调用)
import { createApp } from 'vue';
// ...其他导入
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');
// 示例:获取系统平台信息
window.electronAPI.invoke('system:getPlatform').then(platform => {
console.log('当前平台:', platform);
});
// 示例:监听主进程发送的事件
window.electronAPI.on('file:open-external', (filePath) => {
alert(`主进程请求打开文件: ${filePath}`);
// 可以在这里处理文件打开逻辑,例如切换到预览视图
});
主进程的 index.js 中已包含 IPC 事件处理,确保了渲染进程的请求能够被正确响应。
文件管理器需要处理大量文件和高频操作,因此性能优化至关重要。以下是一些常见的优化策略:
// src/shared/utils/performanceOptimizer.js (示例)
const { throttle, debounce } = require('lodash'); // 假设引入 lodash 或自行实现
class PerformanceOptimizer {
constructor() {
this.imageCache = new Map();
}
// 虚拟滚动计算 (渲染进程使用)
getVirtualScrollRange(containerHeight, itemHeight, totalItems, scrollTop) {
const visibleItemsCount = Math.ceil(containerHeight / itemHeight);
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(startIndex + visibleItemsCount + 2, totalItems - 1); // 额外渲染一些项,避免白屏
return {
startIndex: Math.max(0, startIndex - 2), // 向上多渲染一些
endIndex: endIndex,
paddingTop: Math.max(0, startIndex * itemHeight),
paddingBottom: Math.max(0, (totalItems - endIndex -1) * itemHeight)
};
}
// 图像缓存
cacheImage(key, dataUrl) {
this.imageCache.set(key, dataUrl);
}
getImageFromCache(key) {
return this.imageCache.get(key);
}
// 清理缓存
clearAllCaches() {
this.imageCache.clear();
console.log('所有性能缓存已清除。');
}
// 防抖函数 (示例,实际项目可直接用 npm 包)
debounce(func, delay) {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), delay);
};
}
// 节流函数 (示例)
throttle(func, delay) {
let inThrottle, lastFn, lastTime;
return function() {
const context = this, args = arguments;
if (!inThrottle) {
func.apply(context, args);
lastTime = Date.now();
inThrottle = true;
} else {
clearTimeout(lastFn);
lastFn = setTimeout(function() {
if (Date.now() - lastTime >= delay) {
func.apply(context, args);
lastTime = Date.now();
}
}, Math.max(delay - (Date.now() - lastTime), 0));
}
};
}
}
module.exports = new PerformanceOptimizer();
利用 Node.js 的 process.platform 可以轻松检测当前运行平台,从而应用平台特定的逻辑或 UI 调整。
// src/shared/utils/platformAdapter.js
const { platform, arch } = require('process');
const { app, shell } = require('electron');
class PlatformAdapter {
constructor() {
this.currentPlatform = platform;
this.isWindows = platform === 'win32';
this.isMacOS = platform === 'darwin';
this.isLinux = platform === 'linux';
}
/**
* 获取平台特定的路径分隔符
* @returns {string} '/' for Unix-like, '\' for Windows
*/
getPathSeparator() {
return this.isWindows ? '\\' : '/';
}
/**
* 判断文件或目录是否被视为隐藏
* @param {string} name - 文件或目录名称
* @returns {boolean}
*/
isItemHidden(name) {
if (this.isWindows) {
// Windows 隐藏文件需要通过文件属性判断,Node.js fs.stat 无法直接获取
// 在此简化处理,更准确需要调用 shell 或 winapi
return false; // 暂时不处理Windows隐藏属性
}
return name.startsWith('.'); // Unix-like 系统以 '.' 开头的文件为隐藏文件
}
/**
* 在系统文件管理器中打开指定路径
* @param {string} itemPath - 文件或目录路径
* @returns {Promise}
*/
async openInSystemFileManager(itemPath) {
try {
// shell.showItemInFolder 会打开父目录并选中目标文件
shell.showItemInFolder(itemPath);
} catch (error) {
console.error(`在系统文件管理器中打开 ${itemPath} 失败:`, error);
throw new Error(`无法在系统文件管理器中打开: ${error.message}`);
}
}
/**
* 获取平台特定的窗口标题栏样式
* @returns {string}
*/
getTitleBarStyle() {
return this.isMacOS ? 'hiddenInset' : 'default';
}
/**
* 获取平台默认的主目录
* @returns {string}
*/
getHomeDirectory() {
return require('os').homedir();
}
/**
* 设置 macOS 应用程序用户模型 ID
*/
setupMacOSAppId() {
if (this.isMacOS) {
app.setAppUserModelId('com.explorerpro.app');
}
}
/**
* 设置 Windows 应用程序用户模型 ID
*/
setupWindowsAppId() {
if (this.isWindows) {
app.setAppUserModelId('Explorer Pro');
}
}
}
module.exports = new PlatformAdapter();
macOS 用户期待应用能够融入系统体验,例如定制菜单、Touch Bar 支持等。
// src/main/system/macOSAdapter.js (示例)
const { app, Menu, TouchBar, nativeImage } = require('electron');
class MacOSAdapter {
constructor(mainWindow) {
this.mainWindow = mainWindow;
// this.setupTouchBar(); // 示例:可以根据需要启用
}
// 示例:设置 Touch Bar
setupTouchBar() {
if (!this.mainWindow || !TouchBar) return;
const { TouchBarButton, TouchBarGroup } = TouchBar;
const touchBar = new TouchBar([
new TouchBarGroup({
items: [
new TouchBarButton({
label: '⬆️ Up',
backgroundColor: '#3498db',
click: () => this.mainWindow.webContents.send('app:navigate-up')
}),
new TouchBarButton({
label: '🔄 Refresh',
backgroundColor: '#2ecc71',
click: () => this.mainWindow.webContents.send('app:refresh')
}),
new TouchBarButton({
label: '🔍 Search',
backgroundColor: '#f39c12',
click: () => this.mainWindow.webContents.send('app:focus-search')
})
]
})
]);
this.mainWindow.setTouchBar(touchBar);
}
// 可以在这里添加其他 macOS 专属集成,例如:
// - Finder 集成 (服务菜单、扩展)
// - Retina 显示屏优化 (Electron 默认已处理大部分)
// - Dock 栏菜单
}
module.exports = MacOSAdapter;
Windows 平台也有其独特的集成点,如任务栏跳转列表、进度条、文件关联。
// src/main/system/windowsAdapter.js (示例)
const { app, shell, BrowserWindow } = require('electron');
class WindowsAdapter {
constructor(mainWindow) {
this.mainWindow = mainWindow;
// this.setupJumpList(); // 示例:可以根据需要启用
}
// 示例:设置任务栏跳转列表 (JumpList)
setupJumpList() {
if (!app.setJumpList) return; // 检查是否支持
app.setJumpList([
{
type: 'custom',
name: 'Recent Files',
items: [
// 示例项,实际应从应用内部获取最近文件列表
{ type: 'file', path: 'C:\\Users\\Public\\Documents\\example.txt', title: 'Example Document' },
{ type: 'file', path: 'C:\\Users\\Public\\Pictures\\sample.jpg', title: 'Sample Image' }
]
},
{ type: 'separator' },
{
type: 'custom',
name: 'Quick Actions',
items: [
{
type: 'task',
title: 'New Folder',
description: 'Create a new folder in current directory',
program: process.execPath, // 当前应用路径
args: '--action=new-folder', // 传递给应用的命令行参数
iconPath: process.execPath,
iconIndex: 0 // 应用图标的索引
},
{
type: 'task',
title: 'Open Settings',
description: 'Open application settings',
program: process.execPath,
args: '--action=open-settings',
iconPath: process.execPath,
iconIndex: 0
}
]
}
]);
}
// 示例:设置任务栏进度条
setTaskbarProgress(progress = -1) { // -1 移除,0-1 进度
if (this.mainWindow && this.mainWindow.setProgressBar) {
this.mainWindow.setProgressBar(progress);
}
}
// 可以在这里添加其他 Windows 专属集成,例如:
// - 自定义文件关联 (通过 package.json 的 electron-builder 配置)
// - 系统通知定制
}
module.exports = WindowsAdapter;
Explorer Pro - 一个现代化的跨平台文件管理器,具备以下核心功能:
src/renderer/App.vue)App.vue 是 Vue 应用的根组件,负责整体布局、路由、导航和全局状态管理。
<!-- src/renderer/App.vue -->
<template>
<div id="app" :class="[{ 'dark-theme': settingsStore.isDarkTheme }]">
<!-- 侧边栏 -->
<aside class="sidebar">
<div class="sidebar-header">
<h2>Explorer Pro</h2>
</div>
<nav class="nav-menu">
<div
v-for="item in navItems"
:key="item.name"
:class="['nav-item', { active: currentView === item.view }]"
@click="currentView = item.view"
>
<span class="nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.name }}</span>
</div>
</nav>
<div class="sidebar-footer">
<div class="storage-info">
<div class="storage-bar">
<div class="storage-used" :style="{ width: '45%' }"></div> <!-- 示例值 -->
</div>
<span class="storage-text">45% 已使用</span>
</div>
</div>
</aside>
<!-- 主内容区域 -->
<main class="main-content">
<!-- 工具栏 -->
<header class="toolbar">
<div class="toolbar-group">
<button @click="navigateUp" :disabled="!fileStore.parentPath" class="toolbar-btn">⬆️</button>
<button @click="fileStore.loadDirectory(fileStore.currentPath)" class="toolbar-btn">🔄</button>
<button @click="showNewFolderDialog = true" class="toolbar-btn">📁</button>
<button @click="showNewFileDialog = true" class="toolbar-btn">📄</button>
</div>
<div class="breadcrumb">
<span
v-for="(crumb, index) in fileStore.breadcrumbs"
:key="index"
@click="fileStore.loadDirectory(crumb.path)"
class="crumb"
>
{{ crumb.name }}
<span v-if="index < fileStore.breadcrumbs.length - 1" class="separator">/</span>
</span>
</div>
<div class="toolbar-group">
<div class="search-box">
<input
v-model="searchQuery"
@keyup.enter="performSearch"
placeholder="搜索文件..."
class="search-input"
>
<button @click="performSearch" class="search-btn">🔍</button>
</div>
<button @click="settingsStore.toggleTheme" class="toolbar-btn theme-toggle-btn">
{{ settingsStore.isDarkTheme ? '☀️' : '🌙' }}
</button>
</div>
</header>
<!-- 视图切换器 -->
<nav class="view-switcher">
<button
:class="['view-btn', { active: settingsStore.viewMode === 'list' }]"
@click="settingsStore.setViewMode('list')"
>
📋 列表
</button>
<button
:class="['view-btn', { active: settingsStore.viewMode === 'grid' }]"
@click="settingsStore.setViewMode('grid')"
>
🔲 网格
</button>
</nav>
<!-- 主视图区域 -->
<section class="view-area">
<FileBrowser
v-if="currentView === 'browser'"
:view-mode="settingsStore.viewMode"
@file-selected="onFileSelected"
/>
<SearchResults
v-else-if="currentView === 'search'"
:query="lastSearchQuery"
@file-selected="onFileSelected"
/>
<RecentFiles
v-else-if="currentView === 'recent'"
@file-selected="onFileSelected"
/>
<SettingsView
v-else-if="currentView === 'settings'"
/>
</section>
<!-- 状态栏 -->
<footer class="status-bar">
<span>当前目录: {{ fileStore.currentPath }}</span>
<span v-if="fileStore.selectedItems.length > 0">已选择: {{ fileStore.selectedItems.length }} 项</span>
<span v-if="fileStore.loading">加载中...</span>
</footer>
</main>
<!-- 文件预览抽屉 -->
<FilePreviewDrawer
v-if="previewFile"
:file="previewFile"
@close="closePreview"
/>
<!-- 对话框 -->
<InputDialog
v-if="showNewFolderDialog"
title="新建文件夹"
placeholder="请输入文件夹名称"
@confirm="createNewFolder"
@cancel="showNewFolderDialog = false"
/>
<InputDialog
v-if="showNewFileDialog"
title="新建文件"
placeholder="请输入文件名称"
@confirm="createNewFile"
@cancel="showNewFileDialog = false"
/>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useFileStore } from './stores/fileStore';
import { useSettingsStore } from './stores/settingsStore';
import FileBrowser from './components/FileBrowser.vue';
import SearchResults from './components/SearchResults.vue';
import RecentFiles from './components/RecentFiles.vue';
import SettingsView from './views/SettingsView.vue';
import FilePreviewDrawer from './components/FilePreviewDrawer.vue';
import InputDialog from './components/common/InputDialog.vue'; // 通用输入对话框
const fileStore = useFileStore();
const settingsStore = useSettingsStore();
const currentView = ref('browser'); // 'browser', 'search', 'recent', 'settings'
const searchQuery = ref('');
const lastSearchQuery = ref('');
const previewFile = ref(null);
const showNewFolderDialog = ref(false);
const showNewFileDialog = ref(false);
const navItems = [
{ name: '文件浏览器', view: 'browser', icon: '📁' },
{ name: '搜索结果', view: 'search', icon: '🔍' },
{ name: '最近文件', view: 'recent', icon: '📂' },
{ name: '设置', view: 'settings', icon: '⚙️' }
];
// 方法
const navigateUp = async () => {
if (fileStore.parentPath) {
await fileStore.loadDirectory(fileStore.parentPath);
}
};
const performSearch = async () => {
if (!searchQuery.value.trim()) return;
lastSearchQuery.value = searchQuery.value;
currentView.value = 'search';
// 触发搜索,SearchResults 组件会监听 lastSearchQuery 变化
// await fileStore.searchFiles(fileStore.homePath, lastSearchQuery.value); // 搜索逻辑应在 SearchResults 内部
};
const createNewFolder = async (folderName) => {
if (folderName) {
try {
await fileStore.createNewFolder(fileStore.currentPath, folderName);
showNewFolderDialog.value = false;
await window.electronAPI.invoke('notification:show', '成功', `文件夹 "${folderName}" 已创建。`);
} catch (error) {
alert(`创建文件夹失败: ${error.message}`);
}
}
};
const createNewFile = async (fileName) => {
if (fileName) {
try {
await fileStore.createNewFile(fileStore.currentPath, fileName);
showNewFileDialog.value = false;
await window.electronAPI.invoke('notification:show', '成功', `文件 "${fileName}" 已创建。`);
} catch (error) {
alert(`创建文件失败: ${error.message}`);
}
}
};
const onFileSelected = (file) => {
if (file.type === 'file') {
previewFile.value = file;
} else {
previewFile.value = null; // 目录不预览
}
};
const closePreview = () => {
previewFile.value = null;
};
// 监听主进程事件
const setupMainProcessListeners = () => {
window.electronAPI.on('app:open-settings', () => {
currentView.value = 'settings';
showNewFolderDialog.value = false; // 关闭其他对话框
showNewFileDialog.value = false;
});
window.electronAPI.on('app:new-folder', () => {
showNewFolderDialog.value = true;
});
// 可以添加更多监听,例如文件打开等
};
onMounted(async () => {
await fileStore.initialize(); // 初始化文件存储,加载主目录
settingsStore.loadSettings(); // 加载用户设置
setupMainProcessListeners();
});
onUnmounted(() => {
// 移除事件监听器
});
</script>
<style>
/* 全局变量和基础样式 */
:root {
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--text-primary: #343a40;
--text-secondary: #6c757d;
--border-color: #e9ecef;
--accent-color: #007bff;
--accent-dark: #0056b3;
--accent-bg: #e6f2ff;
--hover-bg: #e2e6ea;
--code-bg: #f5f5f5;
--code-text: #c7254e;
--code-inline-bg: #e0e0e0;
--code-inline-text: #c7254e;
}
.dark-theme {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--border-color: #444444;
--accent-color: #007bff; /* 可以保持不变或调整 */
--accent-dark: #0056b3;
--accent-bg: #004085;
--hover-bg: #3a3a3a;
--code-bg: #222222;
--code-text: #f92672;
--code-inline-bg: #3a3a3a;
--code-inline-text: #f92672;
}
#app {
display: flex;
height: 100vh;
overflow: hidden; /* 防止主内容溢出 */
color: var(--text-primary);
background-color: var(--bg-primary);
transition: all 0.3s ease;
}
aside.sidebar {
width: 250px;
background-color: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h2 {
margin: 0;
color: var(--accent-color);
font-size: 1.5em;
text-align: center;
}
.nav-menu {
flex: 1;
padding: 10px 0;
}
.nav-item {
display: flex;
align-items: center;
padding: 12px 20px;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
.nav-item:hover {
background-color: var(--hover-bg);
}
.nav-item.active {
background-color: var(--accent-bg);
color: var(--accent-color);
border-right: 3px solid var(--accent-color);
}
.nav-icon {
margin-right: 10px;
font-size: 1.1em;
}
.sidebar-footer {
padding: 20px;
border-top: 1px solid var(--border-color);
}
.storage-info {
font-size: 0.85em;
color: var(--text-secondary);
}
.storage-bar {
height: 6px;
background-color: var(--border-color);
border-radius: 3px;
overflow: hidden;
margin-bottom: 5px;
}
.storage-used {
height: 100%;
background-color: var(--accent-color);
width: 45%; /* 示例值 */
transition: width 0.3s ease;
}
main.main-content {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--bg-primary);
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
gap: 15px;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 10px;
}
.toolbar-btn {
background: none;
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
}
.toolbar-btn:hover:not(:disabled) {
background-color: var(--hover-bg);
border-color: var(--accent-color);
color: var(--accent-color);
}
.toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.breadcrumb {
flex: 1;
display: flex;
align-items: center;
flex-wrap: nowrap;
overflow-x: auto; /* 允许水平滚动 */
-webkit-overflow-scrolling: touch;
scrollbar-width: none; /* 隐藏滚动条 for Firefox */
&::-webkit-scrollbar {
display: none; /* 隐藏滚动条 for Chrome/Safari */
}
}
.crumb {
white-space: nowrap; /* 不换行 */
padding: 3px 5px;
cursor: pointer;
color: var(--accent-color);
border-radius: 3px;
transition: background-color 0.2s;
}
.crumb:hover {
background-color: var(--accent-bg);
}
.separator {
margin: 0 5px;
color: var(--text-secondary);
}
.search-box {
display: flex;
align-items: center;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.search-input {
border: none;
background: none;
padding: 8px 12px;
outline: none;
color: var(--text-primary);
width: 180px;
}
.search-btn {
background: none;
border: none;
padding: 8px 12px;
cursor: pointer;
color: var(--text-secondary);
transition: color 0.2s;
}
.search-btn:hover {
color: var(--accent-color);
}
.view-switcher {
display: flex;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.view-btn {
flex: 1;
padding: 12px;
background: none;
border: none;
cursor: pointer;
color: var(--text-primary);
transition: background-color 0.2s, color 0.2s, border-bottom 0.2s;
}
.view-btn:hover {
background-color: var(--hover-bg);
}
.view-btn.active {
background-color: var(--accent-bg);
color: var(--accent-color);
border-bottom: 2px solid var(--accent-color);
}
.view-area {
flex: 1;
overflow: hidden; /* 由内部 FileBrowser 管理滚动 */
display: flex;
}
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 20px;
background-color: var(--bg-secondary);
border-top: 1px solid var(--border-color);
font-size: 0.85em;
color: var(--text-secondary);
flex-shrink: 0;
}
</style>
src/renderer/router/index.js)即使在单页面应用中,Vue Router 也能帮助我们组织不同视图间的切换逻辑。
// src/renderer/router/index.js
import { createRouter, createWebHashHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import SettingsView from '../views/SettingsView.vue';
import SearchResultsView from '../views/SearchResultsView.vue';
import RecentFilesView from '../views/RecentFilesView.vue';
const routes = [
{
path: '/',
name: 'Home',
component: HomeView
},
{
path: '/settings',
name: 'Settings',
component: SettingsView
},
{
path: '/search',
name: 'SearchResults',
component: SearchResultsView
},
{
path: '/recent',
name: 'RecentFiles',
component: RecentFilesView
}
];
const router = createRouter({
history: createWebHashHistory(), // 使用哈希模式,更适合 Electron
routes
});
export default router;
src/renderer/utils/helpers.js)提供一些常用的格式化函数。
// src/renderer/utils/helpers.js
/**
* 格式化文件大小
* @param {number} bytes - 文件大小 (字节)
* @returns {string} 格式化后的字符串
*/
export function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* 格式化日期时间
* @param {number} timestamp - 时间戳 (毫秒)
* @returns {string} 格式化后的日期字符串
*/
export function formatDate(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString(); // 根据用户本地设置格式化
}
/**
* 通用的防抖函数
* @param {Function} func - 要防抖的函数
* @param {number} delay - 延迟时间 (毫秒)
* @returns {Function} 防抖后的函数
*/
export function debounce(func, delay) {
let timeout;
return function(...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), delay);
};
}
/**
* 通用的节流函数
* @param {Function} func - 要节流的函数
* @param {number} limit - 限制时间 (毫秒)
* @returns {Function} 节流后的函数
*/
export function throttle(func, limit) {
let inThrottle;
let lastResult;
return function(...args) {
const context = this;
if (!inThrottle) {
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
lastResult = func.apply(context, args);
}
return lastResult;
};
}
electron-builder.json 打包配置electron-builder 是一个功能强大的 Electron 打包工具,支持多平台和各种自定义配置。
{
"appId": "com.explorerpro.app",
"productName": "Explorer Pro",
"copyright": "Copyright © 2024 ${author}",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"package.json",
"node_modules/**/*"
],
"asar": true,
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"mac": {
"icon": "build/icon.icns",
"category": "public.app-category.utilities",
"target": [
"dmg",
"zip"
],
"entitlementsInherit": "build/entitlements.mac.plist",
"hardenedRuntime": true,
"gatekeeperAssess": false
},
"win": {
"icon": "build/icon.ico",
"target": [
"nsis",
"portable"
],
"requestedExecutionLevel": "asInvoker"
},
"linux": {
"icon": "build/icons",
"target": [
"AppImage",
"deb",
"rpm"
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "Explorer Pro",
"installerIcon": "build/installerIcon.ico",
"uninstallerIcon": "build/uninstallerIcon.ico"
},
"publish": {
"provider": "github",
"owner": "your-github-username",
"repo": "explorer-pro"
}
}
appId: 应用程序的唯一标识符。productName: 应用名称。directories.output: 输出打包文件的目录。files: 需要打包进应用程序的源文件。asar: 是否将应用资源打包成 ASAR 档案,可以保护源码并提高性能。mac, win, linux: 各平台特定的配置,如图标、目标格式、类别等。nsis: Windows 安装程序 (NSIS) 的配置,可自定义安装流程。publish: 用于集成自动更新或发布到 GitHub Release。通过 package.json 中定义的脚本,可以轻松执行构建和打包操作。
# 构建所有平台 (根据 electron-builder.json 配置)
pnpm run package
# 或者单独构建 Windows 版本
pnpm run dist:win
# 或者单独构建 macOS 版本
pnpm run dist:mac
# 构建完成后,打包文件会出现在 'release' 目录下
对于自动化发布,您可以集成到 CI/CD 流程中,例如使用 GitHub Actions:
# .github/workflows/release.yml
name: Release Electron App
on:
push:
tags:
- 'v*' # 监听以 'v' 开头的标签,例如 v1.0.0
jobs:
build-and-release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, windows-latest]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
- name: Install pnpm
run: npm install -g pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build and Package Electron App
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }} # 用于 GitHub Release 的 Token
run: |
if [ "${{ matrix.os }}" == "macos-latest" ]; then
pnpm run dist:mac
else
pnpm run dist:win
fi
- name: Upload Artifacts
uses: actions/upload-artifact@v3
with:
name: ExplorerPro-${{ matrix.os }}
path: release/
create-github-release:
needs: build-and-release
runs-on: ubuntu-latest
if: success() # 只有前面的构建成功才执行
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Download all artifacts
uses: actions/download-artifact@v3
with:
path: artifacts/ # 下载到 artifacts 目录
- name: List downloaded artifacts
run: ls -R artifacts/
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
files: artifacts/**/* # 将所有下载的打包文件作为 Release 附件
draft: false # 默认为 true,改为 false 立即发布
prerelease: false # 是否为预发布版本
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # GitHub 自动提供的 Token
secrets.GH_TOKEN (或 secrets.GITHUB_TOKEN) 以便 GitHub Actions 可以创建 Release。electron-updater 实现应用的自动更新功能,提升用户体验。通过本教程,您已经学会了:
您现在已经掌握了开发一款中等复杂度跨平台桌面应用所需的核心技能!
继续完善 Explorer Pro 项目,添加更多高级功能,或者基于本教程的知识构建您自己的桌面应用!
记住:实践是最好的老师,不断尝试和创新将让您的技能更上一层楼!祝您开发愉快!