新增 配置文件多管理功能

This commit is contained in:
yuantao
2026-04-14 17:42:04 +08:00
parent 85574798f2
commit 95aef170eb
4 changed files with 484 additions and 68 deletions

137
AGENTS.md Normal file
View File

@@ -0,0 +1,137 @@
# iFlow Settings Editor - Agent 文档
## 项目概述
iFlow 设置编辑器是一个基于 Electron + Vue 3 的桌面应用程序,用于编辑 `C:\Users\<USER>\.iflow\settings.json` 配置文件。
## 技术栈
| 技术 | 版本 | 用途 |
|------|------|------|
| Electron | ^28.0.0 | 桌面应用框架 |
| Vue | ^3.4.0 | 前端框架 (组合式 API) |
| Vite | ^8.0.8 | 构建工具 |
| @icon-park/vue-next | ^1.4.2 | 图标库 |
| @vitejs/plugin-vue | ^6.0.6 | Vue 插件 |
## 项目结构
```
iflow-settings-editor/
├── main.js # Electron 主进程 (窗口管理、IPC、文件操作)
├── preload.js # 预加载脚本 (contextBridge API)
├── index.html # HTML 入口
├── package.json # 项目配置
├── vite.config.js # Vite 配置
├── src/
│ ├── main.js # Vue 入口
│ └── App.vue # 主组件 (所有业务逻辑)
```
## 核心架构
### 进程模型
- **Main Process (main.js)**: Electron 主进程处理窗口管理、IPC 通信、文件系统操作
- **Preload (preload.js)**: 通过 `contextBridge.exposeInMainWorld` 暴露安全 API
- **Renderer (Vue)**: 渲染进程,只通过 preload 暴露的 API 与主进程通信
### IPC 通信
```javascript
// preload.js 暴露的 API
window.electronAPI = {
loadSettings: () => ipcRenderer.invoke('load-settings'),
saveSettings: (data) => ipcRenderer.invoke('save-settings', data),
showMessage: (options) => ipcRenderer.invoke('show-message', options),
isMaximized: () => ipcRenderer.invoke('is-maximized'),
minimize: () => ipcRenderer.send('window-minimize'),
maximize: () => ipcRenderer.send('window-maximize'),
close: () => ipcRenderer.send('window-close')
}
```
### 窗口配置
- 窗口尺寸: 1100x750最小尺寸: 900x600
- 无边框窗口 (frame: false),自定义标题栏
- 开发模式加载 `http://localhost:5173`,生产模式加载 `dist/index.html`
### API 配置切换
- 支持多环境配置: 默认配置、开发环境、预发布环境、生产环境
- 切换前检查未保存的更改
- 单独保存每个环境的 API 配置到 `apiProfiles` 对象
## 可用命令
```bash
npm install # 安装依赖
npm run dev # 启动 Vite 开发服务器
npm run build # 构建 Vue 应用到 dist 目录
npm start # 运行 Electron (需先build)
npm run electron:dev # 同时运行 Vite + Electron (开发模式)
npm run electron:start # 构建 + 运行 Electron (生产模式)
```
## 功能模块
### 1. 常规设置 (General)
- **语言**: zh-CN / en-US / ja-JP
- **主题**: Xcode / Dark / Light / Solarized Dark
- **启动动画**: 已显示 / 未显示
- **检查点保存**: 启用 / 禁用
### 2. API 配置 (API)
- **配置切换**: 支持多环境 (默认/开发/预发布/生产)
- **认证方式**: iFlow / API Key
- **API Key**: 密码输入框
- **Base URL**: API 端点
- **模型名称**: AI 模型标识
- **搜索 API Key**: 搜索服务认证
- **CNA**: CNA 标识
### 3. MCP 服务器管理 (MCP)
- 服务器列表展示
- 添加/编辑/删除服务器
- 服务器配置: 名称、描述、命令、工作目录、参数(每行一个)、环境变量(JSON)
## 关键实现细节
### 设置文件路径
```javascript
const SETTINGS_FILE = path.join(app.getPath('home'), '.iflow', 'settings.json');
```
### 保存时自动备份
```javascript
if (fs.existsSync(SETTINGS_FILE)) {
const backupPath = SETTINGS_FILE + '.bak';
fs.copyFileSync(SETTINGS_FILE, backupPath);
}
```
### 安全配置
- `contextIsolation: true` - 隔离上下文
- `nodeIntegration: false` - 禁用 Node.js
- `webSecurity: false` - 仅开发环境解决 CSP 问题
### Vue 组件状态管理
- `settings` - 当前设置 (ref)
- `originalSettings` - 原始设置 (用于检测修改)
- `modified` - 是否已修改 (computed/diff)
- `currentSection` - 当前显示的板块
- `currentServerName` - 当前选中的 MCP 服务器
## 开发注意事项
1. **修改检测**: 通过 `watch(settings, () => { modified.value = true }, { deep: true })` 深度监听
2. **服务器编辑**: 使用 DOM 操作收集表单数据 (`collectServerData`)
3. **MCP 参数**: 每行一个参数,通过换行分割
4. **环境变量**: 支持 JSON 格式输入
5. **窗口控制**: 通过 IPC 发送指令,主进程处理实际窗口操作
6. **API 配置切换**: 多个环境配置存储在 `settings.apiProfiles` 对象中
7. **序列化问题**: IPC 通信使用 `JSON.parse(JSON.stringify())` 避免 Vue 响应式代理问题
## 图标使用
使用 `@icon-park/vue-next` 图标库:
```javascript
import { Refresh, Save, Config, Key, Server, Globe, Setting, Robot, Search, Add, Edit, Delete } from '@icon-park/vue-next';
```

