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);
  }
};
相关推荐
牧天白衣.42 分钟前
html中margin的用法
前端·html
NoneCoder44 分钟前
HTML与安全性:XSS、防御与最佳实践
前端·html·xss
沃野_juededa1 小时前
关于uniapp 中uview input组件设置为readonly 或者disabled input区域不可点击问题
java·前端·uni-app
哎哟喂_!1 小时前
UniApp 实现分享功能
前端·javascript·vue.js·uni-app
k1955142391 小时前
uniapp常用
前端·javascript·uni-app
wuhen_n3 小时前
CSS元素动画篇:基于页面位置的变换动画
前端·css·html·css3·html5
sql123456789114 小时前
前端——CSS1
前端
Nueuis4 小时前
微信小程序分页和下拉刷新
服务器·前端·微信小程序
小白64024 小时前
前端性能优化(实践篇)
前端·性能优化
白瑕4 小时前
[JavaScript]对象关联风格与行为委托模式
javascript