You've already forked iFlow-Settings-Editor-GUI
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c282962da | |||
| 978c4c295c | |||
| 400e617528 | |||
| 2a43b8a838 | |||
| aa375bfff0 | |||
| 2d2804ef22 | |||
| 3577e139b9 | |||
| d184cfef6e | |||
| 5ee6ee75f1 | |||
| 538d1ffb50 | |||
| b1de0e14f1 | |||
| 3329e0ddbf | |||
| cdddcccfe0 | |||
| 0318c67ea7 |
167
AGENTS.md
167
AGENTS.md
@@ -15,28 +15,46 @@ iFlow 设置编辑器是一个基于 Electron + Vue 3 的桌面应用程序,
|
||||
| @vitejs/plugin-vue | ^6.0.6 | Vue 插件 |
|
||||
| concurrently | ^8.2.2 | 并发执行工具 |
|
||||
| electron-builder | ^24.13.3 | 应用打包工具 |
|
||||
| vitest | ^4.1.4 | 单元测试框架 |
|
||||
| @vue/test-utils | ^2.5.0 | Vue 组件测试工具 |
|
||||
| happy-dom | Latest | 浏览器环境模拟 |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
iflow-settings-editor/
|
||||
├── main.js # Electron 主进程 (窗口管理、IPC、文件操作)
|
||||
├── main.js # Electron 主进程 (窗口管理、IPC、文件操作、系统托盘)
|
||||
├── preload.js # 预加载脚本 (contextBridge API)
|
||||
├── index.html # HTML 入口
|
||||
├── package.json # 项目配置
|
||||
├── vite.config.js # Vite 配置
|
||||
├── vitest.config.js # Vitest 测试配置
|
||||
├── src/
|
||||
│ ├── 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/ # 构建资源 (图标等)
|
||||
├── dist/ # Vite 构建输出
|
||||
├── release/ # 打包输出目录
|
||||
└── screenshots/ # 截图资源
|
||||
└── screenshots/ # 截图资源
|
||||
```
|
||||
|
||||
## 核心架构
|
||||
|
||||
### 进程模型
|
||||
- **Main Process (main.js)**: Electron 主进程,处理窗口管理、IPC 通信、文件系统操作
|
||||
- **Main Process (main.js)**: Electron 主进程,处理窗口管理、IPC 通信、文件系统操作、系统托盘
|
||||
- **Preload (preload.js)**: 通过 `contextBridge.exposeInMainWorld` 暴露安全 API
|
||||
- **Renderer (Vue)**: 渲染进程,只通过 preload 暴露的 API 与主进程通信
|
||||
|
||||
@@ -44,19 +62,29 @@ iflow-settings-editor/
|
||||
```javascript
|
||||
// preload.js 暴露的 API
|
||||
window.electronAPI = {
|
||||
// 基本设置操作
|
||||
loadSettings: () => ipcRenderer.invoke('load-settings'),
|
||||
saveSettings: (data) => ipcRenderer.invoke('save-settings', data),
|
||||
showMessage: (options) => ipcRenderer.invoke('show-message', options),
|
||||
|
||||
// 窗口控制
|
||||
isMaximized: () => ipcRenderer.invoke('is-maximized'),
|
||||
minimize: () => ipcRenderer.send('window-minimize'),
|
||||
maximize: () => ipcRenderer.send('window-maximize'),
|
||||
close: () => ipcRenderer.send('window-close'),
|
||||
|
||||
// API 配置管理(单文件内多配置)
|
||||
listApiProfiles: () => ipcRenderer.invoke('list-api-profiles'),
|
||||
switchApiProfile: (profileName) => ipcRenderer.invoke('switch-api-profile', profileName),
|
||||
createApiProfile: (name) => ipcRenderer.invoke('create-api-profile', name),
|
||||
deleteApiProfile: (name) => ipcRenderer.invoke('delete-api-profile', name),
|
||||
renameApiProfile: (oldName, newName) => ipcRenderer.invoke('rename-api-profile', oldName, newName),
|
||||
duplicateApiProfile: (name, newName) => ipcRenderer.invoke('duplicate-api-profile', name, newName),
|
||||
isMaximized: () => ipcRenderer.invoke('is-maximized'),
|
||||
minimize: () => ipcRenderer.send('window-minimize'),
|
||||
maximize: () => ipcRenderer.send('window-maximize'),
|
||||
close: () => ipcRenderer.send('window-close')
|
||||
duplicateApiProfile: (sourceName, newName) => ipcRenderer.invoke('duplicate-api-profile', sourceName, newName),
|
||||
|
||||
// 托盘事件监听
|
||||
onApiProfileSwitched: (callback) => {
|
||||
ipcRenderer.on('api-profile-switched', (event, profileName) => callback(profileName))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -64,12 +92,22 @@ window.electronAPI = {
|
||||
- 窗口尺寸: 1100x750,最小尺寸: 900x600
|
||||
- 无边框窗口 (frame: false),自定义标题栏
|
||||
- 开发模式加载 `http://localhost:5173`,生产模式加载 `dist/index.html`
|
||||
- **关闭窗口时隐藏到系统托盘**,双击托盘图标可重新显示
|
||||
|
||||
### 系统托盘
|
||||
- 托盘图标显示应用状态
|
||||
- 右键菜单支持:
|
||||
- 显示主窗口
|
||||
- 切换 API 配置(显示所有配置列表,当前配置带勾选标记)
|
||||
- 退出应用
|
||||
- 双击托盘图标显示主窗口
|
||||
|
||||
### API 配置切换
|
||||
- 支持多环境配置: 默认配置、开发环境、预发布环境、生产环境
|
||||
- 配置文件管理: 支持创建、编辑、复制、删除、重命名
|
||||
- 单独保存每个环境的 API 配置到 `apiProfiles` 对象
|
||||
- 切换配置时直接应用新配置,无需确认
|
||||
- 支持从系统托盘快速切换配置
|
||||
|
||||
## 可用命令
|
||||
|
||||
@@ -87,6 +125,10 @@ npm run build:win32 # 构建 Windows x86 安装包
|
||||
npm run build:win-portable # 构建可移植版本
|
||||
npm run build:win-installer # 构建 NSIS 安装包
|
||||
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)
|
||||
- **配置列表**: 显示所有可用的 API 配置文件
|
||||
- **配置列表**: 显示所有可用的 API 配置文件,带彩色图标和状态标记
|
||||
- **配置切换**: 点击配置卡片直接切换,无需确认
|
||||
- **创建配置**: 新建 API 配置文件
|
||||
- **创建配置**: 新建 API 配置文件(支持设置认证方式、API Key、Base URL 等)
|
||||
- **编辑配置**: 修改现有配置的认证方式、API Key、Base URL 等
|
||||
- **复制配置**: 基于现有配置创建新配置
|
||||
- **删除配置**: 删除非默认配置
|
||||
@@ -112,8 +154,9 @@ npm run dist # 完整构建和打包
|
||||
- **CNA**: CNA 标识
|
||||
|
||||
### 3. MCP 服务器管理 (MCP)
|
||||
- 服务器列表展示
|
||||
- 服务器列表展示(带描述信息)
|
||||
- 添加/编辑/删除服务器
|
||||
- 侧边面板编辑界面
|
||||
- 服务器配置: 名称、描述、命令、工作目录、参数(每行一个)、环境变量(JSON)
|
||||
|
||||
## 关键实现细节
|
||||
@@ -144,6 +187,12 @@ if (fs.existsSync(SETTINGS_FILE)) {
|
||||
- `currentServerName` - 当前选中的 MCP 服务器
|
||||
- `currentApiProfile` - 当前使用的 API 配置名称
|
||||
- `apiProfiles` - 可用的 API 配置列表
|
||||
- `isLoading` - 加载状态标志
|
||||
|
||||
### API 配置字段
|
||||
```javascript
|
||||
const API_FIELDS = ['selectedAuthType', 'apiKey', 'baseUrl', 'modelName', 'searchApiKey', 'cna'];
|
||||
```
|
||||
|
||||
### 数据初始化
|
||||
在 `loadSettings` 函数中确保所有字段都有默认值:
|
||||
@@ -151,7 +200,7 @@ if (fs.existsSync(SETTINGS_FILE)) {
|
||||
- `theme`: 'Xcode'
|
||||
- `bootAnimationShown`: true
|
||||
- `checkpointing`: { enabled: true }
|
||||
- `selectedAuthType`: 'iflow'
|
||||
- `selectedAuthType`: 'openai-compatible'
|
||||
- `apiKey`: ''
|
||||
- `baseUrl`: ''
|
||||
- `modelName`: ''
|
||||
@@ -159,23 +208,79 @@ if (fs.existsSync(SETTINGS_FILE)) {
|
||||
- `cna`: ''
|
||||
- `apiProfiles`: { 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 })` 深度监听
|
||||
2. **服务器编辑**: 使用 DOM 操作收集表单数据 (`collectServerData`)
|
||||
2. **服务器编辑**: 使用侧边面板 (Side Panel) 收集表单数据
|
||||
3. **MCP 参数**: 每行一个参数,通过换行分割
|
||||
4. **环境变量**: 支持 JSON 格式输入
|
||||
5. **窗口控制**: 通过 IPC 发送指令,主进程处理实际窗口操作
|
||||
6. **API 配置切换**: 多个环境配置存储在 `settings.apiProfiles` 对象中
|
||||
7. **序列化问题**: IPC 通信使用 `JSON.parse(JSON.stringify())` 避免 Vue 响应式代理问题
|
||||
8. **默认值处理**: 加载配置时检查 `undefined` 并应用默认值,防止界面显示异常
|
||||
9. **托盘切换事件**: 监听 `onApiProfileSwitched` 处理托盘发起的配置切换
|
||||
|
||||
## 图标使用
|
||||
|
||||
使用 `@icon-park/vue-next` 图标库:
|
||||
```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/` - 所有打包输出的根目录
|
||||
- 安装包命名: `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
60
CHANGELOG.md
Normal 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 通信
|
||||
- 无边框窗口
|
||||
- 自定义标题栏
|
||||
- 最小化、最大化、关闭按钮
|
||||
50
README.md
50
README.md
@@ -17,17 +17,31 @@
|
||||
|
||||
```
|
||||
iflow-settings-editor/
|
||||
├── main.js # Electron 主进程 (窗口管理、IPC、文件操作)
|
||||
├── preload.js # 预加载脚本 (IPC 通信)
|
||||
├── main.js # Electron 主进程 (窗口管理、IPC、文件操作、系统托盘)
|
||||
├── preload.js # 预加载脚本 (contextBridge API)
|
||||
├── index.html # HTML 入口
|
||||
├── package.json # 项目配置
|
||||
├── vite.config.js # Vite 配置
|
||||
├── vitest.config.js # Vitest 测试配置
|
||||
├── src/
|
||||
│ ├── 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/ # 构建资源 (图标等)
|
||||
├── dist/ # Vite 构建输出
|
||||
├── release/ # 打包输出目录
|
||||
└── screenshots/ # 截图资源
|
||||
└── screenshots/ # 截图资源
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
@@ -61,7 +75,8 @@ npm run build:win # 构建 Windows 安装包 (NSIS)
|
||||
npm run build:win64 # 构建 Windows x64 安装包
|
||||
npm run build:win32 # 构建 Windows x86 安装包
|
||||
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 Key、Base URL 等
|
||||
@@ -98,9 +113,9 @@ npm run build:dist # 完整构建和打包
|
||||
|
||||
管理 Model Context Protocol 服务器配置:
|
||||
|
||||
- **服务器列表**: 显示所有已配置的服务器
|
||||
- **服务器列表**: 显示所有已配置的服务器,带描述信息
|
||||
- **添加服务器**: 创建新的 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
|
||||
- **Renderer (Vue)**: 渲染进程,只通过 preload 暴露的 API 与主进程通信
|
||||
|
||||
@@ -121,6 +136,15 @@ npm run build:dist # 完整构建和打包
|
||||
- 窗口尺寸: 1100x750,最小尺寸: 900x600
|
||||
- 无边框窗口 (frame: false),自定义标题栏
|
||||
- 开发模式加载 `http://localhost:5173`,生产模式加载 `dist/index.html`
|
||||
- **关闭窗口时隐藏到系统托盘**,双击托盘图标可重新显示
|
||||
|
||||
### 系统托盘
|
||||
- 托盘图标显示应用状态
|
||||
- 右键菜单支持:
|
||||
- 显示主窗口
|
||||
- 切换 API 配置(显示所有配置列表,当前配置带勾选标记)
|
||||
- 退出应用
|
||||
- 双击托盘图标显示主窗口
|
||||
|
||||
### 安全配置
|
||||
- `contextIsolation: true` - 隔离上下文
|
||||
@@ -136,7 +160,7 @@ npm run build:dist # 完整构建和打包
|
||||
- 允许修改安装目录
|
||||
- 允许提升权限
|
||||
- 创建桌面和开始菜单快捷方式
|
||||
- 支持中文和英文界面界面
|
||||
- 支持中文和英文界面 (zh_CN, en_US)
|
||||
- 卸载时保留用户数据
|
||||
|
||||
### 输出目录
|
||||
@@ -150,18 +174,20 @@ npm run build:dist # 完整构建和打包
|
||||
- 保存设置时会自动创建备份 (`settings.json.bak`)
|
||||
- MCP 服务器参数每行一个,环境变量支持 JSON 格式
|
||||
- API 配置切换时会直接应用新配置,未保存的更改会被替换
|
||||
- 关闭窗口时应用会隐藏到系统托盘,不会退出应用
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
1. **修改检测**: 通过 `watch(settings, () => { modified.value = true }, { deep: true })` 深度监听
|
||||
2. **服务器编辑**: 使用 DOM 操作收集表单数据
|
||||
2. **服务器编辑**: 使用侧边面板 (Side Panel) 收集表单数据
|
||||
3. **MCP 参数**: 每行一个参数,通过换行分割
|
||||
4. **环境变量**: 支持 JSON 格式输入
|
||||
5. **窗口控制**: 通过 IPC 发送指令,主进程处理实际窗口操作
|
||||
6. **API 配置切换**: 多个环境配置存储在 `settings.apiProfiles` 对象中
|
||||
7. **序列化问题**: IPC 通信使用 `JSON.parse(JSON.stringify())` 避免 Vue 响应式代理问题
|
||||
8. **默认值处理**: 加载配置时检查 `undefined` 并应用默认值,防止界面显示异常
|
||||
9. **托盘切换事件**: 监听 `onApiProfileSwitched` 处理托盘发起的配置切换
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
MIT License
|
||||
|
||||
235
main.js
235
main.js
@@ -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 fs = require('fs')
|
||||
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')
|
||||
console.log('SETTINGS_FILE:', SETTINGS_FILE)
|
||||
let mainWindow
|
||||
let tray
|
||||
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() {
|
||||
console.log('Creating window...')
|
||||
mainWindow = new BrowserWindow({
|
||||
@@ -42,6 +227,7 @@ function createWindow() {
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
console.log('Window ready to show')
|
||||
mainWindow.show()
|
||||
createTray()
|
||||
})
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
@@ -49,7 +235,7 @@ function createWindow() {
|
||||
}
|
||||
app.whenReady().then(createWindow)
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
if (process.platform !== 'darwin' && app.isQuitting) {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
@@ -67,8 +253,18 @@ ipcMain.on('window-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.on('language-changed', () => {
|
||||
updateTrayMenu()
|
||||
})
|
||||
// API 配置相关的字段
|
||||
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) => {
|
||||
try {
|
||||
const settings = readSettings()
|
||||
const t = getErrorTranslation()
|
||||
if (!settings) {
|
||||
return { success: false, error: '配置文件不存在' }
|
||||
return { success: false, error: t.configNotFound }
|
||||
}
|
||||
const profiles = settings.apiProfiles || {}
|
||||
if (!profiles[profileName]) {
|
||||
return { success: false, error: `配置 "${profileName}" 不存在` }
|
||||
return { success: false, error: t.configNotExist.replace('{name}', profileName) }
|
||||
}
|
||||
// 保存当前配置到 apiProfiles(如果当前配置存在)
|
||||
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) => {
|
||||
try {
|
||||
const settings = readSettings()
|
||||
const t = getErrorTranslation()
|
||||
if (!settings) {
|
||||
return { success: false, error: '配置文件不存在' }
|
||||
return { success: false, error: t.configNotFound }
|
||||
}
|
||||
if (!settings.apiProfiles) {
|
||||
settings.apiProfiles = { default: {} }
|
||||
@@ -166,7 +364,7 @@ ipcMain.handle('create-api-profile', async (event, name) => {
|
||||
}
|
||||
}
|
||||
if (settings.apiProfiles[name]) {
|
||||
return { success: false, error: `配置 "${name}" 已存在` }
|
||||
return { success: false, error: t.configAlreadyExists.replace('{name}', name) }
|
||||
}
|
||||
// 复制当前配置到新配置
|
||||
const newConfig = {}
|
||||
@@ -186,15 +384,16 @@ ipcMain.handle('create-api-profile', async (event, name) => {
|
||||
ipcMain.handle('delete-api-profile', async (event, name) => {
|
||||
try {
|
||||
const settings = readSettings()
|
||||
const t = getErrorTranslation()
|
||||
if (!settings) {
|
||||
return { success: false, error: '配置文件不存在' }
|
||||
return { success: false, error: t.configNotFound }
|
||||
}
|
||||
if (name === 'default') {
|
||||
return { success: false, error: '不能删除默认配置' }
|
||||
return { success: false, error: t.cannotDeleteDefault }
|
||||
}
|
||||
const profiles = settings.apiProfiles || {}
|
||||
if (!profiles[name]) {
|
||||
return { success: false, error: `配置 "${name}" 不存在` }
|
||||
return { success: false, error: t.configNotExist.replace('{name}', name) }
|
||||
}
|
||||
delete profiles[name]
|
||||
settings.apiProfiles = profiles
|
||||
@@ -219,18 +418,19 @@ ipcMain.handle('delete-api-profile', async (event, name) => {
|
||||
ipcMain.handle('rename-api-profile', async (event, oldName, newName) => {
|
||||
try {
|
||||
const settings = readSettings()
|
||||
const t = getErrorTranslation()
|
||||
if (!settings) {
|
||||
return { success: false, error: '配置文件不存在' }
|
||||
return { success: false, error: t.configNotFound }
|
||||
}
|
||||
if (oldName === 'default') {
|
||||
return { success: false, error: '不能重命名默认配置' }
|
||||
return { success: false, error: t.cannotRenameDefault }
|
||||
}
|
||||
const profiles = settings.apiProfiles || {}
|
||||
if (!profiles[oldName]) {
|
||||
return { success: false, error: `配置 "${oldName}" 不存在` }
|
||||
return { success: false, error: t.configNotExist.replace('{name}', oldName) }
|
||||
}
|
||||
if (profiles[newName]) {
|
||||
return { success: false, error: `配置 "${newName}" 已存在` }
|
||||
return { success: false, error: t.configAlreadyExists.replace('{name}', newName) }
|
||||
}
|
||||
profiles[newName] = 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) => {
|
||||
try {
|
||||
const settings = readSettings()
|
||||
const t = getErrorTranslation()
|
||||
if (!settings) {
|
||||
return { success: false, error: '配置文件不存在' }
|
||||
return { success: false, error: t.configNotFound }
|
||||
}
|
||||
const profiles = settings.apiProfiles || {}
|
||||
if (!profiles[sourceName]) {
|
||||
return { success: false, error: `配置 "${sourceName}" 不存在` }
|
||||
return { success: false, error: t.configNotExist.replace('{name}', sourceName) }
|
||||
}
|
||||
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]))
|
||||
|
||||
1045
package-lock.json
generated
1045
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "iflow-settings-editor",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"description": "一个用于编辑 iFlow CLI 配置文件的桌面应用程序。",
|
||||
"main": "main.js",
|
||||
"author": "上海潘哆呐科技有限公司",
|
||||
@@ -20,7 +20,11 @@
|
||||
"build:win32": "vite build && electron-builder --win --ia32",
|
||||
"build:win-portable": "vite build && electron-builder --win portable",
|
||||
"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": {
|
||||
"appId": "com.iflow.settings-editor",
|
||||
@@ -30,6 +34,12 @@
|
||||
"output": "release",
|
||||
"buildResources": "build"
|
||||
},
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/icon.ico",
|
||||
"to": "icon/icon.ico"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"main.js",
|
||||
@@ -78,12 +88,22 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"-": "^0.0.1",
|
||||
"@icon-park/vue-next": "^1.4.2",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vitest/ui": "^4.1.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"concurrently": "^8.2.2",
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.13.3",
|
||||
"happy-dom": "^20.9.0",
|
||||
"less": "^4.6.4",
|
||||
"less-loader": "^12.3.2",
|
||||
"vite": "^8.0.8",
|
||||
"vitest": "^4.1.4",
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue-i18n": "^9.14.5"
|
||||
}
|
||||
}
|
||||
|
||||
16
preload.js
16
preload.js
@@ -5,18 +5,28 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
loadSettings: () => ipcRenderer.invoke('load-settings'),
|
||||
saveSettings: (data) => ipcRenderer.invoke('save-settings', data),
|
||||
showMessage: (options) => ipcRenderer.invoke('show-message', options),
|
||||
|
||||
|
||||
// 窗口控制
|
||||
isMaximized: () => ipcRenderer.invoke('is-maximized'),
|
||||
minimize: () => ipcRenderer.send('window-minimize'),
|
||||
maximize: () => ipcRenderer.send('window-maximize'),
|
||||
close: () => ipcRenderer.send('window-close'),
|
||||
|
||||
|
||||
// API 配置管理(单文件内多配置)
|
||||
listApiProfiles: () => ipcRenderer.invoke('list-api-profiles'),
|
||||
switchApiProfile: (profileName) => ipcRenderer.invoke('switch-api-profile', profileName),
|
||||
createApiProfile: (name) => ipcRenderer.invoke('create-api-profile', name),
|
||||
deleteApiProfile: (name) => ipcRenderer.invoke('delete-api-profile', name),
|
||||
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
BIN
screenshots/theme-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
screenshots/theme-solarized-dark.png
Normal file
BIN
screenshots/theme-solarized-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
screenshots/theme-xcode.png
Normal file
BIN
screenshots/theme-xcode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
1939
src/App.vue
1939
src/App.vue
File diff suppressed because it is too large
Load Diff
181
src/components/ApiProfileDialog.vue
Normal file
181
src/components/ApiProfileDialog.vue
Normal 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>
|
||||
80
src/components/Footer.test.js
Normal file
80
src/components/Footer.test.js
Normal 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
43
src/components/Footer.vue
Normal 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>
|
||||
94
src/components/InputDialog.vue
Normal file
94
src/components/InputDialog.vue
Normal 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>
|
||||
113
src/components/MessageDialog.vue
Normal file
113
src/components/MessageDialog.vue
Normal 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>
|
||||
199
src/components/ServerPanel.vue
Normal file
199
src/components/ServerPanel.vue
Normal 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>
|
||||
164
src/components/SideBar.test.js
Normal file
164
src/components/SideBar.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
97
src/components/SideBar.vue
Normal file
97
src/components/SideBar.vue
Normal 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>
|
||||
131
src/components/TitleBar.test.js
Normal file
131
src/components/TitleBar.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
86
src/components/TitleBar.vue
Normal file
86
src/components/TitleBar.vue
Normal 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
130
src/locales/en-US.js
Normal 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
130
src/locales/index.js
Normal 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
130
src/locales/ja-JP.js
Normal 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': '日本語'
|
||||
}
|
||||
}
|
||||
23
src/main.js
23
src/main.js
@@ -1,4 +1,21 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import { createApp } from '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
749
src/styles/global.less
Normal 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
344
src/views/ApiConfig.test.js
Normal 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
246
src/views/ApiConfig.vue
Normal 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>
|
||||
157
src/views/GeneralSettings.test.js
Normal file
157
src/views/GeneralSettings.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
77
src/views/GeneralSettings.vue
Normal file
77
src/views/GeneralSettings.vue
Normal 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>
|
||||
256
src/views/McpServers.test.js
Normal file
256
src/views/McpServers.test.js
Normal 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
156
src/views/McpServers.vue
Normal 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>
|
||||
@@ -13,5 +13,12 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
less: {
|
||||
javascriptEnabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
29
vitest.config.js
Normal file
29
vitest.config.js
Normal 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
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user