Electron + Vite + Vue3 文件管理工具开发实战

Electron + Vite + Vue3 文件管理工具开发实战

从零构建跨平台桌面文件管理器 | 支持 Windows + macOS

⚡ Vite
⚛️ Vue 3
🖥️ Electron
🔧 TypeScript
📦 Pinia

🎯 项目实战概览

Explorer Pro - 功能完善的跨平台文件管理器

包含文件浏览、搜索、预览、标签管理、云同步等核心功能

第一部分:项目初始化与环境搭建

1.1 技术栈选型与架构设计

本教程将构建一个现代化的文件管理工具,具备以下技术特点:

⚡ Vite 构建优势

极速的热重载、高效的构建性能、优化的开发体验,支持原生 ES 模块

⚛️ Vue3 组合式 API

更灵活的组件逻辑复用、更清晰的代码组织、性能提升 30%

🖥️ Electron 原生体验

真正的桌面应用体验、系统级 API 访问、原生窗口管理

1.2 项目架构设计

我们将采用 Electron 推荐的“主进程 + 渲染进程”模式,结合 Vite 的高效构建能力和 Vue3 的响应式框架,实现高性能的桌面应用。整体架构如下所示:

graph TD A[Electron Main Process] --> B(Window 1: Renderer Process - Vue/Vite) A --> C(Window 2: Renderer Process - Vue/Vite) A -- IPC --> B A -- IPC --> C B -- IPC --> A C -- IPC --> A A -- Node.js APIs --> D(File System) A -- Node.js APIs --> E(OS Utilities) B -- Preload Script --> A C -- Preload Script --> A A -- Electron APIs --> F(Menu/Tray/Dialogs) subgraph Renderer Process Vue3/Vite G[Vue Components] --> H(Pinia Store) H --> I(Vue Router) G --> J(HTTP Client: Axios) end B -- Renders --> G C -- Renders --> G

架构说明:

explorer-pro/ ├── src/ │ ├── main/ # Electron 主进程代码 │ │ ├── index.js # 主进程入口文件,负责窗口创建和应用生命周期 │ │ ├── preload.js # 预加载脚本,安全地将IPC暴露给渲染进程 │ │ ├── ipc/ # IPC 事件处理器 │ │ │ └── handlers.js # 处理渲染进程的请求,调用Node.js API │ │ └── system/ # 主进程系统级工具,如菜单、通知 │ ├── renderer/ # Vue3 渲染进程代码 │ │ ├── index.html # 渲染进程的入口HTML │ │ ├── main.js # Vue 应用入口 │ │ ├── App.vue # 根组件 │ │ ├── components/ # 可复用Vue组件,如 FileBrowser, FilePreview │ │ ├── views/ # 页面级Vue组件,如 Home, Settings │ │ ├── stores/ # Pinia 状态管理模块 │ │ ├── router/ # Vue Router 配置 │ │ └── utils/ # 渲染进程工具函数 │ └── shared/ # 主进程和渲染进程共享代码 │ ├── services/ # 业务逻辑服务,如 FileService, SearchService │ ├── utils/ # 通用工具函数,如 platformAdapter, helpers │ └── types/ # TypeScript 类型定义 ├── public/ # 静态资源,如图标、图片 ├── dist/ # 构建输出目录 ├── package.json # 项目配置及依赖 ├── vite.config.js # Vite 渲染进程构建配置 ├── vite.main.config.js # Vite 主进程构建配置 └── electron-builder.json # Electron Builder 打包配置

1.4 环境搭建与项目创建

首先,请确保您的开发环境满足以下要求,并安装必要的工具:

⚠️ 环境要求

您可以通过以下命令检查 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请求和本地存储

1.5 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"
  ]
}

💡 脚本说明

第二部分:项目基础架构搭建

2.1 Vite 配置 (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') // 别名配置
    }
  }
});

2.2 Vite 主进程配置 (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' // 明确排除
      ]
    }
  }
});

2.3 Vue3 渲染进程入口 (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>

2.4 Electron 主进程搭建 (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 应用的安全性至关重要,特别是渲染进程:

这些配置共同构成了 Electron 应用的基础安全防线。

2.5 预加载脚本 (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)
});

📢 IPC 通信模型

