14 Commits
v1.5 ... main

34 changed files with 5314 additions and 1844 deletions

165
AGENTS.md
View File

@@ -15,28 +15,46 @@ iFlow 设置编辑器是一个基于 Electron + Vue 3 的桌面应用程序,
| @vitejs/plugin-vue | ^6.0.6 | Vue 插件 | | @vitejs/plugin-vue | ^6.0.6 | Vue 插件 |
| concurrently | ^8.2.2 | 并发执行工具 | | concurrently | ^8.2.2 | 并发执行工具 |
| electron-builder | ^24.13.3 | 应用打包工具 | | electron-builder | ^24.13.3 | 应用打包工具 |
| vitest | ^4.1.4 | 单元测试框架 |
| @vue/test-utils | ^2.5.0 | Vue 组件测试工具 |
| happy-dom | Latest | 浏览器环境模拟 |
## 项目结构 ## 项目结构
``` ```
iflow-settings-editor/ iflow-settings-editor/
├── main.js # Electron 主进程 (窗口管理、IPC、文件操作) ├── main.js # Electron 主进程 (窗口管理、IPC、文件操作、系统托盘)
├── preload.js # 预加载脚本 (contextBridge API) ├── preload.js # 预加载脚本 (contextBridge API)
├── index.html # HTML 入口 ├── index.html # HTML 入口
├── package.json # 项目配置 ├── package.json # 项目配置
├── vite.config.js # Vite 配置 ├── vite.config.js # Vite 配置
├── vitest.config.js # Vitest 测试配置
├── src/ ├── src/
│ ├── main.js # Vue 入口 │ ├── main.js # Vue 入口
── App.vue # 主组件 (所有业务逻辑) ── components/ # 可复用组件
│ │ ├── ApiProfileDialog.vue
│ │ ├── Footer.vue
│ │ ├── InputDialog.vue
│ │ ├── MessageDialog.vue
│ │ ├── ServerPanel.vue
│ │ ├── SideBar.vue
│ │ └── TitleBar.vue
│ ├── views/ # 页面视图组件
│ │ ├── ApiConfig.vue
│ │ ├── GeneralSettings.vue
│ │ └── McpServers.vue
│ └── styles/ # 全局样式
│ └── global.less
├── build/ # 构建资源 (图标等) ├── build/ # 构建资源 (图标等)
├── dist/ # Vite 构建输出
├── release/ # 打包输出目录 ├── release/ # 打包输出目录
└── screenshots/ # 截图资源 └── screenshots/ # 截图资源
``` ```
## 核心架构 ## 核心架构
### 进程模型 ### 进程模型
- **Main Process (main.js)**: Electron 主进程处理窗口管理、IPC 通信、文件系统操作 - **Main Process (main.js)**: Electron 主进程处理窗口管理、IPC 通信、文件系统操作、系统托盘
- **Preload (preload.js)**: 通过 `contextBridge.exposeInMainWorld` 暴露安全 API - **Preload (preload.js)**: 通过 `contextBridge.exposeInMainWorld` 暴露安全 API
- **Renderer (Vue)**: 渲染进程,只通过 preload 暴露的 API 与主进程通信 - **Renderer (Vue)**: 渲染进程,只通过 preload 暴露的 API 与主进程通信
@@ -44,19 +62,29 @@ iflow-settings-editor/
```javascript ```javascript
// preload.js 暴露的 API // preload.js 暴露的 API
window.electronAPI = { window.electronAPI = {
// 基本设置操作
loadSettings: () => ipcRenderer.invoke('load-settings'), loadSettings: () => ipcRenderer.invoke('load-settings'),
saveSettings: (data) => ipcRenderer.invoke('save-settings', data), saveSettings: (data) => ipcRenderer.invoke('save-settings', data),
showMessage: (options) => ipcRenderer.invoke('show-message', options), 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'),
// API 配置管理(单文件内多配置)
listApiProfiles: () => ipcRenderer.invoke('list-api-profiles'), listApiProfiles: () => ipcRenderer.invoke('list-api-profiles'),
switchApiProfile: (profileName) => ipcRenderer.invoke('switch-api-profile', profileName), switchApiProfile: (profileName) => ipcRenderer.invoke('switch-api-profile', profileName),
createApiProfile: (name) => ipcRenderer.invoke('create-api-profile', name), createApiProfile: (name) => ipcRenderer.invoke('create-api-profile', name),
deleteApiProfile: (name) => ipcRenderer.invoke('delete-api-profile', name), deleteApiProfile: (name) => ipcRenderer.invoke('delete-api-profile', name),
renameApiProfile: (oldName, newName) => ipcRenderer.invoke('rename-api-profile', oldName, newName), renameApiProfile: (oldName, newName) => ipcRenderer.invoke('rename-api-profile', oldName, newName),
duplicateApiProfile: (name, newName) => ipcRenderer.invoke('duplicate-api-profile', name, newName), duplicateApiProfile: (sourceName, newName) => ipcRenderer.invoke('duplicate-api-profile', sourceName, newName),
isMaximized: () => ipcRenderer.invoke('is-maximized'),
minimize: () => ipcRenderer.send('window-minimize'), // 托盘事件监听
maximize: () => ipcRenderer.send('window-maximize'), onApiProfileSwitched: (callback) => {
close: () => ipcRenderer.send('window-close') ipcRenderer.on('api-profile-switched', (event, profileName) => callback(profileName))
}
} }
``` ```
@@ -64,12 +92,22 @@ window.electronAPI = {
- 窗口尺寸: 1100x750最小尺寸: 900x600 - 窗口尺寸: 1100x750最小尺寸: 900x600
- 无边框窗口 (frame: false),自定义标题栏 - 无边框窗口 (frame: false),自定义标题栏
- 开发模式加载 `http://localhost:5173`,生产模式加载 `dist/index.html` - 开发模式加载 `http://localhost:5173`,生产模式加载 `dist/index.html`
- **关闭窗口时隐藏到系统托盘**,双击托盘图标可重新显示
### 系统托盘
- 托盘图标显示应用状态
- 右键菜单支持:
- 显示主窗口
- 切换 API 配置(显示所有配置列表,当前配置带勾选标记)
- 退出应用
- 双击托盘图标显示主窗口
### API 配置切换 ### API 配置切换
- 支持多环境配置: 默认配置、开发环境、预发布环境、生产环境 - 支持多环境配置: 默认配置、开发环境、预发布环境、生产环境
- 配置文件管理: 支持创建、编辑、复制、删除、重命名 - 配置文件管理: 支持创建、编辑、复制、删除、重命名
- 单独保存每个环境的 API 配置到 `apiProfiles` 对象 - 单独保存每个环境的 API 配置到 `apiProfiles` 对象
- 切换配置时直接应用新配置,无需确认 - 切换配置时直接应用新配置,无需确认
- 支持从系统托盘快速切换配置
## 可用命令 ## 可用命令
@@ -87,6 +125,10 @@ npm run build:win32 # 构建 Windows x86 安装包
npm run build:win-portable # 构建可移植版本 npm run build:win-portable # 构建可移植版本
npm run build:win-installer # 构建 NSIS 安装包 npm run build:win-installer # 构建 NSIS 安装包
npm run dist # 完整构建和打包 npm run dist # 完整构建和打包
npm run test # 运行所有测试(监听模式)
npm run test:run # 运行测试一次
npm run test:ui # 运行测试 UI 界面
npm run test:coverage # 生成测试覆盖率报告
``` ```
## 功能模块 ## 功能模块
@@ -98,9 +140,9 @@ npm run dist # 完整构建和打包
- **检查点保存**: 启用 / 禁用 - **检查点保存**: 启用 / 禁用
### 2. API 配置 (API) ### 2. API 配置 (API)
- **配置列表**: 显示所有可用的 API 配置文件 - **配置列表**: 显示所有可用的 API 配置文件,带彩色图标和状态标记
- **配置切换**: 点击配置卡片直接切换,无需确认 - **配置切换**: 点击配置卡片直接切换,无需确认
- **创建配置**: 新建 API 配置文件 - **创建配置**: 新建 API 配置文件支持设置认证方式、API Key、Base URL 等)
- **编辑配置**: 修改现有配置的认证方式、API Key、Base URL 等 - **编辑配置**: 修改现有配置的认证方式、API Key、Base URL 等
- **复制配置**: 基于现有配置创建新配置 - **复制配置**: 基于现有配置创建新配置
- **删除配置**: 删除非默认配置 - **删除配置**: 删除非默认配置
@@ -112,8 +154,9 @@ npm run dist # 完整构建和打包
- **CNA**: CNA 标识 - **CNA**: CNA 标识
### 3. MCP 服务器管理 (MCP) ### 3. MCP 服务器管理 (MCP)
- 服务器列表展示 - 服务器列表展示(带描述信息)
- 添加/编辑/删除服务器 - 添加/编辑/删除服务器
- 侧边面板编辑界面
- 服务器配置: 名称、描述、命令、工作目录、参数(每行一个)、环境变量(JSON) - 服务器配置: 名称、描述、命令、工作目录、参数(每行一个)、环境变量(JSON)
## 关键实现细节 ## 关键实现细节
@@ -144,6 +187,12 @@ if (fs.existsSync(SETTINGS_FILE)) {
- `currentServerName` - 当前选中的 MCP 服务器 - `currentServerName` - 当前选中的 MCP 服务器
- `currentApiProfile` - 当前使用的 API 配置名称 - `currentApiProfile` - 当前使用的 API 配置名称
- `apiProfiles` - 可用的 API 配置列表 - `apiProfiles` - 可用的 API 配置列表
- `isLoading` - 加载状态标志
### API 配置字段
```javascript
const API_FIELDS = ['selectedAuthType', 'apiKey', 'baseUrl', 'modelName', 'searchApiKey', 'cna'];
```
### 数据初始化 ### 数据初始化
`loadSettings` 函数中确保所有字段都有默认值: `loadSettings` 函数中确保所有字段都有默认值:
@@ -151,7 +200,7 @@ if (fs.existsSync(SETTINGS_FILE)) {
- `theme`: 'Xcode' - `theme`: 'Xcode'
- `bootAnimationShown`: true - `bootAnimationShown`: true
- `checkpointing`: { enabled: true } - `checkpointing`: { enabled: true }
- `selectedAuthType`: 'iflow' - `selectedAuthType`: 'openai-compatible'
- `apiKey`: '' - `apiKey`: ''
- `baseUrl`: '' - `baseUrl`: ''
- `modelName`: '' - `modelName`: ''
@@ -159,23 +208,79 @@ if (fs.existsSync(SETTINGS_FILE)) {
- `cna`: '' - `cna`: ''
- `apiProfiles`: { default: {} } - `apiProfiles`: { default: {} }
- `currentApiProfile`: 'default' - `currentApiProfile`: 'default'
- `mcpServers`: {}
### 8. 测试框架 (Vitest)
**测试配置**
- 使用 Vitest 作为测试运行器
- 使用 `@vue/test-utils` 进行 Vue 组件测试
- 使用 `happy-dom` 作为浏览器环境模拟
- 配置文件:`vitest.config.js`
- 全局变量启用:`globals: true`
- 覆盖率工具:`v8`
- 覆盖率报告格式text、json、html
**测试文件结构**
```
src/
├── components/
│ ├── Footer.test.js # Footer 组件测试 (5 个测试)
│ ├── SideBar.test.js # 侧边栏测试 (10 个测试)
│ └── TitleBar.test.js # 标题栏测试 (8 个测试)
└── views/
├── ApiConfig.test.js # API 配置测试 (15 个测试)
├── GeneralSettings.test.js # 常规设置测试 (8 个测试)
└── McpServers.test.js # MCP 服务器测试 (12 个测试)
```
**测试覆盖范围**
- **视图组件**
- GeneralSettings - 常规设置页面
- ApiConfig - API 配置管理
- McpServers - MCP 服务器管理
- **UI 组件**
- Footer - 状态栏
- TitleBar - 窗口标题栏
- SideBar - 侧边导航栏
**测试命令**
```bash
npm run test # 运行所有测试(监听模式)
npm run test:run # 运行测试一次
npm run test:ui # 运行测试 UI 界面 (http://localhost:51204/__vitest__/)
npm run test:coverage # 生成测试覆盖率报告
```
**测试统计**
- 总测试文件6 个
- 总测试用例58 个
- 测试执行时间:约 5-6 秒
- 覆盖率:可查看 HTML 报告
**测试策略**
- 使用 mock 函数模拟外部 API`window.electronAPI`
- 测试组件渲染、事件触发、状态管理
- 验证用户交互流程
- 测试边界情况和错误处理
## 开发注意事项 ## 开发注意事项
1. **修改检测**: 通过 `watch(settings, () => { modified.value = true }, { deep: true })` 深度监听 1. **修改检测**: 通过 `watch(settings, () => { modified.value = true }, { deep: true })` 深度监听
2. **服务器编辑**: 使用 DOM 操作收集表单数据 (`collectServerData`) 2. **服务器编辑**: 使用侧边面板 (Side Panel) 收集表单数据
3. **MCP 参数**: 每行一个参数,通过换行分割 3. **MCP 参数**: 每行一个参数,通过换行分割
4. **环境变量**: 支持 JSON 格式输入 4. **环境变量**: 支持 JSON 格式输入
5. **窗口控制**: 通过 IPC 发送指令,主进程处理实际窗口操作 5. **窗口控制**: 通过 IPC 发送指令,主进程处理实际窗口操作
6. **API 配置切换**: 多个环境配置存储在 `settings.apiProfiles` 对象中 6. **API 配置切换**: 多个环境配置存储在 `settings.apiProfiles` 对象中
7. **序列化问题**: IPC 通信使用 `JSON.parse(JSON.stringify())` 避免 Vue 响应式代理问题 7. **序列化问题**: IPC 通信使用 `JSON.parse(JSON.stringify())` 避免 Vue 响应式代理问题
8. **默认值处理**: 加载配置时检查 `undefined` 并应用默认值,防止界面显示异常 8. **默认值处理**: 加载配置时检查 `undefined` 并应用默认值,防止界面显示异常
9. **托盘切换事件**: 监听 `onApiProfileSwitched` 处理托盘发起的配置切换
## 图标使用 ## 图标使用
使用 `@icon-park/vue-next` 图标库: 使用 `@icon-park/vue-next` 图标库:
```javascript ```javascript
import { Refresh, Save, Config, Key, Server, Globe, Setting, Robot, Search, Add, Edit, Delete, Exchange, Copy } from '@icon-park/vue-next'; import { Save, Config, Key, Server, Globe, Setting, Add, Edit, Delete, Exchange, Copy } from '@icon-park/vue-next';
``` ```
## 打包配置 ## 打包配置
@@ -187,10 +292,38 @@ import { Refresh, Save, Config, Key, Server, Globe, Setting, Robot, Search, Add,
- 允许修改安装目录 - 允许修改安装目录
- 允许提升权限 - 允许提升权限
- 创建桌面和开始菜单快捷方式 - 创建桌面和开始菜单快捷方式
- 支持中文和英文界面 - 支持中文和英文界面 (zh_CN, en_US)
- 卸载时保留用户数据 - 卸载时保留用户数据
### 输出目录 ### 输出目录
- `release/` - 所有打包输出的根目录 - `release/` - 所有打包输出的根目录
- 安装包命名: `iFlow Settings Editor-${version}-${arch}-setup.${ext}` - 安装包命名: `iFlow Settings Editor-${version}-${arch}-setup.${ext}`
- 可移植版本命名: `iFlow Settings Editor-${version}-portable.${ext}` - 可移植版本命名: `iFlow Settings Editor-${version}-portable.${ext}`
## UI 组件
### 对话框类型
- **输入对话框**: 用于重命名、复制等需要文本输入的场景
- **确认对话框**: 用于删除等需要确认的操作
- **消息对话框**: 显示 info/success/warning/error 四种类型,带图标
### 侧边面板
- MCP 服务器编辑使用侧边面板 (从右侧滑入)
- API 配置编辑使用模态对话框
### 主题变量
```css
:root {
--bg-primary: #f8fafc;
--bg-secondary: #ffffff;
--bg-tertiary: #f1f5f9;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-tertiary: #94a3b8;
--accent: #3b82f6;
--accent-hover: #2563eb;
--border: #e2e8f0;
--success: #10b981;
--danger: #ef4444;
}
```

60
CHANGELOG.md Normal file
View File

@@ -0,0 +1,60 @@
# 更新日志
所有重要的版本更新都会记录在此文件中。
## [1.5.1] - 2026-04-17
### 新增
- **系统托盘功能**
- 窗口关闭时最小化到托盘而非退出应用
- 托盘右键菜单:显示主窗口、切换 API 配置、退出
- 双击托盘图标恢复显示主窗口
- 从托盘快速切换 API 配置(自动保存当前配置)
### 优化
- API 配置编辑对话框的数据回填逻辑,使用 `(profile && profile.xxx) || fallback` 模式
- 改进 API 配置保存逻辑,确保切换配置时自动同步保存
- 移除 API 配置切换时的未保存更改确认弹窗
### 修复
- MCP 服务器保存及消息对话框被遮挡问题
- API 配置编辑功能,确保编辑按钮显示正确的配置数据
- 弹框点击空白处误关闭的问题
## [1.5.0] - 2026-04-16
### 新增
- **API 配置重命名功能**
- **自定义消息对话框组件**,支持 info/success/warning/error 四种类型
- **应用窗口图标支持**
- **Windows 平台编译打包配置**
### 优化
- MCP 服务器添加交互改为侧边面板一步操作
- 整体 UI 样式和交互动画效果
- 配置文件图标使用渐变色
- **架构变更**: API 配置管理从多文件改为单文件内 `apiProfiles` 对象管理
### 修复
- API 配置初始化时配置名称和认证方式显示为空的问题
- 对话框取消逻辑
## [1.4.0] - 2026-04-14
### 新增
- **配置文件多管理功能**
- 支持多环境配置文件apiProfiles
- 创建、编辑、复制、删除 API 配置
- 配置文件切换无需确认,直接应用
## [1.0.0] - 2026-04-14
### 新增
- 项目初始化
- Electron + Vue 3 基础架构
- 基础 UI 样式
- 设置文件读取和保存功能
- 基础 IPC 通信
- 无边框窗口
- 自定义标题栏
- 最小化、最大化、关闭按钮

View File

@@ -17,17 +17,31 @@
``` ```
iflow-settings-editor/ iflow-settings-editor/
├── main.js # Electron 主进程 (窗口管理、IPC、文件操作) ├── main.js # Electron 主进程 (窗口管理、IPC、文件操作、系统托盘)
├── preload.js # 预加载脚本 (IPC 通信) ├── preload.js # 预加载脚本 (contextBridge API)
├── index.html # HTML 入口 ├── index.html # HTML 入口
├── package.json # 项目配置 ├── package.json # 项目配置
├── vite.config.js # Vite 配置 ├── vite.config.js # Vite 配置
├── vitest.config.js # Vitest 测试配置
├── src/ ├── src/
│ ├── main.js # Vue 入口 │ ├── main.js # Vue 入口
── App.vue # 主组件 (所有业务逻辑) ── components/ # 可复用组件
│ │ ├── ApiProfileDialog.vue
│ │ ├── Footer.vue
│ │ ├── InputDialog.vue
│ │ ├── MessageDialog.vue
│ │ ├── ServerPanel.vue
│ │ ├── SideBar.vue
│ │ └── TitleBar.vue
│ ├── views/ # 页面视图组件
│ │ ├── ApiConfig.vue
│ │ ├── GeneralSettings.vue
│ │ └── McpServers.vue
│ └── App.vue # 主组件 (所有业务逻辑、UI 组件)
├── build/ # 构建资源 (图标等) ├── build/ # 构建资源 (图标等)
├── dist/ # Vite 构建输出
├── release/ # 打包输出目录 ├── release/ # 打包输出目录
└── screenshots/ # 截图资源 └── screenshots/ # 截图资源
``` ```
## 快速开始 ## 快速开始
@@ -61,7 +75,8 @@ npm run build:win # 构建 Windows 安装包 (NSIS)
npm run build:win64 # 构建 Windows x64 安装包 npm run build:win64 # 构建 Windows x64 安装包
npm run build:win32 # 构建 Windows x86 安装包 npm run build:win32 # 构建 Windows x86 安装包
npm run build:win-portable # 构建可移植版本 npm run build:win-portable # 构建可移植版本
npm run build:dist # 完整构建和打 npm run build:win-installer # 构建 NSIS 安装
npm run dist # 完整构建和打包
``` ```
## 功能模块 ## 功能模块
@@ -81,7 +96,7 @@ npm run build:dist # 完整构建和打包
管理多个环境的 API 配置: 管理多个环境的 API 配置:
- **配置列表**: 显示所有可用的 API 配置文件 - **配置列表**: 显示所有可用的 API 配置文件,带彩色图标和状态标记
- **配置切换**: 点击配置卡片直接切换 - **配置切换**: 点击配置卡片直接切换
- **创建配置**: 新建 API 配置文件 - **创建配置**: 新建 API 配置文件
- **编辑配置**: 修改现有配置的认证方式、API Key、Base URL 等 - **编辑配置**: 修改现有配置的认证方式、API Key、Base URL 等
@@ -98,9 +113,9 @@ npm run build:dist # 完整构建和打包
管理 Model Context Protocol 服务器配置: 管理 Model Context Protocol 服务器配置:
- **服务器列表**: 显示所有已配置的服务器 - **服务器列表**: 显示所有已配置的服务器,带描述信息
- **添加服务器**: 创建新的 MCP 服务器配置 - **添加服务器**: 创建新的 MCP 服务器配置
- **编辑服务器**: 修改现有服务器配置 - **编辑服务器**: 通过侧边面板修改现有服务器配置
- **删除服务器**: 移除服务器配置 - **删除服务器**: 移除服务器配置
- **服务器配置项**: - **服务器配置项**:
- 名称 - 名称
@@ -113,7 +128,7 @@ npm run build:dist # 完整构建和打包
## 核心架构 ## 核心架构
### 进程模型 ### 进程模型
- **Main Process (main.js)**: Electron 主进程处理窗口管理、IPC 通信、文件系统操作 - **Main Process (main.js)**: Electron 主进程处理窗口管理、IPC 通信、文件系统操作、系统托盘
- **Preload (preload.js)**: 通过 `contextBridge.exposeInMainWorld` 暴露安全 API - **Preload (preload.js)**: 通过 `contextBridge.exposeInMainWorld` 暴露安全 API
- **Renderer (Vue)**: 渲染进程,只通过 preload 暴露的 API 与主进程通信 - **Renderer (Vue)**: 渲染进程,只通过 preload 暴露的 API 与主进程通信
@@ -121,6 +136,15 @@ npm run build:dist # 完整构建和打包
- 窗口尺寸: 1100x750最小尺寸: 900x600 - 窗口尺寸: 1100x750最小尺寸: 900x600
- 无边框窗口 (frame: false),自定义标题栏 - 无边框窗口 (frame: false),自定义标题栏
- 开发模式加载 `http://localhost:5173`,生产模式加载 `dist/index.html` - 开发模式加载 `http://localhost:5173`,生产模式加载 `dist/index.html`
- **关闭窗口时隐藏到系统托盘**,双击托盘图标可重新显示
### 系统托盘
- 托盘图标显示应用状态
- 右键菜单支持:
- 显示主窗口
- 切换 API 配置(显示所有配置列表,当前配置带勾选标记)
- 退出应用
- 双击托盘图标显示主窗口
### 安全配置 ### 安全配置
- `contextIsolation: true` - 隔离上下文 - `contextIsolation: true` - 隔离上下文
@@ -136,7 +160,7 @@ npm run build:dist # 完整构建和打包
- 允许修改安装目录 - 允许修改安装目录
- 允许提升权限 - 允许提升权限
- 创建桌面和开始菜单快捷方式 - 创建桌面和开始菜单快捷方式
- 支持中文和英文界面界面 - 支持中文和英文界面 (zh_CN, en_US)
- 卸载时保留用户数据 - 卸载时保留用户数据
### 输出目录 ### 输出目录
@@ -150,17 +174,19 @@ npm run build:dist # 完整构建和打包
- 保存设置时会自动创建备份 (`settings.json.bak`) - 保存设置时会自动创建备份 (`settings.json.bak`)
- MCP 服务器参数每行一个,环境变量支持 JSON 格式 - MCP 服务器参数每行一个,环境变量支持 JSON 格式
- API 配置切换时会直接应用新配置,未保存的更改会被替换 - API 配置切换时会直接应用新配置,未保存的更改会被替换
- 关闭窗口时应用会隐藏到系统托盘,不会退出应用
## 开发注意事项 ## 开发注意事项
1. **修改检测**: 通过 `watch(settings, () => { modified.value = true }, { deep: true })` 深度监听 1. **修改检测**: 通过 `watch(settings, () => { modified.value = true }, { deep: true })` 深度监听
2. **服务器编辑**: 使用 DOM 操作收集表单数据 2. **服务器编辑**: 使用侧边面板 (Side Panel) 收集表单数据
3. **MCP 参数**: 每行一个参数,通过换行分割 3. **MCP 参数**: 每行一个参数,通过换行分割
4. **环境变量**: 支持 JSON 格式输入 4. **环境变量**: 支持 JSON 格式输入
5. **窗口控制**: 通过 IPC 发送指令,主进程处理实际窗口操作 5. **窗口控制**: 通过 IPC 发送指令,主进程处理实际窗口操作
6. **API 配置切换**: 多个环境配置存储在 `settings.apiProfiles` 对象中 6. **API 配置切换**: 多个环境配置存储在 `settings.apiProfiles` 对象中
7. **序列化问题**: IPC 通信使用 `JSON.parse(JSON.stringify())` 避免 Vue 响应式代理问题 7. **序列化问题**: IPC 通信使用 `JSON.parse(JSON.stringify())` 避免 Vue 响应式代理问题
8. **默认值处理**: 加载配置时检查 `undefined` 并应用默认值,防止界面显示异常 8. **默认值处理**: 加载配置时检查 `undefined` 并应用默认值,防止界面显示异常
9. **托盘切换事件**: 监听 `onApiProfileSwitched` 处理托盘发起的配置切换
## 许可证 ## 许可证

235
main.js
View File

@@ -1,4 +1,4 @@
const { app, BrowserWindow, ipcMain, dialog } = require('electron') const { app, BrowserWindow, ipcMain, dialog, Tray, Menu, nativeImage } = require('electron')
const path = require('path') const path = require('path')
const fs = require('fs') const fs = require('fs')
console.log('main.js loaded') console.log('main.js loaded')
@@ -6,7 +6,192 @@ console.log('app.getPath("home"):', app.getPath('home'))
const SETTINGS_FILE = path.join(app.getPath('home'), '.iflow', 'settings.json') const SETTINGS_FILE = path.join(app.getPath('home'), '.iflow', 'settings.json')
console.log('SETTINGS_FILE:', SETTINGS_FILE) console.log('SETTINGS_FILE:', SETTINGS_FILE)
let mainWindow let mainWindow
let tray
const isDev = process.argv.includes('--dev') const isDev = process.argv.includes('--dev')
// 主进程翻译
const trayTranslations = {
'zh-CN': {
showWindow: '显示主窗口',
switchApiConfig: '切换 API 配置',
exit: '退出',
tooltip: 'iFlow 设置编辑器'
},
'en-US': {
showWindow: 'Show Window',
switchApiConfig: 'Switch API Config',
exit: 'Exit',
tooltip: 'iFlow Settings Editor'
},
'ja-JP': {
showWindow: 'メインウィンドウを表示',
switchApiConfig: 'API 設定切替',
exit: '終了',
tooltip: 'iFlow 設定エディタ'
}
}
function getTrayTranslation() {
const settings = readSettings()
const lang = settings?.language || 'zh-CN'
return trayTranslations[lang] || trayTranslations['zh-CN']
}
// 错误消息翻译
const errorTranslations = {
'zh-CN': {
configNotFound: '配置文件不存在',
configNotExist: '配置 "{name}" 不存在',
configAlreadyExists: '配置 "{name}" 已存在',
cannotDeleteDefault: '不能删除默认配置',
cannotRenameDefault: '不能重命名默认配置',
switchFailed: '切换API配置失败'
},
'en-US': {
configNotFound: 'Configuration file not found',
configNotExist: 'Configuration "{name}" does not exist',
configAlreadyExists: 'Configuration "{name}" already exists',
cannotDeleteDefault: 'Cannot delete default configuration',
cannotRenameDefault: 'Cannot rename default configuration',
switchFailed: 'Failed to switch API configuration'
},
'ja-JP': {
configNotFound: '設定ファイルが存在しません',
configNotExist: 'プロファイル "{name}" が存在しません',
configAlreadyExists: 'プロファイル "{name}" が既に存在します',
cannotDeleteDefault: 'デフォルトプロファイルは削除できません',
cannotRenameDefault: 'デフォルトプロファイルは名前変更できません',
switchFailed: 'API 設定の切替に失敗しました'
}
}
function getErrorTranslation() {
const settings = readSettings()
const lang = settings?.language || 'zh-CN'
return errorTranslations[lang] || errorTranslations['zh-CN']
}
// 创建系统托盘
function createTray() {
// 获取图标路径 - 打包后需要从 extraResources 获取
let iconPath
if (app.isPackaged) {
iconPath = path.join(process.resourcesPath, 'icon', 'icon.ico')
} else {
iconPath = path.join(__dirname, 'build', 'icon.ico')
}
let trayIcon
if (fs.existsSync(iconPath)) {
trayIcon = nativeImage.createFromPath(iconPath)
} else {
// 创建一个简单的图标
trayIcon = nativeImage.createEmpty()
}
// 调整图标大小以适应托盘
trayIcon = trayIcon.resize({ width: 16, height: 16 })
tray = new Tray(trayIcon)
tray.setToolTip(getTrayTranslation().tooltip)
updateTrayMenu()
// 双击托盘显示主窗口
tray.on('double-click', () => {
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
}
})
}
// 更新托盘菜单
function updateTrayMenu() {
const settings = readSettings()
const profiles = settings?.apiProfiles || {}
const currentProfile = settings?.currentApiProfile || 'default'
const profileList = Object.keys(profiles).length > 0
? Object.keys(profiles)
: ['default']
const profileMenuItems = profileList.map(name => ({
label: name + (name === currentProfile ? ' ✓' : ''),
type: 'radio',
checked: name === currentProfile,
click: () => switchApiProfileFromTray(name)
}))
const t = getTrayTranslation()
const contextMenu = Menu.buildFromTemplate([
{
label: t.showWindow,
click: () => {
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
}
}
},
{ type: 'separator' },
{
label: t.switchApiConfig,
submenu: profileMenuItems
},
{ type: 'separator' },
{
label: t.exit,
click: () => {
app.isQuitting = true
app.quit()
}
}
])
tray.setContextMenu(contextMenu)
}
// 从托盘切换 API 配置
function switchApiProfileFromTray(profileName) {
try {
const settings = readSettings()
if (!settings) return
const profiles = settings.apiProfiles || {}
if (!profiles[profileName]) return
// 保存当前配置到 apiProfiles
const currentProfile = settings.currentApiProfile || 'default'
if (profiles[currentProfile]) {
const currentConfig = {}
for (const field of API_FIELDS) {
if (settings[field] !== undefined) {
currentConfig[field] = settings[field]
}
}
profiles[currentProfile] = currentConfig
}
// 从 apiProfiles 加载新配置到主字段
const newConfig = profiles[profileName]
for (const field of API_FIELDS) {
if (newConfig[field] !== undefined) {
settings[field] = newConfig[field]
}
}
settings.currentApiProfile = profileName
settings.apiProfiles = profiles
writeSettings(settings)
updateTrayMenu()
// 通知渲染进程刷新
if (mainWindow && mainWindow.webContents) {
mainWindow.webContents.send('api-profile-switched', profileName)
}
} catch (error) {
console.error('切换API配置失败:', error)
}
}
function createWindow() { function createWindow() {
console.log('Creating window...') console.log('Creating window...')
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
@@ -42,6 +227,7 @@ function createWindow() {
mainWindow.once('ready-to-show', () => { mainWindow.once('ready-to-show', () => {
console.log('Window ready to show') console.log('Window ready to show')
mainWindow.show() mainWindow.show()
createTray()
}) })
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
mainWindow = null mainWindow = null
@@ -49,7 +235,7 @@ function createWindow() {
} }
app.whenReady().then(createWindow) app.whenReady().then(createWindow)
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin' && app.isQuitting) {
app.quit() app.quit()
} }
}) })
@@ -67,8 +253,18 @@ ipcMain.on('window-maximize', () => {
mainWindow.maximize() mainWindow.maximize()
} }
}) })
ipcMain.on('window-close', () => mainWindow.close()) ipcMain.on('window-close', () => {
if (!app.isQuitting) {
mainWindow.hide()
} else {
mainWindow.close()
}
})
ipcMain.handle('is-maximized', () => mainWindow.isMaximized()) ipcMain.handle('is-maximized', () => mainWindow.isMaximized())
// 监听语言切换以更新托盘菜单
ipcMain.on('language-changed', () => {
updateTrayMenu()
})
// API 配置相关的字段 // API 配置相关的字段
const API_FIELDS = ['selectedAuthType', 'apiKey', 'baseUrl', 'modelName', 'searchApiKey', 'cna'] const API_FIELDS = ['selectedAuthType', 'apiKey', 'baseUrl', 'modelName', 'searchApiKey', 'cna']
// 读取设置文件 // 读取设置文件
@@ -116,12 +312,13 @@ ipcMain.handle('list-api-profiles', async () => {
ipcMain.handle('switch-api-profile', async (event, profileName) => { ipcMain.handle('switch-api-profile', async (event, profileName) => {
try { try {
const settings = readSettings() const settings = readSettings()
const t = getErrorTranslation()
if (!settings) { if (!settings) {
return { success: false, error: '配置文件不存在' } return { success: false, error: t.configNotFound }
} }
const profiles = settings.apiProfiles || {} const profiles = settings.apiProfiles || {}
if (!profiles[profileName]) { if (!profiles[profileName]) {
return { success: false, error: `配置 "${profileName}" 不存在` } return { success: false, error: t.configNotExist.replace('{name}', profileName) }
} }
// 保存当前配置到 apiProfiles如果当前配置存在 // 保存当前配置到 apiProfiles如果当前配置存在
const currentProfile = settings.currentApiProfile || 'default' const currentProfile = settings.currentApiProfile || 'default'
@@ -153,8 +350,9 @@ ipcMain.handle('switch-api-profile', async (event, profileName) => {
ipcMain.handle('create-api-profile', async (event, name) => { ipcMain.handle('create-api-profile', async (event, name) => {
try { try {
const settings = readSettings() const settings = readSettings()
const t = getErrorTranslation()
if (!settings) { if (!settings) {
return { success: false, error: '配置文件不存在' } return { success: false, error: t.configNotFound }
} }
if (!settings.apiProfiles) { if (!settings.apiProfiles) {
settings.apiProfiles = { default: {} } settings.apiProfiles = { default: {} }
@@ -166,7 +364,7 @@ ipcMain.handle('create-api-profile', async (event, name) => {
} }
} }
if (settings.apiProfiles[name]) { if (settings.apiProfiles[name]) {
return { success: false, error: `配置 "${name}" 已存在` } return { success: false, error: t.configAlreadyExists.replace('{name}', name) }
} }
// 复制当前配置到新配置 // 复制当前配置到新配置
const newConfig = {} const newConfig = {}
@@ -186,15 +384,16 @@ ipcMain.handle('create-api-profile', async (event, name) => {
ipcMain.handle('delete-api-profile', async (event, name) => { ipcMain.handle('delete-api-profile', async (event, name) => {
try { try {
const settings = readSettings() const settings = readSettings()
const t = getErrorTranslation()
if (!settings) { if (!settings) {
return { success: false, error: '配置文件不存在' } return { success: false, error: t.configNotFound }
} }
if (name === 'default') { if (name === 'default') {
return { success: false, error: '不能删除默认配置' } return { success: false, error: t.cannotDeleteDefault }
} }
const profiles = settings.apiProfiles || {} const profiles = settings.apiProfiles || {}
if (!profiles[name]) { if (!profiles[name]) {
return { success: false, error: `配置 "${name}" 不存在` } return { success: false, error: t.configNotExist.replace('{name}', name) }
} }
delete profiles[name] delete profiles[name]
settings.apiProfiles = profiles settings.apiProfiles = profiles
@@ -219,18 +418,19 @@ ipcMain.handle('delete-api-profile', async (event, name) => {
ipcMain.handle('rename-api-profile', async (event, oldName, newName) => { ipcMain.handle('rename-api-profile', async (event, oldName, newName) => {
try { try {
const settings = readSettings() const settings = readSettings()
const t = getErrorTranslation()
if (!settings) { if (!settings) {
return { success: false, error: '配置文件不存在' } return { success: false, error: t.configNotFound }
} }
if (oldName === 'default') { if (oldName === 'default') {
return { success: false, error: '不能重命名默认配置' } return { success: false, error: t.cannotRenameDefault }
} }
const profiles = settings.apiProfiles || {} const profiles = settings.apiProfiles || {}
if (!profiles[oldName]) { if (!profiles[oldName]) {
return { success: false, error: `配置 "${oldName}" 不存在` } return { success: false, error: t.configNotExist.replace('{name}', oldName) }
} }
if (profiles[newName]) { if (profiles[newName]) {
return { success: false, error: `配置 "${newName}" 已存在` } return { success: false, error: t.configAlreadyExists.replace('{name}', newName) }
} }
profiles[newName] = profiles[oldName] profiles[newName] = profiles[oldName]
delete profiles[oldName] delete profiles[oldName]
@@ -248,15 +448,16 @@ ipcMain.handle('rename-api-profile', async (event, oldName, newName) => {
ipcMain.handle('duplicate-api-profile', async (event, sourceName, newName) => { ipcMain.handle('duplicate-api-profile', async (event, sourceName, newName) => {
try { try {
const settings = readSettings() const settings = readSettings()
const t = getErrorTranslation()
if (!settings) { if (!settings) {
return { success: false, error: '配置文件不存在' } return { success: false, error: t.configNotFound }
} }
const profiles = settings.apiProfiles || {} const profiles = settings.apiProfiles || {}
if (!profiles[sourceName]) { if (!profiles[sourceName]) {
return { success: false, error: `配置 "${sourceName}" 不存在` } return { success: false, error: t.configNotExist.replace('{name}', sourceName) }
} }
if (profiles[newName]) { if (profiles[newName]) {
return { success: false, error: `配置 "${newName}" 已存在` } return { success: false, error: t.configAlreadyExists.replace('{name}', newName) }
} }
// 深拷贝配置 // 深拷贝配置
profiles[newName] = JSON.parse(JSON.stringify(profiles[sourceName])) profiles[newName] = JSON.parse(JSON.stringify(profiles[sourceName]))

1045
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "iflow-settings-editor", "name": "iflow-settings-editor",
"version": "1.5.0", "version": "1.5.1",
"description": "一个用于编辑 iFlow CLI 配置文件的桌面应用程序。", "description": "一个用于编辑 iFlow CLI 配置文件的桌面应用程序。",
"main": "main.js", "main": "main.js",
"author": "上海潘哆呐科技有限公司", "author": "上海潘哆呐科技有限公司",
@@ -20,7 +20,11 @@
"build:win32": "vite build && electron-builder --win --ia32", "build:win32": "vite build && electron-builder --win --ia32",
"build:win-portable": "vite build && electron-builder --win portable", "build:win-portable": "vite build && electron-builder --win portable",
"build:win-installer": "vite build && electron-builder --win nsis", "build:win-installer": "vite build && electron-builder --win nsis",
"dist": "vite build && electron-builder" "dist": "vite build && electron-builder",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:run": "vitest run"
}, },
"build": { "build": {
"appId": "com.iflow.settings-editor", "appId": "com.iflow.settings-editor",
@@ -30,6 +34,12 @@
"output": "release", "output": "release",
"buildResources": "build" "buildResources": "build"
}, },
"extraResources": [
{
"from": "build/icon.ico",
"to": "icon/icon.ico"
}
],
"files": [ "files": [
"dist/**/*", "dist/**/*",
"main.js", "main.js",
@@ -78,12 +88,22 @@
} }
}, },
"devDependencies": { "devDependencies": {
"-": "^0.0.1",
"@icon-park/vue-next": "^1.4.2", "@icon-park/vue-next": "^1.4.2",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vitest/ui": "^4.1.4",
"@vue/test-utils": "^2.4.6",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"electron": "^28.0.0", "electron": "^28.0.0",
"electron-builder": "^24.13.3", "electron-builder": "^24.13.3",
"happy-dom": "^20.9.0",
"less": "^4.6.4",
"less-loader": "^12.3.2",
"vite": "^8.0.8", "vite": "^8.0.8",
"vitest": "^4.1.4",
"vue": "^3.4.0" "vue": "^3.4.0"
},
"dependencies": {
"vue-i18n": "^9.14.5"
} }
} }

View File

@@ -18,5 +18,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
createApiProfile: (name) => ipcRenderer.invoke('create-api-profile', name), createApiProfile: (name) => ipcRenderer.invoke('create-api-profile', name),
deleteApiProfile: (name) => ipcRenderer.invoke('delete-api-profile', name), deleteApiProfile: (name) => ipcRenderer.invoke('delete-api-profile', name),
renameApiProfile: (oldName, newName) => ipcRenderer.invoke('rename-api-profile', oldName, newName), renameApiProfile: (oldName, newName) => ipcRenderer.invoke('rename-api-profile', oldName, newName),
duplicateApiProfile: (sourceName, newName) => ipcRenderer.invoke('duplicate-api-profile', sourceName, newName) duplicateApiProfile: (sourceName, newName) => ipcRenderer.invoke('duplicate-api-profile', sourceName, newName),
// 托盘事件监听
onApiProfileSwitched: (callback) => {
ipcRenderer.on('api-profile-switched', (event, profileName) => callback(profileName))
},
// 语言切换通知
notifyLanguageChanged: () => {
ipcRenderer.send('language-changed')
}
}) })

BIN
screenshots/theme-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
screenshots/theme-xcode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,181 @@
<template>
<!-- API Create Dialog -->
<div v-if="showCreate" class="dialog-overlay dialog-overlay-top" @keyup.esc="$emit('close-create')" tabindex="-1">
<div class="dialog api-edit-dialog" @click.stop>
<div class="dialog-header">
<div class="dialog-title">
<Key size="18" />
{{ $t('api.createTitle') }}
</div>
<button class="side-panel-close" @click="$emit('close-create')">
<svg viewBox="0 0 10 10">
<line x1="0" y1="0" x2="10" y2="10" />
<line x1="10" y1="0" x2="0" y2="10" />
</svg>
</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label class="form-label">{{ $t('api.configName') }} <span class="form-required">*</span></label>
<input type="text" class="form-input" v-model="createData.name" :placeholder="$t('api.configNamePlaceholder')" />
</div>
<div class="form-group">
<label class="form-label">{{ $t('api.authType') }}</label>
<select class="form-select" v-model="createData.selectedAuthType">
<option value="iflow">{{ $t('api.auth.iflow') }}</option>
<option value="api">{{ $t('api.auth.api') }}</option>
<option value="openai-compatible">{{ $t('api.auth.openaiCompatible') }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label">{{ $t('api.apiKey') }}</label>
<input type="password" class="form-input" v-model="createData.apiKey" :placeholder="$t('api.apiKeyPlaceholder')" />
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ $t('api.baseUrl') }}</label>
<input type="text" class="form-input" v-model="createData.baseUrl" :placeholder="$t('api.baseUrlPlaceholder')" />
</div>
<div class="form-group">
<label class="form-label">{{ $t('api.modelName') }}</label>
<input type="text" class="form-input" v-model="createData.modelName" :placeholder="$t('api.modelNamePlaceholder')" />
</div>
</div>
<div class="form-group">
<label class="form-label">{{ $t('api.searchApiKey') }}</label>
<input type="password" class="form-input" v-model="createData.searchApiKey" :placeholder="$t('api.searchApiKeyPlaceholder')" />
</div>
<div class="form-group">
<label class="form-label">{{ $t('api.cna') }}</label>
<input type="text" class="form-input" v-model="createData.cna" :placeholder="$t('api.cnaPlaceholder')" />
</div>
</div>
<div class="dialog-actions">
<button class="btn btn-secondary" @click="$emit('close-create')">{{ $t('dialog.cancel') }}</button>
<button class="btn btn-primary" @click="$emit('save-create', createData)">
<Save size="14" />
{{ $t('api.create') }}
</button>
</div>
</div>
</div>
<!-- API Edit Dialog -->
<div v-if="showEdit" class="dialog-overlay dialog-overlay-top" @keyup.esc="$emit('close-edit')" tabindex="-1">
<div class="dialog api-edit-dialog" @click.stop>
<div class="dialog-header">
<div class="dialog-title">
<Key size="18" />
{{ $t('api.editTitle') }}
</div>
<button class="side-panel-close" @click="$emit('close-edit')">
<svg viewBox="0 0 10 10">
<line x1="0" y1="0" x2="10" y2="10" />
<line x1="10" y1="0" x2="0" y2="10" />
</svg>
</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label class="form-label">{{ $t('api.authType') }}</label>
<select class="form-select" v-model="editData.selectedAuthType">
<option value="iflow">{{ $t('api.auth.iflow') }}</option>
<option value="api">{{ $t('api.auth.api') }}</option>
<option value="openai-compatible">{{ $t('api.auth.openaiCompatible') }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label">{{ $t('api.apiKey') }}</label>
<input type="password" class="form-input" v-model="editData.apiKey" :placeholder="$t('api.apiKeyPlaceholder')" />
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ $t('api.baseUrl') }}</label>
<input type="text" class="form-input" v-model="editData.baseUrl" :placeholder="$t('api.baseUrlPlaceholder')" />
</div>
<div class="form-group">
<label class="form-label">{{ $t('api.modelName') }}</label>
<input type="text" class="form-input" v-model="editData.modelName" :placeholder="$t('api.modelNamePlaceholder')" />
</div>
</div>
<div class="form-group">
<label class="form-label">{{ $t('api.searchApiKey') }}</label>
<input type="password" class="form-input" v-model="editData.searchApiKey" :placeholder="$t('api.searchApiKeyPlaceholder')" />
</div>
<div class="form-group">
<label class="form-label">{{ $t('api.cna') }}</label>
<input type="text" class="form-input" v-model="editData.cna" :placeholder="$t('api.cnaPlaceholder')" />
</div>
</div>
<div class="dialog-actions">
<button class="btn btn-secondary" @click="$emit('close-edit')">{{ $t('dialog.cancel') }}</button>
<button class="btn btn-primary" @click="$emit('save-edit', editData)">
<Save size="14" />
{{ $t('api.save') }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { Key, Save } from '@icon-park/vue-next'
defineProps({
showCreate: Boolean,
showEdit: Boolean,
createData: Object,
editData: Object
})
defineEmits([
'close-create', 'save-create',
'close-edit', 'save-edit'
])
</script>
<style lang="less" scoped>
.api-edit-dialog {
min-width: 480px;
max-width: 520px;
padding: 0;
overflow: hidden;
}
.api-edit-dialog .dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
background: var(--bg-tertiary);
}
.api-edit-dialog .dialog-title {
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
margin-bottom: 0;
}
.api-edit-dialog .dialog-title .iconpark-icon {
color: var(--accent);
}
.api-edit-dialog .dialog-body {
padding: 24px;
max-height: 60vh;
overflow-y: auto;
}
.api-edit-dialog .dialog-body .form-group {
margin-bottom: 18px;
}
.api-edit-dialog .dialog-body .form-group:last-child {
margin-bottom: 0;
}
.api-edit-dialog .dialog-actions {
padding: 16px 24px;
border-top: 1px solid var(--border);
background: var(--bg-tertiary);
margin-top: 0;
}
</style>

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import Footer from './Footer.vue';
describe('Footer.vue', () => {
it('renders correctly with default props', () => {
const wrapper = mount(Footer, {
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.exists()).toBe(true);
expect(wrapper.find('.footer').exists()).toBe(true);
expect(wrapper.find('.footer-status').exists()).toBe(true);
});
it('displays current profile correctly', () => {
const wrapper = mount(Footer, {
props: {
currentProfile: 'dev',
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const statusText = wrapper.find('.footer-status').text();
expect(statusText).toContain('dev');
});
it('displays default profile when no prop provided', () => {
const wrapper = mount(Footer, {
global: {
mocks: {
$t: (key) => key,
},
},
});
const statusText = wrapper.find('.footer-status').text();
expect(statusText).toContain('default');
});
it('displays status dot', () => {
const wrapper = mount(Footer, {
props: {
currentProfile: 'production',
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.footer-status-dot').exists()).toBe(true);
});
it('applies translation correctly', () => {
const wrapper = mount(Footer, {
props: {
currentProfile: 'test-profile',
},
global: {
mocks: {
$t: (key) => `translated-${key}`,
},
},
});
const statusText = wrapper.find('.footer-status').text();
expect(statusText).toContain('translated-api.currentConfig');
expect(statusText).toContain('test-profile');
});
});

43
src/components/Footer.vue Normal file
View File

@@ -0,0 +1,43 @@
<template>
<footer class="footer">
<div class="footer-status">
<div class="footer-status-dot"></div>
<span>{{ $t('api.currentConfig') }}: {{ currentProfile }}</span>
</div>
</footer>
</template>
<script setup>
defineProps({
currentProfile: {
type: String,
default: 'default'
}
})
</script>
<style lang="less" scoped>
.footer {
height: 28px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
flex-shrink: 0;
}
.footer-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-tertiary);
}
.footer-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div v-if="dialog.show" class="dialog-overlay dialog-overlay-top">
<div class="dialog" @click.stop>
<div class="dialog-title">{{ dialog.title }}</div>
<div v-if="dialog.isConfirm" class="dialog-confirm-text">{{ dialog.placeholder }}</div>
<input
v-else
type="text"
class="form-input"
v-model="inputValue"
:placeholder="dialog.placeholder"
@keyup.enter="$emit('confirm', dialog.isConfirm ? true : inputValue)"
autofocus
/>
<div class="dialog-actions">
<button class="btn btn-secondary" @click="$emit('cancel')">{{ $t('dialog.cancel') }}</button>
<button class="btn btn-primary" @click="$emit('confirm', dialog.isConfirm ? true : inputValue)">{{ $t('dialog.confirm') }}</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
dialog: {
type: Object,
default: () => ({ show: false, title: '', placeholder: '', isConfirm: false })
}
})
defineEmits(['confirm', 'cancel'])
const inputValue = ref('')
watch(() => props.dialog.show, (show) => {
if (show) {
inputValue.value = props.dialog.defaultValue || ''
}
})
</script>
<style lang="less" scoped>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1300;
animation: fadeIn 0.15s ease;
}
.dialog {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 24px;
min-width: 360px;
max-width: 480px;
box-shadow: var(--shadow-lg);
animation: slideUp 0.2s ease;
}
.dialog-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 18px;
letter-spacing: -0.01em;
}
.dialog-confirm-text {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 8px;
line-height: 1.5;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 22px;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,113 @@
<template>
<div v-if="dialog.show" class="dialog-overlay dialog-overlay-top">
<div class="dialog message-dialog" @click.stop>
<div class="message-dialog-icon" :class="'message-dialog-icon-' + dialog.type">
<svg v-if="dialog.type === 'info'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</svg>
<svg v-else-if="dialog.type === 'success'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M9 12l2 2 4-4" />
</svg>
<svg v-else-if="dialog.type === 'warning'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<svg v-else-if="dialog.type === 'error'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</div>
<div class="message-dialog-title">{{ dialog.title }}</div>
<div class="message-dialog-message">{{ dialog.message }}</div>
<div class="dialog-actions">
<button class="btn btn-primary" @click="$emit('close')">{{ $t('dialog.confirm') }}</button>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
dialog: {
type: Object,
default: () => ({ show: false, type: 'info', title: '', message: '' })
}
})
defineEmits(['close'])
</script>
<style lang="less" scoped>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1300;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.message-dialog {
position: relative;
text-align: center;
padding: 32px 24px;
z-index: 1400;
}
.message-dialog-icon {
width: 48px;
height: 48px;
margin: 0 auto 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.message-dialog-icon svg {
width: 24px;
height: 24px;
}
.message-dialog-icon-info {
background: rgba(59, 130, 246, 0.1);
color: var(--accent);
}
.message-dialog-icon-success {
background: rgba(16, 185, 129, 0.1);
color: var(--success);
}
.message-dialog-icon-warning {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.message-dialog-icon-error {
background: rgba(239, 68, 68, 0.1);
color: var(--danger);
}
.message-dialog-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
color: var(--text-primary);
}
.message-dialog-message {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.5;
}
.message-dialog .dialog-actions {
justify-content: center;
margin-top: 24px;
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<div v-if="show" class="side-panel-overlay" @keyup.esc="$emit('close')" tabindex="-1" ref="overlay">
<div class="side-panel" @click.stop>
<div class="side-panel-header">
<div class="side-panel-title">
<Server size="18" />
{{ isEditing ? $t('mcp.editServer') : $t('mcp.addServer') }}
</div>
<button class="side-panel-close" @click="$emit('close')">
<svg viewBox="0 0 10 10">
<line x1="0" y1="0" x2="10" y2="10" />
<line x1="10" y1="0" x2="0" y2="10" />
</svg>
</button>
</div>
<div class="side-panel-body">
<div class="form-group">
<label class="form-label">{{ $t('mcp.serverName') }} <span class="form-required">*</span></label>
<input type="text" class="form-input" v-model="localData.name" :placeholder="$t('mcp.serverNamePlaceholder')" />
</div>
<div class="form-group">
<label class="form-label">{{ $t('mcp.descriptionLabel') }}</label>
<input type="text" class="form-input" v-model="localData.description" :placeholder="$t('mcp.descriptionPlaceholder')" />
</div>
<div class="form-group">
<label class="form-label">{{ $t('mcp.command') }} <span class="form-required">*</span></label>
<input type="text" class="form-input" v-model="localData.command" :placeholder="$t('mcp.commandPlaceholder')" />
</div>
<div class="form-group">
<label class="form-label">{{ $t('mcp.workingDir') }}</label>
<input type="text" class="form-input" v-model="localData.cwd" :placeholder="$t('mcp.cwdPlaceholder')" />
</div>
<div class="form-group">
<label class="form-label">{{ $t('mcp.args') }}</label>
<textarea class="form-textarea" v-model="localData.args" rows="4" :placeholder="$t('mcp.argsPlaceholder')"></textarea>
</div>
<div class="form-group">
<label class="form-label">{{ $t('mcp.envVars') }}</label>
<textarea class="form-textarea" v-model="localData.env" rows="3" :placeholder="$t('mcp.envVarsPlaceholder')"></textarea>
</div>
</div>
<div class="side-panel-footer">
<button v-if="isEditing" class="btn btn-danger" @click="$emit('delete')">
<Delete size="14" />
{{ $t('mcp.delete') }}
</button>
<div class="side-panel-footer-right">
<button class="btn btn-secondary" @click="$emit('close')">{{ $t('dialog.cancel') }}</button>
<button class="btn btn-primary" @click="$emit('save', localData)">
<Save size="14" />
{{ isEditing ? $t('mcp.saveChanges') : $t('mcp.addServer') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { Server, Save, Delete } from '@icon-park/vue-next'
const props = defineProps({
show: Boolean,
isEditing: Boolean,
data: Object
})
const emit = defineEmits(['close', 'save', 'delete'])
const overlay = ref(null)
const localData = ref({
name: '',
description: '',
command: 'npx',
cwd: '.',
args: '',
env: ''
})
watch(() => props.show, (val) => {
if (val && props.data) {
localData.value = { ...props.data }
nextTick(() => overlay.value?.focus())
}
})
watch(() => props.data, (val) => {
if (val) {
localData.value = { ...val }
}
}, { immediate: true })
</script>
<style lang="less" scoped>
.side-panel-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.5);
backdrop-filter: blur(2px);
z-index: 1000;
animation: fadeIn 0.2s ease;
}
.side-panel {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 420px;
max-width: 100%;
background: var(--bg-secondary);
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
animation: slideInRight 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.side-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
background: var(--bg-tertiary);
}
.side-panel-title {
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.side-panel-title .iconpark-icon {
color: var(--accent);
}
.side-panel-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: var(--radius);
transition: all 0.2s ease;
}
.side-panel-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.side-panel-close svg {
width: 14px;
height: 14px;
stroke: currentColor;
stroke-width: 1.5;
fill: none;
}
.side-panel-body {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.side-panel-body .form-group {
margin-bottom: 20px;
}
.side-panel-body .form-group:last-child {
margin-bottom: 0;
}
.form-required {
color: var(--danger);
font-weight: 500;
}
.side-panel-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid var(--border);
background: var(--bg-tertiary);
}
.side-panel-footer-right {
display: flex;
gap: 10px;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideInRight {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>

View File

@@ -0,0 +1,164 @@
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import SideBar from './SideBar.vue';
describe('SideBar.vue', () => {
it('renders correctly with default props', () => {
const wrapper = mount(SideBar, {
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.exists()).toBe(true);
expect(wrapper.find('.sidebar').exists()).toBe(true);
});
it('has three nav items', () => {
const wrapper = mount(SideBar, {
global: {
mocks: {
$t: (key) => key,
},
},
});
const navItems = wrapper.findAll('.nav-item');
expect(navItems.length).toBe(3);
});
it('has two sections', () => {
const wrapper = mount(SideBar, {
global: {
mocks: {
$t: (key) => key,
},
},
});
const sections = wrapper.findAll('.sidebar-section');
expect(sections.length).toBe(2);
});
it('highlights active section correctly', () => {
const wrapper = mount(SideBar, {
props: {
currentSection: 'api',
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const navItems = wrapper.findAll('.nav-item');
expect(navItems[0].classes('active')).toBe(false);
expect(navItems[1].classes('active')).toBe(true);
expect(navItems[2].classes('active')).toBe(false);
});
it('emits navigate event when nav item is clicked', async () => {
const wrapper = mount(SideBar, {
props: {
currentSection: 'general',
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const navItems = wrapper.findAll('.nav-item');
await navItems[1].trigger('click');
expect(wrapper.emitted('navigate')).toBeTruthy();
expect(wrapper.emitted('navigate')[0][0]).toBe('api');
});
it('displays server count badge correctly', () => {
const wrapper = mount(SideBar, {
props: {
currentSection: 'general',
serverCount: 5
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const badges = wrapper.findAll('.nav-item-badge');
expect(badges.length).toBe(1);
expect(badges[0].text()).toBe('5');
});
it('displays zero server count', () => {
const wrapper = mount(SideBar, {
props: {
currentSection: 'general',
serverCount: 0
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const badges = wrapper.findAll('.nav-item-badge');
expect(badges.length).toBe(1);
expect(badges[0].text()).toBe('0');
});
it('applies translation to section titles', () => {
const wrapper = mount(SideBar, {
global: {
mocks: {
$t: (key) => `translated-${key}`,
},
},
});
const sectionTitles = wrapper.findAll('.sidebar-title');
expect(sectionTitles[0].text()).toBe('translated-sidebar.general');
expect(sectionTitles[1].text()).toBe('translated-sidebar.advanced');
});
it('applies translation to nav item texts', () => {
const wrapper = mount(SideBar, {
global: {
mocks: {
$t: (key) => `translated-${key}`,
},
},
});
const navItems = wrapper.findAll('.nav-item-text');
expect(navItems[0].text()).toBe('translated-sidebar.basicSettings');
expect(navItems[1].text()).toBe('translated-sidebar.apiConfig');
expect(navItems[2].text()).toBe('translated-sidebar.mcpServers');
});
it('handles null currentSection', () => {
const wrapper = mount(SideBar, {
props: {
currentSection: null,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const navItems = wrapper.findAll('.nav-item');
expect(navItems[0].classes('active')).toBe(false);
expect(navItems[1].classes('active')).toBe(false);
expect(navItems[2].classes('active')).toBe(false);
});
});

View File

@@ -0,0 +1,97 @@
<template>
<aside class="sidebar">
<div class="sidebar-section">
<div class="sidebar-title">{{ $t('sidebar.general') }}</div>
<div class="nav-item" :class="{ active: currentSection === 'general' }" @click="$emit('navigate', 'general')">
<Config size="16" />
<span class="nav-item-text">{{ $t('sidebar.basicSettings') }}</span>
</div>
<div class="nav-item" :class="{ active: currentSection === 'api' }" @click="$emit('navigate', 'api')">
<Key size="16" />
<span class="nav-item-text">{{ $t('sidebar.apiConfig') }}</span>
</div>
</div>
<div class="sidebar-section">
<div class="sidebar-title">{{ $t('sidebar.advanced') }}</div>
<div class="nav-item" :class="{ active: currentSection === 'mcp' }" @click="$emit('navigate', 'mcp')">
<Server size="16" />
<span class="nav-item-text">{{ $t('sidebar.mcpServers') }}</span>
<span class="nav-item-badge">{{ serverCount }}</span>
</div>
</div>
</aside>
</template>
<script setup>
import { Config, Key, Server } from '@icon-park/vue-next'
defineProps({
currentSection: {
type: String,
default: 'general',
},
serverCount: {
type: Number,
default: 0,
},
})
defineEmits(['navigate'])
</script>
<style lang="less" scoped>
.sidebar {
width: 240px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
padding: 24px 20px;
display: flex;
flex-direction: column;
gap: 28px;
flex-shrink: 0;
overflow-y: auto;
}
.sidebar-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.sidebar-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-tertiary);
padding: 0 14px;
margin-bottom: 8px;
}
.nav-item {
display: flex;
align-items: center;
padding: 12px 14px;
border-radius: var(--radius);
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
gap: 5px;
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-item.active {
background: var(--accent-light);
color: var(--accent);
}
.nav-item-badge {
margin-left: auto;
background: var(--bg-tertiary);
color: var(--text-tertiary);
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount } from '@vue/test-utils';
import TitleBar from './TitleBar.vue';
describe('TitleBar.vue', () => {
beforeEach(() => {
// Mock window.electronAPI
global.window.electronAPI = {
minimize: vi.fn(),
maximize: vi.fn(),
close: vi.fn()
};
});
afterEach(() => {
vi.clearAllMocks();
});
it('renders correctly', () => {
const wrapper = mount(TitleBar, {
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.exists()).toBe(true);
expect(wrapper.find('.titlebar').exists()).toBe(true);
expect(wrapper.find('.titlebar-title').exists()).toBe(true);
expect(wrapper.find('.titlebar-controls').exists()).toBe(true);
});
it('displays app title', () => {
const wrapper = mount(TitleBar, {
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.titlebar-title').text()).toBe('app.title');
});
it('has three window control buttons', () => {
const wrapper = mount(TitleBar, {
global: {
mocks: {
$t: (key) => key,
},
},
});
const buttons = wrapper.findAll('.titlebar-btn');
expect(buttons.length).toBe(3);
});
it('calls minimize when minimize button is clicked', async () => {
const wrapper = mount(TitleBar, {
global: {
mocks: {
$t: (key) => key,
},
},
});
const minimizeButton = wrapper.findAll('.titlebar-btn')[0];
await minimizeButton.trigger('click');
expect(window.electronAPI.minimize).toHaveBeenCalledOnce();
});
it('calls maximize when maximize button is clicked', async () => {
const wrapper = mount(TitleBar, {
global: {
mocks: {
$t: (key) => key,
},
},
});
const maximizeButton = wrapper.findAll('.titlebar-btn')[1];
await maximizeButton.trigger('click');
expect(window.electronAPI.maximize).toHaveBeenCalledOnce();
});
it('calls close when close button is clicked', async () => {
const wrapper = mount(TitleBar, {
global: {
mocks: {
$t: (key) => key,
},
},
});
const closeButton = wrapper.findAll('.titlebar-btn')[2];
await closeButton.trigger('click');
expect(window.electronAPI.close).toHaveBeenCalledOnce();
});
it('has close button with close class', () => {
const wrapper = mount(TitleBar, {
global: {
mocks: {
$t: (key) => key,
},
},
});
const closeButton = wrapper.findAll('.titlebar-btn')[2];
expect(closeButton.classes()).toContain('close');
});
it('applies translation to button tooltips', () => {
const wrapper = mount(TitleBar, {
global: {
mocks: {
$t: (key) => `translated-${key}`,
},
},
});
const buttons = wrapper.findAll('.titlebar-btn');
expect(buttons[0].attributes('title')).toBe('translated-window.minimize');
expect(buttons[1].attributes('title')).toBe('translated-window.maximize');
expect(buttons[2].attributes('title')).toBe('translated-window.close');
});
});

View File

@@ -0,0 +1,86 @@
<template>
<div class="titlebar">
<div class="titlebar-left">
<span class="titlebar-title">{{ $t('app.title') }}</span>
</div>
<div class="titlebar-controls">
<button class="titlebar-btn" @click="minimize" :title="$t('window.minimize')">
<svg viewBox="0 0 10 1"><line x1="0" y1="0.5" x2="10" y2="0.5" /></svg>
</button>
<button class="titlebar-btn" @click="maximize" :title="$t('window.maximize')">
<svg viewBox="0 0 10 10"><rect x="0.5" y="0.5" width="9" height="9" stroke-width="1" stroke="currentColor" fill="none" /></svg>
</button>
<button class="titlebar-btn close" @click="close" :title="$t('window.close')">
<svg viewBox="0 0 10 10">
<line x1="0" y1="0" x2="10" y2="10" />
<line x1="10" y1="0" x2="0" y2="10" />
</svg>
</button>
</div>
</div>
</template>
<script setup>
const minimize = () => window.electronAPI.minimize()
const maximize = () => window.electronAPI.maximize()
const close = () => window.electronAPI.close()
</script>
<style lang="less" scoped>
.titlebar {
height: 32px;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
-webkit-app-region: drag;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.titlebar-left {
display: flex;
align-items: center;
gap: 8px;
}
.titlebar-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.titlebar-controls {
display: flex;
align-items: center;
gap: 4px;
-webkit-app-region: no-drag;
}
.titlebar-btn {
width: 32px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
}
.titlebar-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.titlebar-btn.close:hover {
background: var(--danger);
color: white;
}
.titlebar-btn svg {
width: 10px;
height: 10px;
stroke: currentColor;
stroke-width: 1.5;
fill: none;
}
</style>

130
src/locales/en-US.js Normal file
View File

@@ -0,0 +1,130 @@
export default {
app: {
title: 'iFlow Settings Editor'
},
window: {
minimize: 'Minimize',
maximize: 'Maximize',
close: 'Close'
},
sidebar: {
general: 'General',
basicSettings: 'Basic Settings',
apiConfig: 'API Config',
advanced: 'Advanced',
mcpServers: 'MCP Servers'
},
general: {
title: 'Basic Settings',
description: 'Configure general application options',
language: 'Language',
theme: 'Theme',
languageInterface: 'Language & Interface',
otherSettings: 'Other Settings',
bootAnimation: 'Boot Animation',
bootAnimationShown: 'Shown',
bootAnimationNotShown: 'Not Shown',
checkpointing: 'Checkpointing',
enabled: 'Enabled',
disabled: 'Disabled'
},
theme: {
xcode: 'Xcode',
dark: 'Dark',
light: 'Light',
solarizedDark: 'Solarized Dark'
},
api: {
title: 'API Configuration',
description: 'Configure AI services and search API',
currentConfig: 'Current Config',
createTitle: 'Create API Configuration',
editTitle: 'Edit API Configuration',
profileManagement: 'Profile Management',
newProfile: 'New Profile',
profileName: 'Profile Name',
configName: 'Profile Name',
configNamePlaceholder: 'Enter configuration name',
newConfigNamePlaceholder: 'Enter new configuration name',
authType: 'Auth Type',
apiKey: 'API Key',
apiKeyPlaceholder: 'sk-cp-XXXXX...',
baseUrl: 'Base URL',
baseUrlPlaceholder: 'https://api.minimaxi.com/v1',
modelName: 'Model Name',
modelNamePlaceholder: 'MiniMax-M2.7',
searchApiKey: 'Search API Key',
searchApiKeyPlaceholder: 'sk-XXXXX...',
cna: 'CNA',
cnaPlaceholder: 'CNA identifier',
inUse: 'In Use',
cancel: 'Cancel',
create: 'Create',
save: 'Save',
edit: 'Edit',
duplicate: 'Duplicate',
delete: 'Delete',
unconfigured: 'Not configured',
noBaseUrl: 'Base URL not configured',
configCreated: 'Configuration "{name}" created',
configDeleted: 'Configuration deleted',
configCopied: 'Configuration copied as "{name}"',
switchFailed: 'Switch failed',
auth: {
iflow: 'iFlow',
api: 'API Key',
openaiCompatible: 'OpenAI Compatible'
}
},
mcp: {
title: 'MCP Servers',
description: 'Manage Model Context Protocol server configurations',
serverList: 'Server List',
addServer: 'Add Server',
editServer: 'Edit Server',
serverName: 'Server Name',
serverNamePlaceholder: 'my-mcp-server',
descriptionLabel: 'Description',
descriptionPlaceholder: 'Server description',
command: 'Command',
commandPlaceholder: 'npx',
workingDir: 'Working Directory',
cwdPlaceholder: '.',
args: 'Arguments (one per line)',
argsPlaceholder: '-y\\npackage-name',
envVars: 'Environment Variables (JSON)',
envVarsPlaceholder: "e.g. API_KEY=xxx",
invalidEnvJson: 'Invalid environment variables JSON format',
noServers: 'No MCP Servers',
addFirstServer: 'Click the button above to add your first server',
noDescription: 'No description',
delete: 'Delete',
cancel: 'Cancel',
saveChanges: 'Save Changes',
addServerBtn: 'Add Server',
inputServerName: 'Please enter server name',
serverNameExists: 'Server name already exists'
},
messages: {
error: 'Error',
warning: 'Warning',
success: 'Success',
info: 'Info',
cannotDeleteDefault: 'Cannot delete default configuration',
inputConfigName: 'Please enter configuration name',
confirmDeleteConfig: 'Are you sure you want to delete configuration "{name}"?',
confirmDeleteServer: 'Are you sure you want to delete server "{name}"?'
},
dialog: {
confirm: 'Confirm',
cancel: 'Cancel'
},
footer: {
config: 'Config'
},
languages: {
'zh-CN': '简体中文',
'en-US': 'English',
'ja-JP': '日本語'
}
}

130
src/locales/index.js Normal file
View File

@@ -0,0 +1,130 @@
export default {
app: {
title: 'iFlow 设置编辑器'
},
window: {
minimize: '最小化',
maximize: '最大化',
close: '关闭'
},
sidebar: {
general: '常规',
basicSettings: '基本设置',
apiConfig: 'API 配置',
advanced: '高级',
mcpServers: 'MCP 服务器'
},
general: {
title: '基本设置',
description: '配置应用程序的常规选项',
language: '语言',
theme: '主题',
languageInterface: '语言与界面',
otherSettings: '其他设置',
bootAnimation: '启动动画',
bootAnimationShown: '已显示',
bootAnimationNotShown: '未显示',
checkpointing: '检查点保存',
enabled: '已启用',
disabled: '已禁用'
},
theme: {
xcode: 'Xcode',
dark: '深色',
light: '浅色',
solarizedDark: 'Solarized Dark'
},
api: {
title: 'API 配置',
description: '配置 AI 服务和搜索 API',
currentConfig: '当前配置',
createTitle: '新建 API 配置',
editTitle: '编辑 API 配置',
profileManagement: '配置文件管理',
newProfile: '新建配置',
profileName: '配置名称',
configName: '配置名称',
configNamePlaceholder: '请输入配置名称',
newConfigNamePlaceholder: '请输入新配置的名称',
authType: '认证方式',
apiKey: 'API Key',
apiKeyPlaceholder: 'sk-cp-XXXXX...',
baseUrl: 'Base URL',
baseUrlPlaceholder: 'https://api.minimaxi.com/v1',
modelName: '模型名称',
modelNamePlaceholder: 'MiniMax-M2.7',
searchApiKey: '搜索 API Key',
searchApiKeyPlaceholder: 'sk-XXXXX...',
cna: 'CNA',
cnaPlaceholder: 'CNA 标识',
inUse: '使用中',
cancel: '取消',
create: '创建',
save: '保存',
edit: '编辑',
duplicate: '复制',
delete: '删除',
unconfigured: '未配置',
noBaseUrl: '未配置 Base URL',
configCreated: '配置 "{name}" 已创建',
configDeleted: '配置已删除',
configCopied: '配置已复制为 "{name}"',
switchFailed: '切换失败',
auth: {
iflow: 'iFlow',
api: 'API Key',
openaiCompatible: 'OpenAI 兼容'
}
},
mcp: {
title: 'MCP 服务器',
description: '管理 Model Context Protocol 服务器配置',
serverList: '服务器列表',
addServer: '添加服务器',
editServer: '编辑服务器',
serverName: '服务器名称',
serverNamePlaceholder: 'my-mcp-server',
descriptionLabel: '描述',
descriptionPlaceholder: '服务器描述信息',
command: '命令',
commandPlaceholder: 'npx',
workingDir: '工作目录',
cwdPlaceholder: '.',
args: '参数 (每行一个)',
argsPlaceholder: '-y\\npackage-name',
envVars: '环境变量 (JSON 格式)',
envVarsPlaceholder: "例如: API_KEY=xxx",
invalidEnvJson: '环境变量 JSON 格式错误',
noServers: '暂无 MCP 服务器',
addFirstServer: '点击上方按钮添加第一个服务器',
noDescription: '无描述',
delete: '删除',
cancel: '取消',
saveChanges: '保存更改',
addServerBtn: '添加服务器',
inputServerName: '请输入服务器名称',
serverNameExists: '服务器名称已存在'
},
messages: {
error: '错误',
warning: '警告',
success: '成功',
info: '信息',
cannotDeleteDefault: '不能删除默认配置',
inputConfigName: '请输入配置名称',
confirmDeleteConfig: '确定要删除配置 "{name}" 吗?',
confirmDeleteServer: '确定要删除服务器 "{name}" 吗?'
},
dialog: {
confirm: '确定',
cancel: '取消'
},
footer: {
config: '配置'
},
languages: {
'zh-CN': '简体中文',
'en-US': 'English',
'ja-JP': '日本語'
}
}

130
src/locales/ja-JP.js Normal file
View File

@@ -0,0 +1,130 @@
export default {
app: {
title: 'iFlow 設定エディタ'
},
window: {
minimize: '最小化',
maximize: '最大化',
close: '閉じる'
},
sidebar: {
general: '一般',
basicSettings: '基本設定',
apiConfig: 'API 設定',
advanced: '詳細',
mcpServers: 'MCP サーバー'
},
general: {
title: '基本設定',
description: 'アプリケーションの一般設定を構成',
language: '言語',
theme: 'テーマ',
languageInterface: '言語とインターフェース',
otherSettings: 'その他の設定',
bootAnimation: '起動アニメーション',
bootAnimationShown: '表示済み',
bootAnimationNotShown: '未表示',
checkpointing: 'チェックポイント保存',
enabled: '有効',
disabled: '無効'
},
theme: {
xcode: 'Xcode',
dark: 'ダーク',
light: 'ライト',
solarizedDark: 'Solarized Dark'
},
api: {
title: 'API 設定',
description: 'AI サービスと検索 API を構成',
currentConfig: '現在設定',
createTitle: 'API 設定を作成',
editTitle: 'API 設定を編集',
profileManagement: 'プロファイル管理',
newProfile: '新規プロファイル',
profileName: 'プロファイル名',
configName: 'プロファイル名',
configNamePlaceholder: 'プロファイル名を入力',
newConfigNamePlaceholder: '新しいプロファイル名を入力',
authType: '認証方式',
apiKey: 'API Key',
apiKeyPlaceholder: 'sk-cp-XXXXX...',
baseUrl: 'Base URL',
baseUrlPlaceholder: 'https://api.minimaxi.com/v1',
modelName: 'モデル名',
modelNamePlaceholder: 'MiniMax-M2.7',
searchApiKey: '検索 API Key',
searchApiKeyPlaceholder: 'sk-XXXXX...',
cna: 'CNA',
cnaPlaceholder: 'CNA 識別子',
inUse: '使用中',
cancel: 'キャンセル',
create: '作成',
save: '保存',
edit: '編集',
duplicate: '複製',
delete: '削除',
unconfigured: '未設定',
noBaseUrl: 'Base URL 未設定',
configCreated: 'プロファイル "{name}" を作成しました',
configDeleted: 'プロファイルを削除しました',
configCopied: 'プロファイルを "{name}" に複製しました',
switchFailed: '切り替えに失敗しました',
auth: {
iflow: 'iFlow',
api: 'API Key',
openaiCompatible: 'OpenAI 互換'
}
},
mcp: {
title: 'MCP サーバー',
description: 'Model Context Protocol サーバー設定を管理',
serverList: 'サーバー一覧',
addServer: 'サーバーを追加',
editServer: 'サーバーを編集',
serverName: 'サーバー名',
serverNamePlaceholder: 'my-mcp-server',
descriptionLabel: '説明',
descriptionPlaceholder: 'サーバーの説明',
command: 'コマンド',
commandPlaceholder: 'npx',
workingDir: '作業ディレクトリ',
cwdPlaceholder: '.',
args: '引数1行に1つ',
argsPlaceholder: '-y\\npackage-name',
envVars: '環境変数JSON形式',
envVarsPlaceholder: "例: API_KEY=xxx",
invalidEnvJson: '環境変数の JSON 形式が無効です',
noServers: 'MCP サーバーがありません',
addFirstServer: '上のボタンをクリックして最初のサーバーを追加',
noDescription: '説明なし',
delete: '削除',
cancel: 'キャンセル',
saveChanges: '変更を保存',
addServerBtn: 'サーバーを追加',
inputServerName: 'サーバー名を入力してください',
serverNameExists: 'サーバー名は既に存在します'
},
messages: {
error: 'エラー',
warning: '警告',
success: '成功',
info: '情報',
cannotDeleteDefault: 'デフォルトプロファイルは削除できません',
inputConfigName: 'プロファイル名を入力してください',
confirmDeleteConfig: 'プロファイル "{name}" を削除してもよろしいですか?',
confirmDeleteServer: 'サーバー "{name}" を削除してもよろしいですか?'
},
dialog: {
confirm: '確認',
cancel: 'キャンセル'
},
footer: {
config: '設定'
},
languages: {
'zh-CN': '简体中文',
'en-US': 'English',
'ja-JP': '日本語'
}
}

View File

@@ -1,4 +1,21 @@
import { createApp } from 'vue'; import { createApp } from 'vue'
import App from './App.vue'; import { createI18n } from 'vue-i18n'
import App from './App.vue'
import zhCN from './locales/index.js'
import enUS from './locales/en-US.js'
import jaJP from './locales/ja-JP.js'
createApp(App).mount('#app'); const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': zhCN,
'en-US': enUS,
'ja-JP': jaJP
}
})
const app = createApp(App)
app.use(i18n)
app.mount('#app')

749
src/styles/global.less Normal file
View File

@@ -0,0 +1,749 @@
// Global styles
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #f8fafc;
--bg-secondary: #ffffff;
--bg-tertiary: #f1f5f9;
--bg-hover: #e2e8f0;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-tertiary: #94a3b8;
--accent: #3b82f6;
--accent-hover: #2563eb;
--accent-light: #eff6ff;
--border: #e2e8f0;
--border-light: #f1f5f9;
--success: #10b981;
--danger: #ef4444;
--radius: 6px;
--radius-lg: 10px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.06), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
user-select: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.dark,
.solarized-dark {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-tertiary: #0f3460;
--bg-hover: #1f4068;
--text-primary: #e4e4e7;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
--accent: #60a5fa;
--accent-hover: #3b82f6;
--accent-light: rgba(96, 165, 250, 0.15);
--border: #2d2d44;
--border-light: #232338;
--success: #34d399;
--danger: #f87171;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
}
.solarized-dark {
--bg-primary: #002b36;
--bg-secondary: #073642;
--bg-tertiary: #094856;
--bg-hover: #0a5a6f;
--text-primary: #839496;
--text-secondary: #93a1a1;
--text-tertiary: #586e75;
--accent: #268bd2;
--accent-hover: #1a73c0;
--accent-light: rgba(38, 139, 210, 0.15);
--border: #1d3a47;
--border-light: #0d3a47;
--success: #2aa198;
--danger: #dc322f;
}
// Scrollbar styles
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
::-webkit-scrollbar-corner {
background: transparent;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
// Shared layout styles
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
.main {
display: flex;
flex: 1;
overflow: hidden;
}
.content {
flex: 1;
padding: 28px 32px;
overflow-y: auto;
background: var(--bg-primary);
}
.content section {
animation: fadeIn 0.35s ease;
}
// Content header (shared across all views)
.content-header {
margin-bottom: 24px;
}
.content-title {
font-size: 22px;
font-weight: 600;
margin-bottom: 6px;
letter-spacing: -0.03em;
animation: slideIn 0.3s ease;
color: var(--text-primary);
}
.content-desc {
font-size: 14px;
color: var(--text-tertiary);
animation: fadeIn 0.4s ease 0.1s backwards;
margin-top: 6px;
line-height: 1.5;
}
.content-header {
margin-bottom: 28px;
}
// Card (shared across GeneralSettings and ApiConfig)
.card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 20px 24px;
margin-bottom: 20px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeIn 0.4s ease backwards;
}
.card:last-child {
margin-bottom: 0;
}
.card-title {
font-size: 14px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text-primary);
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-light);
display: flex;
gap: 5px;
}
.card-title .iconpark-icon {
color: var(--accent);
}
.card:nth-child(1) {
animation-delay: 0.05s;
}
.card:nth-child(2) {
animation-delay: 0.1s;
}
.card:hover {
box-shadow: var(--shadow);
transform: translateY(-1px);
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
// Form styles
.form-group {
margin-bottom: 18px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 8px;
color: var(--text-secondary);
letter-spacing: -0.01em;
}
.form-required {
color: var(--danger);
margin-left: 3px;
}
.form-input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
background: var(--bg-secondary);
color: var(--text-primary);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
letter-spacing: -0.01em;
}
.form-input:hover {
border-color: var(--text-tertiary);
}
.form-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-light);
transform: translateY(-1px);
}
.form-input::placeholder {
color: var(--text-tertiary);
}
.form-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.form-select {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: inherit;
font-size: 13px;
font-weight: 400;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
appearance: none;
letter-spacing: -0.01em;
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 40px;
transition: all 0.2s ease;
position: relative;
}
.form-select:hover {
border-color: var(--text-tertiary);
background-color: var(--bg-tertiary);
}
.form-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-light);
}
.form-textarea {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-family: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
background: var(--bg-secondary);
color: var(--text-primary);
resize: vertical;
min-height: 80px;
line-height: 1.5;
transition: all 0.2s ease;
}
.form-textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-light);
}
// Icon styles
.iconpark-icon {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: -0.125em;
flex-shrink: 0;
}
.iconpark-icon svg {
display: block;
}
// Button styles
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 9px 18px;
border: none;
border-radius: var(--radius);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
letter-spacing: -0.01em;
position: relative;
overflow: hidden;
}
.btn::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
transform: translate(-50%, -50%);
transition:
width 0.4s ease,
height 0.4s ease;
}
.btn:active::after {
width: 200px;
height: 200px;
}
.btn-primary {
background: var(--accent);
color: white;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
}
.btn-primary:hover {
background: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.4);
}
.btn-primary:active {
transform: translateY(0) scale(0.98);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--text-tertiary);
}
.btn-secondary:active {
transform: scale(0.98);
}
.btn-danger {
background: var(--danger);
color: white;
border: 1px solid var(--danger);
}
.btn-danger:hover {
background: #dc2626;
transform: translateY(-1px);
}
.btn-danger:active {
transform: translateY(0) scale(0.98);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
// Side panel close button (used by ServerPanel and ApiProfileDialog)
.side-panel-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: var(--radius);
transition: all 0.2s ease;
}
.side-panel-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.side-panel-close svg {
width: 14px;
height: 14px;
stroke: currentColor;
stroke-width: 1.5;
fill: none;
}
// Empty state
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
background: var(--bg-tertiary);
border-radius: var(--radius-lg);
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.3;
color: var(--text-tertiary);
}
.empty-state-title {
font-size: 15px;
font-weight: 500;
margin-bottom: 6px;
color: var(--text-secondary);
}
.empty-state-desc {
font-size: 13px;
color: var(--text-tertiary);
margin-bottom: 20px;
}
// Dialog overlay
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1300;
animation: fadeIn 0.15s ease;
}
.dialog-overlay-top {
z-index: 1400;
}
.dialog {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 24px;
min-width: 360px;
max-width: 480px;
box-shadow: var(--shadow-lg);
animation: slideUp 0.2s ease;
}
.dialog-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 18px;
letter-spacing: -0.01em;
}
.dialog-confirm-text {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 8px;
line-height: 1.5;
}
.dialog-body {
padding: 20px 24px;
max-height: 60vh;
overflow-y: auto;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 22px;
}
// Message dialog
.message-dialog {
position: relative;
text-align: center;
padding: 32px 24px;
z-index: 1400;
}
.message-dialog-icon {
width: 48px;
height: 48px;
margin: 0 auto 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.message-dialog-icon svg {
width: 24px;
height: 24px;
}
.message-dialog-icon-info {
background: rgba(59, 130, 246, 0.1);
color: var(--accent);
}
.message-dialog-icon-success {
background: rgba(16, 185, 129, 0.1);
color: var(--success);
}
.message-dialog-icon-warning {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.message-dialog-icon-error {
background: rgba(239, 68, 68, 0.1);
color: var(--danger);
}
.message-dialog-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
color: var(--text-primary);
}
.message-dialog-message {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.5;
}
.message-dialog .dialog-actions {
justify-content: center;
margin-top: 24px;
}
.message-dialog .dialog-actions .btn {
min-width: 100px;
}
// Server list (used by ApiConfig too)
.server-list {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--bg-secondary);
}
.server-item {
display: flex;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeIn 0.3s ease backwards;
}
.server-item:nth-child(1) {
animation-delay: 0.02s;
}
.server-item:nth-child(2) {
animation-delay: 0.04s;
}
.server-item:nth-child(3) {
animation-delay: 0.06s;
}
.server-item:nth-child(4) {
animation-delay: 0.08s;
}
.server-item:nth-child(5) {
animation-delay: 0.1s;
}
.server-item:nth-child(6) {
animation-delay: 0.12s;
}
.server-item:nth-child(7) {
animation-delay: 0.14s;
}
.server-item:nth-child(8) {
animation-delay: 0.16s;
}
.server-item:last-child {
border-bottom: none;
}
.server-item:hover {
background: var(--bg-tertiary);
transform: translateX(4px);
}
.server-item.selected {
background: var(--accent-light);
border-left: 3px solid var(--accent);
padding-left: 15px;
}
.server-info {
flex: 1;
min-width: 0;
}
.server-name {
font-size: 13px;
font-weight: 500;
letter-spacing: -0.01em;
}
.server-desc {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.server-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 6px rgba(16, 185, 129, 0.5);
animation: pulse 2s ease-in-out infinite;
}

344
src/views/ApiConfig.test.js Normal file
View File

@@ -0,0 +1,344 @@
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import ApiConfig from './ApiConfig.vue';
describe('ApiConfig.vue', () => {
const mockSettings = {
apiProfiles: {
'default': {
baseUrl: 'https://api.default.com',
selectedAuthType: 'openai-compatible',
apiKey: '',
modelName: '',
searchApiKey: '',
cna: ''
},
'dev': {
baseUrl: 'https://api.dev.com',
selectedAuthType: 'openai-compatible',
apiKey: 'dev-key',
modelName: 'gpt-4',
searchApiKey: '',
cna: ''
},
'prod': {
baseUrl: 'https://api.prod.com',
selectedAuthType: 'openai-compatible',
apiKey: 'prod-key',
modelName: 'gpt-4',
searchApiKey: '',
cna: ''
}
},
currentApiProfile: 'default'
};
const mockProfiles = [
{ name: 'default' },
{ name: 'dev' },
{ name: 'prod' }
];
it('renders correctly with props', () => {
const wrapper = mount(ApiConfig, {
props: {
profiles: mockProfiles,
currentProfile: 'default',
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.exists()).toBe(true);
expect(wrapper.find('.content-title').exists()).toBe(true);
expect(wrapper.find('.card').exists()).toBe(true);
expect(wrapper.find('.profile-list').exists()).toBe(true);
});
it('displays all profiles', () => {
const wrapper = mount(ApiConfig, {
props: {
profiles: mockProfiles,
currentProfile: 'default',
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const profileItems = wrapper.findAll('.profile-item');
expect(profileItems.length).toBe(3);
});
it('highlights current profile', () => {
const wrapper = mount(ApiConfig, {
props: {
profiles: mockProfiles,
currentProfile: 'dev',
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const profileItems = wrapper.findAll('.profile-item');
expect(profileItems[0].classes('active')).toBe(false);
expect(profileItems[1].classes('active')).toBe(true);
expect(profileItems[2].classes('active')).toBe(false);
});
it('shows status badge only for current profile', () => {
const wrapper = mount(ApiConfig, {
props: {
profiles: mockProfiles,
currentProfile: 'default',
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const statusBadges = wrapper.findAll('.status-badge');
expect(statusBadges.length).toBe(1);
expect(wrapper.findAll('.profile-item')[0].find('.status-badge').exists()).toBe(true);
expect(wrapper.findAll('.profile-item')[1].find('.status-badge').exists()).toBe(false);
});
it('emits create-profile event when create button is clicked', async () => {
const wrapper = mount(ApiConfig, {
props: {
profiles: mockProfiles,
currentProfile: 'default',
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.btn-primary').trigger('click');
expect(wrapper.emitted('create-profile')).toBeTruthy();
});
it('emits select-profile event when profile is clicked', async () => {
const wrapper = mount(ApiConfig, {
props: {
profiles: mockProfiles,
currentProfile: 'default',
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const profileItems = wrapper.findAll('.profile-item');
await profileItems[1].trigger('click');
expect(wrapper.emitted('select-profile')).toBeTruthy();
expect(wrapper.emitted('select-profile')[0][0]).toBe('dev');
});
it('emits edit-profile event when edit button is clicked', async () => {
const wrapper = mount(ApiConfig, {
props: {
profiles: mockProfiles,
currentProfile: 'default',
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const editButtons = wrapper.findAll('.action-btn');
await editButtons[0].trigger('click');
expect(wrapper.emitted('edit-profile')).toBeTruthy();
expect(wrapper.emitted('edit-profile')[0][0]).toBe('default');
});
it('emits duplicate-profile event when duplicate button is clicked', async () => {
const wrapper = mount(ApiConfig, {
props: {
profiles: mockProfiles,
currentProfile: 'default',
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const duplicateButtons = wrapper.findAll('.action-btn');
await duplicateButtons[1].trigger('click');
expect(wrapper.emitted('duplicate-profile')).toBeTruthy();
expect(wrapper.emitted('duplicate-profile')[0][0]).toBe('default');
});
it('shows delete button only for non-default profiles', () => {
const wrapper = mount(ApiConfig, {
props: {
profiles: mockProfiles,
currentProfile: 'default',
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const profileItems = wrapper.findAll('.profile-item');
const deleteButtons = wrapper.findAll('.action-btn-danger');
expect(deleteButtons.length).toBe(2);
expect(profileItems[0].find('.action-btn-danger').exists()).toBe(false);
expect(profileItems[1].find('.action-btn-danger').exists()).toBe(true);
expect(profileItems[2].find('.action-btn-danger').exists()).toBe(true);
});
it('emits delete-profile event when delete button is clicked', async () => {
const wrapper = mount(ApiConfig, {
props: {
profiles: mockProfiles,
currentProfile: 'default',
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const deleteButtons = wrapper.findAll('.action-btn-danger');
await deleteButtons[0].trigger('click');
expect(wrapper.emitted('delete-profile')).toBeTruthy();
expect(wrapper.emitted('delete-profile')[0][0]).toBe('dev');
});
it('displays correct profile names', () => {
const wrapper = mount(ApiConfig, {
props: {
profiles: mockProfiles,
currentProfile: 'default',
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const profileNames = wrapper.findAll('.profile-name');
expect(profileNames[0].text()).toBe('default');
expect(profileNames[1].text()).toBe('dev');
expect(profileNames[2].text()).toBe('prod');
});
it('displays correct profile URLs', () => {
const wrapper = mount(ApiConfig, {
props: {
profiles: mockProfiles,
currentProfile: 'default',
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const profileUrls = wrapper.findAll('.profile-url');
expect(profileUrls[0].text()).toBe('https://api.default.com');
expect(profileUrls[1].text()).toBe('https://api.dev.com');
expect(profileUrls[2].text()).toBe('https://api.prod.com');
});
it('displays correct profile initials', () => {
const wrapper = mount(ApiConfig, {
props: {
profiles: mockProfiles,
currentProfile: 'default',
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const iconTexts = wrapper.findAll('.profile-icon-text');
expect(iconTexts[0].text()).toBe('D');
expect(iconTexts[1].text()).toBe('D');
expect(iconTexts[2].text()).toBe('P');
});
it('handles empty profiles array', () => {
const wrapper = mount(ApiConfig, {
props: {
profiles: [],
currentProfile: 'default',
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const profileItems = wrapper.findAll('.profile-item');
expect(profileItems.length).toBe(0);
});
it('handles missing apiProfiles in settings', () => {
const settingsWithoutProfiles = { currentApiProfile: 'default' };
const wrapper = mount(ApiConfig, {
props: {
profiles: mockProfiles,
currentProfile: 'default',
settings: settingsWithoutProfiles,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const profileUrls = wrapper.findAll('.profile-url');
expect(profileUrls[0].text()).toBe('');
expect(profileUrls[1].text()).toBe('');
expect(profileUrls[2].text()).toBe('');
});
});

246
src/views/ApiConfig.vue Normal file
View File

@@ -0,0 +1,246 @@
<template>
<section>
<div class="content-header">
<h1 class="content-title">{{ $t('api.title') }}</h1>
<p class="content-desc">{{ $t('api.description') }}</p>
</div>
<div class="card">
<div class="card-title">
<Exchange size="16" />
{{ $t('api.profileManagement') }}
<button class="btn btn-primary btn-sm" @click="$emit('create-profile')" style="margin-left: auto">
<Add size="14" />
{{ $t('api.newProfile') }}
</button>
</div>
<div class="profile-list">
<div v-for="profile in profiles" :key="profile.name" class="profile-item" :class="{ active: currentProfile === profile.name }" @click="$emit('select-profile', profile.name)">
<div class="profile-icon" :style="getProfileIconStyle(profile.name)">
<span class="profile-icon-text">{{ getProfileInitial(profile.name) }}</span>
</div>
<div class="profile-info">
<div class="profile-name">{{ profile.name }}</div>
<div class="profile-url">{{ getProfileUrl(profile.name) }}</div>
</div>
<div class="profile-status" v-if="currentProfile === profile.name">
<span class="status-badge">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3,8 6,11 13,4"></polyline>
</svg>
{{ $t('api.inUse') }}
</span>
</div>
<div class="profile-actions">
<button class="action-btn" @click.stop="$emit('edit-profile', profile.name)" :title="$t('api.edit')">
<Edit size="14" />
</button>
<button class="action-btn" @click.stop="$emit('duplicate-profile', profile.name)" :title="$t('api.duplicate')">
<Copy size="14" />
</button>
<button class="action-btn action-btn-danger" v-if="profile.name !== 'default'" @click.stop="$emit('delete-profile', profile.name)" :title="$t('api.delete')">
<Delete size="14" />
</button>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { Add, Edit, Delete, Exchange, Copy } from '@icon-park/vue-next'
const props = defineProps({
profiles: {
type: Array,
default: () => [],
},
currentProfile: {
type: String,
default: 'default',
},
settings: {
type: Object,
required: true,
},
})
defineEmits(['create-profile', 'select-profile', 'edit-profile', 'duplicate-profile', 'delete-profile'])
const profileColors = [
'linear-gradient(135deg, #f97316 0%, #fb923c 100%)',
'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)',
'linear-gradient(135deg, #06b6d4 0%, #22d3ee 100%)',
'linear-gradient(135deg, #10b981 0%, #34d399 100%)',
'linear-gradient(135deg, #f43f5e 0%, #fb7185 100%)',
'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)',
]
const getProfileInitial = name => (name ? name.charAt(0).toUpperCase() : '?')
const getProfileUrl = name => {
if (!props.settings.apiProfiles || !props.settings.apiProfiles[name]) {
return ''
}
const profile = props.settings.apiProfiles[name]
return profile.baseUrl || ''
}
const getProfileIconStyle = name => {
if (name === 'default') {
return { background: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)' }
}
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
const index = Math.abs(hash) % profileColors.length
return { background: profileColors[index] }
}
</script>
<style lang="less" scoped>
.profile-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.profile-item {
display: flex;
align-items: center;
padding: 14px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeIn 0.3s ease backwards;
}
.profile-item:nth-child(1) {
animation-delay: 0.02s;
}
.profile-item:nth-child(2) {
animation-delay: 0.04s;
}
.profile-item:nth-child(3) {
animation-delay: 0.06s;
}
.profile-item:nth-child(4) {
animation-delay: 0.08s;
}
.profile-item:nth-child(5) {
animation-delay: 0.1s;
}
.profile-item:hover {
background: var(--bg-tertiary);
border-color: var(--text-tertiary);
transform: translateX(4px);
}
.profile-item.active {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%);
box-shadow:
0 0 0 1px var(--accent),
0 4px 12px rgba(59, 130, 246, 0.15);
}
.profile-icon {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.profile-icon-text {
font-size: 16px;
font-weight: 700;
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.profile-info {
flex: 1;
min-width: 0;
margin-left: 14px;
}
.profile-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.profile-url {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-status {
margin-left: 12px;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
background: rgba(59, 130, 246, 0.1);
color: var(--accent);
border-radius: 16px;
font-size: 12px;
font-weight: 500;
}
.status-badge svg {
width: 12px;
height: 12px;
}
.profile-actions {
display: flex;
align-items: center;
gap: 4px;
margin-left: 12px;
opacity: 0;
transition: opacity 0.2s ease;
}
.profile-item:hover .profile-actions,
.profile-item.active .profile-actions {
opacity: 1;
}
.action-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: var(--radius);
transition: all 0.2s ease;
}
.action-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.action-btn.action-btn-danger:hover {
background: rgba(239, 68, 68, 0.1);
color: var(--danger);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
align-self: flex-end;
}
</style>

View File

@@ -0,0 +1,157 @@
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import GeneralSettings from './GeneralSettings.vue';
describe('GeneralSettings.vue', () => {
const mockSettings = {
language: 'zh-CN',
theme: 'Xcode',
bootAnimationShown: true,
checkpointing: { enabled: true },
};
it('renders correctly with props', () => {
const wrapper = mount(GeneralSettings, {
props: {
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.exists()).toBe(true);
expect(wrapper.find('.content-title').exists()).toBe(true);
expect(wrapper.findAll('.card').length).toBe(2);
});
it('displays language options correctly', () => {
const wrapper = mount(GeneralSettings, {
props: {
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const languageOptions = wrapper.findAll('.form-select')[0].findAll('option');
expect(languageOptions.length).toBe(3);
expect(languageOptions[0].attributes('value')).toBe('zh-CN');
expect(languageOptions[1].attributes('value')).toBe('en-US');
expect(languageOptions[2].attributes('value')).toBe('ja-JP');
});
it('displays theme options correctly', () => {
const wrapper = mount(GeneralSettings, {
props: {
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const themeOptions = wrapper.findAll('.form-select')[1].findAll('option');
expect(themeOptions.length).toBe(4);
expect(themeOptions[0].attributes('value')).toBe('Xcode');
expect(themeOptions[1].attributes('value')).toBe('Dark');
expect(themeOptions[2].attributes('value')).toBe('Light');
expect(themeOptions[3].attributes('value')).toBe('Solarized Dark');
});
it('reflects current settings in form controls', async () => {
const wrapper = mount(GeneralSettings, {
props: {
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await nextTick();
const selectElements = wrapper.findAll('.form-select');
expect(selectElements[0].element.value).toBe('zh-CN');
expect(selectElements[1].element.value).toBe('Xcode');
expect(selectElements[2].element.value).toBe('true');
expect(selectElements[3].element.value).toBe('true');
});
it('applies translation correctly', () => {
const wrapper = mount(GeneralSettings, {
props: {
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => `translated-${key}`,
},
},
});
expect(wrapper.find('.content-title').text()).toBe('translated-general.title');
expect(wrapper.find('.content-desc').text()).toBe('translated-general.description');
});
it('has two cards for settings sections', () => {
const wrapper = mount(GeneralSettings, {
props: {
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const cards = wrapper.findAll('.card');
expect(cards.length).toBe(2);
});
it('displays card titles with icons', () => {
const wrapper = mount(GeneralSettings, {
props: {
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const cardTitles = wrapper.findAll('.card-title');
expect(cardTitles.length).toBe(2);
expect(cardTitles[0].text()).toContain('general.languageInterface');
expect(cardTitles[1].text()).toContain('general.otherSettings');
});
it('shows all form controls with proper structure', () => {
const wrapper = mount(GeneralSettings, {
props: {
settings: mockSettings,
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.findAll('.form-row').length).toBe(2);
expect(wrapper.findAll('.form-group').length).toBe(4);
expect(wrapper.findAll('.form-label').length).toBe(4);
expect(wrapper.findAll('.form-select').length).toBe(4);
});
});

View File

@@ -0,0 +1,77 @@
<template>
<section>
<div class="content-header">
<h1 class="content-title">{{ $t('general.title') }}</h1>
<p class="content-desc">{{ $t('general.description') }}</p>
</div>
<div class="card">
<div class="card-title">
<Globe size="16" />
{{ $t('general.languageInterface') }}
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ $t('general.language') }}</label>
<select class="form-select" v-model="localSettings.language">
<option value="zh-CN">{{ $t('languages.zh-CN') }}</option>
<option value="en-US">{{ $t('languages.en-US') }}</option>
<option value="ja-JP">{{ $t('languages.ja-JP') }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label">{{ $t('general.theme') }}</label>
<select class="form-select" v-model="localSettings.theme">
<option value="Xcode">{{ $t('theme.xcode') }}</option>
<option value="Dark">{{ $t('theme.dark') }}</option>
<option value="Light">{{ $t('theme.light') }}</option>
<option value="Solarized Dark">{{ $t('theme.solarizedDark') }}</option>
</select>
</div>
</div>
</div>
<div class="card">
<div class="card-title">
<Setting size="16" />
{{ $t('general.otherSettings') }}
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ $t('general.bootAnimation') }}</label>
<select class="form-select" v-model="localSettings.bootAnimationShown">
<option :value="true">{{ $t('general.bootAnimationShown') }}</option>
<option :value="false">{{ $t('general.bootAnimationNotShown') }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label">{{ $t('general.checkpointing') }}</label>
<select class="form-select" v-model="localSettings.checkpointing.enabled">
<option :value="true">{{ $t('general.enabled') }}</option>
<option :value="false">{{ $t('general.disabled') }}</option>
</select>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { Globe, Setting } from '@icon-park/vue-next'
const props = defineProps({
settings: {
type: Object,
required: true,
},
})
const emit = defineEmits(['update:settings'])
import { computed } from 'vue'
const localSettings = computed({
get: () => props.settings,
set: val => emit('update:settings', val),
})
</script>
<style lang="less" scoped></style>

View File

@@ -0,0 +1,256 @@
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import McpServers from './McpServers.vue';
describe('McpServers.vue', () => {
const mockServers = {
'server1': {
description: '第一个服务器',
command: 'node server.js',
args: ['--port', '3000'],
env: {}
},
'server2': {
description: '第二个服务器',
command: 'python server.py',
args: [],
env: { 'PYTHONPATH': '/path/to/python' }
},
'server3': {
command: 'java -jar server.jar',
args: [],
env: {}
}
};
it('renders correctly with props', () => {
const wrapper = mount(McpServers, {
props: {
servers: mockServers,
selectedServer: 'server1',
serverCount: 3
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.exists()).toBe(true);
expect(wrapper.find('.content-title').exists()).toBe(true);
expect(wrapper.find('.server-list').exists()).toBe(true);
});
it('displays all servers', () => {
const wrapper = mount(McpServers, {
props: {
servers: mockServers,
selectedServer: 'server1',
serverCount: 3
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const serverItems = wrapper.findAll('.server-item');
expect(serverItems.length).toBe(3);
});
it('highlights selected server', () => {
const wrapper = mount(McpServers, {
props: {
servers: mockServers,
selectedServer: 'server2',
serverCount: 3
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const serverItems = wrapper.findAll('.server-item');
expect(serverItems[0].classes('selected')).toBe(false);
expect(serverItems[1].classes('selected')).toBe(true);
expect(serverItems[2].classes('selected')).toBe(false);
});
it('shows empty state when no servers', () => {
const wrapper = mount(McpServers, {
props: {
servers: {},
selectedServer: null,
serverCount: 0
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.empty-state').exists()).toBe(true);
expect(wrapper.find('.empty-state-title').exists()).toBe(true);
expect(wrapper.find('.empty-state-desc').exists()).toBe(true);
expect(wrapper.findAll('.server-item').length).toBe(0);
});
it('emits add-server event when add button is clicked', async () => {
const wrapper = mount(McpServers, {
props: {
servers: mockServers,
selectedServer: 'server1',
serverCount: 3
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.btn-primary').trigger('click');
expect(wrapper.emitted('add-server')).toBeTruthy();
});
it('emits select-server event when server is clicked', async () => {
const wrapper = mount(McpServers, {
props: {
servers: mockServers,
selectedServer: 'server1',
serverCount: 3
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const serverItems = wrapper.findAll('.server-item');
await serverItems[1].trigger('click');
expect(wrapper.emitted('select-server')).toBeTruthy();
expect(wrapper.emitted('select-server')[0][0]).toBe('server2');
});
it('displays correct server names', () => {
const wrapper = mount(McpServers, {
props: {
servers: mockServers,
selectedServer: 'server1',
serverCount: 3
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const serverNames = wrapper.findAll('.server-name');
expect(serverNames[0].text()).toBe('server1');
expect(serverNames[1].text()).toBe('server2');
expect(serverNames[2].text()).toBe('server3');
});
it('displays correct server descriptions', () => {
const wrapper = mount(McpServers, {
props: {
servers: mockServers,
selectedServer: 'server1',
serverCount: 3
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const serverDescs = wrapper.findAll('.server-desc');
expect(serverDescs[0].text()).toBe('第一个服务器');
expect(serverDescs[1].text()).toBe('第二个服务器');
expect(serverDescs[2].text()).toBe('mcp.noDescription');
});
it('displays status indicators for all servers', () => {
const wrapper = mount(McpServers, {
props: {
servers: mockServers,
selectedServer: 'server1',
serverCount: 3
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const statusIndicators = wrapper.findAll('.server-status');
expect(statusIndicators.length).toBe(3);
});
it('handles null selectedServer prop', () => {
const wrapper = mount(McpServers, {
props: {
servers: mockServers,
selectedServer: null,
serverCount: 3
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const serverItems = wrapper.findAll('.server-item');
expect(serverItems.length).toBe(3);
expect(serverItems[0].classes('selected')).toBe(false);
expect(serverItems[1].classes('selected')).toBe(false);
expect(serverItems[2].classes('selected')).toBe(false);
});
it('handles zero serverCount with empty servers object', () => {
const wrapper = mount(McpServers, {
props: {
servers: {},
selectedServer: null,
serverCount: 0
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.empty-state').exists()).toBe(true);
expect(wrapper.findAll('.server-item').length).toBe(0);
});
it('displays empty state title correctly', () => {
const wrapper = mount(McpServers, {
props: {
servers: {},
selectedServer: null,
serverCount: 0
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.empty-state-title').text()).toBe('mcp.noServers');
expect(wrapper.find('.empty-state-desc').text()).toBe('mcp.addFirstServer');
});
});

156
src/views/McpServers.vue Normal file
View File

@@ -0,0 +1,156 @@
<template>
<section>
<div class="content-header">
<h1 class="content-title">{{ $t('mcp.title') }}</h1>
<p class="content-desc">{{ $t('mcp.description') }}</p>
</div>
<div class="form-group">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px">
<label class="form-label" style="margin: 0">{{ $t('mcp.serverList') }}</label>
<button class="btn btn-primary" @click="$emit('add-server')" style="padding: 6px 12px; font-size: 12px">
<Add size="12" />
{{ $t('mcp.addServerBtn') }}
</button>
</div>
<div class="server-list">
<template v-if="serverCount > 0">
<div
v-for="(config, name) in servers"
:key="name"
class="server-item"
:class="{ selected: selectedServer === name }"
@click="$emit('select-server', name)"
>
<div class="server-info">
<div class="server-name">{{ name }}</div>
<div class="server-desc">{{ config.description || $t('mcp.noDescription') }}</div>
</div>
<div class="server-status"></div>
</div>
</template>
<div v-else class="empty-state">
<Server size="48" class="empty-state-icon" />
<div class="empty-state-title">{{ $t('mcp.noServers') }}</div>
<div class="empty-state-desc">{{ $t('mcp.addFirstServer') }}</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { Server, Add } from '@icon-park/vue-next'
defineProps({
servers: {
type: Object,
default: () => ({})
},
selectedServer: {
type: String,
default: null
},
serverCount: {
type: Number,
default: 0
}
})
defineEmits(['add-server', 'select-server'])
</script>
<style lang="less" scoped>
.server-list {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--bg-secondary);
}
.server-item {
display: flex;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeIn 0.3s ease backwards;
}
.server-item:nth-child(1) { animation-delay: 0.02s; }
.server-item:nth-child(2) { animation-delay: 0.04s; }
.server-item:nth-child(3) { animation-delay: 0.06s; }
.server-item:nth-child(4) { animation-delay: 0.08s; }
.server-item:nth-child(5) { animation-delay: 0.1s; }
.server-item:nth-child(6) { animation-delay: 0.12s; }
.server-item:nth-child(7) { animation-delay: 0.14s; }
.server-item:nth-child(8) { animation-delay: 0.16s; }
.server-item:last-child { border-bottom: none; }
.server-item:hover {
background: var(--bg-tertiary);
transform: translateX(4px);
}
.server-item.selected {
background: var(--accent-light);
border-left: 3px solid var(--accent);
padding-left: 15px;
}
.server-info {
flex: 1;
min-width: 0;
}
.server-name {
font-size: 13px;
font-weight: 500;
letter-spacing: -0.01em;
}
.server-desc {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.server-status {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 6px rgba(16, 185, 129, 0.5);
animation: pulse 2s ease-in-out infinite;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
background: var(--bg-tertiary);
border-radius: var(--radius-lg);
}
.empty-state-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.3;
color: var(--text-tertiary);
}
.empty-state-title {
font-size: 15px;
font-weight: 500;
margin-bottom: 6px;
color: var(--text-secondary);
}
.empty-state-desc {
font-size: 13px;
color: var(--text-tertiary);
margin-bottom: 20px;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
</style>

View File

@@ -13,5 +13,12 @@ export default defineConfig({
alias: { alias: {
'@': path.resolve(__dirname, 'src') '@': path.resolve(__dirname, 'src')
} }
},
css: {
preprocessorOptions: {
less: {
javascriptEnabled: true
}
}
} }
}); });

29
vitest.config.js Normal file
View File

@@ -0,0 +1,29 @@
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: [],
include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
exclude: ['node_modules', 'dist', 'release', '.git'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
'release/',
'test/',
'**/*.config.js',
'main.js',
'preload.js'
]
}
},
server: {
port: 5174
}
});