You've already forked iFlow-Settings-Editor-GUI
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20b065af5b | |||
| cf1070a279 | |||
| 9c282962da | |||
| 978c4c295c | |||
| 400e617528 | |||
| 2a43b8a838 | |||
| aa375bfff0 | |||
| 2d2804ef22 | |||
| 3577e139b9 | |||
| d184cfef6e | |||
| 5ee6ee75f1 | |||
| 538d1ffb50 | |||
| b1de0e14f1 | |||
| 3329e0ddbf | |||
| cdddcccfe0 | |||
| 0318c67ea7 |
201
AGENTS.md
201
AGENTS.md
@@ -15,20 +15,45 @@ iFlow 设置编辑器是一个基于 Electron + Vue 3 的桌面应用程序,
|
|||||||
| @vitejs/plugin-vue | ^6.0.6 | Vue 插件 |
|
| @vitejs/plugin-vue | ^6.0.6 | Vue 插件 |
|
||||||
| concurrently | ^8.2.2 | 并发执行工具 |
|
| concurrently | ^8.2.2 | 并发执行工具 |
|
||||||
| electron-builder | ^24.13.3 | 应用打包工具 |
|
| electron-builder | ^24.13.3 | 应用打包工具 |
|
||||||
|
| vitest | ^4.1.4 | 单元测试框架 |
|
||||||
|
| @vue/test-utils | ^2.4.6 | Vue 组件测试工具 |
|
||||||
|
| happy-dom | ^20.9.0 | 浏览器环境模拟 |
|
||||||
|
| vue-i18n | ^9.14.5 | 国际化支持 |
|
||||||
|
| less | ^4.6.4 | CSS 预处理器 |
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
iflow-settings-editor/
|
iflow-settings-editor/
|
||||||
├── main.js # Electron 主进程 (窗口管理、IPC、文件操作)
|
├── main.js # Electron 主进程 (窗口管理、IPC、文件操作、系统托盘)
|
||||||
├── preload.js # 预加载脚本 (contextBridge API)
|
├── preload.js # 预加载脚本 (contextBridge API)
|
||||||
├── index.html # HTML 入口
|
├── index.html # HTML 入口
|
||||||
├── package.json # 项目配置
|
├── package.json # 项目配置
|
||||||
├── vite.config.js # Vite 配置
|
├── vite.config.js # Vite 配置
|
||||||
|
├── vitest.config.js # Vitest 测试配置
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── main.js # Vue 入口
|
│ ├── main.js # Vue 入口
|
||||||
│ └── App.vue # 主组件 (所有业务逻辑)
|
│ ├── 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
|
||||||
|
│ └── locales/ # 国际化资源
|
||||||
|
│ ├── index.js
|
||||||
|
│ ├── en-US.js
|
||||||
|
│ └── ja-JP.js
|
||||||
├── build/ # 构建资源 (图标等)
|
├── build/ # 构建资源 (图标等)
|
||||||
|
├── dist/ # Vite 构建输出
|
||||||
├── release/ # 打包输出目录
|
├── release/ # 打包输出目录
|
||||||
└── screenshots/ # 截图资源
|
└── screenshots/ # 截图资源
|
||||||
```
|
```
|
||||||
@@ -36,7 +61,7 @@ iflow-settings-editor/
|
|||||||
## 核心架构
|
## 核心架构
|
||||||
|
|
||||||
### 进程模型
|
### 进程模型
|
||||||
- **Main Process (main.js)**: Electron 主进程,处理窗口管理、IPC 通信、文件系统操作
|
- **Main Process (main.js)**: Electron 主进程,处理窗口管理、IPC 通信、文件系统操作、系统托盘
|
||||||
- **Preload (preload.js)**: 通过 `contextBridge.exposeInMainWorld` 暴露安全 API
|
- **Preload (preload.js)**: 通过 `contextBridge.exposeInMainWorld` 暴露安全 API
|
||||||
- **Renderer (Vue)**: 渲染进程,只通过 preload 暴露的 API 与主进程通信
|
- **Renderer (Vue)**: 渲染进程,只通过 preload 暴露的 API 与主进程通信
|
||||||
|
|
||||||
@@ -44,19 +69,32 @@ iflow-settings-editor/
|
|||||||
```javascript
|
```javascript
|
||||||
// preload.js 暴露的 API
|
// preload.js 暴露的 API
|
||||||
window.electronAPI = {
|
window.electronAPI = {
|
||||||
|
// 基本设置操作
|
||||||
loadSettings: () => ipcRenderer.invoke('load-settings'),
|
loadSettings: () => ipcRenderer.invoke('load-settings'),
|
||||||
saveSettings: (data) => ipcRenderer.invoke('save-settings', data),
|
saveSettings: (data) => ipcRenderer.invoke('save-settings', data),
|
||||||
showMessage: (options) => ipcRenderer.invoke('show-message', options),
|
showMessage: (options) => ipcRenderer.invoke('show-message', options),
|
||||||
|
|
||||||
|
// 窗口控制
|
||||||
|
isMaximized: () => ipcRenderer.invoke('is-maximized'),
|
||||||
|
minimize: () => ipcRenderer.send('window-minimize'),
|
||||||
|
maximize: () => ipcRenderer.send('window-maximize'),
|
||||||
|
close: () => ipcRenderer.send('window-close'),
|
||||||
|
|
||||||
|
// API 配置管理(单文件内多配置)
|
||||||
listApiProfiles: () => ipcRenderer.invoke('list-api-profiles'),
|
listApiProfiles: () => ipcRenderer.invoke('list-api-profiles'),
|
||||||
switchApiProfile: (profileName) => ipcRenderer.invoke('switch-api-profile', profileName),
|
switchApiProfile: (profileName) => ipcRenderer.invoke('switch-api-profile', profileName),
|
||||||
createApiProfile: (name) => ipcRenderer.invoke('create-api-profile', name),
|
createApiProfile: (name) => ipcRenderer.invoke('create-api-profile', name),
|
||||||
deleteApiProfile: (name) => ipcRenderer.invoke('delete-api-profile', name),
|
deleteApiProfile: (name) => ipcRenderer.invoke('delete-api-profile', name),
|
||||||
renameApiProfile: (oldName, newName) => ipcRenderer.invoke('rename-api-profile', oldName, newName),
|
renameApiProfile: (oldName, newName) => ipcRenderer.invoke('rename-api-profile', oldName, newName),
|
||||||
duplicateApiProfile: (name, newName) => ipcRenderer.invoke('duplicate-api-profile', name, newName),
|
duplicateApiProfile: (sourceName, newName) => ipcRenderer.invoke('duplicate-api-profile', sourceName, newName),
|
||||||
isMaximized: () => ipcRenderer.invoke('is-maximized'),
|
|
||||||
minimize: () => ipcRenderer.send('window-minimize'),
|
// 托盘事件监听
|
||||||
maximize: () => ipcRenderer.send('window-maximize'),
|
onApiProfileSwitched: (callback) => {
|
||||||
close: () => ipcRenderer.send('window-close')
|
ipcRenderer.on('api-profile-switched', (event, profileName) => callback(profileName))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 语言切换通知
|
||||||
|
notifyLanguageChanged: () => ipcRenderer.send('language-changed')
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -64,12 +102,22 @@ window.electronAPI = {
|
|||||||
- 窗口尺寸: 1100x750,最小尺寸: 900x600
|
- 窗口尺寸: 1100x750,最小尺寸: 900x600
|
||||||
- 无边框窗口 (frame: false),自定义标题栏
|
- 无边框窗口 (frame: false),自定义标题栏
|
||||||
- 开发模式加载 `http://localhost:5173`,生产模式加载 `dist/index.html`
|
- 开发模式加载 `http://localhost:5173`,生产模式加载 `dist/index.html`
|
||||||
|
- **关闭窗口时隐藏到系统托盘**,双击托盘图标可重新显示
|
||||||
|
|
||||||
|
### 系统托盘
|
||||||
|
- 托盘图标显示应用状态
|
||||||
|
- 右键菜单支持:
|
||||||
|
- 显示主窗口
|
||||||
|
- 切换 API 配置(显示所有配置列表,当前配置带勾选标记)
|
||||||
|
- 退出应用
|
||||||
|
- 双击托盘图标显示主窗口
|
||||||
|
|
||||||
### API 配置切换
|
### API 配置切换
|
||||||
- 支持多环境配置: 默认配置、开发环境、预发布环境、生产环境
|
- 支持多环境配置: 默认配置,开发环境、预发布环境、生产环境
|
||||||
- 配置文件管理: 支持创建、编辑、复制、删除、重命名
|
- 配置文件管理: 支持创建、编辑、复制、删除、重命名
|
||||||
- 单独保存每个环境的 API 配置到 `apiProfiles` 对象
|
- 单独保存每个环境的 API 配置到 `apiProfiles` 对象
|
||||||
- 切换配置时直接应用新配置,无需确认
|
- 切换配置时直接应用新配置,无需确认
|
||||||
|
- 支持从系统托盘快速切换配置
|
||||||
|
|
||||||
## 可用命令
|
## 可用命令
|
||||||
|
|
||||||
@@ -87,20 +135,24 @@ npm run build:win32 # 构建 Windows x86 安装包
|
|||||||
npm run build:win-portable # 构建可移植版本
|
npm run build:win-portable # 构建可移植版本
|
||||||
npm run build:win-installer # 构建 NSIS 安装包
|
npm run build:win-installer # 构建 NSIS 安装包
|
||||||
npm run dist # 完整构建和打包
|
npm run dist # 完整构建和打包
|
||||||
|
npm run test # 运行所有测试(监听模式)
|
||||||
|
npm run test:run # 运行测试一次
|
||||||
|
npm run test:ui # 运行测试 UI 界面
|
||||||
|
npm run test:coverage # 生成测试覆盖率报告
|
||||||
```
|
```
|
||||||
|
|
||||||
## 功能模块
|
## 功能模块
|
||||||
|
|
||||||
### 1. 常规设置 (General)
|
### 1. 常规设置 (General)
|
||||||
- **语言**: zh-CN / en-US / ja-JP
|
- **语言**: zh-CN / en-US / ja-JP
|
||||||
- **主题**: Xcode / Dark / Light / Solarized Dark
|
- **主题**: Xcode / Dark / Solarized Dark
|
||||||
- **启动动画**: 已显示 / 未显示
|
- **启动动画**: 已显示 / 未显示
|
||||||
- **检查点保存**: 启用 / 禁用
|
- **检查点保存**: 启用 / 禁用
|
||||||
|
|
||||||
### 2. API 配置 (API)
|
### 2. API 配置 (API)
|
||||||
- **配置列表**: 显示所有可用的 API 配置文件
|
- **配置列表**: 显示所有可用的 API 配置文件,带彩色图标和状态标记
|
||||||
- **配置切换**: 点击配置卡片直接切换,无需确认
|
- **配置切换**: 点击配置卡片直接切换,无需确认
|
||||||
- **创建配置**: 新建 API 配置文件
|
- **创建配置**: 新建 API 配置文件(支持设置认证方式、API Key、Base URL 等)
|
||||||
- **编辑配置**: 修改现有配置的认证方式、API Key、Base URL 等
|
- **编辑配置**: 修改现有配置的认证方式、API Key、Base URL 等
|
||||||
- **复制配置**: 基于现有配置创建新配置
|
- **复制配置**: 基于现有配置创建新配置
|
||||||
- **删除配置**: 删除非默认配置
|
- **删除配置**: 删除非默认配置
|
||||||
@@ -112,8 +164,9 @@ npm run dist # 完整构建和打包
|
|||||||
- **CNA**: CNA 标识
|
- **CNA**: CNA 标识
|
||||||
|
|
||||||
### 3. MCP 服务器管理 (MCP)
|
### 3. MCP 服务器管理 (MCP)
|
||||||
- 服务器列表展示
|
- 服务器列表展示(带描述信息)
|
||||||
- 添加/编辑/删除服务器
|
- 添加/编辑/删除服务器
|
||||||
|
- 侧边面板编辑界面
|
||||||
- 服务器配置: 名称、描述、命令、工作目录、参数(每行一个)、环境变量(JSON)
|
- 服务器配置: 名称、描述、命令、工作目录、参数(每行一个)、环境变量(JSON)
|
||||||
|
|
||||||
## 关键实现细节
|
## 关键实现细节
|
||||||
@@ -144,6 +197,12 @@ if (fs.existsSync(SETTINGS_FILE)) {
|
|||||||
- `currentServerName` - 当前选中的 MCP 服务器
|
- `currentServerName` - 当前选中的 MCP 服务器
|
||||||
- `currentApiProfile` - 当前使用的 API 配置名称
|
- `currentApiProfile` - 当前使用的 API 配置名称
|
||||||
- `apiProfiles` - 可用的 API 配置列表
|
- `apiProfiles` - 可用的 API 配置列表
|
||||||
|
- `isLoading` - 加载状态标志
|
||||||
|
|
||||||
|
### API 配置字段
|
||||||
|
```javascript
|
||||||
|
const API_FIELDS = ['selectedAuthType', 'apiKey', 'baseUrl', 'modelName', 'searchApiKey', 'cna'];
|
||||||
|
```
|
||||||
|
|
||||||
### 数据初始化
|
### 数据初始化
|
||||||
在 `loadSettings` 函数中确保所有字段都有默认值:
|
在 `loadSettings` 函数中确保所有字段都有默认值:
|
||||||
@@ -151,7 +210,7 @@ if (fs.existsSync(SETTINGS_FILE)) {
|
|||||||
- `theme`: 'Xcode'
|
- `theme`: 'Xcode'
|
||||||
- `bootAnimationShown`: true
|
- `bootAnimationShown`: true
|
||||||
- `checkpointing`: { enabled: true }
|
- `checkpointing`: { enabled: true }
|
||||||
- `selectedAuthType`: 'iflow'
|
- `selectedAuthType`: 'openai-compatible'
|
||||||
- `apiKey`: ''
|
- `apiKey`: ''
|
||||||
- `baseUrl`: ''
|
- `baseUrl`: ''
|
||||||
- `modelName`: ''
|
- `modelName`: ''
|
||||||
@@ -159,23 +218,110 @@ if (fs.existsSync(SETTINGS_FILE)) {
|
|||||||
- `cna`: ''
|
- `cna`: ''
|
||||||
- `apiProfiles`: { default: {} }
|
- `apiProfiles`: { default: {} }
|
||||||
- `currentApiProfile`: 'default'
|
- `currentApiProfile`: 'default'
|
||||||
|
- `mcpServers`: {}
|
||||||
|
|
||||||
|
## 设计系统
|
||||||
|
|
||||||
|
### Windows UI Kit - Fluent Design
|
||||||
|
|
||||||
|
本项目采用 Windows 11 Fluent Design 设计规范,实现统一的视觉效果。
|
||||||
|
|
||||||
|
#### 主题变量
|
||||||
|
|
||||||
|
**Light 模式:**
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--bg-primary: rgba(243, 243, 243, 0.85);
|
||||||
|
--bg-secondary: rgba(255, 255, 255, 0.70);
|
||||||
|
--bg-tertiary: #ebebeb;
|
||||||
|
--bg-elevated: rgba(255, 255, 255, 0.95);
|
||||||
|
--text-primary: #1a1a1a;
|
||||||
|
--text-secondary: #5d5d5d;
|
||||||
|
--accent: #0078d4;
|
||||||
|
--accent-hover: #106ebe;
|
||||||
|
--border: #e0e0e0;
|
||||||
|
--control-fill: rgba(249, 249, 249, 0.85);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dark 模式:**
|
||||||
|
```css
|
||||||
|
.dark {
|
||||||
|
--bg-primary: #1f1f1f;
|
||||||
|
--bg-secondary: #2d2d2d;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--accent: #60cdff;
|
||||||
|
--control-fill: #333333;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solarized Dark 模式:**
|
||||||
|
```css
|
||||||
|
.solarized-dark {
|
||||||
|
--bg-primary: #002b36;
|
||||||
|
--bg-secondary: #073642;
|
||||||
|
--text-primary: #839496;
|
||||||
|
--accent: #268bd2;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 设计原则
|
||||||
|
- **Mica -inspired 层次感**: 使用半透明背景和分层深度
|
||||||
|
- **圆角系统**: 4px / 6px / 8px / 12px 四级圆角
|
||||||
|
- **阴影层次**: sm / md / lg / xl 四级阴影
|
||||||
|
- **过渡动画**: 0.1s-0.2s 流畅曲线
|
||||||
|
- **Segoe UI Variable 字体**: Windows 11 原生字体
|
||||||
|
|
||||||
|
## 测试框架 (Vitest)
|
||||||
|
|
||||||
|
**测试配置**:
|
||||||
|
- 使用 Vitest 作为测试运行器
|
||||||
|
- 使用 `@vue/test-utils` 进行 Vue 组件测试
|
||||||
|
- 使用 `happy-dom` 作为浏览器环境模拟
|
||||||
|
- 配置文件:`vitest.config.js`
|
||||||
|
- 全局变量启用:`globals: true`
|
||||||
|
- 覆盖率工具:`v8`
|
||||||
|
- 覆盖率报告格式:text、json、html
|
||||||
|
|
||||||
|
**测试文件结构**:
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── Footer.test.js # Footer 组件测试
|
||||||
|
│ ├── SideBar.test.js # 侧边栏测试
|
||||||
|
│ └── TitleBar.test.js # 标题栏测试
|
||||||
|
└── views/
|
||||||
|
├── ApiConfig.test.js # API 配置测试
|
||||||
|
├── GeneralSettings.test.js # 常规设置测试
|
||||||
|
└── McpServers.test.js # MCP 服务器测试
|
||||||
|
```
|
||||||
|
|
||||||
|
**测试命令**:
|
||||||
|
```bash
|
||||||
|
npm run test # 运行所有测试(监听模式)
|
||||||
|
npm run test:run # 运行测试一次
|
||||||
|
npm run test:ui # 运行测试 UI 界面 (http://localhost:5174/__vitest__/)
|
||||||
|
npm run test:coverage # 生成测试覆盖率报告
|
||||||
|
```
|
||||||
|
|
||||||
## 开发注意事项
|
## 开发注意事项
|
||||||
|
|
||||||
1. **修改检测**: 通过 `watch(settings, () => { modified.value = true }, { deep: true })` 深度监听
|
1. **修改检测**: 通过 `watch(settings, () => { modified.value = true }, { deep: true })` 深度监听
|
||||||
2. **服务器编辑**: 使用 DOM 操作收集表单数据 (`collectServerData`)
|
2. **服务器编辑**: 使用侧边面板 (Side Panel) 收集表单数据
|
||||||
3. **MCP 参数**: 每行一个参数,通过换行分割
|
3. **MCP 参数**: 每行一个参数,通过换行分割
|
||||||
4. **环境变量**: 支持 JSON 格式输入
|
4. **环境变量**: 支持 JSON 格式输入
|
||||||
5. **窗口控制**: 通过 IPC 发送指令,主进程处理实际窗口操作
|
5. **窗口控制**: 通过 IPC 发送指令,主进程处理实际窗口操作
|
||||||
6. **API 配置切换**: 多个环境配置存储在 `settings.apiProfiles` 对象中
|
6. **API 配置切换**: 多个环境配置存储在 `settings.apiProfiles` 对象中
|
||||||
7. **序列化问题**: IPC 通信使用 `JSON.parse(JSON.stringify())` 避免 Vue 响应式代理问题
|
7. **序列化问题**: IPC 通信使用 `JSON.parse(JSON.stringify())` 避免 Vue 响应式代理问题
|
||||||
8. **默认值处理**: 加载配置时检查 `undefined` 并应用默认值,防止界面显示异常
|
8. **默认值处理**: 加载配置时检查 `undefined` 并应用默认值,防止界面显示异常
|
||||||
|
9. **托盘切换事件**: 监听 `onApiProfileSwitched` 处理托盘发起的配置切换
|
||||||
|
10. **样式系统**: 使用 Windows UI Kit 设计系统,所有变量在 `global.less` 中定义
|
||||||
|
|
||||||
## 图标使用
|
## 图标使用
|
||||||
|
|
||||||
使用 `@icon-park/vue-next` 图标库:
|
使用 `@icon-park/vue-next` 图标库:
|
||||||
```javascript
|
```javascript
|
||||||
import { Refresh, Save, Config, Key, Server, Globe, Setting, Robot, Search, Add, Edit, Delete, Exchange, Copy } from '@icon-park/vue-next';
|
import { Save, Config, Key, Server, Globe, Setting, Add, Edit, Delete, Exchange, Copy } from '@icon-park/vue-next';
|
||||||
```
|
```
|
||||||
|
|
||||||
## 打包配置
|
## 打包配置
|
||||||
@@ -187,10 +333,31 @@ import { Refresh, Save, Config, Key, Server, Globe, Setting, Robot, Search, Add,
|
|||||||
- 允许修改安装目录
|
- 允许修改安装目录
|
||||||
- 允许提升权限
|
- 允许提升权限
|
||||||
- 创建桌面和开始菜单快捷方式
|
- 创建桌面和开始菜单快捷方式
|
||||||
- 支持中文和英文界面
|
- 支持中文和英文界面 (zh_CN, en_US)
|
||||||
- 卸载时保留用户数据
|
- 卸载时保留用户数据
|
||||||
|
|
||||||
### 输出目录
|
### 输出目录
|
||||||
- `release/` - 所有打包输出的根目录
|
- `release/` - 所有打包输出的根目录
|
||||||
- 安装包命名: `iFlow Settings Editor-${version}-${arch}-setup.${ext}`
|
- 安装包命名: `iFlow Settings Editor-${version}-${arch}-setup.${ext}`
|
||||||
- 可移植版本命名: `iFlow Settings Editor-${version}-portable.${ext}`
|
- 可移植版本命名: `iFlow Settings Editor-${version}-portable.${ext}`
|
||||||
|
|
||||||
|
## UI 组件
|
||||||
|
|
||||||
|
### 对话框类型
|
||||||
|
- **输入对话框**: 用于重命名、复制等需要文本输入的场景
|
||||||
|
- **确认对话框**: 用于删除等需要确认的操作
|
||||||
|
- **消息对话框**: 显示 info/success/warning/error 四种类型,带图标
|
||||||
|
|
||||||
|
### 侧边面板
|
||||||
|
- MCP 服务器编辑使用侧边面板 (从右侧滑入)
|
||||||
|
- API 配置编辑使用模态对话框
|
||||||
|
|
||||||
|
## 版本历史
|
||||||
|
|
||||||
|
| 版本 | 日期 | 主要变更 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 1.6.0 | 2026-04-18 | 架构:重构样式系统,采用 Windows 11 Fluent Design 规范 |
|
||||||
|
| 1.5.1 | 2026-04-17 | 新增系统托盘功能,托盘快速切换 API 配置 |
|
||||||
|
| 1.5.0 | 2026-04-16 | 新增自定义消息对话框,API 配置重命名 |
|
||||||
|
| 1.4.0 | 2026-04-14 | 新增多环境配置文件管理 |
|
||||||
|
| 1.0.0 | 2026-04-14 | 项目初始化 |
|
||||||
|
|||||||
74
CHANGELOG.md
Normal file
74
CHANGELOG.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# 更新日志
|
||||||
|
|
||||||
|
所有重要的版本更新都会记录在此文件中。
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-04-18
|
||||||
|
|
||||||
|
### 架构
|
||||||
|
- **重构样式系统:采用 Windows 11 Fluent Design 规范**
|
||||||
|
- 完整实现 Windows UI Kit 设计系统
|
||||||
|
- 三种主题支持:Xcode / Dark / Solarized Dark
|
||||||
|
- Mica-inspired 半透明层次设计
|
||||||
|
- Segoe UI Variable 字体系统
|
||||||
|
- 四级圆角和阴影层次
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- **vue-i18n 国际化支持**
|
||||||
|
- **less CSS 预处理器**
|
||||||
|
|
||||||
|
## [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 通信
|
||||||
|
- 无边框窗口
|
||||||
|
- 自定义标题栏
|
||||||
|
- 最小化、最大化、关闭按钮
|
||||||
68
README.md
68
README.md
@@ -12,20 +12,36 @@
|
|||||||
| @icon-park/vue-next | ^1.4.2 | 图标库 |
|
| @icon-park/vue-next | ^1.4.2 | 图标库 |
|
||||||
| concurrently | ^8.2.2 | 并发执行工具 |
|
| concurrently | ^8.2.2 | 并发执行工具 |
|
||||||
| electron-builder | ^24.13.3 | 应用打包工具 |
|
| electron-builder | ^24.13.3 | 应用打包工具 |
|
||||||
|
| vue-i18n | ^9.14.5 | 国际化支持 |
|
||||||
|
| less | ^4.6.4 | CSS 预处理器 |
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
iflow-settings-editor/
|
iflow-settings-editor/
|
||||||
├── main.js # Electron 主进程 (窗口管理、IPC、文件操作)
|
├── main.js # Electron 主进程 (窗口管理、IPC、文件操作、系统托盘)
|
||||||
├── preload.js # 预加载脚本 (IPC 通信)
|
├── preload.js # 预加载脚本 (contextBridge API)
|
||||||
├── index.html # HTML 入口
|
├── index.html # HTML 入口
|
||||||
├── package.json # 项目配置
|
├── package.json # 项目配置
|
||||||
├── vite.config.js # Vite 配置
|
├── vite.config.js # Vite 配置
|
||||||
|
├── vitest.config.js # Vitest 测试配置
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── main.js # Vue 入口
|
│ ├── main.js # Vue 入口
|
||||||
│ └── App.vue # 主组件 (所有业务逻辑)
|
│ ├── components/ # 可复用组件
|
||||||
|
│ │ ├── ApiProfileDialog.vue
|
||||||
|
│ │ ├── Footer.vue
|
||||||
|
│ │ ├── InputDialog.vue
|
||||||
|
│ │ ├── MessageDialog.vue
|
||||||
|
│ │ ├── ServerPanel.vue
|
||||||
|
│ │ ├── SideBar.vue
|
||||||
|
│ │ └── TitleBar.vue
|
||||||
|
│ ├── views/ # 页面视图组件
|
||||||
|
│ │ ├── ApiConfig.vue
|
||||||
|
│ │ ├── GeneralSettings.vue
|
||||||
|
│ │ └── McpServers.vue
|
||||||
|
│ └── App.vue # 主组件 (所有业务逻辑、UI 组件)
|
||||||
├── build/ # 构建资源 (图标等)
|
├── build/ # 构建资源 (图标等)
|
||||||
|
├── dist/ # Vite 构建输出
|
||||||
├── release/ # 打包输出目录
|
├── release/ # 打包输出目录
|
||||||
└── screenshots/ # 截图资源
|
└── screenshots/ # 截图资源
|
||||||
```
|
```
|
||||||
@@ -61,7 +77,8 @@ npm run build:win # 构建 Windows 安装包 (NSIS)
|
|||||||
npm run build:win64 # 构建 Windows x64 安装包
|
npm run build:win64 # 构建 Windows x64 安装包
|
||||||
npm run build:win32 # 构建 Windows x86 安装包
|
npm run build:win32 # 构建 Windows x86 安装包
|
||||||
npm run build:win-portable # 构建可移植版本
|
npm run build:win-portable # 构建可移植版本
|
||||||
npm run build:dist # 完整构建和打包
|
npm run build:win-installer # 构建 NSIS 安装包
|
||||||
|
npm run dist # 完整构建和打包
|
||||||
```
|
```
|
||||||
|
|
||||||
## 功能模块
|
## 功能模块
|
||||||
@@ -73,7 +90,7 @@ npm run build:dist # 完整构建和打包
|
|||||||
配置应用程序的常规选项:
|
配置应用程序的常规选项:
|
||||||
|
|
||||||
- **语言**: zh-CN / en-US / ja-JP
|
- **语言**: zh-CN / en-US / ja-JP
|
||||||
- **主题**: Xcode / Dark / Light / Solarized Dark
|
- **主题**: Xcode / Dark / Solarized Dark
|
||||||
- **启动动画**: 已显示 / 未显示
|
- **启动动画**: 已显示 / 未显示
|
||||||
- **检查点保存**: 启用 / 禁用
|
- **检查点保存**: 启用 / 禁用
|
||||||
|
|
||||||
@@ -81,7 +98,7 @@ npm run build:dist # 完整构建和打包
|
|||||||
|
|
||||||
管理多个环境的 API 配置:
|
管理多个环境的 API 配置:
|
||||||
|
|
||||||
- **配置列表**: 显示所有可用的 API 配置文件
|
- **配置列表**: 显示所有可用的 API 配置文件,带彩色图标和状态标记
|
||||||
- **配置切换**: 点击配置卡片直接切换
|
- **配置切换**: 点击配置卡片直接切换
|
||||||
- **创建配置**: 新建 API 配置文件
|
- **创建配置**: 新建 API 配置文件
|
||||||
- **编辑配置**: 修改现有配置的认证方式、API Key、Base URL 等
|
- **编辑配置**: 修改现有配置的认证方式、API Key、Base URL 等
|
||||||
@@ -98,9 +115,9 @@ npm run build:dist # 完整构建和打包
|
|||||||
|
|
||||||
管理 Model Context Protocol 服务器配置:
|
管理 Model Context Protocol 服务器配置:
|
||||||
|
|
||||||
- **服务器列表**: 显示所有已配置的服务器
|
- **服务器列表**: 显示所有已配置的服务器,带描述信息
|
||||||
- **添加服务器**: 创建新的 MCP 服务器配置
|
- **添加服务器**: 创建新的 MCP 服务器配置
|
||||||
- **编辑服务器**: 修改现有服务器的配置
|
- **编辑服务器**: 通过侧边面板修改现有服务器配置
|
||||||
- **删除服务器**: 移除服务器配置
|
- **删除服务器**: 移除服务器配置
|
||||||
- **服务器配置项**:
|
- **服务器配置项**:
|
||||||
- 名称
|
- 名称
|
||||||
@@ -113,7 +130,7 @@ npm run build:dist # 完整构建和打包
|
|||||||
## 核心架构
|
## 核心架构
|
||||||
|
|
||||||
### 进程模型
|
### 进程模型
|
||||||
- **Main Process (main.js)**: Electron 主进程,处理窗口管理、IPC 通信、文件系统操作
|
- **Main Process (main.js)**: Electron 主进程,处理窗口管理、IPC 通信、文件系统操作、系统托盘
|
||||||
- **Preload (preload.js)**: 通过 `contextBridge.exposeInMainWorld` 暴露安全 API
|
- **Preload (preload.js)**: 通过 `contextBridge.exposeInMainWorld` 暴露安全 API
|
||||||
- **Renderer (Vue)**: 渲染进程,只通过 preload 暴露的 API 与主进程通信
|
- **Renderer (Vue)**: 渲染进程,只通过 preload 暴露的 API 与主进程通信
|
||||||
|
|
||||||
@@ -121,12 +138,37 @@ npm run build:dist # 完整构建和打包
|
|||||||
- 窗口尺寸: 1100x750,最小尺寸: 900x600
|
- 窗口尺寸: 1100x750,最小尺寸: 900x600
|
||||||
- 无边框窗口 (frame: false),自定义标题栏
|
- 无边框窗口 (frame: false),自定义标题栏
|
||||||
- 开发模式加载 `http://localhost:5173`,生产模式加载 `dist/index.html`
|
- 开发模式加载 `http://localhost:5173`,生产模式加载 `dist/index.html`
|
||||||
|
- **关闭窗口时隐藏到系统托盘**,双击托盘图标可重新显示
|
||||||
|
|
||||||
|
### 系统托盘
|
||||||
|
- 托盘图标显示应用状态
|
||||||
|
- 右键菜单支持:
|
||||||
|
- 显示主窗口
|
||||||
|
- 切换 API 配置(显示所有配置列表,当前配置带勾选标记)
|
||||||
|
- 退出应用
|
||||||
|
- 双击托盘图标显示主窗口
|
||||||
|
|
||||||
### 安全配置
|
### 安全配置
|
||||||
- `contextIsolation: true` - 隔离上下文
|
- `contextIsolation: true` - 隔离上下文
|
||||||
- `nodeIntegration: false` - 禁用 Node.js
|
- `nodeIntegration: false` - 禁用 Node.js
|
||||||
- `webSecurity: false` - 仅开发环境解决 CSP 问题
|
- `webSecurity: false` - 仅开发环境解决 CSP 问题
|
||||||
|
|
||||||
|
## 设计系统
|
||||||
|
|
||||||
|
本项目采用 **Windows 11 Fluent Design** 设计规范,实现统一的视觉效果。
|
||||||
|
|
||||||
|
### 主题支持
|
||||||
|
- **Xcode**: macOS 风格浅色主题
|
||||||
|
- **Dark**: Windows 11 风格深色主题
|
||||||
|
- **Solarized Dark**: Solarized 配色深色主题
|
||||||
|
|
||||||
|
### 设计特点
|
||||||
|
- **Mica-inspired 层次感**: 使用半透明背景和分层深度
|
||||||
|
- **圆角系统**: 4px / 6px / 8px / 12px 四级圆角
|
||||||
|
- **阴影层次**: sm / md / lg / xl 四级阴影
|
||||||
|
- **过渡动画**: 0.1s-0.2s 流畅曲线
|
||||||
|
- **Segoe UI Variable 字体**: Windows 11 原生字体
|
||||||
|
|
||||||
## 打包配置
|
## 打包配置
|
||||||
|
|
||||||
### Windows 平台
|
### Windows 平台
|
||||||
@@ -136,7 +178,7 @@ npm run build:dist # 完整构建和打包
|
|||||||
- 允许修改安装目录
|
- 允许修改安装目录
|
||||||
- 允许提升权限
|
- 允许提升权限
|
||||||
- 创建桌面和开始菜单快捷方式
|
- 创建桌面和开始菜单快捷方式
|
||||||
- 支持中文和英文界面界面
|
- 支持中文和英文界面 (zh_CN, en_US)
|
||||||
- 卸载时保留用户数据
|
- 卸载时保留用户数据
|
||||||
|
|
||||||
### 输出目录
|
### 输出目录
|
||||||
@@ -150,18 +192,22 @@ npm run build:dist # 完整构建和打包
|
|||||||
- 保存设置时会自动创建备份 (`settings.json.bak`)
|
- 保存设置时会自动创建备份 (`settings.json.bak`)
|
||||||
- MCP 服务器参数每行一个,环境变量支持 JSON 格式
|
- MCP 服务器参数每行一个,环境变量支持 JSON 格式
|
||||||
- API 配置切换时会直接应用新配置,未保存的更改会被替换
|
- API 配置切换时会直接应用新配置,未保存的更改会被替换
|
||||||
|
- 关闭窗口时应用会隐藏到系统托盘,不会退出应用
|
||||||
|
|
||||||
## 开发注意事项
|
## 开发注意事项
|
||||||
|
|
||||||
1. **修改检测**: 通过 `watch(settings, () => { modified.value = true }, { deep: true })` 深度监听
|
1. **修改检测**: 通过 `watch(settings, () => { modified.value = true }, { deep: true })` 深度监听
|
||||||
2. **服务器编辑**: 使用 DOM 操作收集表单数据
|
2. **服务器编辑**: 使用侧边面板 (Side Panel) 收集表单数据
|
||||||
3. **MCP 参数**: 每行一个参数,通过换行分割
|
3. **MCP 参数**: 每行一个参数,通过换行分割
|
||||||
4. **环境变量**: 支持 JSON 格式输入
|
4. **环境变量**: 支持 JSON 格式输入
|
||||||
5. **窗口控制**: 通过 IPC 发送指令,主进程处理实际窗口操作
|
5. **窗口控制**: 通过 IPC 发送指令,主进程处理实际窗口操作
|
||||||
6. **API 配置切换**: 多个环境配置存储在 `settings.apiProfiles` 对象中
|
6. **API 配置切换**: 多个环境配置存储在 `settings.apiProfiles` 对象中
|
||||||
7. **序列化问题**: IPC 通信使用 `JSON.parse(JSON.stringify())` 避免 Vue 响应式代理问题
|
7. **序列化问题**: IPC 通信使用 `JSON.parse(JSON.stringify())` 避免 Vue 响应式代理问题
|
||||||
8. **默认值处理**: 加载配置时检查 `undefined` 并应用默认值,防止界面显示异常
|
8. **默认值处理**: 加载配置时检查 `undefined` 并应用默认值,防止界面显示异常
|
||||||
|
9. **托盘切换事件**: 监听 `onApiProfileSwitched` 处理托盘发起的配置切换
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
|
Copyright © 2026 上海潘哆呐科技有限公司
|
||||||
|
|||||||
237
main.js
237
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 path = require('path')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
console.log('main.js loaded')
|
console.log('main.js loaded')
|
||||||
@@ -6,7 +6,192 @@ console.log('app.getPath("home"):', app.getPath('home'))
|
|||||||
const SETTINGS_FILE = path.join(app.getPath('home'), '.iflow', 'settings.json')
|
const SETTINGS_FILE = path.join(app.getPath('home'), '.iflow', 'settings.json')
|
||||||
console.log('SETTINGS_FILE:', SETTINGS_FILE)
|
console.log('SETTINGS_FILE:', SETTINGS_FILE)
|
||||||
let mainWindow
|
let mainWindow
|
||||||
|
let tray
|
||||||
const isDev = process.argv.includes('--dev')
|
const isDev = process.argv.includes('--dev')
|
||||||
|
|
||||||
|
// 主进程翻译
|
||||||
|
const trayTranslations = {
|
||||||
|
'zh-CN': {
|
||||||
|
showWindow: '显示主窗口',
|
||||||
|
switchApiConfig: '切换 API 配置',
|
||||||
|
exit: '退出',
|
||||||
|
tooltip: 'iFlow 设置编辑器'
|
||||||
|
},
|
||||||
|
'en-US': {
|
||||||
|
showWindow: 'Show Window',
|
||||||
|
switchApiConfig: 'Switch API Config',
|
||||||
|
exit: 'Exit',
|
||||||
|
tooltip: 'iFlow Settings Editor'
|
||||||
|
},
|
||||||
|
'ja-JP': {
|
||||||
|
showWindow: 'メインウィンドウを表示',
|
||||||
|
switchApiConfig: 'API 設定切替',
|
||||||
|
exit: '終了',
|
||||||
|
tooltip: 'iFlow 設定エディタ'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrayTranslation() {
|
||||||
|
const settings = readSettings()
|
||||||
|
const lang = settings?.language || 'zh-CN'
|
||||||
|
return trayTranslations[lang] || trayTranslations['zh-CN']
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误消息翻译
|
||||||
|
const errorTranslations = {
|
||||||
|
'zh-CN': {
|
||||||
|
configNotFound: '配置文件不存在',
|
||||||
|
configNotExist: '配置 "{name}" 不存在',
|
||||||
|
configAlreadyExists: '配置 "{name}" 已存在',
|
||||||
|
cannotDeleteDefault: '不能删除默认配置',
|
||||||
|
cannotRenameDefault: '不能重命名默认配置',
|
||||||
|
switchFailed: '切换API配置失败'
|
||||||
|
},
|
||||||
|
'en-US': {
|
||||||
|
configNotFound: 'Configuration file not found',
|
||||||
|
configNotExist: 'Configuration "{name}" does not exist',
|
||||||
|
configAlreadyExists: 'Configuration "{name}" already exists',
|
||||||
|
cannotDeleteDefault: 'Cannot delete default configuration',
|
||||||
|
cannotRenameDefault: 'Cannot rename default configuration',
|
||||||
|
switchFailed: 'Failed to switch API configuration'
|
||||||
|
},
|
||||||
|
'ja-JP': {
|
||||||
|
configNotFound: '設定ファイルが存在しません',
|
||||||
|
configNotExist: 'プロファイル "{name}" が存在しません',
|
||||||
|
configAlreadyExists: 'プロファイル "{name}" が既に存在します',
|
||||||
|
cannotDeleteDefault: 'デフォルトプロファイルは削除できません',
|
||||||
|
cannotRenameDefault: 'デフォルトプロファイルは名前変更できません',
|
||||||
|
switchFailed: 'API 設定の切替に失敗しました'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorTranslation() {
|
||||||
|
const settings = readSettings()
|
||||||
|
const lang = settings?.language || 'zh-CN'
|
||||||
|
return errorTranslations[lang] || errorTranslations['zh-CN']
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建系统托盘
|
||||||
|
function createTray() {
|
||||||
|
// 获取图标路径 - 打包后需要从 extraResources 获取
|
||||||
|
let iconPath
|
||||||
|
if (app.isPackaged) {
|
||||||
|
iconPath = path.join(process.resourcesPath, 'icon', 'icon.ico')
|
||||||
|
} else {
|
||||||
|
iconPath = path.join(__dirname, 'build', 'icon.ico')
|
||||||
|
}
|
||||||
|
|
||||||
|
let trayIcon
|
||||||
|
if (fs.existsSync(iconPath)) {
|
||||||
|
trayIcon = nativeImage.createFromPath(iconPath)
|
||||||
|
} else {
|
||||||
|
// 创建一个简单的图标
|
||||||
|
trayIcon = nativeImage.createEmpty()
|
||||||
|
}
|
||||||
|
// 调整图标大小以适应托盘
|
||||||
|
trayIcon = trayIcon.resize({ width: 16, height: 16 })
|
||||||
|
|
||||||
|
tray = new Tray(trayIcon)
|
||||||
|
tray.setToolTip(getTrayTranslation().tooltip)
|
||||||
|
|
||||||
|
updateTrayMenu()
|
||||||
|
|
||||||
|
// 双击托盘显示主窗口
|
||||||
|
tray.on('double-click', () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.show()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新托盘菜单
|
||||||
|
function updateTrayMenu() {
|
||||||
|
const settings = readSettings()
|
||||||
|
const profiles = settings?.apiProfiles || {}
|
||||||
|
const currentProfile = settings?.currentApiProfile || 'default'
|
||||||
|
const profileList = Object.keys(profiles).length > 0
|
||||||
|
? Object.keys(profiles)
|
||||||
|
: ['default']
|
||||||
|
|
||||||
|
const profileMenuItems = profileList.map(name => ({
|
||||||
|
label: name + (name === currentProfile ? ' ✓' : ''),
|
||||||
|
type: 'radio',
|
||||||
|
checked: name === currentProfile,
|
||||||
|
click: () => switchApiProfileFromTray(name)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const t = getTrayTranslation()
|
||||||
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: t.showWindow,
|
||||||
|
click: () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.show()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: t.switchApiConfig,
|
||||||
|
submenu: profileMenuItems
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: t.exit,
|
||||||
|
click: () => {
|
||||||
|
app.isQuitting = true
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
tray.setContextMenu(contextMenu)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从托盘切换 API 配置
|
||||||
|
function switchApiProfileFromTray(profileName) {
|
||||||
|
try {
|
||||||
|
const settings = readSettings()
|
||||||
|
if (!settings) return
|
||||||
|
|
||||||
|
const profiles = settings.apiProfiles || {}
|
||||||
|
if (!profiles[profileName]) return
|
||||||
|
|
||||||
|
// 保存当前配置到 apiProfiles
|
||||||
|
const currentProfile = settings.currentApiProfile || 'default'
|
||||||
|
if (profiles[currentProfile]) {
|
||||||
|
const currentConfig = {}
|
||||||
|
for (const field of API_FIELDS) {
|
||||||
|
if (settings[field] !== undefined) {
|
||||||
|
currentConfig[field] = settings[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
profiles[currentProfile] = currentConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 apiProfiles 加载新配置到主字段
|
||||||
|
const newConfig = profiles[profileName]
|
||||||
|
for (const field of API_FIELDS) {
|
||||||
|
if (newConfig[field] !== undefined) {
|
||||||
|
settings[field] = newConfig[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settings.currentApiProfile = profileName
|
||||||
|
settings.apiProfiles = profiles
|
||||||
|
writeSettings(settings)
|
||||||
|
|
||||||
|
updateTrayMenu()
|
||||||
|
// 通知渲染进程刷新
|
||||||
|
if (mainWindow && mainWindow.webContents) {
|
||||||
|
mainWindow.webContents.send('api-profile-switched', profileName)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('切换API配置失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
console.log('Creating window...')
|
console.log('Creating window...')
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
@@ -14,7 +199,7 @@ function createWindow() {
|
|||||||
height: 750,
|
height: 750,
|
||||||
minWidth: 900,
|
minWidth: 900,
|
||||||
minHeight: 600,
|
minHeight: 600,
|
||||||
backgroundColor: '#f3f3f3',
|
backgroundMaterial: 'acrylic', // on Windows 11
|
||||||
frame: false,
|
frame: false,
|
||||||
show: false,
|
show: false,
|
||||||
icon: path.join(__dirname, 'build', 'icon.ico'),
|
icon: path.join(__dirname, 'build', 'icon.ico'),
|
||||||
@@ -42,6 +227,7 @@ function createWindow() {
|
|||||||
mainWindow.once('ready-to-show', () => {
|
mainWindow.once('ready-to-show', () => {
|
||||||
console.log('Window ready to show')
|
console.log('Window ready to show')
|
||||||
mainWindow.show()
|
mainWindow.show()
|
||||||
|
createTray()
|
||||||
})
|
})
|
||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => {
|
||||||
mainWindow = null
|
mainWindow = null
|
||||||
@@ -49,7 +235,7 @@ function createWindow() {
|
|||||||
}
|
}
|
||||||
app.whenReady().then(createWindow)
|
app.whenReady().then(createWindow)
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin' && app.isQuitting) {
|
||||||
app.quit()
|
app.quit()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -67,8 +253,18 @@ ipcMain.on('window-maximize', () => {
|
|||||||
mainWindow.maximize()
|
mainWindow.maximize()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
ipcMain.on('window-close', () => mainWindow.close())
|
ipcMain.on('window-close', () => {
|
||||||
|
if (!app.isQuitting) {
|
||||||
|
mainWindow.hide()
|
||||||
|
} else {
|
||||||
|
mainWindow.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
ipcMain.handle('is-maximized', () => mainWindow.isMaximized())
|
ipcMain.handle('is-maximized', () => mainWindow.isMaximized())
|
||||||
|
// 监听语言切换以更新托盘菜单
|
||||||
|
ipcMain.on('language-changed', () => {
|
||||||
|
updateTrayMenu()
|
||||||
|
})
|
||||||
// API 配置相关的字段
|
// API 配置相关的字段
|
||||||
const API_FIELDS = ['selectedAuthType', 'apiKey', 'baseUrl', 'modelName', 'searchApiKey', 'cna']
|
const API_FIELDS = ['selectedAuthType', 'apiKey', 'baseUrl', 'modelName', 'searchApiKey', 'cna']
|
||||||
// 读取设置文件
|
// 读取设置文件
|
||||||
@@ -116,12 +312,13 @@ ipcMain.handle('list-api-profiles', async () => {
|
|||||||
ipcMain.handle('switch-api-profile', async (event, profileName) => {
|
ipcMain.handle('switch-api-profile', async (event, profileName) => {
|
||||||
try {
|
try {
|
||||||
const settings = readSettings()
|
const settings = readSettings()
|
||||||
|
const t = getErrorTranslation()
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
return { success: false, error: '配置文件不存在' }
|
return { success: false, error: t.configNotFound }
|
||||||
}
|
}
|
||||||
const profiles = settings.apiProfiles || {}
|
const profiles = settings.apiProfiles || {}
|
||||||
if (!profiles[profileName]) {
|
if (!profiles[profileName]) {
|
||||||
return { success: false, error: `配置 "${profileName}" 不存在` }
|
return { success: false, error: t.configNotExist.replace('{name}', profileName) }
|
||||||
}
|
}
|
||||||
// 保存当前配置到 apiProfiles(如果当前配置存在)
|
// 保存当前配置到 apiProfiles(如果当前配置存在)
|
||||||
const currentProfile = settings.currentApiProfile || 'default'
|
const currentProfile = settings.currentApiProfile || 'default'
|
||||||
@@ -153,8 +350,9 @@ ipcMain.handle('switch-api-profile', async (event, profileName) => {
|
|||||||
ipcMain.handle('create-api-profile', async (event, name) => {
|
ipcMain.handle('create-api-profile', async (event, name) => {
|
||||||
try {
|
try {
|
||||||
const settings = readSettings()
|
const settings = readSettings()
|
||||||
|
const t = getErrorTranslation()
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
return { success: false, error: '配置文件不存在' }
|
return { success: false, error: t.configNotFound }
|
||||||
}
|
}
|
||||||
if (!settings.apiProfiles) {
|
if (!settings.apiProfiles) {
|
||||||
settings.apiProfiles = { default: {} }
|
settings.apiProfiles = { default: {} }
|
||||||
@@ -166,7 +364,7 @@ ipcMain.handle('create-api-profile', async (event, name) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (settings.apiProfiles[name]) {
|
if (settings.apiProfiles[name]) {
|
||||||
return { success: false, error: `配置 "${name}" 已存在` }
|
return { success: false, error: t.configAlreadyExists.replace('{name}', name) }
|
||||||
}
|
}
|
||||||
// 复制当前配置到新配置
|
// 复制当前配置到新配置
|
||||||
const newConfig = {}
|
const newConfig = {}
|
||||||
@@ -186,15 +384,16 @@ ipcMain.handle('create-api-profile', async (event, name) => {
|
|||||||
ipcMain.handle('delete-api-profile', async (event, name) => {
|
ipcMain.handle('delete-api-profile', async (event, name) => {
|
||||||
try {
|
try {
|
||||||
const settings = readSettings()
|
const settings = readSettings()
|
||||||
|
const t = getErrorTranslation()
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
return { success: false, error: '配置文件不存在' }
|
return { success: false, error: t.configNotFound }
|
||||||
}
|
}
|
||||||
if (name === 'default') {
|
if (name === 'default') {
|
||||||
return { success: false, error: '不能删除默认配置' }
|
return { success: false, error: t.cannotDeleteDefault }
|
||||||
}
|
}
|
||||||
const profiles = settings.apiProfiles || {}
|
const profiles = settings.apiProfiles || {}
|
||||||
if (!profiles[name]) {
|
if (!profiles[name]) {
|
||||||
return { success: false, error: `配置 "${name}" 不存在` }
|
return { success: false, error: t.configNotExist.replace('{name}', name) }
|
||||||
}
|
}
|
||||||
delete profiles[name]
|
delete profiles[name]
|
||||||
settings.apiProfiles = profiles
|
settings.apiProfiles = profiles
|
||||||
@@ -219,18 +418,19 @@ ipcMain.handle('delete-api-profile', async (event, name) => {
|
|||||||
ipcMain.handle('rename-api-profile', async (event, oldName, newName) => {
|
ipcMain.handle('rename-api-profile', async (event, oldName, newName) => {
|
||||||
try {
|
try {
|
||||||
const settings = readSettings()
|
const settings = readSettings()
|
||||||
|
const t = getErrorTranslation()
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
return { success: false, error: '配置文件不存在' }
|
return { success: false, error: t.configNotFound }
|
||||||
}
|
}
|
||||||
if (oldName === 'default') {
|
if (oldName === 'default') {
|
||||||
return { success: false, error: '不能重命名默认配置' }
|
return { success: false, error: t.cannotRenameDefault }
|
||||||
}
|
}
|
||||||
const profiles = settings.apiProfiles || {}
|
const profiles = settings.apiProfiles || {}
|
||||||
if (!profiles[oldName]) {
|
if (!profiles[oldName]) {
|
||||||
return { success: false, error: `配置 "${oldName}" 不存在` }
|
return { success: false, error: t.configNotExist.replace('{name}', oldName) }
|
||||||
}
|
}
|
||||||
if (profiles[newName]) {
|
if (profiles[newName]) {
|
||||||
return { success: false, error: `配置 "${newName}" 已存在` }
|
return { success: false, error: t.configAlreadyExists.replace('{name}', newName) }
|
||||||
}
|
}
|
||||||
profiles[newName] = profiles[oldName]
|
profiles[newName] = profiles[oldName]
|
||||||
delete profiles[oldName]
|
delete profiles[oldName]
|
||||||
@@ -248,15 +448,16 @@ ipcMain.handle('rename-api-profile', async (event, oldName, newName) => {
|
|||||||
ipcMain.handle('duplicate-api-profile', async (event, sourceName, newName) => {
|
ipcMain.handle('duplicate-api-profile', async (event, sourceName, newName) => {
|
||||||
try {
|
try {
|
||||||
const settings = readSettings()
|
const settings = readSettings()
|
||||||
|
const t = getErrorTranslation()
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
return { success: false, error: '配置文件不存在' }
|
return { success: false, error: t.configNotFound }
|
||||||
}
|
}
|
||||||
const profiles = settings.apiProfiles || {}
|
const profiles = settings.apiProfiles || {}
|
||||||
if (!profiles[sourceName]) {
|
if (!profiles[sourceName]) {
|
||||||
return { success: false, error: `配置 "${sourceName}" 不存在` }
|
return { success: false, error: t.configNotExist.replace('{name}', sourceName) }
|
||||||
}
|
}
|
||||||
if (profiles[newName]) {
|
if (profiles[newName]) {
|
||||||
return { success: false, error: `配置 "${newName}" 已存在` }
|
return { success: false, error: t.configAlreadyExists.replace('{name}', newName) }
|
||||||
}
|
}
|
||||||
// 深拷贝配置
|
// 深拷贝配置
|
||||||
profiles[newName] = JSON.parse(JSON.stringify(profiles[sourceName]))
|
profiles[newName] = JSON.parse(JSON.stringify(profiles[sourceName]))
|
||||||
|
|||||||
1045
package-lock.json
generated
1045
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "iflow-settings-editor",
|
"name": "iflow-settings-editor",
|
||||||
"version": "1.5.0",
|
"version": "1.6.0",
|
||||||
"description": "一个用于编辑 iFlow CLI 配置文件的桌面应用程序。",
|
"description": "一个用于编辑 iFlow CLI 配置文件的桌面应用程序。",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"author": "上海潘哆呐科技有限公司",
|
"author": "上海潘哆呐科技有限公司",
|
||||||
@@ -20,16 +20,26 @@
|
|||||||
"build:win32": "vite build && electron-builder --win --ia32",
|
"build:win32": "vite build && electron-builder --win --ia32",
|
||||||
"build:win-portable": "vite build && electron-builder --win portable",
|
"build:win-portable": "vite build && electron-builder --win portable",
|
||||||
"build:win-installer": "vite build && electron-builder --win nsis",
|
"build:win-installer": "vite build && electron-builder --win nsis",
|
||||||
"dist": "vite build && electron-builder"
|
"dist": "vite build && electron-builder",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest --coverage",
|
||||||
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.iflow.settings-editor",
|
"appId": "com.iflow.settings-editor",
|
||||||
"productName": "iFlow Settings Editor",
|
"productName": "iFlow Settings Editor",
|
||||||
"copyright": "Copyright © 2025 上海潘哆呐科技有限公司",
|
"copyright": "Copyright © 2026 上海潘哆呐科技有限公司",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release",
|
"output": "release",
|
||||||
"buildResources": "build"
|
"buildResources": "build"
|
||||||
},
|
},
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "build/icon.ico",
|
||||||
|
"to": "icon/icon.ico"
|
||||||
|
}
|
||||||
|
],
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"main.js",
|
"main.js",
|
||||||
@@ -78,12 +88,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"-": "^0.0.1",
|
||||||
"@icon-park/vue-next": "^1.4.2",
|
"@icon-park/vue-next": "^1.4.2",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"@vitest/ui": "^4.1.4",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"electron": "^28.0.0",
|
"electron": "^28.0.0",
|
||||||
"electron-builder": "^24.13.3",
|
"electron-builder": "^24.13.3",
|
||||||
|
"happy-dom": "^20.9.0",
|
||||||
|
"less": "^4.6.4",
|
||||||
|
"less-loader": "^12.3.2",
|
||||||
"vite": "^8.0.8",
|
"vite": "^8.0.8",
|
||||||
|
"vitest": "^4.1.4",
|
||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue-i18n": "^9.14.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
preload.js
12
preload.js
@@ -18,5 +18,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
createApiProfile: (name) => ipcRenderer.invoke('create-api-profile', name),
|
createApiProfile: (name) => ipcRenderer.invoke('create-api-profile', name),
|
||||||
deleteApiProfile: (name) => ipcRenderer.invoke('delete-api-profile', name),
|
deleteApiProfile: (name) => ipcRenderer.invoke('delete-api-profile', name),
|
||||||
renameApiProfile: (oldName, newName) => ipcRenderer.invoke('rename-api-profile', oldName, newName),
|
renameApiProfile: (oldName, newName) => ipcRenderer.invoke('rename-api-profile', oldName, newName),
|
||||||
duplicateApiProfile: (sourceName, newName) => ipcRenderer.invoke('duplicate-api-profile', sourceName, newName)
|
duplicateApiProfile: (sourceName, newName) => ipcRenderer.invoke('duplicate-api-profile', sourceName, newName),
|
||||||
|
|
||||||
|
// 托盘事件监听
|
||||||
|
onApiProfileSwitched: (callback) => {
|
||||||
|
ipcRenderer.on('api-profile-switched', (event, profileName) => callback(profileName))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 语言切换通知
|
||||||
|
notifyLanguageChanged: () => {
|
||||||
|
ipcRenderer.send('language-changed')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
BIN
screenshots/theme-dark.png
Normal file
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 |
1947
src/App.vue
1947
src/App.vue
File diff suppressed because it is too large
Load Diff
190
src/components/ApiProfileDialog.vue
Normal file
190
src/components/ApiProfileDialog.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<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>
|
||||||
|
// Windows 11 Style API Edit Dialog - Fluent Design
|
||||||
|
.api-edit-dialog {
|
||||||
|
min-width: 480px;
|
||||||
|
max-width: 520px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-edit-dialog .dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-lg) var(--space-xl);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
background: var(--control-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-edit-dialog .dialog-title {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
.iconpark-icon {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-edit-dialog .dialog-body {
|
||||||
|
padding: var(--space-xl);
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-edit-dialog .dialog-actions {
|
||||||
|
padding: var(--space-lg) var(--space-xl);
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
background: var(--control-fill);
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
47
src/components/Footer.vue
Normal file
47
src/components/Footer.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<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>
|
||||||
|
// Windows 11 Style Footer - Fluent Design
|
||||||
|
.footer {
|
||||||
|
height: 26px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
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: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--success);
|
||||||
|
box-shadow: 0 0 4px rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
102
src/components/InputDialog.vue
Normal file
102
src/components/InputDialog.vue
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<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>
|
||||||
|
// Windows 11 Style Input Dialog - Fluent Design
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1300;
|
||||||
|
animation: fadeIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
min-width: 360px;
|
||||||
|
max-width: 480px;
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
animation: scaleIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-confirm-text {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from { opacity: 0; transform: scale(0.96); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
134
src/components/MessageDialog.vue
Normal file
134
src/components/MessageDialog.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<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>
|
||||||
|
// Windows 11 Style Message Dialog - Fluent Design
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
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: var(--space-2xl) var(--space-xl);
|
||||||
|
animation: scaleIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-dialog-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin: 0 auto var(--space-md);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-dialog-icon-info {
|
||||||
|
background: var(--info-bg);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-dialog-icon-success {
|
||||||
|
background: var(--success-bg);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-dialog-icon-warning {
|
||||||
|
background: var(--warning-bg);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-dialog-icon-error {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-dialog-title {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-dialog-message {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-dialog .dialog-actions {
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from { opacity: 0; transform: scale(0.96); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
210
src/components/ServerPanel.vue
Normal file
210
src/components/ServerPanel.vue
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<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>
|
||||||
|
// Windows 11 Style Side Panel - Fluent Design
|
||||||
|
.side-panel-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.32);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fadeIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 420px;
|
||||||
|
max-width: 100%;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: slideInFromRight 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-lg) var(--space-xl);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
background: var(--control-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel-title {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
.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-sm);
|
||||||
|
transition: all var(--transition);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--control-fill-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-xl);
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-lg) var(--space-xl);
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
background: var(--control-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-panel-footer-right {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInFromRight {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
</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);
|
||||||
|
});
|
||||||
|
});
|
||||||
115
src/components/SideBar.vue
Normal file
115
src/components/SideBar.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<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>
|
||||||
|
// Windows 11 Style Sidebar - Fluent Design
|
||||||
|
.sidebar {
|
||||||
|
width: 220px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border-light);
|
||||||
|
padding: 16px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
padding: 0 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--control-fill);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.iconpark-icon {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--control-fill);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</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');
|
||||||
|
});
|
||||||
|
});
|
||||||
100
src/components/TitleBar.vue
Normal file
100
src/components/TitleBar.vue
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<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>
|
||||||
|
// Windows 11 Style Title Bar - Fluent Design
|
||||||
|
.titlebar {
|
||||||
|
height: 32px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 4px 0 12px;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-btn {
|
||||||
|
width: 46px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--control-fill-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: var(--control-fill-pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.close:hover {
|
||||||
|
background: #c42b1c;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.close:active {
|
||||||
|
background: #a72b1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { createApp } from 'vue'
|
||||||
import App from './App.vue';
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import App from './App.vue'
|
||||||
|
import zhCN from './locales/index.js'
|
||||||
|
import enUS from './locales/en-US.js'
|
||||||
|
import jaJP from './locales/ja-JP.js'
|
||||||
|
|
||||||
createApp(App).mount('#app');
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'zh-CN',
|
||||||
|
fallbackLocale: 'zh-CN',
|
||||||
|
messages: {
|
||||||
|
'zh-CN': zhCN,
|
||||||
|
'en-US': enUS,
|
||||||
|
'ja-JP': jaJP
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(i18n)
|
||||||
|
app.mount('#app')
|
||||||
|
|||||||
907
src/styles/global.less
Normal file
907
src/styles/global.less
Normal file
@@ -0,0 +1,907 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// Windows UI Kit Design System - Fluent Design
|
||||||
|
// Based on Windows 11 Fluent Design principles
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
// Windows UI Kit - Light Mode Color System
|
||||||
|
// Background layers ( Mica-inspired depth )
|
||||||
|
--bg-primary: #f3f3f38e;
|
||||||
|
--bg-secondary: #ffffff70;
|
||||||
|
--bg-tertiary: #ebebeb;
|
||||||
|
--bg-elevated: #ffffffe3;
|
||||||
|
--bg-mica: rgba(243, 243, 243, 0.473);
|
||||||
|
|
||||||
|
// Text colors ( semantic naming )
|
||||||
|
--text-primary: #1a1a1a;
|
||||||
|
--text-secondary: #5d5d5d;
|
||||||
|
--text-tertiary: #8a8a8a;
|
||||||
|
--text-disabled: #b8b8b8;
|
||||||
|
|
||||||
|
// Accent colors ( Windows blue )
|
||||||
|
--accent: #0078d4;
|
||||||
|
--accent-hover: #106ebe;
|
||||||
|
--accent-pressed: #005a9e;
|
||||||
|
--accent-light: rgba(0, 120, 212, 0.1);
|
||||||
|
--accent-text: #0078d4;
|
||||||
|
|
||||||
|
// Border colors
|
||||||
|
--border: #e0e0e0;
|
||||||
|
--border-light: #f0f0f0;
|
||||||
|
--border-strong: #c8c8c8;
|
||||||
|
--border-focus: var(--accent);
|
||||||
|
|
||||||
|
// Status colors
|
||||||
|
--success: #107c10;
|
||||||
|
--success-bg: rgba(16, 124, 16, 0.1);
|
||||||
|
--danger: #c42b1c;
|
||||||
|
--danger-bg: rgba(196, 43, 28, 0.1);
|
||||||
|
--warning: #9d5d00;
|
||||||
|
--warning-bg: rgba(157, 93, 0, 0.1);
|
||||||
|
--info: #0078d4;
|
||||||
|
--info-bg: rgba(0, 120, 212, 0.1);
|
||||||
|
|
||||||
|
// Control colors ( Windows 11 style )
|
||||||
|
--control-fill: #f9f9f98e;
|
||||||
|
--control-fill-hover: #eaeaea;
|
||||||
|
--control-fill-pressed: #e0e0e0;
|
||||||
|
--control-fill-disabled: #f5f5f5;
|
||||||
|
|
||||||
|
// Shadow system ( layer-based depth )
|
||||||
|
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow: 0 4px 8px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.12), 0 4px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-xl: 0 16px 32px rgba(0, 0, 0, 0.16), 0 8px 16px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
// Radius system ( Windows 11 uses 4-8px )
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius: 6px;
|
||||||
|
--radius-lg: 8px;
|
||||||
|
--radius-xl: 12px;
|
||||||
|
|
||||||
|
// Spacing system
|
||||||
|
--space-xs: 4px;
|
||||||
|
--space-sm: 8px;
|
||||||
|
--space-md: 12px;
|
||||||
|
--space-lg: 16px;
|
||||||
|
--space-xl: 20px;
|
||||||
|
--space-2xl: 24px;
|
||||||
|
--space-3xl: 32px;
|
||||||
|
|
||||||
|
// Typography
|
||||||
|
--font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
--font-size-xs: 11px;
|
||||||
|
--font-size-sm: 12px;
|
||||||
|
--font-size-base: 14px;
|
||||||
|
--font-size-lg: 16px;
|
||||||
|
--font-size-xl: 20px;
|
||||||
|
--font-size-2xl: 24px;
|
||||||
|
|
||||||
|
// Animation
|
||||||
|
--transition-fast: 0.1s ease;
|
||||||
|
--transition: 0.15s ease;
|
||||||
|
--transition-smooth: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Dark Mode ( Windows 11 Dark Theme )
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--bg-primary: #1f1f1f;
|
||||||
|
--bg-secondary: #2d2d2d;
|
||||||
|
--bg-tertiary: #383838;
|
||||||
|
--bg-elevated: #333333;
|
||||||
|
--bg-mica: rgba(31, 31, 31, 0.85);
|
||||||
|
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #b8b8b8;
|
||||||
|
--text-tertiary: #787878;
|
||||||
|
--text-disabled: #555555;
|
||||||
|
|
||||||
|
--accent: #60cdff;
|
||||||
|
--accent-hover: #82d1ff;
|
||||||
|
--accent-pressed: #4ab3ff;
|
||||||
|
--accent-light: rgba(96, 205, 255, 0.15);
|
||||||
|
--accent-text: #60cdff;
|
||||||
|
|
||||||
|
--border: #404040;
|
||||||
|
--border-light: #333333;
|
||||||
|
--border-strong: #555555;
|
||||||
|
|
||||||
|
--success: #6ccb5f;
|
||||||
|
--success-bg: rgba(108, 203, 95, 0.15);
|
||||||
|
--danger: #ff6b6b;
|
||||||
|
--danger-bg: rgba(255, 107, 107, 0.15);
|
||||||
|
--warning: #fce100;
|
||||||
|
--warning-bg: rgba(252, 225, 0, 0.15);
|
||||||
|
--info: #60cdff;
|
||||||
|
--info-bg: rgba(96, 205, 255, 0.15);
|
||||||
|
|
||||||
|
--control-fill: #333333;
|
||||||
|
--control-fill-hover: #3d3d3d;
|
||||||
|
--control-fill-pressed: #474747;
|
||||||
|
--control-fill-disabled: #2d2d2d;
|
||||||
|
|
||||||
|
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow: 0 4px 8px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.4), 0 4px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
--shadow-xl: 0 16px 32px rgba(0, 0, 0, 0.5), 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solarized Dark Mode
|
||||||
|
.solarized-dark {
|
||||||
|
--bg-primary: #002b36;
|
||||||
|
--bg-secondary: #073642;
|
||||||
|
--bg-tertiary: #094856;
|
||||||
|
--bg-elevated: #0a4a5c;
|
||||||
|
--bg-mica: rgba(0, 43, 54, 0.9);
|
||||||
|
|
||||||
|
--text-primary: #839496;
|
||||||
|
--text-secondary: #93a1a1;
|
||||||
|
--text-tertiary: #586e75;
|
||||||
|
--text-disabled: #3d5a64;
|
||||||
|
|
||||||
|
--accent: #268bd2;
|
||||||
|
--accent-hover: #2d9cdb;
|
||||||
|
--accent-pressed: #1a73c0;
|
||||||
|
--accent-light: rgba(38, 139, 210, 0.15);
|
||||||
|
--accent-text: #268bd2;
|
||||||
|
|
||||||
|
--border: #1d3a47;
|
||||||
|
--border-light: #0d3a47;
|
||||||
|
--border-strong: #2d5a6f;
|
||||||
|
|
||||||
|
--success: #2aa198;
|
||||||
|
--success-bg: rgba(42, 161, 152, 0.15);
|
||||||
|
--danger: #dc322f;
|
||||||
|
--danger-bg: rgba(220, 50, 47, 0.15);
|
||||||
|
--warning: #b58900;
|
||||||
|
--warning-bg: rgba(181, 137, 0, 0.15);
|
||||||
|
--info: #268bd2;
|
||||||
|
--info-bg: rgba(38, 139, 210, 0.15);
|
||||||
|
|
||||||
|
--control-fill: #073642;
|
||||||
|
--control-fill-hover: #0a4a5c;
|
||||||
|
--control-fill-pressed: #0d5a70;
|
||||||
|
--control-fill-disabled: #053845;
|
||||||
|
|
||||||
|
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow: 0 4px 8px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.5), 0 4px 8px rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-xl: 0 16px 32px rgba(0, 0, 0, 0.6), 0 8px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Animations
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInFromRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Base Styles
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Scrollbar ( Windows 11 Style )
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--border-strong);
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// App Layout
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-3xl) var(--space-2xl);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content section {
|
||||||
|
animation: fadeInUp 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Content Header
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.content-header {
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-title {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
animation: fadeInDown 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-desc {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.5;
|
||||||
|
animation: fadeIn 0.3s ease 0.05s backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Card Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
transition:
|
||||||
|
box-shadow var(--transition-smooth),
|
||||||
|
border-color var(--transition-smooth);
|
||||||
|
animation: fadeInUp 0.3s ease backwards;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:nth-child(1) {
|
||||||
|
animation-delay: 0.02s;
|
||||||
|
}
|
||||||
|
.card:nth-child(2) {
|
||||||
|
animation-delay: 0.05s;
|
||||||
|
}
|
||||||
|
.card:nth-child(3) {
|
||||||
|
animation-delay: 0.08s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
padding-bottom: var(--space-sm);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
|
||||||
|
.iconpark-icon {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Form Controls
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-required {
|
||||||
|
color: var(--danger);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
background: var(--control-fill);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: all var(--transition);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: var(--control-fill-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-light);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: var(--control-fill-disabled);
|
||||||
|
color: var(--text-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 400;
|
||||||
|
background: var(--control-fill);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%235d5d5d' d='M2.5 4.5L6 8l3.5-3.5'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right var(--space-md) center;
|
||||||
|
padding-right: 36px;
|
||||||
|
transition: all var(--transition);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background-color: var(--control-fill-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background-color: var(--control-fill-disabled);
|
||||||
|
color: var(--text-disabled);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
background: var(--control-fill);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
line-height: 1.5;
|
||||||
|
transition: all var(--transition);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-light);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Icon
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.iconpark-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
vertical-align: -0.125em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Button ( Windows 11 Style )
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: all var(--transition);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #ffffff;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: var(--accent-pressed);
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--control-fill);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--control-fill-hover);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: var(--control-fill-pressed);
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #ffffff;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: #b71c1c;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--control-fill-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
background: var(--control-fill-pressed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Side Panel
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.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-sm);
|
||||||
|
transition: all var(--transition);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--control-fill-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Empty State
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-3xl) var(--space-xl);
|
||||||
|
text-align: center;
|
||||||
|
background: var(--control-fill);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
opacity: 0.4;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-title {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-desc {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Dialog
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
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-elevated);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
min-width: 360px;
|
||||||
|
max-width: 480px;
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
animation: scaleIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-confirm-text {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
padding: var(--space-md) 0;
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message Dialog
|
||||||
|
.message-dialog {
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-2xl) var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-dialog-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin: 0 auto var(--space-md);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-dialog-icon-info {
|
||||||
|
background: var(--info-bg);
|
||||||
|
color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-dialog-icon-success {
|
||||||
|
background: var(--success-bg);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-dialog-icon-warning {
|
||||||
|
background: var(--warning-bg);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-dialog-icon-error {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-dialog-title {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-dialog-message {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-dialog .dialog-actions {
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// List Items ( Server List, Profile List )
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.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: var(--space-md) var(--space-lg);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-smooth);
|
||||||
|
animation: fadeIn 0.25s ease backwards;
|
||||||
|
|
||||||
|
&:nth-child(1) {
|
||||||
|
animation-delay: 0.02s;
|
||||||
|
}
|
||||||
|
&:nth-child(2) {
|
||||||
|
animation-delay: 0.04s;
|
||||||
|
}
|
||||||
|
&:nth-child(3) {
|
||||||
|
animation-delay: 0.06s;
|
||||||
|
}
|
||||||
|
&:nth-child(4) {
|
||||||
|
animation-delay: 0.08s;
|
||||||
|
}
|
||||||
|
&:nth-child(5) {
|
||||||
|
animation-delay: 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--control-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: var(--accent-light);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding-left: calc(var(--space-lg) - 3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-name {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-desc {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-status {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--success);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
253
src/views/ApiConfig.vue
Normal file
253
src/views/ApiConfig.vue
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<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>
|
||||||
|
// Windows 11 Style Profile List - Fluent Design
|
||||||
|
.profile-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
animation: fadeIn 0.3s ease backwards;
|
||||||
|
|
||||||
|
&:nth-child(1) { animation-delay: 0.02s; }
|
||||||
|
&:nth-child(2) { animation-delay: 0.04s; }
|
||||||
|
&:nth-child(3) { animation-delay: 0.06s; }
|
||||||
|
&:nth-child(4) { animation-delay: 0.08s; }
|
||||||
|
&:nth-child(5) { animation-delay: 0.1s; }
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--control-fill);
|
||||||
|
border-color: var(--border);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--accent-light);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-icon-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-url {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-status {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: 8px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
|
||||||
|
.profile-item:hover &,
|
||||||
|
.profile-item.active & {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
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.1s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--control-fill);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.action-btn-danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 5px 10px;
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
168
src/views/McpServers.vue
Normal file
168
src/views/McpServers.vue
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<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>
|
||||||
|
// Windows 11 Style Server List - Fluent Design
|
||||||
|
.server-list {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
animation: fadeIn 0.3s ease backwards;
|
||||||
|
|
||||||
|
&:nth-child(1) { animation-delay: 0.02s; }
|
||||||
|
&:nth-child(2) { animation-delay: 0.04s; }
|
||||||
|
&:nth-child(3) { animation-delay: 0.06s; }
|
||||||
|
&:nth-child(4) { animation-delay: 0.08s; }
|
||||||
|
&:nth-child(5) { animation-delay: 0.1s; }
|
||||||
|
&:nth-child(6) { animation-delay: 0.12s; }
|
||||||
|
&:nth-child(7) { animation-delay: 0.14s; }
|
||||||
|
&:nth-child(8) { animation-delay: 0.16s; }
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--control-fill);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: var(--accent-light);
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-status {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--success);
|
||||||
|
box-shadow: 0 0 4px rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 24px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.4;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -13,5 +13,12 @@ export default defineConfig({
|
|||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src')
|
'@': path.resolve(__dirname, 'src')
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
less: {
|
||||||
|
javascriptEnabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
29
vitest.config.js
Normal file
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