手把手使用 Vite + React + Electron 构建的现代桌面应用程序模板 - 对话框管理

手把手使用 Vite + React + Electron 构建的现代桌面应用程序模板

一个使用 Vite + React + Electron 构建的现代桌面应用程序模板。

仓库地址:github.com/leaf0412/vi...

手把手使用 Vite + React + Electron 构建的现代桌面应用程序模板 一 初始化

手把手使用 Vite + React + Electron 构建的现代桌面应用程序模板 二 窗口管理

一、Electron 原生对话框

1.1 什么是 Electron Dialog?

Electron Dialog 是 Electron 提供的原生对话框 API,它允许应用程序显示系统原生的对话框用于:

  • 打开文件/文件夹
  • 保存文件
  • 显示消息提示
  • 显示错误警告
  • 进行用户确认

这些对话框完全遵循操作系统的原生外观和行为,给用户带来熟悉的体验。

1.2 基本使用方法

1.2.1 文件选择对话框
typescript 复制代码
// 在主进程中
import { dialog } from 'electron';

// 打开文件对话框
async function openFile() {
  const result = await dialog.showOpenDialog({
    title: '选择文件',
    properties: ['openFile', 'multiSelections'],
    filters: [
      { name: '图片', extensions: ['jpg', 'png'] }
    ]
  });

  if (!result.canceled) {
    console.log('选择的文件:', result.filePaths);
  }
}

// 打开文件夹对话框
async function openDirectory() {
  const result = await dialog.showOpenDialog({
    title: '选择文件夹',
    properties: ['openDirectory']
  });

  if (!result.canceled) {
    console.log('选择的文件夹:', result.filePaths[0]);
  }
}
1.2.2 保存文件对话框
typescript 复制代码
async function saveFile() {
  const result = await dialog.showSaveDialog({
    title: '保存文件',
    defaultPath: 'untitled.txt',
    filters: [
      { name: '文本文件', extensions: ['txt'] },
      { name: '所有文件', extensions: ['*'] }
    ]
  });

  if (!result.canceled && result.filePath) {
    console.log('保存路径:', result.filePath);
  }
}
1.2.3 消息对话框
typescript 复制代码
// 信息提示
async function showInfo() {
  const result = await dialog.showMessageBox({
    type: 'info',
    title: '提示',
    message: '操作已完成',
    buttons: ['确定']
  });
}

// 错误提示
async function showError() {
  const result = await dialog.showMessageBox({
    type: 'error',
    title: '错误',
    message: '操作失败',
    detail: '请检查网络连接',
    buttons: ['重试', '取消']
  });
}

// 确认对话框
async function showConfirm() {
  const result = await dialog.showMessageBox({
    type: 'question',
    title: '确认',
    message: '是否删除?',
    detail: '此操作无法撤销',
    buttons: ['确定', '取消'],
    defaultId: 1,
    cancelId: 1
  });

  return result.response === 0;
}

1.3 对话框选项说明

1.3.1 通用选项
typescript 复制代码
interface CommonOptions {
  title?: string;          // 对话框标题
  defaultPath?: string;    // 默认路径
  buttonLabel?: string;    // 确认按钮文字
}
1.3.2 文件选择对话框选项
typescript 复制代码
interface OpenDialogOptions {
  properties?: Array<
    | 'openFile'          // 选择文件
    | 'openDirectory'     // 选择目录
    | 'multiSelections'   // 允许多选
    | 'showHiddenFiles'   // 显示隐藏文件
    | 'createDirectory'   // 允许创建目录
    | 'promptToCreate'    // 提示创建不存在的文件
  >;
  filters?: Array<{
    name: string;         // 过滤器名称
    extensions: string[]; // 文件扩展名列表
  }>;
}
1.3.3 消息对话框选项
typescript 复制代码
interface MessageBoxOptions {
  type?: 'none' | 'info' | 'error' | 'question' | 'warning';
  buttons?: string[];     // 按钮文字数组
  defaultId?: number;     // 默认选中的按钮
  cancelId?: number;      // 取消按钮的索引
  message: string;        // 主要信息
  detail?: string;        // 详细信息
  checkboxLabel?: string; // 复选框文字
  checkboxChecked?: boolean; // 复选框是否选中
}