我们主要使用 ipcRenderer.invokeipcMain.handle 进行双向通信,这是 Electron 推荐的安全通信方式。

sequenceDiagram participant R as Renderer Process (Vue) participant P as Preload Script participant M as Main Process R->>P: window.electronAPI.invoke('channel', data) P->>M: ipcRenderer.invoke('channel', data) M-->>P: ipcMain.handle('channel', (event, data) => result) P-->>R: Promise.resolve(result) M->>P: mainWindow.webContents.send('event:name', data) P->>R: ipcRenderer.on('event:name', (event, data) => callback(data))

第三部分:文件管理核心功能开发

3.1 文件系统服务 (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} - 包含文件内容的对象
   */
  async readFile(filePath, encoding = 'utf8') {
    try {
      const content = await readFile(filePath, encoding);
      return { content };
    } catch (error) {
      console.error(`读取文件 ${filePath} 失败:`, error);
      throw new Error(`文件读取失败: ${error.message}`);
    }
  }

  /**
   * 创建文件并写入内容
   * @param {string} filePath - 文件路径
   * @param {string} content - 要写入的内容
   * @param {string} encoding - 编码格式,默认为 utf8
   * @returns {Promise}
   */
  async createFile(parentPath, fileName, content = '', encoding = 'utf8') {
    try {
      const filePath = join(parentPath, fileName);
      await writeFile(filePath, content, encoding);
      return filePath;
    } catch (error) {
      console.error(`创建文件 ${filePath} 失败:`, error);
      throw new Error(`文件创建失败: ${error.message}`);
    }
  }

  /**
   * 创建目录
   * @param {string} parentPath - 父目录路径
   * @param {string} dirName - 要创建的目录名称
   * @returns {Promise} - 新创建的目录路径
   */
  async createDirectory(parentPath, dirName) {
    try {
      const dirPath = join(parentPath, dirName);
      await mkdir(dirPath, { recursive: true });
      return dirPath;
    } catch (error) {
      console.error(`创建目录 ${dirPath} 失败:`, error);
      throw new Error(`目录创建失败: ${error.message}`);
    }
  }

  /**
   * 删除文件或空目录
   * @param {string} itemPath - 要删除的文件或目录路径
   * @returns {Promise}
   */
  async deleteItem(itemPath) {
    try {
      const stats = await stat(itemPath);
      if (stats.isDirectory()) {
        await rm(itemPath, { recursive: true, force: true }); // 递归删除非空目录
      } else {
        await rm(itemPath); // 删除文件
      }
    } catch (error) {
      console.error(`删除 ${itemPath} 失败:`, error);
      throw new Error(`删除失败: ${error.message}`);
    }
  }

  /**
   * 重命名文件或目录
   * @param {string} oldPath - 旧路径
   * @param {string} newName - 新名称
   * @returns {Promise} - 新路径
   */
  async renameItem(oldPath, newName) {
    try {
      const newPath = join(dirname(oldPath), newName);
      await rename(oldPath, newPath);
      return newPath;
    } catch (error) {
      console.error(`重命名 ${oldPath} 为 ${newName} 失败:`, error);
      throw new Error(`重命名失败: ${error.message}`);
    }
  }

  /**
   * 获取用户主目录
   * @returns {string}
   */
  getHomeDirectory() {
    return this.homeDirectory;
  }
}

module.exports = new FileService();


            

3.2 渲染进程状态管理 (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 = [];
    }
  }
});