201
main.js
View File

@@ -1,19 +1,22 @@
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
const fs = require('fs');
const { app, BrowserWindow, ipcMain, dialog } = require('electron')
const path = require('path')
const fs = require('fs')
console.log('main.js loaded');
console.log('app.getPath("home"):', app.getPath('home'));
console.log('main.js loaded')
console.log('app.getPath(\"home\"):', app.getPath('home'))
const SETTINGS_FILE = path.join(app.getPath('home'), '.iflow', 'settings.json');
console.log('SETTINGS_FILE:', SETTINGS_FILE);
const DEFAULT_SETTINGS_FILE = path.join(app.getPath('home'), '.iflow', 'settings.json')
const CONFIG_DIR = path.join(app.getPath('home'), '.iflow', 'configs')
console.log('DEFAULT_SETTINGS_FILE:', DEFAULT_SETTINGS_FILE)
console.log('CONFIG_DIR:', CONFIG_DIR)
let mainWindow;
let currentSettingsFile = DEFAULT_SETTINGS_FILE
let mainWindow
const isDev = process.argv.includes('--dev');
const isDev = process.argv.includes('--dev')
function createWindow() {
console.log('Creating window...');
console.log('Creating window...')
mainWindow = new BrowserWindow({
width: 1100,
@@ -24,94 +27,184 @@ function createWindow() {
frame: false,
show: false,
webPreferences: {
devTools: true,
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
webSecurity: false
}
});
webSecurity: false,
},
})
console.log('Loading index.html...');
console.log('Loading index.html...')
if (isDev) {
mainWindow.loadURL('http://localhost:5173');
mainWindow.loadURL('http://localhost:5173')
} else {
mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html'));
mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html'))
}
console.log('index.html loading initiated');
console.log('index.html loading initiated')
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
console.error('Failed to load:', errorCode, errorDescription);
});
console.error('Failed to load:', errorCode, errorDescription)
})
mainWindow.webContents.on('console-message', (event, level, message, line, sourceId) => {
console.log('Console [' + level + ']:', message);
});
console.log('Console [' + level + ']:', message)
})
mainWindow.once('ready-to-show', () => {
console.log('Window ready to show');
mainWindow.show();
});
console.log('Window ready to show')
mainWindow.show()
})
mainWindow.on('closed', () => {
mainWindow = null;
});
mainWindow = null
})
}
app.whenReady().then(createWindow);
app.whenReady().then(createWindow)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
app.quit()
}
});
})
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
createWindow()
}
});
})
// Window controls
ipcMain.on('window-minimize', () => mainWindow.minimize());
ipcMain.on('window-minimize', () => mainWindow.minimize())
ipcMain.on('window-maximize', () => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
mainWindow.unmaximize()
} else {
mainWindow.maximize();
mainWindow.maximize()
}
});
ipcMain.on('window-close', () => mainWindow.close());
})
ipcMain.on('window-close', () => mainWindow.close())
ipcMain.handle('is-maximized', () => mainWindow.isMaximized());
ipcMain.handle('is-maximized', () => mainWindow.isMaximized())
// Get current config file path
ipcMain.handle('get-current-config', () => {
return { filePath: currentSettingsFile, fileName: path.basename(currentSettingsFile) }
})
// List all config files
ipcMain.handle('list-configs', async () => {
try {
const configs = []
// Always include default settings
if (fs.existsSync(DEFAULT_SETTINGS_FILE)) {
const stats = fs.statSync(DEFAULT_SETTINGS_FILE)
configs.push({
name: '默认配置',
filePath: DEFAULT_SETTINGS_FILE,
modified: stats.mtime
})
}
// Add configs from CONFIG_DIR
if (fs.existsSync(CONFIG_DIR)) {
const files = fs.readdirSync(CONFIG_DIR).filter(f => f.endsWith('.json'))
for (const file of files) {
const filePath = path.join(CONFIG_DIR, file)
const stats = fs.statSync(filePath)
configs.push({
name: file.replace('.json', ''),
filePath: filePath,
modified: stats.mtime
})
}
} else {
fs.mkdirSync(CONFIG_DIR, { recursive: true })
}
return { success: true, configs: configs }
} catch (error) {
return { success: false, error: error.message, configs: [] }
}
})
// Create new config
ipcMain.handle('create-config', async (event, name) => {
try {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true })
}
const fileName = name + '.json'
const filePath = path.join(CONFIG_DIR, fileName)
if (fs.existsSync(filePath)) {
return { success: false, error: '配置文件已存在' }
}
let data = {}
if (fs.existsSync(currentSettingsFile)) {
const content = fs.readFileSync(currentSettingsFile, 'utf-8')
data = JSON.parse(content)
}
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8')
return { success: true, filePath: filePath }
} catch (error) {
return { success: false, error: error.message }
}
})
// Delete config
ipcMain.handle('delete-config', async (event, filePath) => {
try {
if (filePath === DEFAULT_SETTINGS_FILE) {
return { success: false, error: '不能删除默认配置' }
}
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath)
}
return { success: true }
} catch (error) {
return { success: false, error: error.message }
}
})
// Switch to config
ipcMain.handle('switch-config', async (event, filePath) => {
try {
if (!fs.existsSync(filePath)) {
return { success: false, error: '配置文件不存在' }
}
currentSettingsFile = filePath
return { success: true, filePath: currentSettingsFile }
} catch (error) {
return { success: false, error: error.message }
}
})
// IPC Handlers
ipcMain.handle('load-settings', async () => {
try {
if (!fs.existsSync(SETTINGS_FILE)) {
return { success: false, error: 'File not found', data: null };
if (!fs.existsSync(currentSettingsFile)) {
return { success: false, error: 'File not found', data: null }
}
const data = fs.readFileSync(SETTINGS_FILE, 'utf-8');
const json = JSON.parse(data);
return { success: true, data: json };
const data = fs.readFileSync(currentSettingsFile, 'utf-8')
const json = JSON.parse(data)
return { success: true, data: json }
} catch (error) {
return { success: false, error: error.message, data: null };
return { success: false, error: error.message, data: null }
}
});
})
ipcMain.handle('save-settings', async (event, data) => {
try {
// Backup
if (fs.existsSync(SETTINGS_FILE)) {
const backupPath = SETTINGS_FILE + '.bak';
fs.copyFileSync(SETTINGS_FILE, backupPath);
if (fs.existsSync(currentSettingsFile)) {
const backupPath = currentSettingsFile + '.bak'
fs.copyFileSync(currentSettingsFile, backupPath)
}
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(data, null, 2), 'utf-8');
return { success: true };
fs.writeFileSync(currentSettingsFile, JSON.stringify(data, null, 2), 'utf-8')
return { success: true }
} catch (error) {
return { success: false, error: error.message };
return { success: false, error: error.message }
}
});
})
ipcMain.handle('show-message', async (event, { type, title, message }) => {
return dialog.showMessageBox(mainWindow, { type, title, message });
});
return dialog.showMessageBox(mainWindow, { type, title, message })
})