1.4 使用限制与注意事项

  1. 进程限制

    • dialog API 只能在主进程中使用
    • 渲染进程需要通过 IPC 通信调用
  2. 阻塞性

    • 对话框是模态的,会阻塞进程直到用户响应
    • 避免在关键操作中频繁使用
  3. 平台差异

    • 不同操作系统的对话框外观和行为可能有差异
    • 某些选项在特定平台可能不支持

二、DialogManager 实现详解

2.1 为什么需要 DialogManager?

原生 Dialog API 存在以下问题:

  1. 只能在主进程使用,需要手动处理 IPC 通信
  2. 缺乏统一的错误处理机制
  3. 代码组织分散,难以维护
  4. 类型提示不完善

DialogManager 通过封装提供:

  • 统一的接口
  • 完整的类型支持
  • 自动的 IPC 通信
  • 标准的错误处理

2.2 核心实现

2.2.1 IPC 事件定义
typescript 复制代码
// src/constants/ipc-events.ts
export const DialogEvents = {
  DIALOG_OPEN: 'dialog:open',
  DIALOG_SAVE: 'dialog:save',
  DIALOG_MESSAGE: 'dialog:message',
  DIALOG_ERROR: 'dialog:error',
  DIALOG_INFO: 'dialog:info',
  DIALOG_WARNING: 'dialog:warning',
  DIALOG_QUESTION: 'dialog:question'
} as const;

export type DialogEventType = typeof DialogEvents[keyof typeof DialogEvents];
2.2.2 主进程处理器
typescript 复制代码
// src/main/dialog/handler.ts
import { dialog, ipcMain } from 'electron';
import { DialogEvents } from '@/constants/ipc-events';

export class DialogHandler {
  static init() {
    this.initOpenDialog();
    this.initSaveDialog();
    this.initMessageDialog();
    this.initTypedMessageDialogs();
  }

  private static initOpenDialog() {
    ipcMain.handle(DialogEvents.DIALOG_OPEN, async (_event, options) => {
      try {
        return await dialog.showOpenDialog(options);
      } catch (error) {
        console.error('Open dialog error:', error);
        throw error;
      }
    });
  }

  private static initSaveDialog() {
    ipcMain.handle(DialogEvents.DIALOG_SAVE, async (_event, options) => {
      try {
        return await dialog.showSaveDialog(options);
      } catch (error) {
        console.error('Save dialog error:', error);
        throw error;
      }
    });
  }

  private static initMessageDialog() {
    ipcMain.handle(DialogEvents.DIALOG_MESSAGE, async (_event, options) => {
      try {
        return await dialog.showMessageBox(options);
      } catch (error) {
        console.error('Message dialog error:', error);
        throw error;
      }
    });
  }

  private static initTypedMessageDialogs() {
    const typeHandlers = [
      { event: DialogEvents.DIALOG_ERROR, type: 'error' },
      { event: DialogEvents.DIALOG_INFO, type: 'info' },
      { event: DialogEvents.DIALOG_WARNING, type: 'warning' },
      { event: DialogEvents.DIALOG_QUESTION, type: 'question' }
    ];

    typeHandlers.forEach(({ event, type }) => {
      ipcMain.handle(event, async (_event, options) => {
        try {
          return await dialog.showMessageBox({ ...options, type });
        } catch (error) {
          console.error(`${type} dialog error:`, error);
          throw error;
        }
      });
    });
  }

  static destroy() {
    Object.values(DialogEvents).forEach(event => {
      ipcMain.removeHandler(event);
    });
  }
}
2.2.3 渲染进程接口
typescript 复制代码
// src/renderer/dialog/types.ts
import type {
  OpenDialogOptions,
  SaveDialogOptions,
  MessageBoxOptions,
  OpenDialogReturnValue,
  SaveDialogReturnValue,
  MessageBoxReturnValue
} from 'electron';

