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

最终效果


定义默认配置

src/initData.ts

ts 复制代码
export const DEFAULT_CONFIG: AppConfig = {
  language: "zh",
  fontSize: 14,
  providerConfigs: {},
};

src/types.ts

ts 复制代码
export interface AppConfig {
  language: 'zh' | 'en'
  fontSize: number
  providerConfigs: Record<string, Record<string, string>>
}

从本地加载配置

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

src/main.ts

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

src/config.ts

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

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

渲染进程中使用配置

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

src/views/Settings.vue

ts 复制代码
onMounted(async () => {
  const config = await (window as any).electronAPI.getConfig();
});

src/preload.ts

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

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

src/main.ts

ts 复制代码
import { setupIPC } from "./ipc";
ts 复制代码
setupIPC(mainWindow);

配置页更新配置

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

src/views/Settings.vue

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

ts 复制代码
// 深度监听配置变化并自动保存
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

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

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

src/config.ts

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

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

html 复制代码
<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">
        <TabsTrigger
          value="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>
        <TabsTrigger
          value="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]">
            <SelectTrigger
              class="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>
                    <SelectItem
                      value="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>
                      <SelectItemIndicator
                        class="absolute left-2 inline-flex items-center"
                      >
                        <Icon icon="radix-icons:check" />
                      </SelectItemIndicator>
                    </SelectItem>
                    <SelectItem
                      value="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>
                      <SelectItemIndicator
                        class="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>
          <NumberFieldRoot
            v-model="currentConfig.fontSize"
            class="inline-flex w-[100px]"
          >
            <NumberFieldDecrement
              class="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>
            <NumberFieldInput
              class="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"
            />
            <NumberFieldIncrement
              class="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>
          <AccordionItem
            v-for="provider in providers"
            :key="provider.id"
            :value="provider.name"
            class="border rounded-lg mb-2"
          >
            <AccordionTrigger
              class="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>
              <Icon
                icon="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">
                <div
                  v-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

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

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

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);
  }
};
相关推荐
Nan_Shu_61417 小时前
学习: Threejs (2)
前端·javascript·学习
G_G#17 小时前
纯前端js插件实现同一浏览器控制只允许打开一个标签,处理session变更问题
前端·javascript·浏览器标签页通信·只允许一个标签页
@大迁世界17 小时前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路17 小时前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug18 小时前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu1213818 小时前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中18 小时前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路18 小时前
GDAL 实现矢量合并
前端
hxjhnct18 小时前
React useContext的缺陷
前端·react.js·前端框架
冰暮流星18 小时前
javascript逻辑运算符
开发语言·javascript·ecmascript