3.3 文件浏览器组件 (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 设计模式,具有以下特点:

  • 状态管理:通过 Pinia 集中管理文件系统状态,实现数据共享和可预测性。
  • 用户交互:支持单击选择、双击打开、右键菜单等常见文件管理器操作。
  • 视图切换:支持列表 (list) 和网格 (grid) 两种视图模式。
  • 图标与信息:根据文件类型显示不同图标,并展示文件大小和修改日期。
  • 异步操作:所有文件操作都通过 IPC 调用主进程,避免阻塞 UI。

第四部分:进阶功能实现

4.1 文件搜索功能 (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();

4.2 文件预览组件 (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, '$1'); // 支持图片 } } 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>

第五部分:系统集成与优化

5.1 渲染进程与主进程的通信 (IPC)

通过预加载脚本暴露的 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 事件处理,确保了渲染进程的请求能够被正确响应。

5.2 性能优化

文件管理器需要处理大量文件和高频操作,因此性能优化至关重要。以下是一些常见的优化策略:

⚡ 性能优化策略

  • 虚拟滚动 (Virtual Scrolling):对于包含成千上万个文件/文件夹的目录,只渲染当前可见的列表项,大大减少 DOM 元素的数量,提高滚动性能。
  • 图片懒加载 (Lazy Loading):只在图片进入用户视野时才加载,减少初始加载时间和内存占用。
  • 防抖与节流 (Debounce & Throttle):对高频事件(如搜索输入、窗口调整大小)进行限制,避免不必要的重复计算。
  • Web Workers:将复杂的、计算密集型的任务(如深度文件搜索、文件内容索引)放到 Web Worker 中,避免阻塞 UI 线程。
  • 文件操作异步化:所有文件操作都在主进程中异步执行,并通过 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();

第六部分:跨平台适配

6.1 平台检测与适配

利用 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();

6.2 macOS 特定功能集成

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;

6.3 Windows 特定功能集成

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"

7.1 项目规划与设计

🎯 项目目标

Explorer Pro - 一个现代化的跨平台文件管理器,具备以下核心功能:

  • 📁 直观的文件浏览界面 (列表/网格视图)
  • 🔍 强大的搜索功能 (文件名/内容搜索)
  • 👀 文件预览支持 (文本、图片、Markdown、JSON、PDF)
  • 🏷️ 标签管理系统 (文件/文件夹标签)
  • ☁️ 云同步支持 (占位,待未来扩展)
  • ⚡ 高性能渲染 (虚拟滚动、懒加载)
  • 🖥️ 跨平台兼容性 (Windows, macOS)
  • ⚙️ 用户偏好设置

7.2 主体应用组件 (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>

7.3 路由配置 (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;

7.4 辅助工具函数 (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;
  };
}

第八部分:构建与发布

8.1 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。

8.2 构建与发布流程

通过 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

🚀 持续集成/部署 (CI/CD) 建议

  • 配置 secrets.GH_TOKEN (或 secrets.GITHUB_TOKEN) 以便 GitHub Actions 可以创建 Release。
  • 对于 macOS 应用,可能需要配置 Apple 开发者账号进行代码签名和公证,这通常涉及在 CI 环境中配置证书和密钥。
  • 考虑集成 electron-updater 实现应用的自动更新功能,提升用户体验。

🎉 总结与进阶学习

✨ 项目成果

通过本教程,您已经学会了:

  • 🔧 完整的 Electron + Vite + Vue3 项目架构设计与搭建。
  • 📁 文件管理器的核心功能实现,包括文件浏览、创建、删除、重命名。
  • 🔍 高级搜索和多类型文件预览功能。
  • 🖥️ 跨平台兼容处理策略与实践。
  • ⚡ 性能优化技巧,以提升桌面应用的响应速度。
  • 📦 详尽的构建与发布流程,并集成自动化部署。
  • 🔒 Electron 应用的安全最佳实践。

您现在已经掌握了开发一款中等复杂度跨平台桌面应用所需的核心技能!

🚀 进阶功能建议

  • 云存储集成:支持连接 Google Drive, OneDrive, Dropbox 等云服务。
  • 标签与收藏:允许用户为文件/文件夹添加标签和收藏,便于快速查找。
  • 文件版本历史:集成 Git 或本地快照功能,记录文件修改历史。
  • 媒体播放器:内建简单的音视频播放器。
  • 文件压缩/解压:直接在应用内进行文件打包和解压操作。
  • 自定义主题与插件系统:提供更强的个性化和扩展能力。
  • 文件同步与备份:实现特定文件夹的自动同步或备份功能。

📚 推荐资源

🎯 下一步行动

继续完善 Explorer Pro 项目,添加更多高级功能,或者基于本教程的知识构建您自己的桌面应用!

记住:实践是最好的老师,不断尝试和创新将让您的技能更上一层楼!祝您开发愉快!

互动区域

登录后可以点赞此内容

参与互动

登录后可以点赞和评论此内容,与作者互动交流