export interface DialogAPI {
  showOpenDialog(options: OpenDialogOptions): Promise<OpenDialogReturnValue>;
  showSaveDialog(options: SaveDialogOptions): Promise<SaveDialogReturnValue>;
  showMessage(options: MessageBoxOptions): Promise<MessageBoxReturnValue>;
  showError(options: Omit<MessageBoxOptions, 'type'>): Promise<MessageBoxReturnValue>;
  showInfo(options: Omit<MessageBoxOptions, 'type'>): Promise<MessageBoxReturnValue>;
  showWarning(options: Omit<MessageBoxOptions, 'type'>): Promise<MessageBoxReturnValue>;
  showQuestion(options: Omit<MessageBoxOptions, 'type'>): Promise<MessageBoxReturnValue>;
}
typescript 复制代码
// src/renderer/dialog/index.ts
import { ipcRenderer } from 'electron';
import { DialogEvents } from '@/constants/ipc-events';
import type { DialogAPI } from './types';

class DialogManager implements DialogAPI {
  async showOpenDialog(options) {
    return await ipcRenderer.invoke(DialogEvents.DIALOG_OPEN, options);
  }

  async showSaveDialog(options) {
    return await ipcRenderer.invoke(DialogEvents.DIALOG_SAVE, options);
  }

  async showMessage(options) {
    return await ipcRenderer.invoke(DialogEvents.DIALOG_MESSAGE, options);
  }

  async showError(options) {
    return await ipcRenderer.invoke(DialogEvents.DIALOG_ERROR, options);
  }

  async showInfo(options) {
    return await ipcRenderer.invoke(DialogEvents.DIALOG_INFO, options);
  }

  async showWarning(options) {
    return await ipcRenderer.invoke(DialogEvents.DIALOG_WARNING, options);
  }

  async showQuestion(options) {
    return await ipcRenderer.invoke(DialogEvents.DIALOG_QUESTION, options);
  }
}

// 导出单例实例
export const dialog = new DialogManager();

// 扩展 window 接口
declare global {
  interface Window {
    dialog: DialogAPI;
  }
}

2.3 功能扩展

2.3.1 错误处理
typescript 复制代码
// src/renderer/dialog/error-handler.ts
export class DialogErrorHandler {
  constructor(private dialog: DialogAPI) {}

  async handle(error: Error, title = '错误'): Promise<void> {
    try {
      await this.dialog.showError({
        title,
        message: error.message,
        detail: error.stack,
        buttons: ['确定']
      });
    } catch (e) {
      // 降级到控制台输出
      console.error('Failed to show error dialog:', e);
      console.error('Original error:', error);
    }
  }
}
2.3.2 对话框队列
typescript 复制代码
// src/renderer/dialog/queue.ts
export class DialogQueue {
  private queue: Array<() => Promise<any>> = [];
  private isProcessing = false;

  async add<T>(dialog: () => Promise<T>): Promise<T> {
    return new Promise((resolve, reject) => {
      this.queue.push(async () => {
        try {
          const result = await dialog();
          resolve(result);
        } catch (error) {
          reject(error);
        }
      });
      
      this.processQueue();
    });
  }

  private async processQueue() {
    if (this.isProcessing || this.queue.length === 0) {
      return;
    }

    this.isProcessing = true;
    const next = this.queue.shift();
    
    try {
      await next?.();
    } finally {
      this.isProcessing = false;
      this.processQueue();
    }
  }
}

2.4 使用示例

2.4.1 基础使用
typescript 复制代码
// 在 React 组件中
import { useState } from 'react';

export function FileUploader() {
  const [filePath, setFilePath] = useState<string | null>(null);

  const handleFileSelect = async () => {
    try {
      const result = await window.dialog.showOpenDialog({
        title: '选择文件',
        properties: ['openFile'],
        filters: [
          { name: '图片', extensions: ['jpg', 'png'] }
        ]
      });

      if (!result.canceled && result.filePaths.length > 0) {
        setFilePath(result.filePaths[0]);
      }
    } catch (error) {
      await window.dialog.showError({
        title: '错误',
        message: '选择文件失败',
        detail: error.message
      });
    }
  };

  return (
    <div>
      <button onClick={handleFileSelect}>选择文件</button>
      {filePath && <p>已选择: {filePath}</p>}
    </div>
  );
}
2.4.2 错误处理
typescript 复制代码
const errorHandler = new DialogErrorHandler(window.dialog);

