当前位置: 首页 > news >正文

Electron Forge【实战】桌面应用 —— 将项目配置保存到本地

最终效果

在这里插入图片描述
在这里插入图片描述

定义默认配置

src/initData.ts

export const DEFAULT_CONFIG: AppConfig = {language: "zh",fontSize: 14,providerConfigs: {},
};

src/types.ts

export interface AppConfig {language: 'zh' | 'en'fontSize: numberproviderConfigs: Record<string, Record<string, string>>
}

从本地加载配置

因读取配置文件需要时间,在创建主窗口前,便开始加载

src/main.ts

import { configManager } from './config'
const createWindow = async () => {// 加载配置await configManager.load();

src/config.ts

import { app } from "electron";
import path from "path";
import fs from "fs/promises";
import { AppConfig } from "./types";
import { DEFAULT_CONFIG } from "./initData";// 配置文件路径,在windows 中是 C:\Users\用户名\AppData\Roaming\项目名\config.json
const configPath = path.join(app.getPath("userData"), "config.json");
let config = { ...DEFAULT_CONFIG };export const configManager = {async load() {try {const data = await fs.readFile(configPath, "utf-8");config = { ...DEFAULT_CONFIG, ...JSON.parse(data) };} catch {await this.save();}return config;},async save() {await fs.writeFile(configPath, JSON.stringify(config, null, 2));return config;},async update(newConfig: Partial<AppConfig>) {config = { ...config, ...newConfig };await this.save();return config;},get() {return config;},
};

主进程中使用配置

直接调用 configManager 的 get 方法即可

src/providers/createProvider.ts

import { configManager } from "../config";
  const config = configManager.get();const providerConfig = config.providerConfigs[providerName] || {};

渲染进程中使用配置

需借助 electron 的 IPC 通信从主进程中获取

src/views/Settings.vue

onMounted(async () => {const config = await (window as any).electronAPI.getConfig();
});

src/preload.ts

contextBridge.exposeInMainWorld("electronAPI", {startChat: (data: CreateChatProps) => ipcRenderer.send("start-chat", data),onUpdateMessage: (callback: OnUpdatedCallback) =>ipcRenderer.on("update-message", (_event, value) => callback(value)),// 获取配置getConfig: () => ipcRenderer.invoke("get-config"),// 更新配置updateConfig: (config: Partial<AppConfig>) =>ipcRenderer.invoke("update-config", config),
});

src/ipc.ts

import { ipcMain, BrowserWindow } from "electron";
import { configManager } from './config'
export function setupIPC(mainWindow: BrowserWindow) {ipcMain.handle('get-config', () => {return configManager.get()})

src/main.ts

import { setupIPC } from "./ipc";
setupIPC(mainWindow);

配置页更新配置

  1. 配置页深度监听配置变量,当页面配置发生改变时,触发 electron 的 updateConfig 事件,将新配置传给主进程
  2. 主进程将新配置写入本地文件

src/views/Settings.vue

深度监听配置变量,当页面配置发生改变时,触发 electron 的 updateConfig 事件,将新配置传给主进程

// 深度监听配置变化并自动保存
watch(currentConfig,async (newConfig) => {// 创建一个普通对象来传递配置const configToSave = {language: newConfig.language,fontSize: newConfig.fontSize,providerConfigs: JSON.parse(JSON.stringify(newConfig.providerConfigs)),};// 由于 TypeScript 提示 window 上不存在 electronAPI 属性,我们可以使用类型断言来解决这个问题await (window as any).electronAPI.updateConfig(configToSave);// 更新界面语言locale.value = newConfig.language;},{ deep: true }
);

src/preload.ts

contextBridge.exposeInMainWorld("electronAPI", {startChat: (data: CreateChatProps) => ipcRenderer.send("start-chat", data),onUpdateMessage: (callback: OnUpdatedCallback) =>ipcRenderer.on("update-message", (_event, value) => callback(value)),// 获取配置getConfig: () => ipcRenderer.invoke("get-config"),// 更新配置updateConfig: (config: Partial<AppConfig>) =>ipcRenderer.invoke("update-config", config),
});

src/ipc.ts

import { ipcMain, BrowserWindow } from "electron";
import { configManager } from './config'
export function setupIPC(mainWindow: BrowserWindow) {ipcMain.handle("update-config", async (event, newConfig) => {const updatedConfig = await configManager.update(newConfig);return updatedConfig;});

src/config.ts

完整代码见上文,此处仅截取更新配置的代码

  async update(newConfig: Partial<AppConfig>) {config = { ...config, ...newConfig };await this.save();return config;},async save() {await fs.writeFile(configPath, JSON.stringify(config, null, 2));return config;},

配置页完整代码

src/views/Settings.vue

<template><div class="w-[80%] mx-auto p-8"><h1 class="text-2xl font-bold mb-8">{{ t("settings.title") }}</h1><TabsRoot v-model="activeTab" class="w-full"><TabsList class="flex border-b border-gray-200 mb-6"><TabsTriggervalue="general"class="px-4 py-2 -mb-[1px] text-sm font-medium text-gray-600 hover:text-gray-800 data-[state=active]:text-green-600 data-[state=active]:border-b-2 data-[state=active]:border-green-600">{{ t("settings.general") }}</TabsTrigger><TabsTriggervalue="models"class="px-4 py-2 -mb-[1px] text-sm font-medium text-gray-600 hover:text-gray-800 data-[state=active]:text-green-600 data-[state=active]:border-b-2 data-[state=active]:border-green-600">{{ t("settings.models") }}</TabsTrigger></TabsList><TabsContent value="general" class="space-y-6 max-w-[500px]"><!-- Language Setting --><div class="setting-item flex items-center gap-8"><label class="text-sm font-medium text-gray-700 w-24">{{ t("settings.language") }}</label><SelectRoot v-model="currentConfig.language" class="w-[160px]"><SelectTriggerclass="inline-flex items-center justify-between rounded-md px-3 py-2 text-sm gap-1 bg-white border border-gray-300"><SelectValue :placeholder="t('settings.selectLanguage')" /><SelectIcon><Icon icon="radix-icons:chevron-down" /></SelectIcon></SelectTrigger><SelectPortal><SelectContent class="bg-white rounded-md shadow-lg border"><SelectViewport class="p-2"><SelectGroup><SelectItemvalue="zh"class="relative flex items-center px-8 py-2 text-sm text-gray-700 rounded-md cursor-default hover:bg-gray-100"><SelectItemText>{{ t("common.chinese") }}</SelectItemText><SelectItemIndicatorclass="absolute left-2 inline-flex items-center"><Icon icon="radix-icons:check" /></SelectItemIndicator></SelectItem><SelectItemvalue="en"class="relative flex items-center px-8 py-2 text-sm text-gray-700 rounded-md cursor-default hover:bg-gray-100"><SelectItemText>{{ t("common.english") }}</SelectItemText><SelectItemIndicatorclass="absolute left-2 inline-flex items-center"><Icon icon="radix-icons:check" /></SelectItemIndicator></SelectItem></SelectGroup></SelectViewport></SelectContent></SelectPortal></SelectRoot></div><!-- Font Size Setting --><div class="setting-item flex items-center gap-8"><label class="text-sm font-medium text-gray-700 w-24">{{ t("settings.fontSize") }}</label><NumberFieldRootv-model="currentConfig.fontSize"class="inline-flex w-[100px]"><NumberFieldDecrementclass="px-2 border border-r-0 border-gray-300 rounded-l-md hover:bg-gray-100 focus:outline-none"><Icon icon="radix-icons:minus" /></NumberFieldDecrement><NumberFieldInputclass="w-10 px-2 py-2 border border-gray-300 focus:outline-none focus:ring-1 focus:ring-green-500 text-center":min="12":max="20"/><NumberFieldIncrementclass="px-2 border border-l-0 border-gray-300 rounded-r-md hover:bg-gray-100 focus:outline-none"><Icon icon="radix-icons:plus" /></NumberFieldIncrement></NumberFieldRoot></div></TabsContent><TabsContent value="models" class="space-y-4"><AccordionRoot type="single" collapsible><AccordionItemv-for="provider in providers":key="provider.id":value="provider.name"class="border rounded-lg mb-2"><AccordionTriggerclass="flex items-center justify-between w-full p-4 text-left"><div class="flex items-center gap-2"><img:src="provider.avatar":alt="provider.name"class="w-6 h-6 rounded"/><span class="font-medium">{{ provider.title }}</span></div><Iconicon="radix-icons:chevron-down"class="transform transition-transform duration-200 ease-in-out data-[state=open]:rotate-180"/></AccordionTrigger><AccordionContent class="p-4 pt-0"><div class="space-y-4"><divv-for="config in getProviderConfig(provider.name)":key="config.key"class="flex items-center gap-4"><label class="text-sm font-medium text-gray-700 w-24">{{config.label}}</label><input:type="config.type":placeholder="config.placeholder":required="config.required":value="config.value"@input="(e) => updateProviderConfig(provider.name, config.key, (e.target as HTMLInputElement).value)"class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-green-500"/></div></div></AccordionContent></AccordionItem></AccordionRoot></TabsContent></TabsRoot></div>
</template><script setup lang="ts">
import { reactive, onMounted, watch, ref, computed } from "vue";
import { Icon } from "@iconify/vue";
import { useI18n } from "vue-i18n";
import { AppConfig } from "../types";
import { useProviderStore } from "../stores/provider";
import { providerConfigs, ProviderConfigItem } from "../config/providerConfig";
import {SelectContent,SelectGroup,SelectIcon,SelectItem,SelectItemIndicator,SelectItemText,SelectPortal,SelectRoot,SelectTrigger,SelectValue,SelectViewport,NumberFieldRoot,NumberFieldInput,NumberFieldIncrement,NumberFieldDecrement,TabsRoot,TabsList,TabsTrigger,TabsContent,AccordionRoot,AccordionItem,AccordionTrigger,AccordionContent,
} from "radix-vue";const { t, locale } = useI18n();
const activeTab = ref("general");
const providerStore = useProviderStore();
const providers = computed(() => providerStore.items);const currentConfig = reactive<AppConfig>({language: "zh",fontSize: 14,providerConfigs: {},
});onMounted(async () => {const config = await (window as any).electronAPI.getConfig();Object.assign(currentConfig, config);
});// 深度监听配置变化并自动保存
watch(currentConfig,async (newConfig) => {// 创建一个普通对象来传递配置const configToSave = {language: newConfig.language,fontSize: newConfig.fontSize,providerConfigs: JSON.parse(JSON.stringify(newConfig.providerConfigs)),};// 由于 TypeScript 提示 window 上不存在 electronAPI 属性,我们可以使用类型断言来解决这个问题await (window as any).electronAPI.updateConfig(configToSave);// 更新界面语言locale.value = newConfig.language;},{ deep: true }
);// 获取provider对应的配置项
const getProviderConfig = (providerName: string): ProviderConfigItem[] => {const configs = providerConfigs[providerName] || [];// 确保配置值被初始化if (!currentConfig.providerConfigs[providerName]) {currentConfig.providerConfigs[providerName] = {};}return configs.map((config) => ({...config,value:currentConfig.providerConfigs[providerName][config.key] || config.value,}));
};// 更新provider配置值
const updateProviderConfig = (providerName: string,key: string,value: string
) => {if (!currentConfig.providerConfigs[providerName]) {currentConfig.providerConfigs[providerName] = {};}currentConfig.providerConfigs[providerName][key] = value;
};
</script>

src/config/providerConfig.ts

export interface ProviderConfigItem {key: string;label: string;value: string;type: 'text' | 'password' | 'number';required?: boolean;placeholder?: string;
}// 百度文心一言配置
export const qianfanConfig: ProviderConfigItem[] = [{key: 'accessKey',label: 'Access Key',value: '',type: 'text',required: true,placeholder: '请输入Access Key'},{key: 'secretKey',label: 'Secret Key',value: '',type: 'password',required: true,placeholder: '请输入Secret Key'}
];// API Key + Base URL 通用配置模板
export const apiKeyBaseUrlConfig: ProviderConfigItem[] = [{key: 'apiKey',label: 'API Key',value: '',type: 'password',required: true,placeholder: '请输入API Key'},{key: 'baseUrl',label: 'Base URL',value: '',type: 'text',required: false,placeholder: '请输入API基础URL'}
];// 所有Provider的配置映射
export const providerConfigs: Record<string, ProviderConfigItem[]> = {qianfan: qianfanConfig,aliyun: apiKeyBaseUrlConfig,deepseek: apiKeyBaseUrlConfig,openai: apiKeyBaseUrlConfig
}; 

src/stores/provider.ts

import { defineStore } from 'pinia'
import { db } from '../db'
import { ProviderProps } from '../types'export interface ProviderStore {items: ProviderProps[]
}export const useProviderStore = defineStore('provider', {state: (): ProviderStore => {return {items: []}},actions: {async fetchProviders() {const items = await db.providers.toArray()this.items = items}},getters: {getProviderById: (state) => (id: number) => {return state.items.find(item => item.id === id)}}
})

src/db.ts

import Dexie, { type EntityTable } from "dexie";
import { ConversationProps, ProviderProps } from "./types";
import { providers } from "./initData";export const db = new Dexie("AI_chatDatabase") as Dexie & {conversations: EntityTable<ConversationProps, "id">;providers: EntityTable<ProviderProps, "id">;
};db.version(1).stores({// 主键为id,且自增// 新增updatedAt字段,用于排序conversations: "++id, updatedAt",providers: "++id, name",
});export const initProviders = async () => {const count = await db.providers.count();if (count === 0) {db.providers.bulkAdd(providers);}
};

相关文章:

  • 考OCM证书前需要有OCP证书
  • VSCode Verilog环境搭建
  • JVM调优实战(JVM Tuning Pactice)
  • 深入解析 Linux 进程池:原理、实现与高并发优化
  • 【AI面试准备】模型自动化评估
  • 【数据结构与算法】哈希表实现:闭散列 开散列
  • Qt5与现代OpenGL学习(四)X轴方向旋转60度
  • DevExpressWinForms-XtraMessageBox-使用教程
  • Java信任证书
  • 前缀和 --- 二维前缀和
  • SVN子路径权限设置及登录方法详解
  • Prometheus使用Recoding Rules优化性能
  • 20250429-李彦宏口中的MCP:AI时代的“万能接口“
  • 微服务学习笔记
  • AWS MSK 集群升级前配置检查:保障升级平稳进行的关键步骤
  • 基于PyTorch的图像分类特征提取与模型训练文档
  • MapReduce的shuffle过程详解
  • 【C++初阶】--- 模板进阶
  • 将infinigen功能集成到UE5--在ue里面写插件(python和c++)
  • 在Mybatis中写sql的常量应用
  • 国有六大行一季度合计净赚超3444亿,不良贷款余额均上升
  • “光荣之城”2025上海红色文化季启动,红色主题市集亮相
  • 日本希望再次租借大熊猫,外交部:双方就相关合作保持密切沟通
  • 初步结果显示加拿大自由党赢得大选,外交部回应
  • 专访丨青年作家杜梨:以动物的视角去观察这个世界
  • 法院为“外卖骑手”人身权益撑腰:依法认定实际投保人地位