View File

@@ -1,4 +1,4 @@
const { contextBridge, ipcRenderer } = require('electron');
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
loadSettings: () => ipcRenderer.invoke('load-settings'),
@@ -7,5 +7,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
isMaximized: () => ipcRenderer.invoke('is-maximized'),
minimize: () => ipcRenderer.send('window-minimize'),
maximize: () => ipcRenderer.send('window-maximize'),
close: () => ipcRenderer.send('window-close')
});
close: () => ipcRenderer.send('window-close'),
getCurrentConfig: () => ipcRenderer.invoke('get-current-config'),
listConfigs: () => ipcRenderer.invoke('list-configs'),
createConfig: (name) => ipcRenderer.invoke('create-config', name),
deleteConfig: (filePath) => ipcRenderer.invoke('delete-config', filePath),
switchConfig: (filePath) => ipcRenderer.invoke('switch-config', filePath)
})

View File

@@ -119,6 +119,30 @@
<h1 class="content-title">API 配置</h1>
<p class="content-desc">配置 AI 服务和搜索 API</p>
</div>
<div class="card">
<div class="card-title">
<Exchange size="16" />
配置文件管理
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">当前配置文件</label>
<select class="form-select" v-model="currentConfigFilePath" @change="switchConfig">
<option v-for="cfg in configList" :key="cfg.filePath" :value="cfg.filePath">{{ cfg.name }}</option>
</select>
</div>
<div class="form-group" style="display: flex; align-items: flex-end; gap: 8px;">
<button class="btn btn-secondary" @click="createNewConfig" style="height: 40px;">
<Add size="14" />
新建
</button>
<button class="btn btn-danger" @click="deleteConfig" style="height: 40px;" :disabled="configList.length <= 1">
<Delete size="14" />
删除
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-title">
<Robot size="16" />
@@ -129,6 +153,7 @@
<select class="form-select" v-model="settings.selectedAuthType">
<option value="iflow">iFlow</option>
<option value="api">API Key</option>
<option value="openai-compatible">OpenAI 兼容</option>
</select>
</div>
<div class="form-group">
@@ -238,16 +263,37 @@
<footer class="footer">
<div class="footer-status">
<div class="footer-status-dot"></div>
<span>C:\Users\MSI\.iflow\settings.json</span>
<span>{{ currentConfigFilePath }}</span>
</div>
<span :class="{ 'footer-modified': modified }">{{ modified ? '● 已修改' : '✓ 未修改' }}</span>
</footer>
<!-- Input Dialog -->
<div v-if="showInputDialog.show" class="dialog-overlay" @click.self="closeInputDialog(false)">
<div class="dialog">
<div class="dialog-title">{{ showInputDialog.title }}</div>
<div v-if="showInputDialog.isConfirm" class="dialog-confirm-text">{{ showInputDialog.placeholder }}</div>
<input
v-else
type="text"
class="form-input"
v-model="inputDialogValue"
:placeholder="showInputDialog.placeholder"
@keyup.enter="closeInputDialog(true)"
autofocus
/>
<div class="dialog-actions">
<button class="btn btn-secondary" @click="closeInputDialog(false)">取消</button>
<button class="btn btn-primary" @click="closeInputDialog(true)">确定</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue';
import { Refresh, Save, Config, Key, Server, Globe, Setting, Robot, Search, Add, Edit, Delete } from '@icon-park/vue-next';
import { Refresh, Save, Config, Key, Server, Globe, Setting, Robot, Search, Add, Edit, Delete, Exchange } from '@icon-park/vue-next';
const settings = ref({
language: 'zh-CN',
@@ -268,11 +314,82 @@ const modified = ref(false);
const currentSection = ref('general');
const currentServerName = ref(null);
const isLoading = ref(true);
const configList = ref([]);
const currentConfigFilePath = ref('');
const showInputDialog = ref({ show: false, title: '', placeholder: '', callback: null });
const inputDialogValue = ref('');
// Load config list
const loadConfigList = async () => {
const result = await window.electronAPI.listConfigs();
if (result.success) {
configList.value = result.configs;
}
};
// Switch config
const switchConfig = async () => {
if (modified.value) {
const confirmed = await new Promise((resolve) => {
showInputDialog.value = { show: true, title: '切换配置', placeholder: '当前有未保存的更改,切换配置将丢失这些更改,确定要切换吗?', callback: resolve, isConfirm: true };
});
if (!confirmed) return;
}
if (currentConfigFilePath.value) {
const result = await window.electronAPI.switchConfig(currentConfigFilePath.value);
if (result.success) {
await loadSettings();
}
}
};
// Create new config
const createNewConfig = async () => {
const name = await new Promise((resolve) => {
showInputDialog.value = { show: true, title: '新建配置文件', placeholder: '请输入配置名称', callback: resolve };
});
if (!name) return;
const result = await window.electronAPI.createConfig(name);
if (result.success) {
await loadConfigList();
currentConfigFilePath.value = result.filePath;
await loadSettings();
await window.electronAPI.showMessage({ type: 'info', title: '创建成功', message: `配置文件 "${name}" 已创建` });
} else {
await window.electronAPI.showMessage({ type: 'error', title: '创建失败', message: result.error });
}
};
// Delete config
const deleteConfig = async () => {
if (configList.value.length <= 1) {
await window.electronAPI.showMessage({ type: 'warning', title: '无法删除', message: '至少需要保留一个配置文件' });
return;
}
const cfg = configList.value.find(c => c.filePath === currentConfigFilePath.value);
if (!cfg) return;
const confirmed = await new Promise((resolve) => {
showInputDialog.value = { show: true, title: '删除配置文件', placeholder: `确定要删除配置文件 "${cfg.name}" 吗?`, callback: resolve, isConfirm: true };
});
if (!confirmed) return;
const result = await window.electronAPI.deleteConfig(currentConfigFilePath.value);
if (result.success) {
await loadConfigList();
if (configList.value.length > 0) {
currentConfigFilePath.value = configList.value[0].filePath;
await window.electronAPI.switchConfig(currentConfigFilePath.value);
await loadSettings();
}
await window.electronAPI.showMessage({ type: 'info', title: '删除成功', message: `配置文件 "${cfg.name}" 已删除` });
} else {
await window.electronAPI.showMessage({ type: 'error', title: '删除失败', message: result.error });
}
};
const loadSettings = async () => {
const result = await window.electronAPI.loadSettings();
if (result.success) {
const data = result.data;
const data = JSON.parse(JSON.stringify(result.data));
if (!data.checkpointing) data.checkpointing = { enabled: true };
if (!data.mcpServers) data.mcpServers = {};
settings.value = data;
@@ -284,7 +401,8 @@ const loadSettings = async () => {
const saveSettings = async () => {
collectServerData();
const result = await window.electronAPI.saveSettings(settings.value);
const dataToSave = JSON.parse(JSON.stringify(settings.value));
const result = await window.electronAPI.saveSettings(dataToSave);
if (result.success) {
originalSettings.value = JSON.parse(JSON.stringify(settings.value));
modified.value = false;
@@ -295,7 +413,12 @@ const saveSettings = async () => {
};
const reloadSettings = async () => {
if (modified.value && !confirm('当前有未保存的更改,确定要重新加载吗?')) return;
if (modified.value) {
const confirmed = await new Promise((resolve) => {
showInputDialog.value = { show: true, title: '重新加载', placeholder: '当前有未保存的更改,确定要重新加载吗?', callback: resolve, isConfirm: true };
});
if (!confirmed) return;
}
currentServerName.value = null;
await loadSettings();
};
@@ -308,21 +431,26 @@ const serverCount = computed(() => settings.value.mcpServers ? Object.keys(setti
const selectServer = (name) => { currentServerName.value = name; };
const addServer = () => {
const name = prompt('请输入服务器名称:');
const addServer = async () => {
const name = await new Promise((resolve) => {
showInputDialog.value = { show: true, title: '添加服务器', placeholder: '请输入服务器名称', callback: resolve };
});
if (!name) return;
if (!settings.value.mcpServers) settings.value.mcpServers = {};
if (settings.value.mcpServers[name]) {
alert('服务器已存在');
await window.electronAPI.showMessage({ type: 'warning', title: '错误', message: '服务器已存在' });
return;
}
settings.value.mcpServers[name] = { command: 'npx', args: ['-y', 'package-name'] };
currentServerName.value = name;
};
const deleteServer = () => {
const deleteServer = async () => {
if (!currentServerName.value) return;
if (!confirm(`确定要删除服务器 "${currentServerName.value}" 吗?`)) return;
const confirmed = await new Promise((resolve) => {
showInputDialog.value = { show: true, title: '删除服务器', placeholder: `确定要删除服务器 "${currentServerName.value}" 吗?`, callback: resolve, isConfirm: true };
});
if (!confirmed) return;
delete settings.value.mcpServers[currentServerName.value];
currentServerName.value = null;
};
@@ -369,7 +497,23 @@ const minimize = () => window.electronAPI.minimize();
const maximize = () => window.electronAPI.maximize();
const close = () => window.electronAPI.close();
onMounted(() => { loadSettings(); });
const closeInputDialog = (result) => {
if (showInputDialog.value.callback) {
showInputDialog.value.callback(showInputDialog.value.isConfirm ? result : inputDialogValue.value);
}
showInputDialog.value.show = false;
showInputDialog.value.isConfirm = false;
inputDialogValue.value = '';
};
onMounted(async () => {
await loadConfigList();
const current = await window.electronAPI.getCurrentConfig();
if (current.filePath) {
currentConfigFilePath.value = current.filePath;
}
await loadSettings();
});
</script>
<style>
@@ -510,5 +654,41 @@ body {
.footer-status-dot { width: 6px; height: 6px; border-radius: 50%; background: #6ccb5f; }
.footer-modified { color: var(--accent); }
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 24px;
min-width: 320px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.dialog-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
}
.dialog-confirm-text {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.iconpark-icon { display: inline-block; vertical-align: middle; }
</style>