try {
  await someOperation();
} catch (error) {
  await errorHandler.handle(error, '操作失败');
}
2.4.3 对话框队列
typescript 复制代码
const dialogQueue = new DialogQueue();

async function showMultipleDialogs() {
  await dialogQueue.add(() => 
    window.dialog.showMessage({ message: '第一个消息' })
  );
  
  await dialogQueue.add(() =>
    window.dialog.showMessage({ message: '第二个消息' })
  );
}

2.5 测试

typescript 复制代码
  test('showOpenDialog invokes correct IPC event', async () => {
    const mockInvoke = jest.fn();
    (window as any).electron = { ipcRenderer: { invoke: mockInvoke } };
    
    const options = {
      title: 'Test Dialog',
      properties: ['openFile']
    };
    
    await dialogManager.showOpenDialog(options);
    
    expect(mockInvoke).toHaveBeenCalledWith(
      DialogEvents.DIALOG_OPEN,
      options
    );
  });

  test('showError sets correct dialog type', async () => {
    const mockInvoke = jest.fn();
    (window as any).electron = { ipcRenderer: { invoke: mockInvoke } };
    
    const options = {
      title: 'Error',
      message: 'Test error'
    };
    
    await dialogManager.showError(options);
    
    expect(mockInvoke).toHaveBeenCalledWith(
      DialogEvents.DIALOG_ERROR,
      options
    );
  });

  test('DialogQueue processes dialogs sequentially', async () => {
    const queue = new DialogQueue();
    const results: number[] = [];

    await Promise.all([
      queue.add(async () => {
        await new Promise(resolve => setTimeout(resolve, 100));
        results.push(1);
      }),
      queue.add(async () => {
        results.push(2);
      })
    ]);

    expect(results).toEqual([1, 2]);
  });
});

2.6 最佳实践

2.6.1 统一的错误处理
typescript 复制代码
// src/renderer/dialog/utils.ts
export function createSafeDialog<T extends (...args: any[]) => Promise<any>>(
  dialog: T
): T {
  return (async (...args: Parameters<T>) => {
    try {
      return await dialog(...args);
    } catch (error) {
      console.error('Dialog error:', error);
      // 显示错误对话框
      await window.dialog.showError({
        title: '对话框错误',
        message: error.message
      });
      throw error;
    }
  }) as T;
}

// 使用示例
const safeOpenDialog = createSafeDialog(window.dialog.showOpenDialog);

try {
  const result = await safeOpenDialog({
    title: '选择文件'
  });
} catch (error) {
  // 错误已经被处理,这里可以进行额外的处理
}
2.6.2 对话框配置管理
typescript 复制代码
// src/renderer/dialog/config.ts
type DialogConfigKey = 'openFile' | 'saveFile' | 'error' | 'confirm';

export class DialogConfig {
  private static configs = new Map<DialogConfigKey, any>();

  static setConfig(key: DialogConfigKey, config: any) {
    this.configs.set(key, config);
  }

  static getConfig(key: DialogConfigKey) {
    return this.configs.get(key);
  }

  static getDefaultConfigs() {
    return {
      openFile: {
        title: '选择文件',
        properties: ['openFile'],
        filters: [
          { name: '所有文件', extensions: ['*'] }
        ]
      },
      saveFile: {
        title: '保存文件',
        defaultPath: 'untitled.txt'
      },
      error: {
        title: '错误',
        buttons: ['确定']
      },
      confirm: {
        type: 'question',
        buttons: ['确定', '取消'],
        defaultId: 0,
        cancelId: 1
      }
    };
  }
}

// 初始化默认配置
Object.entries(DialogConfig.getDefaultConfigs()).forEach(([key, config]) => {
  DialogConfig.setConfig(key as DialogConfigKey, config);
});

// 使用示例
async function openFile() {
  const config = DialogConfig.getConfig('openFile');
  const result = await window.dialog.showOpenDialog(config);
  return result;
}
2.6.3 性能优化
typescript 复制代码
// src/renderer/dialog/cache.ts
export class DialogCache {
  private static cache = new Map<string, any>();
  private static maxAge = 5 * 60 * 1000; // 5分钟缓存

  static set(key: string, value: any) {
    this.cache.set(key, {
      value,
      timestamp: Date.now()
    });
  }

  static get(key: string) {
    const cached = this.cache.get(key);
    if (!cached) return null;

    if (Date.now() - cached.timestamp > this.maxAge) {
      this.cache.delete(key);
      return null;
    }

    return cached.value;
  }

  static clear() {
    this.cache.clear();
  }
}

// 使用示例
async function getRecentDirectory() {
  const cacheKey = 'lastDirectory';
  const cached = DialogCache.get(cacheKey);
  
  if (cached) {
    return cached;
  }

  const result = await window.dialog.showOpenDialog({
    properties: ['openDirectory']
  });

  if (!result.canceled) {
    DialogCache.set(cacheKey, result.filePaths[0]);
    return result.filePaths[0];
  }

  return null;
}

2.7 常见问题解决方案

2.7.1 对话框被阻止
typescript 复制代码
// src/renderer/dialog/checker.ts
export class DialogChecker {
  static async checkPermission(): Promise<boolean> {
    try {
      await window.dialog.showMessage({
        type: 'none',
        message: 'Dialog Permission Check',
        buttons: ['OK']
      });
      return true;
    } catch (error) {
      console.error('Dialog permission denied:', error);
      return false;
    }
  }
}

// 使用前检查
async function safeShowDialog() {
  const hasPermission = await DialogChecker.checkPermission();
  if (!hasPermission) {
    // 显示备用UI或提示用户
    return;
  }
  
  // 正常显示对话框
}
2.7.2 多窗口处理
typescript 复制代码
// src/renderer/dialog/window-manager.ts
export class DialogWindowManager {
  private static activeWindow: BrowserWindow | null = null;

  static setActiveWindow(window: BrowserWindow) {
    this.activeWindow = window;
  }

  static async showDialog<T>(
    dialogFn: (window: BrowserWindow) => Promise<T>
  ): Promise<T> {
    if (!this.activeWindow) {
      throw new Error('No active window found');
    }

    return await dialogFn(this.activeWindow);
  }
}

// 使用示例
await DialogWindowManager.showDialog(window => 
  dialog.showMessageBox(window, {
    message: '在当前窗口显示'
  })
);

2.8 安全考虑

2.8.1 路径验证
typescript 复制代码
// src/renderer/dialog/security.ts
import { parse, resolve } from 'path';

export class DialogSecurity {
  private static allowedPaths: string[] = [];

  static addAllowedPath(path: string) {
    this.allowedPaths.push(resolve(path));
  }

  static validatePath(path: string): boolean {
    const resolvedPath = resolve(path);
    return this.allowedPaths.some(allowedPath => 
      resolvedPath.startsWith(allowedPath)
    );
  }

  static async validateDialog<T>(
    dialog: () => Promise<T>,
    validateResult: (result: T) => boolean
  ): Promise<T> {
    const result = await dialog();
    if (!validateResult(result)) {
      throw new Error('Invalid dialog result');
    }
    return result;
  }
}

// 使用示例
DialogSecurity.addAllowedPath(app.getPath('downloads'));

const result = await DialogSecurity.validateDialog(
  () => window.dialog.showOpenDialog({ 
    properties: ['openFile'] 
  }),
  result => {
    if (result.canceled) return true;
    return result.filePaths.every(path => 
      DialogSecurity.validatePath(path)
    );
  }
);

总结

通过本文,我们:

  1. 了解了 Electron 原生对话框的基本使用
  2. 实现了一个完整的 DialogManager
  3. 提供了错误处理、性能优化等最佳实践
  4. 解决了常见的问题和安全考虑

建议:

  1. 根据实际需求选择性实现功能
  2. 注意错误处理和安全验证
  3. 合理使用缓存提升性能
  4. 保持代码结构清晰便于维护

参考资源

相关推荐
崔庆才丨静觅2 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60612 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了3 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅3 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅3 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅3 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment4 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅4 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊4 小时前
jwt介绍
前端
爱敲代码的小鱼4 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax