65 Commits
v1.5 ... main

Author SHA1 Message Date
yuantao
54446db0ea fix: 修复 changelog body 换行转义问题,使用 heredoc 保持 markdown 格式 2026-04-20 14:50:45 +08:00
yuantao
08171021ba fix: 修复 changelog 提取时版本号 v 前缀不匹配问题 2026-04-20 14:40:06 +08:00
yuantao
43ebe28a9c refactor: 清理 workflow 文件中的重复内容 2026-04-20 14:34:49 +08:00
yuantao
d0706bf3f1 优化 版本号调整 2026-04-20 14:31:54 +08:00
yuantao
b5e76e20f8 Merge branch 'main' of https://git.pandorastudio.cn/product/iFlow-Settings-Editor-GUI 2026-04-20 14:29:54 +08:00
yuantao
7af6bcd7cf feat: 支持 PR 合并到 release 分支时触发 workflow 2026-04-20 14:23:22 +08:00
yuantao
ce55916998 fix: release job 添加 tag_name 参数指定要创建的 tag 2026-04-20 14:22:05 +08:00
yuantao
63e04c727f fix: 修复 workflow 竞争问题,所有 jobs 在同一运行中顺序执行 2026-04-20 14:21:01 +08:00
1954d90630 Merge pull request 'release' (#11) from release into main
Reviewed-on: #11
2026-04-20 14:20:07 +08:00
yuantao
96d148f6a9 fix: 修复 workflow 竞争问题,所有 jobs 在同一运行中顺序执行 2026-04-20 14:18:36 +08:00
yuantao
ed633018d3 fix: 修复 workflow 竞争问题,所有 jobs 在同一运行中顺序执行 2026-04-20 14:15:16 +08:00
yuantao
bb08830935 fix: 限制 workflow 触发条件为 release 分支和 v* tag 2026-04-20 14:06:02 +08:00
yuantao
773aeba504 修复 GitHub工作流发布逻辑 2026-04-20 13:59:12 +08:00
yuantao
608c0fa0e3 fix: 添加 contents:write 权限以允许 actions 推送 tag 2026-04-20 13:53:42 +08:00
yuantao
4729f192f3 feat: 支持 release 分支推送自动创建 tag 并发布 2026-04-20 13:45:39 +08:00
yuantao
c95bba1cfb Merge branch 'main' of https://git.pandorastudio.cn/product/iFlow-Settings-Editor-GUI 2026-04-20 13:42:27 +08:00
yuantao
56ee3bca24 fix: 修复 changelog 提取脚本匹配逻辑 2026-04-20 13:42:07 +08:00
yuantao
29f22a060d fix: 修复 changelog 提取脚本匹配逻辑 2026-04-20 13:39:29 +08:00
yuantao
2d84b3128a 文档 更新 README 和 CHANGELOG 添加 macOS 支持和 CI/CD 说明 2026-04-20 13:27:45 +08:00
yuantao
c15fbf4693 优化 macOS 平台兼容性支持 2026-04-20 13:26:21 +08:00
yuantao
d45dc6e438 修复 GitHub Actions CI/CD跳过发布问题 2026-04-20 13:20:38 +08:00
yuantao
7efeb9df02 Merge branch 'main' of https://git.pandorastudio.cn/product/iFlow-Settings-Editor-GUI 2026-04-20 13:16:27 +08:00
yuantao
41d4e44c58 新增 macOS 平台编译支持和 GitHub Actions CI/CD 2026-04-20 13:15:36 +08:00
yuantao
0f320f71f1 新增 macOS 平台编译支持和 GitHub Actions CI/CD 2026-04-20 11:55:29 +08:00
yuantao
1e9db38837 修复 API配置管理时托盘菜单不同步更新 2026-04-20 11:29:26 +08:00
7529131bb2 Merge branch 'main' of https://git.pandorastudio.cn/product/iFlow-Settings-Editor-GUI 2026-04-19 23:11:57 +08:00
6993c8a353 文档 更新功能特性、技术栈、项目结构 2026-04-19 23:11:40 +08:00
4dff5640da 文档 更新功能特性、技术栈、项目结构 2026-04-19 23:11:08 +08:00
49a60e6562 优化 移除技能删除的原生对话框并添加测试覆盖率支持 2026-04-19 22:50:24 +08:00
b35a940aea 修复 技能删除功能及对话框层级显示问题 2026-04-19 22:49:38 +08:00
229496c55a 新增仪表盘页面,优化侧边栏交互 2026-04-19 21:40:50 +08:00
25fc578c33 新增 开机自启动功能 2026-04-19 21:06:35 +08:00
c942d2d03d 其他 完善单元测试覆盖 2026-04-19 16:41:49 +08:00
5d50a033a0 新增 API配置重命名功能 2026-04-19 16:08:09 +08:00
c2167bc7b6 新增 API配置列表拖动排序功能 2026-04-19 01:21:14 +08:00
6c9fd93850 优化 统一页面头部操作按钮排版 2026-04-19 01:13:53 +08:00
e9f69ab9f3 修复 API配置编辑保存后缺少成功提示 2026-04-19 01:09:22 +08:00
4b45b44c0e 优化 API配置,移除searchApiKey和cna字段并修复技能导入 2026-04-19 01:06:02 +08:00
fdddf55a54 新增 技能管理功能模块,支持本地/在线导入、导出和删除 2026-04-19 00:39:24 +08:00
08b478c1a5 文档 新增截图展示API配置和MCP服务器管理功能 2026-04-19 00:02:45 +08:00
902013f22f 新增 跟随系统主题切换功能,深色模式自动隐藏亚克力效果 2026-04-18 23:12:08 +08:00
fa255f78b4 优化 精简主题系统,移除废弃的Xcode和Solarized Dark主题 2026-04-18 23:03:16 +08:00
46e128e8ba 文档: 更新CHANGELOG描述主题支持范围 2026-04-18 22:47:43 +08:00
06abae8620 Merge branch 'main' of https://git.pandorastudio.cn/product/iFlow-Settings-Editor-GUI 2026-04-18 22:45:25 +08:00
b4912a197a 修复 亚克力效果滑块进度条初始值不正确的bug 2026-04-18 22:45:22 +08:00
60b7fea394 修复 亚克力效果滑块进度条初始值不正确的bug 2026-04-18 22:43:54 +08:00
bb45ff4512 文档: 重构项目文档,提升可读性和结构化程度 2026-04-18 22:32:21 +08:00
231a9a686f 修复 主题配置键名冲突:将theme改为uiTheme避免与iFlow内部配置冲突 2026-04-18 21:58:16 +08:00
ac87c18e59 新增 亚克力透明度调节功能:支持在浅色主题下调整背景模糊效果强度,范围0-100% 2026-04-18 21:51:30 +08:00
20b065af5b 架构 重构样式系统:采用 Windows 11 Fluent Design 规范 2026-04-18 18:55:06 +08:00
cf1070a279 优化 saveServerFromPanel 函数,通过参数传入数据替代直接访问响应式变量 2026-04-18 17:15:31 +08:00
9c282962da 文档更新:精简 CHANGELOG 只包含客户端功能 2026-04-18 04:09:46 +08:00
978c4c295c 文档更新:同步更新 README 和 CHANGELOG 2026-04-18 03:58:39 +08:00
400e617528 文档 更新:补充测试框架和相关文档 2026-04-18 03:34:07 +08:00
2a43b8a838 修复 编辑当前配置时保存不生效的问题 2026-04-18 03:12:28 +08:00
aa375bfff0 新增 单元测试框架和测试用例 2026-04-18 03:03:19 +08:00
2d2804ef22 架构 样式拆分为组件级LESS样式,添加全局样式文件 2026-04-18 01:48:43 +08:00
3577e139b9 新增 完整的国际化(i18n)支持,支持中英日三种语言 2026-04-18 00:52:34 +08:00
d184cfef6e 文档 更新项目文档,添加系统托盘、更新日志及功能说明 2026-04-17 23:42:54 +08:00
5ee6ee75f1 修复 打包后托盘图标不显示的问题 2026-04-17 23:42:20 +08:00
538d1ffb50 Merge branch 'main' of https://git.pandorastudio.cn/product/iFlow-Settings-Editor-GUI 2026-04-17 23:28:22 +08:00
b1de0e14f1 新增 系统托盘功能及快速切换API配置 2026-04-17 23:28:18 +08:00
3329e0ddbf 新增 系统托盘功能及快速切换API配置 2026-04-17 23:25:09 +08:00
cdddcccfe0 优化 API配置编辑对话框的数据回填逻辑 2026-04-17 23:19:36 +08:00
0318c67ea7 修复 MCP 服务器保存及消息对话框被遮挡问题 2026-04-17 23:11:46 +08:00
50 changed files with 9948 additions and 2156 deletions

186
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,186 @@
name: Build
on:
push:
branches:
- release
pull_request:
branches:
- release
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
create-tag:
if: github.ref == 'refs/heads/release'
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract version from changelog
id: version
run: |
VERSION=$(awk -F'[][]' '/^## \[.*\]/ {print $2; exit}' CHANGELOG.md)
if [ -z "$VERSION" ]; then
echo "无法从 CHANGELOG.md 提取版本号"
exit 1
fi
echo "提取到版本: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Create and push tag
run: |
TAG_NAME="v${{ steps.version.outputs.version }}"
echo "创建 tag: $TAG_NAME"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG_NAME" -m "Release $TAG_NAME"
git push origin "$TAG_NAME"
build-mac:
needs: create-tag
strategy:
fail-fast: false
matrix:
include:
- arch: x64
script: mac64
- arch: arm64
script: mac-arm
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install npm dependencies
run: npm ci
- name: Build & Package macOS
run: npm run build:${{ matrix.script }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload zip artifacts
uses: actions/upload-artifact@v4
with:
name: mac-zip-${{ matrix.arch }}
path: release/*-${{ matrix.arch }}.zip
retention-days: 30
- name: Upload dmg artifacts
uses: actions/upload-artifact@v4
with:
name: mac-dmg-${{ matrix.arch }}
path: release/*-${{ matrix.arch }}.dmg
retention-days: 30
build-windows:
needs: create-tag
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install npm dependencies
run: npm ci
- name: Build & Package Windows
run: npm run build:win
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: windows-artifacts
path: |
release/*.exe
release/*.zip
retention-days: 30
release:
needs: [create-tag, build-mac, build-windows]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
pattern: '*'
merge-multiple: false
- name: Display structure of downloaded files
run: ls -R artifacts
- name: Prepare changelog
id: changelog
run: |
CHANGELOG_FILE="CHANGELOG.md"
VERSION="${{ needs.create-tag.outputs.version }}"
TAG_VERSION="v${VERSION}"
CHANGELOG_CONTENT=$(awk -v version="$VERSION" '
BEGIN { found=0 }
found == 0 && /^## \[.*\]/ && index($0, "[" version "]") > 0 { found=1; next }
found && /^## \[.*\]/ { found=0; exit }
found { print }
' "$CHANGELOG_FILE")
if [ -z "$CHANGELOG_CONTENT" ]; then
echo "body=${TAG_VERSION} 更新内容" >> $GITHUB_OUTPUT
else
# 使用 printf 保持原始换行,不做转义
BODY=$(printf '%s' "$CHANGELOG_CONTENT")
echo "body<<EOF" >> $GITHUB_OUTPUT
echo "$BODY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
echo "版本: $TAG_VERSION"
echo "更新日志:"
echo "$CHANGELOG_CONTENT"
- name: Collect all files
run: |
mkdir -p release-files
cp artifacts/windows-artifacts/*.exe release-files/ 2>/dev/null || true
cp artifacts/windows-artifacts/*.zip release-files/ 2>/dev/null || true
cp artifacts/mac-zip-*/*.zip release-files/ 2>/dev/null || true
cp artifacts/mac-dmg-*/*.dmg release-files/ 2>/dev/null || true
ls -la release-files/
- name: Create Release
uses: softprops/action-gh-release@v2
with:
draft: false
prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
tag_name: v${{ needs.create-tag.outputs.version }}
name: v${{ needs.create-tag.outputs.version }}
body: ${{ steps.changelog.outputs.body }}
files: release-files/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ node_modules
/dist /dist
/release /release
*.local *.local
/coverage

429
AGENTS.md
View File

@@ -1,196 +1,291 @@
# iFlow Settings Editor - Agent 文档 # iFlow Settings Editor - AI Context
## 项目概述 ## 项目概述
iFlow 设置编辑器是一个基于 Electron + Vue 3 的桌面应用程序,用于编辑 `C:\Users\<USER>\.iflow\settings.json` 配置文件 **iFlow 设置编辑器** 是一个用于编辑 iFlow CLI 配置文件 (`~/.iflow/settings.json`) 的桌面应用程序,采用 Electron + Vue 3 技术栈构建
## 技术栈 ### 技术栈
| 技术 | 版本 | 用途 | | 类别 | 技术 |
|------|------|------| |------|------|
| Electron | ^28.0.0 | 桌面应用框架 | | 框架 | Electron 28 + Vue 3.4 |
| Vue | ^3.4.0 | 前端框架 (组合式 API) | | 构建工具 | Vite 8 |
| Vite | ^8.0.8 | 构建工具 | | CSS 预处理器 | Less |
| @icon-park/vue-next | ^1.4.2 | 图标库 | | 国际化 | vue-i18n 9 |
| @vitejs/plugin-vue | ^6.0.6 | Vue 插件 | | 测试框架 | Vitest 4 + happy-dom |
| concurrently | ^8.2.2 | 并发执行工具 | | 打包工具 | electron-builder 24 |
| electron-builder | ^24.13.3 | 应用打包工具 | | 图标库 | @icon-park/vue-next |
### 核心架构
```
┌─────────────────────────────────────────────────────┐
│ Electron 主进程 │
│ main.js - 窗口管理、系统托盘、IPC 通信、文件读写 │
│ preload.js - 安全桥接 (contextBridge) │
└─────────────────────────────────────────────────────┘
↕ IPC
┌─────────────────────────────────────────────────────┐
│ Vue 3 渲染进程 │
│ src/App.vue - 根组件 │
│ ├── TitleBar.vue - 自定义标题栏 │
│ ├── SideBar.vue - 侧边导航 │
│ ├── InputDialog.vue - 输入对话框 │
│ ├── MessageDialog.vue - 消息对话框 │
│ ├── ApiProfileDialog.vue - API 配置弹窗 │
│ ├── ServerPanel.vue - 服务器编辑面板 │
│ └── views/ │
│ ├── GeneralSettings.vue - 基础设置 │
│ ├── ApiConfig.vue - API 配置管理 │
│ ├── McpServers.vue - MCP 服务器管理 │
│ ├── SkillsView.vue - 技能管理 │
│ └── Dashboard.vue - 仪表盘视图 │
└─────────────────────────────────────────────────────┘
```
### 配置文件
- **路径**: `~/.iflow/settings.json`
- **编码**: UTF-8 JSON
- **备份**: 自动生成 `.bak` 备份文件
- **技能目录**: `~/.iflow/skills/`
## 开发命令
```bash
# 安装依赖
npm install
# 开发模式 (Vite Dev Server)
npm run dev
# Electron 开发模式 (并行启动 Vite + Electron)
npm run electron:dev
# Electron 生产模式 (先构建再启动)
npm run electron:start
# 构建生产版本
npm run build
# 打包 Windows 版本 (x64)
npm run build:win
npm run build:win64 # 仅 x64
npm run build:win32 # 仅 ia32
# 打包 Windows 便携版
npm run build:win-portable
# 打包 Windows 安装程序 (NSIS)
npm run build:win-installer
# 打包全部 (根据配置)
npm run dist
# 运行测试
npm run test
# 测试 UI 模式
npm run test:ui
# 测试覆盖率
npm run test:coverage
# 单次运行测试
npm run test:run
```
## 设计规范
### Windows 11 Fluent Design
项目采用 Windows 11 Fluent Design 设计系统,核心规范:
| 属性 | 规范 |
|------|------|
| 字体 | Segoe UI Variable, Segoe UI, system-ui |
| 等宽字体 | Cascadia Code, Consolas |
| 圆角 | 4px (sm) / 6px / 8px (lg) / 12px (xl) |
| 阴影 | 四级层次 (sm/lg/xl) |
| 过渡动画 | 0.1-0.2s ease, 0.15s cubic-bezier(0.4, 0, 0.2, 1) |
### 主题系统
支持三种主题:`Light` (浅色) / `Dark` (深色) / `System` (跟随系统)
CSS 变量定义在 `src/styles/global.less`,包括:
- `--bg-primary/secondary/elevated` - 背景层级
- `--text-primary/secondary/tertiary` - 文本层级
- `--accent` - 主题色 (Windows Blue)
- `--success/warning/danger/info` - 状态色
### 亚克力效果
支持可调节透明度的 Mica-inspired 亚克力效果:
- 背景透明度随 `acrylicIntensity` (0-100) 变化
- 深色/浅色主题有独立的透明度计算逻辑
## 项目结构 ## 项目结构
``` ```
iflow-settings-editor/ src/
├── main.js # Electron 主进程 (窗口管理、IPC、文件操作) ├── main.js # Vue 应用入口
├── preload.js # 预加载脚本 (contextBridge API) ├── App.vue # 根组件
├── index.html # HTML 入口 ├── components/
├── package.json # 项目配置 │ ├── TitleBar.vue # 标题栏 (窗口控制按钮)
├── vite.config.js # Vite 配置 ├── TitleBar.test.js
├── src/ │ ├── SideBar.vue # 侧边导航栏
│ ├── main.js # Vue 入口 │ ├── SideBar.test.js
── App.vue # 主组件 (所有业务逻辑) ── InputDialog.vue # 输入对话框
├── build/ # 构建资源 (图标等) │ ├── InputDialog.test.js
├── release/ # 打包输出目录 │ ├── MessageDialog.vue # 消息对话框
└── screenshots/ # 截图资源 │ ├── MessageDialog.test.js
│ ├── ApiProfileDialog.vue # API 配置弹窗
│ ├── ApiProfileDialog.test.js
│ └── ServerPanel.vue # 服务器编辑面板
│ └── ServerPanel.test.js
├── views/
│ ├── GeneralSettings.vue # 常规设置视图
│ ├── GeneralSettings.test.js
│ ├── ApiConfig.vue # API 配置视图
│ ├── ApiConfig.test.js
│ ├── McpServers.vue # MCP 服务器视图
│ ├── McpServers.test.js
│ ├── SkillsView.vue # 技能管理视图
│ ├── SkillsView.test.js
│ └── Dashboard.vue # 仪表盘视图
├── locales/
│ ├── index.js # 中文 (zh-CN)
│ ├── en-US.js # 英文
│ └── ja-JP.js # 日文
└── styles/
└── global.less # 全局样式 (Windows Fluent Design)
``` ```
## 核心架构 ## 关键模块
### 进程模型
- **Main Process (main.js)**: Electron 主进程处理窗口管理、IPC 通信、文件系统操作
- **Preload (preload.js)**: 通过 `contextBridge.exposeInMainWorld` 暴露安全 API
- **Renderer (Vue)**: 渲染进程,只通过 preload 暴露的 API 与主进程通信
### IPC 通信 ### IPC 通信
**preload.js** 暴露的 API
```javascript ```javascript
// preload.js 暴露的 API // 设置操作
window.electronAPI = { window.electronAPI.loadSettings()
loadSettings: () => ipcRenderer.invoke('load-settings'), window.electronAPI.saveSettings(data)
saveSettings: (data) => ipcRenderer.invoke('save-settings', data),
showMessage: (options) => ipcRenderer.invoke('show-message', options), // 窗口控制
listApiProfiles: () => ipcRenderer.invoke('list-api-profiles'), window.electronAPI.minimize()
switchApiProfile: (profileName) => ipcRenderer.invoke('switch-api-profile', profileName), window.electronAPI.maximize()
createApiProfile: (name) => ipcRenderer.invoke('create-api-profile', name), window.electronAPI.close()
deleteApiProfile: (name) => ipcRenderer.invoke('delete-api-profile', name), window.electronAPI.isMaximized()
renameApiProfile: (oldName, newName) => ipcRenderer.invoke('rename-api-profile', oldName, newName),
duplicateApiProfile: (name, newName) => ipcRenderer.invoke('duplicate-api-profile', name, newName), // 开机自启动
isMaximized: () => ipcRenderer.invoke('is-maximized'), window.electronAPI.getAutoLaunch()
minimize: () => ipcRenderer.send('window-minimize'), window.electronAPI.setAutoLaunch(enabled)
maximize: () => ipcRenderer.send('window-maximize'),
close: () => ipcRenderer.send('window-close') // API 配置管理
window.electronAPI.listApiProfiles()
window.electronAPI.switchApiProfile(name)
window.electronAPI.createApiProfile(name)
window.electronAPI.deleteApiProfile(name)
window.electronAPI.renameApiProfile(oldName, newName)
window.electronAPI.duplicateApiProfile(sourceName, newName)
// 技能管理
window.electronAPI.listSkills()
window.electronAPI.importSkillLocal()
window.electronAPI.importSkillOnline(url, name)
window.electronAPI.exportSkill(name, folderName)
window.electronAPI.deleteSkill(name)
// 事件监听
window.electronAPI.onApiProfileSwitched(callback)
window.electronAPI.notifyLanguageChanged()
```
### API 配置管理
配置文件内使用 `apiProfiles` 对象存储多个配置:
```json
{
"currentApiProfile": "default",
"apiProfiles": {
"default": { "apiKey": "...", "baseUrl": "..." },
"production": { "apiKey": "...", "baseUrl": "..." }
}
} }
``` ```
### 窗口配置 **API 字段**: `selectedAuthType`, `apiKey`, `baseUrl`, `modelName`, `searchApiKey`, `cna`
- 窗口尺寸: 1100x750最小尺寸: 900x600
- 无边框窗口 (frame: false),自定义标题栏
- 开发模式加载 `http://localhost:5173`,生产模式加载 `dist/index.html`
### API 配置切换 ### 技能管理
- 支持多环境配置: 默认配置、开发环境、预发布环境、生产环境
- 配置文件管理: 支持创建、编辑、复制、删除、重命名
- 单独保存每个环境的 API 配置到 `apiProfiles` 对象
- 切换配置时直接应用新配置,无需确认
## 可用命令 技能文件夹位于 `~/.iflow/skills/`,每个技能是一个包含 `SKILL.md` 的文件夹:
- 支持本地 ZIP 导入
- 支持在线 URL 导入GitHub tarball/zipball
- 导出技能到指定目录
- 解析 SKILL.md 的 YAML front matter 获取名称和描述
```bash ### 系统托盘
npm install # 安装依赖
npm run dev # 启动 Vite 开发服务器 - 窗口关闭时隐藏到托盘而非退出
npm run build # 构建 Vue 应用到 dist 目录 - 托盘菜单支持快速切换 API 配置
npm start # 运行 Electron (需先build) - 双击托盘图标显示主窗口
npm run electron:dev # 同时运行 Vite + Electron (开发模式) - 支持多语言托盘菜单
npm run electron:start # 构建 + 运行 Electron (生产模式)
npm run pack # 打包应用(不生成安装包) ### 开机自启动
npm run build:win # 构建 Windows 安装包
npm run build:win64 # 构建 Windows x64 安装包 - 支持开机自动启动功能
npm run build:win32 # 构建 Windows x86 安装包 - 支持后台静默启动模式(`--hidden` / `--silent` 参数)
npm run build:win-portable # 构建可移植版本 - 自启动设置存储在 `~/.iflow/settings.json``autoLaunch` 字段
npm run build:win-installer # 构建 NSIS 安装包
npm run dist # 完整构建和打包 ## 代码风格
### Vue 3 Composition API
使用 `<script setup>` 语法:
```vue
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
const settings = ref({})
const modified = computed(() => ...)
const updateSettings = () => { ... }
watch(settings, () => { ... }, { deep: true })
onMounted(async () => { ... })
</script>
``` ```
## 功能模块 ### 样式规范
### 1. 常规设置 (General) - 使用 Less 预处理器
- **语言**: zh-CN / en-US / ja-JP - 通过 CSS 变量 (`var(--xxx)`) 使用主题色
- **主题**: Xcode / Dark / Light / Solarized Dark - 组件样式使用 BEM-like 命名或直接使用功能类名
- **启动动画**: 已显示 / 未显示 - 动画使用 `@keyframes` 定义
- **检查点保存**: 启用 / 禁用
### 2. API 配置 (API) ### 测试规范
- **配置列表**: 显示所有可用的 API 配置文件
- **配置切换**: 点击配置卡片直接切换,无需确认
- **创建配置**: 新建 API 配置文件
- **编辑配置**: 修改现有配置的认证方式、API Key、Base URL 等
- **复制配置**: 基于现有配置创建新配置
- **删除配置**: 删除非默认配置
- **认证方式**: iFlow / API Key / OpenAI 兼容
- **API Key**: 密码输入框
- **Base URL**: API 端点
- **模型名称**: AI 模型标识
- **搜索 API Key**: 搜索服务认证
- **CNA**: CNA 标识
### 3. MCP 服务器管理 (MCP) - 测试文件命名: `*.test.js`
- 服务器列表展示 - 使用 Vitest + @vue/test-utils
- 添加/编辑/删除服务器 - DOM 测试环境: happy-dom
- 服务器配置: 名称、描述、命令、工作目录、参数(每行一个)、环境变量(JSON) - 覆盖范围排除: node_modules, dist, release, build
## 关键实现细节 ## 快捷键与交互
### 设置文件路径 | 操作 | 说明 |
```javascript |------|------|
const SETTINGS_FILE = path.join(app.getPath('home'), '.iflow', 'settings.json'); | 窗口关闭 | 隐藏到系统托盘 |
``` | 双击托盘 | 显示主窗口 |
| Ctrl+S | 自动保存设置 (通过 watch 监听) |
### 保存时自动备份 ## 常见问题
```javascript
if (fs.existsSync(SETTINGS_FILE)) {
const backupPath = SETTINGS_FILE + '.bak';
fs.copyFileSync(SETTINGS_FILE, backupPath);
}
```
### 安全配置 1. **图标不显示**: 检查 `build/icon.ico` 是否存在
- `contextIsolation: true` - 隔离上下文 2. **配置不保存**: 确认 `~/.iflow/settings.json` 目录可写
- `nodeIntegration: false` - 禁用 Node.js 3. **亚克力效果异常**: 检查 `acrylicIntensity` 值是否在 0-100 范围内
- `webSecurity: false` - 仅开发环境解决 CSP 问题 4. **技能导入失败**: 确保压缩包内包含有效的 SKILL.md 文件
### Vue 组件状态管理
- `settings` - 当前设置 (ref)
- `originalSettings` - 原始设置 (用于检测修改)
- `modified` - 是否已修改 (computed/diff)
- `currentSection` - 当前显示的板块
- `currentServerName` - 当前选中的 MCP 服务器
- `currentApiProfile` - 当前使用的 API 配置名称
- `apiProfiles` - 可用的 API 配置列表
### 数据初始化
`loadSettings` 函数中确保所有字段都有默认值:
- `language`: 'zh-CN'
- `theme`: 'Xcode'
- `bootAnimationShown`: true
- `checkpointing`: { enabled: true }
- `selectedAuthType`: 'iflow'
- `apiKey`: ''
- `baseUrl`: ''
- `modelName`: ''
- `searchApiKey`: ''
- `cna`: ''
- `apiProfiles`: { default: {} }
- `currentApiProfile`: 'default'
## 开发注意事项
1. **修改检测**: 通过 `watch(settings, () => { modified.value = true }, { deep: true })` 深度监听
2. **服务器编辑**: 使用 DOM 操作收集表单数据 (`collectServerData`)
3. **MCP 参数**: 每行一个参数,通过换行分割
4. **环境变量**: 支持 JSON 格式输入
5. **窗口控制**: 通过 IPC 发送指令,主进程处理实际窗口操作
6. **API 配置切换**: 多个环境配置存储在 `settings.apiProfiles` 对象中
7. **序列化问题**: IPC 通信使用 `JSON.parse(JSON.stringify())` 避免 Vue 响应式代理问题
8. **默认值处理**: 加载配置时检查 `undefined` 并应用默认值,防止界面显示异常
## 图标使用
使用 `@icon-park/vue-next` 图标库:
```javascript
import { Refresh, Save, Config, Key, Server, Globe, Setting, Robot, Search, Add, Edit, Delete, Exchange, Copy } from '@icon-park/vue-next';
```
## 打包配置
### Windows 平台
- **NSIS 安装包**: 支持 x64 架构
- **可移植版本**: 无需安装的独立可执行文件
- **安装器特性**:
- 允许修改安装目录
- 允许提升权限
- 创建桌面和开始菜单快捷方式
- 支持中文和英文界面
- 卸载时保留用户数据
### 输出目录
- `release/` - 所有打包输出的根目录
- 安装包命名: `iFlow Settings Editor-${version}-${arch}-setup.${ext}`
- 可移植版本命名: `iFlow Settings Editor-${version}-portable.${ext}`

120
CHANGELOG.md Normal file
View File

@@ -0,0 +1,120 @@
# 更新日志
所有重要的版本更新都会记录在此文件中。
## [1.8.8] - 2026-04-20
### 新增
- **macOS 平台支持** - 应用现已在 macOS 12+ 上运行,支持 x64 和 arm64 架构
- **GitHub Actions CI/CD** - 配置自动化构建和发布流程
- 推送标签自动构建并创建 GitHub Release
- 多平台构建Windows (x64) 和 macOS (x64/arm64)
- 自动提取 CHANGELOG.md 生成发布说明
### 优化
- **macOS 兼容性** - 修复以下平台差异:
- 托盘图标自动选择合适格式 (.icns/.ico)
- 窗口图标回退机制
- acrylic 效果仅在 Windows 启用
- 自启动设置路径处理
## [1.8.0] - 2026-04-19
### 新增
- **开机自启动功能** - 支持开机自动启动应用,可在设置中开启/关闭
- **后台静默启动** - 支持 `--hidden` / `--silent` 参数启动时不显示窗口
- **仪表盘视图** - 新增 Dashboard 页面,直观展示当前配置状态和快捷操作
- **错误消息国际化** - 托盘菜单和错误提示支持多语言显示
### 优化
- 改进 main.js 代码结构,提取公共函数
- 优化托盘菜单更新逻辑
## [1.7.0] - 2026-04-19
### 新增
- **API 配置重命名功能** - 支持在编辑对话框中修改配置名称
- **API 配置拖动排序** - 支持拖动配置文件调整显示顺序
- **技能管理功能** - 完整的技能导入、导出、删除管理界面
- 本地 ZIP 导入
- GitHub 在线导入(支持 tarball/zipball
- 技能导出到指定目录
- 技能删除确认
### 优化
- 统一页面头部操作按钮排版
- 编辑对话框布局优化
### 修复
- 当前使用中的 API 配置无法重命名的问题
## [1.6.0] - 2026-04-18
### 架构
- **重构样式系统:采用 Windows 11 Fluent Design 规范**
- 完整实现 Windows UI Kit 设计系统
- 两种主题支持:浅色 / 深色
- 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 通信
- 无边框窗口
- 自定义标题栏
- 最小化、最大化、关闭按钮

360
README.md
View File

@@ -1,167 +1,223 @@
# iFlow Settings Editor # iFlow Settings Editor
一个基于 Electron + Vue 3 的桌面应用程序,用于编辑 `C:\Users\<USER>\.iflow\settings.json` 配置文件 一个用于编辑 iFlow CLI 配置文件的桌面应用程序
![iFlow Settings Editor](./screenshots/main.png)
## 功能特性
- 📝 **API 配置管理** - 支持多环境配置文件切换、创建、编辑、重命名、复制、删除和拖动排序
- 🖥️ **MCP 服务器管理** - 便捷的 Model Context Protocol 服务器配置界面
- 🎨 **Windows 11 设计风格** - 采用 Fluent Design 设计规范
- 🌈 **多主题支持** - Light / Dark / System (跟随系统) 三种主题
- 🌍 **国际化** - 支持简体中文、English、日語
- 💧 **亚克力效果** - 可调节透明度的现代视觉效果
- 🧩 **技能管理** - 本地和在线导入、导出、删除 iFlow 技能
- 📦 **系统托盘** - 最小化到托盘,快速切换 API 配置
- 🚀 **开机自启动** - 支持开机自动启动,可选后台静默启动
- 📊 **仪表盘视图** - 直观展示当前配置状态和快捷操作
## 技术栈 ## 技术栈
| 技术 | 版本 | 用途 | | 技术 | 版本 |
|------|------|------| |------|------|
| Electron | ^28.0.0 | 桌面应用框架 | | Electron | 28.x |
| Vue | ^3.4.0 | 前端框架 (组合式 API) | | Vue | 3.4.x |
| Vite | ^8.0.8 | 构建工具 | | Vite | 8.x |
| @icon-park/vue-next | ^1.4.2 | 图标库 | | vue-i18n | 9.x |
| concurrently | ^8.2.2 | 并发执行工具 | | Less | 4.x |
| electron-builder | ^24.13.3 | 应用打包工具 | | Vitest | 4.x |
| electron-builder | 24.x |
| @icon-park/vue-next | 1.4.x |
## 支持的系统
- Windows 10 / 11 (x64)
- macOS 12+ (x64 / arm64)
## 安装
### 从源码运行
```bash
# 克隆项目
git clone https://git.pandorastudio.cn/product/iFlow-Settings-Editor-GUI.git
# 进入目录
cd iFlow-Settings-Editor-GUI
# 安装依赖
npm install
# 开发模式运行
npm run electron:dev
```
### 构建安装包
```bash
# 构建 Windows 安装包 (x64)
npm run build:win
# 构建便携版
npm run build:win-portable
# 构建 NSIS 安装程序
npm run build:win-installer
# 构建 macOS 安装包 (x64 + arm64)
npm run build:mac
# 构建 macOS 指定架构
npm run build:mac64 # 仅 x64
npm run build:mac-arm # 仅 arm64
# 构建 macOS DMG 安装包
npm run build:mac-dmg
# 构建 macOS ZIP 压缩包
npm run build:mac-zip
```
构建完成后,安装包位于 `release/` 目录下。
### CI/CD
项目使用 GitHub Actions 进行持续集成和发布:
- **推送标签** `v*` 自动构建并创建 GitHub Release
- 支持 Windows (x64) 和 macOS (x64/arm64) 多平台构建
- 自动提取 CHANGELOG.md 生成发布说明
```bash
# 触发发布
git tag v1.9.0
git push origin v1.9.0
```
## 使用说明
### 基础设置
在「常规」页面可以设置:
- **语言** - 界面显示语言
- **主题** - 视觉主题风格
- **启动动画** - 控制应用启动时的动画显示
- **检查点保存** - 开启/关闭自动保存功能
- **亚克力效果** - 调节窗口背景透明度
### API 配置管理
![编辑API配置](./screenshots/编辑API配置.png)
在「API 配置」页面可以:
- **切换配置** - 点击不同配置文件快速切换
- **新建配置** - 创建新的 API 环境配置
- **编辑配置** - 修改现有配置的名称、认证方式、API Key、Base URL 等
- **重命名配置** - 为配置设置新名称(当前使用中的配置不可重命名)
- **复制配置** - 复制现有配置创建新配置
- **拖动排序** - 拖动配置文件调整显示顺序
- **删除配置** - 删除不需要的配置(默认配置不可删除)
支持的认证方式:
- iFlow
- API Key
- OpenAI 兼容
### MCP 服务器管理
![添加服务器](./screenshots/添加服务器.png)
![MCP管理](./screenshots/MCP管理.png)
在「MCP 服务器」页面可以:
- **添加服务器** - 配置新的 MCP 服务器
- **编辑服务器** - 修改服务器的命令、工作目录、参数等
- **删除服务器** - 移除不需要的服务器
### 技能管理
![技能管理](./screenshots/skills-jp.jpg)
在「技能」页面可以:
- **本地导入** - 从本地 ZIP 压缩包导入技能
- **在线导入** - 从 GitHub URL 导入技能
- **导出技能** - 将技能导出到指定目录
- **删除技能** - 移除不需要的技能
### 系统托盘
- 关闭窗口时,应用会最小化到系统托盘
- 双击托盘图标可重新显示主窗口
- 右键托盘菜单可快速切换 API 配置
## 配置文件
应用配置文件位于:
```
~/.iflow/settings.json
```
每次保存时会自动生成备份文件 `settings.json.bak`
## 测试
```bash
# 运行测试
npm run test
# UI 模式测试
npm run test:ui
# 测试覆盖率
npm run test:coverage
# 单次运行测试
npm run test:run
```
## 项目结构 ## 项目结构
``` ```
iflow-settings-editor/ iFlow-Settings-Editor-GUI/
├── main.js # Electron 主进程 (窗口管理、IPC、文件操作) ├── main.js # Electron 主进程
├── preload.js # 预加载脚本 (IPC 通信) ├── preload.js # 预加载脚本
├── index.html # HTML 入口 ├── index.html # 入口 HTML
├── package.json # 项目配置
├── vite.config.js # Vite 配置 ├── vite.config.js # Vite 配置
├── src/ ├── vitest.config.js # Vitest 测试配置
│ ├── main.js # Vue 入口 ├── build/ # 构建资源
│ └── App.vue # 主组件 (所有业务逻辑) ├── dist/ # Vite 构建输出
├── build/ # 构建资源 (图标等) ├── release/ # Electron Builder 输出
├── release/ # 打包输出目录 ├── screenshots/ # 应用截图
└── screenshots/ # 截图资源 └── src/
├── main.js # Vue 入口
├── App.vue # 根组件
├── components/ # 公共组件
│ ├── TitleBar.vue # 标题栏
│ ├── SideBar.vue # 侧边导航
│ ├── InputDialog.vue # 输入对话框
│ ├── MessageDialog.vue # 消息对话框
│ ├── ApiProfileDialog.vue # API 配置弹窗
│ └── ServerPanel.vue # 服务器编辑面板
├── views/ # 页面视图
│ ├── GeneralSettings.vue # 常规设置
│ ├── ApiConfig.vue # API 配置管理
│ ├── McpServers.vue # MCP 服务器管理
│ ├── SkillsView.vue # 技能管理
│ └── Dashboard.vue # 仪表盘
├── locales/ # 国际化语言包
└── styles/ # 全局样式
``` ```
## 快速开始
### 安装依赖
```bash
npm install
```
### 开发模式
```bash
npm run dev # 启动 Vite 开发服务器
npm run electron:dev # 同时运行 Electron + Vite
```
### 构建与运行
```bash
npm run build # 构建 Vue 应用到 dist 目录
npm start # 运行 Electron 应用
npm run electron:start # 构建 + 运行 Electron
```
### 打包应用
```bash
npm run pack # 打包应用(不生成安装包)
npm run build:win # 构建 Windows 安装包 (NSIS)
npm run build:win64 # 构建 Windows x64 安装包
npm run build:win32 # 构建 Windows x86 安装包
npm run build:win-portable # 构建可移植版本
npm run build:dist # 完整构建和打包
```
## 功能模块
### 1. 常规设置 (General)
![主界面](screenshots/main.png)
配置应用程序的常规选项:
- **语言**: zh-CN / en-US / ja-JP
- **主题**: Xcode / Dark / Light / Solarized Dark
- **启动动画**: 已显示 / 未显示
- **检查点保存**: 启用 / 禁用
### 2. API 配置 (API)
管理多个环境的 API 配置:
- **配置列表**: 显示所有可用的 API 配置文件
- **配置切换**: 点击配置卡片直接切换
- **创建配置**: 新建 API 配置文件
- **编辑配置**: 修改现有配置的认证方式、API Key、Base URL 等
- **复制配置**: 基于现有配置创建新配置
- **删除配置**: 删除非默认配置
- **认证方式**: iFlow / API Key / OpenAI 兼容
- **API Key**: 密码输入框
- **Base URL**: API 端点
- **模型名称**: AI 模型标识
- **搜索 API Key**: 搜索服务认证
- **CNA**: CNA 标识
### 3. MCP 服务器管理 (MCP)
管理 Model Context Protocol 服务器配置:
- **服务器列表**: 显示所有已配置的服务器
- **添加服务器**: 创建新的 MCP 服务器配置
- **编辑服务器**: 修改现有服务器的配置
- **删除服务器**: 移除服务器配置
- **服务器配置项**:
- 名称
- 描述
- 命令
- 工作目录
- 参数 (每行一个)
- 环境变量 (JSON 格式)
## 核心架构
### 进程模型
- **Main Process (main.js)**: Electron 主进程处理窗口管理、IPC 通信、文件系统操作
- **Preload (preload.js)**: 通过 `contextBridge.exposeInMainWorld` 暴露安全 API
- **Renderer (Vue)**: 渲染进程,只通过 preload 暴露的 API 与主进程通信
### 窗口配置
- 窗口尺寸: 1100x750最小尺寸: 900x600
- 无边框窗口 (frame: false),自定义标题栏
- 开发模式加载 `http://localhost:5173`,生产模式加载 `dist/index.html`
### 安全配置
- `contextIsolation: true` - 隔离上下文
- `nodeIntegration: false` - 禁用 Node.js
- `webSecurity: false` - 仅开发环境解决 CSP 问题
## 打包配置
### Windows 平台
- **NSIS 安装包**: 支持 x64 架构
- **可移植版本**: 无需安装的独立可执行文件
- **安装器特性**:
- 允许修改安装目录
- 允许提升权限
- 创建桌面和开始菜单快捷方式
- 支持中文和英文界面界面
- 卸载时保留用户数据
### 输出目录
- `release/` - 所有打包输出的根目录
- 安装包命名: `iFlow Settings Editor-${version}-${arch}-setup.${ext}`
- 可移植版本命名: `iFlow Settings Editor-${version}-portable.${ext}`
## 注意事项
- `webSecurity: false` 仅用于开发环境解决 CSP 问题
- 保存设置时会自动创建备份 (`settings.json.bak`)
- MCP 服务器参数每行一个,环境变量支持 JSON 格式
- API 配置切换时会直接应用新配置,未保存的更改会被替换
## 开发注意事项
1. **修改检测**: 通过 `watch(settings, () => { modified.value = true }, { deep: true })` 深度监听
2. **服务器编辑**: 使用 DOM 操作收集表单数据
3. **MCP 参数**: 每行一个参数,通过换行分割
4. **环境变量**: 支持 JSON 格式输入
5. **窗口控制**: 通过 IPC 发送指令,主进程处理实际窗口操作
6. **API 配置切换**: 多个环境配置存储在 `settings.apiProfiles` 对象中
7. **序列化问题**: IPC 通信使用 `JSON.parse(JSON.stringify())` 避免 Vue 响应式代理问题
8. **默认值处理**: 加载配置时检查 `undefined` 并应用默认值,防止界面显示异常
## 许可证 ## 许可证
MIT License MIT License
## 联系方式
- 公司: 上海潘哆呐科技有限公司
- 项目地址: https://git.pandorastudio.cn/product/iFlow-Settings-Editor-GUI

BIN
build/icon.icns Normal file

Binary file not shown.

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self';"> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:;">
<title>iFlow 设置编辑器</title> <title>iFlow 设置编辑器</title>
</head> </head>
<body> <body>

791
main.js
View File

@@ -1,4 +1,4 @@
const { app, BrowserWindow, ipcMain, dialog } = require('electron') const { app, BrowserWindow, ipcMain, dialog, Tray, Menu, nativeImage } = require('electron')
const path = require('path') const path = require('path')
const fs = require('fs') const fs = require('fs')
console.log('main.js loaded') console.log('main.js loaded')
@@ -6,26 +6,283 @@ 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 isSilentLaunch = process.argv.includes('--hidden') || process.argv.includes('--silent')
// 自启动设置
function getAutoLaunchSettings() {
const settings = readSettings()
return settings?.autoLaunch ?? false
}
function setAutoLaunchEnabled(enabled) {
const settings = readSettings() || {}
settings.autoLaunch = enabled
writeSettings(settings)
// 设置 Electron 的自启动
if (app.isReady()) {
const loginSettings = {
openAtLogin: enabled,
openAsHidden: true, // 静默启动不显示窗口
}
// macOS 不需要指定 pathWindows 需要
if (process.platform !== 'darwin') {
loginSettings.path = app.getPath('exe')
}
app.setLoginItemSettings(loginSettings)
}
}
// 主进程翻译
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() {
// 获取图标路径 - 根据平台选择不同格式
// macOS 使用 .icnsWindows 使用 .ico
let iconPath
const isMac = process.platform === 'darwin'
if (app.isPackaged) {
const iconDir = path.join(process.resourcesPath, 'icon')
if (isMac) {
iconPath = path.join(iconDir, 'icon.icns')
if (!fs.existsSync(iconPath)) {
iconPath = path.join(iconDir, 'icon.ico') // 回退到 ico
}
} else {
iconPath = path.join(iconDir, 'icon.ico')
}
} else {
if (isMac) {
iconPath = path.join(__dirname, 'build', 'icon.icns')
if (!fs.existsSync(iconPath)) {
iconPath = path.join(__dirname, 'build', '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...')
// 根据平台选择图标
const isMac = process.platform === 'darwin'
let windowIcon
if (isMac) {
windowIcon = path.join(__dirname, 'build', 'icon.icns')
if (!fs.existsSync(windowIcon)) {
windowIcon = path.join(__dirname, 'build', 'icon.ico')
}
} else {
windowIcon = path.join(__dirname, 'build', 'icon.ico')
}
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1100, width: 1100,
height: 750, height: 750,
minWidth: 900, minWidth: 900,
minHeight: 600, minHeight: 600,
backgroundColor: '#f3f3f3', backgroundMaterial: isMac ? undefined : 'acrylic', // 仅 Windows 支持 acrylic 效果
frame: false, frame: false,
show: false, show: false,
icon: path.join(__dirname, 'build', 'icon.ico'), icon: windowIcon,
webPreferences: { webPreferences: {
devTools: true, devTools: isDev, // 只在开发模式启用 devTools
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
webSecurity: false, webSecurity: false,
}, },
}) })
// 非开发模式下阻止开发者工具快捷键
if (!isDev) {
mainWindow.webContents.on('before-input-event', (event, input) => {
// 阻止 Ctrl+Shift+I (DevTools) 和 F12 (DevTools)
if (input.ctrl && input.shift && input.key.toLowerCase() === 'i') {
event.preventDefault()
return false
}
if (input.key === 'F12') {
event.preventDefault()
return false
}
})
}
console.log('Loading index.html...') console.log('Loading index.html...')
if (isDev) { if (isDev) {
mainWindow.loadURL('http://localhost:5173') mainWindow.loadURL('http://localhost:5173')
@@ -41,15 +298,36 @@ function createWindow() {
}) })
mainWindow.once('ready-to-show', () => { mainWindow.once('ready-to-show', () => {
console.log('Window ready to show') console.log('Window ready to show')
// 如果是后台静默启动,不显示窗口,只创建托盘
if (isSilentLaunch) {
console.log('Silent launch mode - hiding window')
createTray()
} else {
mainWindow.show() mainWindow.show()
createTray()
}
}) })
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
mainWindow = null mainWindow = null
}) })
} }
app.whenReady().then(createWindow) app.whenReady().then(() => {
app.on('window-all-closed', () => { // 初始化自启动设置
const autoLaunchEnabled = getAutoLaunchSettings()
if (autoLaunchEnabled) {
const loginSettings = {
openAtLogin: true,
openAsHidden: true,
}
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
loginSettings.path = app.getPath('exe')
}
app.setLoginItemSettings(loginSettings)
}
createWindow()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin' && app.isQuitting) {
app.quit() app.quit()
} }
}) })
@@ -67,8 +345,36 @@ 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()
})
// 开机自启动
ipcMain.handle('get-auto-launch', async () => {
try {
return { success: true, enabled: getAutoLaunchSettings() }
} catch (error) {
return { success: false, error: error.message }
}
})
ipcMain.handle('set-auto-launch', async (event, enabled) => {
try {
setAutoLaunchEnabled(enabled)
return { success: true }
} catch (error) {
return { success: false, error: error.message }
}
})
// API 配置相关的字段 // API 配置相关的字段
const API_FIELDS = ['selectedAuthType', 'apiKey', 'baseUrl', 'modelName', 'searchApiKey', 'cna'] const API_FIELDS = ['selectedAuthType', 'apiKey', 'baseUrl', 'modelName', 'searchApiKey', 'cna']
// 读取设置文件 // 读取设置文件
@@ -116,12 +422,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'
@@ -144,6 +451,8 @@ ipcMain.handle('switch-api-profile', async (event, profileName) => {
settings.currentApiProfile = profileName settings.currentApiProfile = profileName
settings.apiProfiles = profiles settings.apiProfiles = profiles
writeSettings(settings) writeSettings(settings)
// 更新托盘菜单
updateTrayMenu()
return { success: true, data: settings } return { success: true, data: settings }
} catch (error) { } catch (error) {
return { success: false, error: error.message } return { success: false, error: error.message }
@@ -153,8 +462,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 +476,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 = {}
@@ -177,6 +487,8 @@ ipcMain.handle('create-api-profile', async (event, name) => {
} }
settings.apiProfiles[name] = newConfig settings.apiProfiles[name] = newConfig
writeSettings(settings) writeSettings(settings)
// 更新托盘菜单
updateTrayMenu()
return { success: true } return { success: true }
} catch (error) { } catch (error) {
return { success: false, error: error.message } return { success: false, error: error.message }
@@ -186,15 +498,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
@@ -210,6 +523,8 @@ ipcMain.handle('delete-api-profile', async (event, name) => {
} }
} }
writeSettings(settings) writeSettings(settings)
// 更新托盘菜单
updateTrayMenu()
return { success: true, data: settings } return { success: true, data: settings }
} catch (error) { } catch (error) {
return { success: false, error: error.message } return { success: false, error: error.message }
@@ -219,18 +534,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]
@@ -239,6 +555,8 @@ ipcMain.handle('rename-api-profile', async (event, oldName, newName) => {
settings.currentApiProfile = newName settings.currentApiProfile = newName
} }
writeSettings(settings) writeSettings(settings)
// 更新托盘菜单
updateTrayMenu()
return { success: true } return { success: true }
} catch (error) { } catch (error) {
return { success: false, error: error.message } return { success: false, error: error.message }
@@ -248,20 +566,23 @@ 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]))
settings.apiProfiles = profiles settings.apiProfiles = profiles
writeSettings(settings) writeSettings(settings)
// 更新托盘菜单
updateTrayMenu()
return { success: true } return { success: true }
} catch (error) { } catch (error) {
return { success: false, error: error.message } return { success: false, error: error.message }
@@ -304,3 +625,431 @@ ipcMain.handle('save-settings', async (event, data) => {
ipcMain.handle('show-message', async (event, { type, title, message }) => { ipcMain.handle('show-message', async (event, { type, title, message }) => {
return dialog.showMessageBox(mainWindow, { type, title, message }) return dialog.showMessageBox(mainWindow, { type, title, message })
}) })
// 技能文件夹路径
const SKILLS_FOLDER = path.join(app.getPath('home'), '.iflow', 'skills')
// 确保技能文件夹存在
function ensureSkillsFolder() {
if (!fs.existsSync(SKILLS_FOLDER)) {
fs.mkdirSync(SKILLS_FOLDER, { recursive: true })
}
}
// 获取技能列表
ipcMain.handle('list-skills', async () => {
try {
ensureSkillsFolder()
const files = fs.readdirSync(SKILLS_FOLDER)
const skills = []
for (const file of files) {
const skillPath = path.join(SKILLS_FOLDER, file)
const stat = fs.statSync(skillPath)
// 技能是文件夹格式,包含 SKILL.md 文件
if (stat.isDirectory()) {
const skillMdPath = path.join(skillPath, 'SKILL.md')
const licensePath = path.join(skillPath, 'LICENSE.txt')
let description = ''
let name = file
if (fs.existsSync(skillMdPath)) {
try {
const content = fs.readFileSync(skillMdPath, 'utf-8')
// 解析 YAML front matter
const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---/)
if (frontMatterMatch) {
const frontMatter = frontMatterMatch[1]
const nameMatch = frontMatter.match(/name:\s*(.+)/)
const descMatch = frontMatter.match(/description:\s*(.+)/)
if (nameMatch) name = nameMatch[1].trim()
if (descMatch) description = descMatch[1].trim()
}
} catch (e) {
console.error('Failed to parse SKILL.md:', e)
}
}
// 计算文件夹总大小
const calcFolderSize = dirPath => {
let size = 0
const items = fs.readdirSync(dirPath)
for (const item of items) {
const itemPath = path.join(dirPath, item)
const itemStat = fs.statSync(itemPath)
if (itemStat.isFile()) {
size += itemStat.size
}
}
return size
}
skills.push({
name,
description,
folderName: file,
size: calcFolderSize(skillPath),
path: skillPath,
hasLicense: fs.existsSync(licensePath),
})
}
}
return { success: true, skills }
} catch (error) {
return { success: false, error: error.message, skills: [] }
}
})
// 本地导入技能
ipcMain.handle('import-skill-local', async () => {
try {
const result = await dialog.showOpenDialog(mainWindow, {
title: '导入技能',
filters: [
{ name: '技能压缩包', extensions: ['zip'] },
{ name: '所有文件', extensions: ['*'] },
],
properties: ['openFile'],
})
if (result.canceled || result.filePaths.length === 0) {
return { success: false, cancelled: true }
}
const sourcePath = result.filePaths[0]
const fileName = path.basename(sourcePath)
const tmpDir = path.join(app.getPath('temp'), `skill-import-${Date.now()}`)
ensureSkillsFolder()
try {
// 创建临时解压目录
fs.mkdirSync(tmpDir, { recursive: true })
// 解压压缩包
const admzip = require('adm-zip')
const zip = new admzip(sourcePath)
zip.extractAllTo(tmpDir, true)
// 检查 SKILL.md 是否直接在解压目录中(不嵌套在文件夹中)
const directSkillMdPath = path.join(tmpDir, 'SKILL.md')
let skillFolder = null
let skillName = ''
if (fs.existsSync(directSkillMdPath)) {
// SKILL.md 直接在解压目录中
skillFolder = tmpDir
const content = fs.readFileSync(directSkillMdPath, 'utf-8')
const nameMatch = content.match(/^---\n([\s\S]*?)\n---/)
if (nameMatch) {
const frontMatter = nameMatch[1]
const nMatch = frontMatter.match(/name:\s*(.+)/)
if (nMatch) skillName = nMatch[1].trim()
}
// 如果没有解析到名称,使用 ZIP 文件名(去掉扩展名)
if (!skillName) {
skillName = path.basename(sourcePath, '.zip')
}
} else {
// 递归查找包含 SKILL.md 的文件夹
const findSkillFolder = (dirPath, depth = 0) => {
if (depth > 3) return null // 防止无限递归
const entries = fs.readdirSync(dirPath)
for (const entry of entries) {
const entryPath = path.join(dirPath, entry)
const stat = fs.statSync(entryPath)
if (stat.isDirectory()) {
const skillMdPath = path.join(entryPath, 'SKILL.md')
if (fs.existsSync(skillMdPath)) {
return entryPath
}
// 递归检查子文件夹
const found = findSkillFolder(entryPath, depth + 1)
if (found) return found
}
}
return null
}
skillFolder = findSkillFolder(tmpDir)
if (skillFolder) {
const skillMdPath = path.join(skillFolder, 'SKILL.md')
const content = fs.readFileSync(skillMdPath, 'utf-8')
const nameMatch = content.match(/^---\n([\s\S]*?)\n---/)
if (nameMatch) {
const frontMatter = nameMatch[1]
const nMatch = frontMatter.match(/name:\s*(.+)/)
if (nMatch) skillName = nMatch[1].trim()
}
// 如果没有解析到名称,使用文件夹名称
if (!skillName) {
skillName = path.basename(skillFolder)
}
}
}
if (!skillFolder) {
// 调试信息:列出解压后的所有文件
const listAllFiles = (dirPath, files = [], baseDepth = 0) => {
try {
const entries = fs.readdirSync(dirPath)
for (const entry of entries) {
const entryPath = path.join(dirPath, entry)
const stat = fs.statSync(entryPath)
if (stat.isDirectory()) {
listAllFiles(entryPath, files, baseDepth + 1)
} else {
files.push(`${' '.repeat(baseDepth)}${entry}`)
}
}
} catch (e) {}
return files
}
const allFiles = listAllFiles(tmpDir)
console.error('解压后文件列表:', allFiles.join('\n'))
return { success: false, error: `压缩包中未找到有效的技能文件夹(缺少 SKILL.md\n解压内容:\n${allFiles.slice(0, 20).join('\n')}` }
}
const destPath = path.join(SKILLS_FOLDER, skillName)
// 如果技能已存在,询问是否覆盖
if (fs.existsSync(destPath)) {
const overwrite = await dialog.showMessageBox(mainWindow, {
type: 'warning',
title: '技能已存在',
message: `技能 "${skillName}" 已存在,是否覆盖?`,
buttons: ['覆盖', '取消'],
defaultId: 1,
})
if (overwrite.response === 1) {
return { success: false, cancelled: true }
}
fs.rmSync(destPath, { recursive: true })
}
// 复制技能文件夹
fs.cpSync(skillFolder, destPath, { recursive: true })
return { success: true, message: `技能 "${skillName}" 导入成功` }
} finally {
// 清理临时目录
if (fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true })
}
}
} catch (error) {
return { success: false, error: error.message }
}
})
// 在线导入技能
ipcMain.handle('import-skill-online', async (event, url, name) => {
try {
ensureSkillsFolder()
const https = require('https')
const http = require('http')
const { URL } = require('url')
const parsedUrl = new URL(url)
const protocol = parsedUrl.protocol === 'https:' ? https : http
const destPath = path.join(SKILLS_FOLDER, name)
// 检查是否已存在
if (fs.existsSync(destPath)) {
const overwrite = await dialog.showMessageBox(mainWindow, {
type: 'warning',
title: '技能已存在',
message: `技能 "${name}" 已存在,是否覆盖?`,
buttons: ['覆盖', '取消'],
defaultId: 1,
})
if (overwrite.response === 1) {
return { success: false, cancelled: true }
}
// 删除旧文件夹
fs.rmSync(destPath, { recursive: true })
}
// 创建目标文件夹
fs.mkdirSync(destPath, { recursive: true })
return new Promise(resolve => {
protocol
.get(url, response => {
// 处理重定向
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
const redirectUrl = new URL(response.headers.location, url)
protocol
.get(redirectUrl.toString(), redirectResponse => {
handleDownload(redirectResponse, destPath, name).then(resolve)
})
.on('error', err => {
resolve({ success: false, error: err.message })
})
return
}
if (response.statusCode !== 200) {
resolve({ success: false, error: `下载失败: HTTP ${response.statusCode}` })
return
}
handleDownload(response, destPath, name).then(resolve)
})
.on('error', err => {
resolve({ success: false, error: err.message })
})
})
} catch (error) {
return { success: false, error: error.message }
}
})
// 处理下载内容
function handleDownload(response, destPath, name) {
return new Promise(resolve => {
// 检测是否为 GitHub 仓库的 tarball/zipball
const contentDisposition = response.headers['content-disposition']
const contentType = response.headers['content-type'] || ''
if (contentType.includes('application/zip') || contentType.includes('application/x-tar') || (contentDisposition && contentDisposition.includes('attachment'))) {
// 下载为压缩包
const chunks = []
response.on('data', chunk => chunks.push(chunk))
response.on('end', () => {
try {
const content = Buffer.concat(chunks)
const tmpPath = path.join(app.getPath('temp'), `skill-${Date.now()}`)
if (contentType.includes('zip') || (contentDisposition && contentDisposition.includes('.zip'))) {
// ZIP 文件
const admzip = require('adm-zip')
fs.writeFileSync(tmpPath + '.zip', content)
const zip = new admzip(tmpPath + '.zip')
// 解压并提取第一个文件夹
zip.extractAllTo(tmpPath, true)
const entries = fs.readdirSync(tmpPath)
const firstEntry = entries.find(e => fs.statSync(path.join(tmpPath, e)).isDirectory())
if (firstEntry) {
const extractedPath = path.join(tmpPath, firstEntry)
const skillFiles = fs.readdirSync(extractedPath)
for (const file of skillFiles) {
fs.cpSync(path.join(extractedPath, file), path.join(destPath, file))
}
}
fs.rmSync(tmpPath, { recursive: true, force: true })
if (fs.existsSync(tmpPath + '.zip')) fs.unlinkSync(tmpPath + '.zip')
} else {
// TAR 文件
fs.writeFileSync(tmpPath, content)
// 使用 tar 解压 (需要系统有 tar 命令)
const { execSync } = require('child_process')
try {
execSync(`tar -xf "${tmpPath}" -C "${tmpPath}-dir"`, { stdio: 'pipe' })
const entries = fs.readdirSync(tmpPath + '-dir')
const firstEntry = entries.find(e => fs.statSync(path.join(tmpPath + '-dir', e)).isDirectory())
if (firstEntry) {
const extractedPath = path.join(tmpPath + '-dir', firstEntry)
const skillFiles = fs.readdirSync(extractedPath)
for (const file of skillFiles) {
fs.cpSync(path.join(extractedPath, file), path.join(destPath, file))
}
}
fs.rmSync(tmpPath, { recursive: true, force: true })
if (fs.existsSync(tmpPath + '-dir')) fs.rmSync(tmpPath + '-dir', { recursive: true, force: true })
} catch (e) {
// 如果没有 tar 命令,尝试直接复制
fs.cpSync(tmpPath, destPath, { recursive: true })
fs.rmSync(tmpPath, { recursive: true, force: true })
}
}
resolve({ success: true, message: `技能 "${name}" 在线导入成功` })
} catch (writeError) {
resolve({ success: false, error: writeError.message })
}
})
} else {
// 直接下载文件内容
const chunks = []
response.on('data', chunk => chunks.push(chunk))
response.on('end', () => {
try {
const content = Buffer.concat(chunks)
// 假设下载的是 SKILL.md 内容
const skillMdPath = path.join(destPath, 'SKILL.md')
fs.writeFileSync(skillMdPath, content)
resolve({ success: true, message: `技能 "${name}" 在线导入成功` })
} catch (writeError) {
resolve({ success: false, error: writeError.message })
}
})
}
})
}
// 导出技能
ipcMain.handle('export-skill', async (event, name, folderName) => {
try {
const skillPath = path.join(SKILLS_FOLDER, folderName)
if (!fs.existsSync(skillPath)) {
return { success: false, error: `技能 "${name}" 不存在` }
}
const result = await dialog.showOpenDialog(mainWindow, {
title: '导出技能到',
buttonLabel: '选择导出位置',
properties: ['openDirectory', 'createDirectory'],
})
if (result.canceled || result.filePaths.length === 0) {
return { success: false, cancelled: true }
}
const destPath = path.join(result.filePaths[0], folderName)
// 如果目标已存在,删除
if (fs.existsSync(destPath)) {
fs.rmSync(destPath, { recursive: true })
}
// 复制整个文件夹
fs.cpSync(skillPath, destPath, { recursive: true })
return { success: true, message: `技能 "${name}" 导出成功` }
} catch (error) {
return { success: false, error: error.message }
}
})
// 删除技能
ipcMain.handle('delete-skill', async (event, name) => {
console.log('delete-skill called with:', name)
try {
const skillPath = path.join(SKILLS_FOLDER, name)
console.log('Deleting skill at:', skillPath)
console.log('Path exists:', fs.existsSync(skillPath))
if (!fs.existsSync(skillPath)) {
return { success: false, error: `技能 "${name}" 不存在` }
}
fs.rmSync(skillPath, { recursive: true })
console.log('Skill deleted successfully')
return { success: true, message: `技能 "${name}" 已删除` }
} catch (error) {
console.error('Delete skill error:', error)
return { success: false, error: error.message }
}
})

1247
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "iflow-settings-editor", "name": "iflow-settings-editor",
"version": "1.5.0", "version": "1.8.8",
"description": "一个用于编辑 iFlow CLI 配置文件的桌面应用程序。", "description": "一个用于编辑 iFlow CLI 配置文件的桌面应用程序。",
"main": "main.js", "main": "main.js",
"author": "上海潘哆呐科技有限公司", "author": "上海潘哆呐科技有限公司",
@@ -20,16 +20,36 @@
"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" "build:mac": "vite build && electron-builder --mac",
"build:mac64": "vite build && electron-builder --mac --x64",
"build:mac-arm": "vite build && electron-builder --mac --arm64",
"build:mac-dmg": "vite build && electron-builder --mac dmg",
"build:mac-zip": "vite build && electron-builder --mac zip",
"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"
}, },
"publish": null,
"extraResources": [
{
"from": "build/icon.ico",
"to": "icon/icon.ico"
},
{
"from": "build/icon.icns",
"to": "icon/icon.icns"
}
],
"files": [ "files": [
"dist/**/*", "dist/**/*",
"main.js", "main.js",
@@ -54,6 +74,19 @@
"icon": "build/icon.ico", "icon": "build/icon.ico",
"artifactName": "${productName}-${version}-${arch}-setup.${ext}" "artifactName": "${productName}-${version}-${arch}-setup.${ext}"
}, },
"mac": {
"target": [
"dmg",
"zip"
],
"icon": "build/icon.icns",
"category": "public.app-category.developer-tools",
"artifactName": "${productName}-${version}-${arch}.${ext}"
},
"dmg": {
"title": "${productName}-${version}",
"artifactName": "${productName}-${version}-${arch}.${ext}"
},
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
@@ -78,12 +111,24 @@
} }
}, },
"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/coverage-v8": "^4.1.4",
"@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": {
"adm-zip": "^0.5.17",
"vue-i18n": "^9.14.5"
} }
} }

View File

@@ -6,6 +6,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
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),
// 开机自启动
getAutoLaunch: () => ipcRenderer.invoke('get-auto-launch'),
setAutoLaunch: (enabled) => ipcRenderer.invoke('set-auto-launch', enabled),
// 窗口控制 // 窗口控制
isMaximized: () => ipcRenderer.invoke('is-maximized'), isMaximized: () => ipcRenderer.invoke('is-maximized'),
minimize: () => ipcRenderer.send('window-minimize'), minimize: () => ipcRenderer.send('window-minimize'),
@@ -18,5 +22,22 @@ 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')
},
// 技能管理
listSkills: () => ipcRenderer.invoke('list-skills'),
importSkillLocal: () => ipcRenderer.invoke('import-skill-local'),
importSkillOnline: (url, name) => ipcRenderer.invoke('import-skill-online', url, name),
exportSkill: (name, fileName) => ipcRenderer.invoke('export-skill', name, fileName),
deleteSkill: (name) => ipcRenderer.invoke('delete-skill', name),
}) })

BIN
screenshots/MCP管理.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 596 KiB

BIN
screenshots/skills-jp.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
screenshots/theme-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
screenshots/theme-xcode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,360 @@
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import ApiProfileDialog from './ApiProfileDialog.vue';
describe('ApiProfileDialog.vue', () => {
const mockCreateData = {
name: '',
selectedAuthType: 'openai-compatible',
apiKey: '',
baseUrl: '',
modelName: ''
};
const mockEditData = {
name: 'production',
selectedAuthType: 'openai-compatible',
apiKey: 'test-key',
baseUrl: 'https://api.test.com',
modelName: 'gpt-4'
};
describe('Create Dialog', () => {
it('renders create dialog when showCreate is true', () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: true,
showEdit: false,
createData: mockCreateData,
editData: {}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.api-edit-dialog').exists()).toBe(true);
expect(wrapper.find('.dialog-title').text()).toContain('api.createTitle');
});
it('does not render when showCreate is false', () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: false,
showEdit: false,
createData: mockCreateData,
editData: {}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.api-edit-dialog').exists()).toBe(false);
});
it('has config name input in create dialog', () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: true,
showEdit: false,
createData: mockCreateData,
editData: {}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const inputs = wrapper.findAll('.form-input');
expect(inputs.length).toBeGreaterThan(0);
});
it('has auth type select in create dialog', () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: true,
showEdit: false,
createData: mockCreateData,
editData: {}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const selects = wrapper.findAll('.form-select');
expect(selects.length).toBe(1);
});
it('emits close-create when cancel button is clicked', async () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: true,
showEdit: false,
createData: mockCreateData,
editData: {}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.btn-secondary').trigger('click');
expect(wrapper.emitted('close-create')).toBeTruthy();
});
it('emits save-create with data when save button is clicked', async () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: true,
showEdit: false,
createData: { ...mockCreateData, name: 'new-config' },
editData: {}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.btn-primary').trigger('click');
expect(wrapper.emitted('save-create')).toBeTruthy();
expect(wrapper.emitted('save-create')[0][0].name).toBe('new-config');
});
it('has create and cancel buttons', () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: true,
showEdit: false,
createData: mockCreateData,
editData: {}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const buttons = wrapper.findAll('.btn');
expect(buttons.length).toBe(2);
expect(buttons[0].classes()).toContain('btn-secondary');
expect(buttons[1].classes()).toContain('btn-primary');
});
});
describe('Edit Dialog', () => {
it('renders edit dialog when showEdit is true', () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: false,
showEdit: true,
createData: {},
editData: mockEditData,
currentProfileName: 'production'
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.api-edit-dialog').exists()).toBe(true);
expect(wrapper.find('.dialog-title').text()).toContain('api.editTitle');
});
it('shows config name as readonly when editing current profile', () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: false,
showEdit: true,
createData: {},
editData: mockEditData,
currentProfileName: 'production'
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const nameInput = wrapper.find('.form-input');
expect(nameInput.attributes('disabled')).toBeDefined();
});
it('allows editing config name when not current profile', () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: false,
showEdit: true,
createData: {},
editData: { ...mockEditData, name: 'other-config' },
currentProfileName: 'production'
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const nameInput = wrapper.find('.form-input');
expect(nameInput.attributes('disabled')).toBeUndefined();
});
it('has correct number of form groups in edit dialog', () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: false,
showEdit: true,
createData: {},
editData: mockEditData,
currentProfileName: 'production'
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const formGroups = wrapper.findAll('.form-group');
expect(formGroups.length).toBe(5); // name, authType, apiKey, baseUrl, modelName
});
it('emits close-edit when cancel button is clicked', async () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: false,
showEdit: true,
createData: {},
editData: mockEditData,
currentProfileName: 'production'
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.btn-secondary').trigger('click');
expect(wrapper.emitted('close-edit')).toBeTruthy();
});
it('emits save-edit with data when save button is clicked', async () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: false,
showEdit: true,
createData: {},
editData: mockEditData,
currentProfileName: 'production'
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.btn-primary').trigger('click');
expect(wrapper.emitted('save-edit')).toBeTruthy();
expect(wrapper.emitted('save-edit')[0][0].name).toBe('production');
});
it('has form-row for baseUrl and modelName', () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: false,
showEdit: true,
createData: {},
editData: mockEditData,
currentProfileName: 'production'
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.form-row').exists()).toBe(true);
});
});
describe('Dialog Overlay', () => {
it('closes on escape key when showCreate is true', async () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: true,
showEdit: false,
createData: mockCreateData,
editData: {}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.dialog-overlay').trigger('keyup.esc');
expect(wrapper.emitted('close-create')).toBeTruthy();
});
it('closes on escape key when showEdit is true', async () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: false,
showEdit: true,
createData: {},
editData: mockEditData,
currentProfileName: 'production'
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.dialog-overlay').trigger('keyup.esc');
expect(wrapper.emitted('close-edit')).toBeTruthy();
});
it('does not close when clicking on dialog content', async () => {
const wrapper = mount(ApiProfileDialog, {
props: {
showCreate: true,
showEdit: false,
createData: mockCreateData,
editData: {}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.api-edit-dialog').trigger('click');
expect(wrapper.emitted('close-create')).toBeFalsy();
});
});
});

View File

@@ -0,0 +1,179 @@
<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>
<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.configName') }}</label>
<input type="text" class="form-input" v-model="editData.name" :disabled="editData.name === currentProfileName" />
</div>
<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>
<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,
currentProfileName: String
})
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>

View File

@@ -0,0 +1,312 @@
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import InputDialog from './InputDialog.vue';
describe('InputDialog.vue', () => {
describe('Basic Rendering', () => {
it('renders when dialog show is true', () => {
const wrapper = mount(InputDialog, {
props: {
dialog: {
show: true,
title: 'Test Title',
placeholder: 'Enter value',
isConfirm: false
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.dialog').exists()).toBe(true);
expect(wrapper.find('.dialog-title').text()).toBe('Test Title');
});
it('does not render when dialog show is false', () => {
const wrapper = mount(InputDialog, {
props: {
dialog: {
show: false,
title: 'Test Title'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.dialog').exists()).toBe(false);
});
it('has correct title', () => {
const wrapper = mount(InputDialog, {
props: {
dialog: {
show: true,
title: 'My Custom Title'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.dialog-title').text()).toBe('My Custom Title');
});
});
describe('Input Mode', () => {
it('shows text input when isConfirm is false', () => {
const wrapper = mount(InputDialog, {
props: {
dialog: {
show: true,
title: 'Input Dialog',
placeholder: 'Enter your name',
isConfirm: false
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const input = wrapper.find('input[type="text"]');
expect(input.exists()).toBe(true);
});
it('has correct placeholder', () => {
const wrapper = mount(InputDialog, {
props: {
dialog: {
show: true,
title: 'Input Dialog',
placeholder: 'Enter your name',
isConfirm: false
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const input = wrapper.find('input[type="text"]');
expect(input.attributes('placeholder')).toBe('Enter your name');
});
it('updates input value when typing', async () => {
const wrapper = mount(InputDialog, {
props: {
dialog: {
show: true,
title: 'Input Dialog',
placeholder: 'Enter value',
isConfirm: false
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const input = wrapper.find('input[type="text"]');
await input.setValue('Test Value');
const inputVm = wrapper.findComponent({ name: 'InputDialog' });
expect(inputVm.vm.inputValue).toBe('Test Value');
});
});
describe('Confirm Mode', () => {
it('shows confirmation text when isConfirm is true', () => {
const wrapper = mount(InputDialog, {
props: {
dialog: {
show: true,
title: 'Confirm Dialog',
placeholder: 'Are you sure?',
isConfirm: true
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const confirmText = wrapper.find('.dialog-confirm-text');
expect(confirmText.exists()).toBe(true);
expect(confirmText.text()).toBe('Are you sure?');
});
it('does not show input when isConfirm is true', () => {
const wrapper = mount(InputDialog, {
props: {
dialog: {
show: true,
title: 'Confirm Dialog',
placeholder: 'Are you sure?',
isConfirm: true
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const input = wrapper.find('input[type="text"]');
expect(input.exists()).toBe(false);
});
});
describe('Actions', () => {
it('has cancel and confirm buttons', () => {
const wrapper = mount(InputDialog, {
props: {
dialog: {
show: true,
title: 'Test Dialog'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const buttons = wrapper.findAll('.btn');
expect(buttons.length).toBe(2);
expect(buttons[0].classes()).toContain('btn-secondary');
expect(buttons[1].classes()).toContain('btn-primary');
});
it('emits cancel when cancel button is clicked', async () => {
const wrapper = mount(InputDialog, {
props: {
dialog: {
show: true,
title: 'Test Dialog'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.btn-secondary').trigger('click');
expect(wrapper.emitted('cancel')).toBeTruthy();
});
it('emits confirm with input value in input mode', async () => {
const wrapper = mount(InputDialog, {
props: {
dialog: {
show: true,
title: 'Input Dialog',
placeholder: 'Enter value',
isConfirm: false
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('input[type="text"]').setValue('Test Value');
await wrapper.find('.btn-primary').trigger('click');
expect(wrapper.emitted('confirm')).toBeTruthy();
expect(wrapper.emitted('confirm')[0][0]).toBe('Test Value');
});
it('emits confirm with true in confirm mode', async () => {
const wrapper = mount(InputDialog, {
props: {
dialog: {
show: true,
title: 'Confirm Dialog',
placeholder: 'Are you sure?',
isConfirm: true
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.btn-primary').trigger('click');
expect(wrapper.emitted('confirm')).toBeTruthy();
expect(wrapper.emitted('confirm')[0][0]).toBe(true);
});
it('emits confirm on Enter key in input mode', async () => {
const wrapper = mount(InputDialog, {
props: {
dialog: {
show: true,
title: 'Input Dialog',
placeholder: 'Enter value',
isConfirm: false
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('input[type="text"]').setValue('Enter Value');
await wrapper.find('input[type="text"]').trigger('keyup.enter');
expect(wrapper.emitted('confirm')).toBeTruthy();
expect(wrapper.emitted('confirm')[0][0]).toBe('Enter Value');
});
});
describe('Watch Behavior', () => {
it('clears input value when dialog show becomes true', async () => {
const wrapper = mount(InputDialog, {
props: {
dialog: {
show: true,
title: 'Test'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
// Initial value should be empty
const inputVm = wrapper.findComponent({ name: 'InputDialog' });
expect(inputVm.vm.inputValue).toBe('');
});
});
});

View 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>

View File

@@ -0,0 +1,392 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import MessageDialog from './MessageDialog.vue';
describe('MessageDialog.vue', () => {
describe('Basic Rendering', () => {
it('renders when dialog show is true', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'info',
title: 'Info Title',
message: 'Info message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.message-dialog').exists()).toBe(true);
});
it('does not render when dialog show is false', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: false,
type: 'info',
title: 'Info Title',
message: 'Info message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.message-dialog').exists()).toBe(false);
});
it('displays correct title', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'info',
title: 'Custom Title',
message: 'Custom message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.message-dialog-title').text()).toBe('Custom Title');
});
it('displays correct message', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'info',
title: 'Info Title',
message: 'This is the message content'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.message-dialog-message').text()).toBe('This is the message content');
});
});
describe('Dialog Types', () => {
it('shows info icon when type is info', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'info',
title: 'Info',
message: 'Info message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const iconContainer = wrapper.find('.message-dialog-icon');
expect(iconContainer.classes()).toContain('message-dialog-icon-info');
});
it('shows success icon when type is success', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'success',
title: 'Success',
message: 'Success message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const iconContainer = wrapper.find('.message-dialog-icon');
expect(iconContainer.classes()).toContain('message-dialog-icon-success');
});
it('shows warning icon when type is warning', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'warning',
title: 'Warning',
message: 'Warning message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const iconContainer = wrapper.find('.message-dialog-icon');
expect(iconContainer.classes()).toContain('message-dialog-icon-warning');
});
it('shows error icon when type is error', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'error',
title: 'Error',
message: 'Error message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const iconContainer = wrapper.find('.message-dialog-icon');
expect(iconContainer.classes()).toContain('message-dialog-icon-error');
});
});
describe('Icon SVG', () => {
it('renders info icon SVG correctly', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'info',
title: 'Info',
message: 'Info message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const svg = wrapper.find('.message-dialog-icon svg');
expect(svg.exists()).toBe(true);
expect(svg.attributes('viewBox')).toBe('0 0 24 24');
});
it('renders success icon SVG with checkmark', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'success',
title: 'Success',
message: 'Success message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const paths = wrapper.findAll('.message-dialog-icon svg path');
expect(paths.length).toBeGreaterThan(0);
});
it('renders warning icon SVG with triangle', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'warning',
title: 'Warning',
message: 'Warning message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const svg = wrapper.find('.message-dialog-icon svg');
expect(svg.exists()).toBe(true);
});
it('renders error icon SVG with X', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'error',
title: 'Error',
message: 'Error message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const lines = wrapper.findAll('.message-dialog-icon svg line');
expect(lines.length).toBe(2);
});
});
describe('Actions', () => {
it('has a confirm button', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'info',
title: 'Info',
message: 'Info message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const buttons = wrapper.findAll('.btn');
expect(buttons.length).toBe(1);
expect(buttons[0].classes()).toContain('btn-primary');
});
it('emits close when confirm button is clicked', async () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'info',
title: 'Info',
message: 'Info message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.btn-primary').trigger('click');
expect(wrapper.emitted('close')).toBeTruthy();
});
it('button text uses $t translation', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'info',
title: 'Info',
message: 'Info message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const button = wrapper.find('.btn-primary');
expect(button.text()).toBe('dialog.confirm');
});
});
describe('Dialog Structure', () => {
it('has centered actions', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'info',
title: 'Info',
message: 'Info message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const actions = wrapper.find('.dialog-actions');
expect(actions.exists()).toBe(true);
});
it('dialog has centered text alignment class', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'info',
title: 'Info',
message: 'Info message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const dialog = wrapper.find('.message-dialog');
expect(dialog.exists()).toBe(true);
expect(dialog.classes()).toContain('message-dialog');
});
it('icon container has correct size', () => {
const wrapper = mount(MessageDialog, {
props: {
dialog: {
show: true,
type: 'info',
title: 'Info',
message: 'Info message'
}
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const icon = wrapper.find('.message-dialog-icon');
expect(icon.exists()).toBe(true);
});
});
});

View File

@@ -0,0 +1,139 @@
<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="handleConfirm">{{ $t('dialog.confirm') }}</button>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
dialog: {
type: Object,
default: () => ({ show: false, type: 'info', title: '', message: '', callback: null })
}
})
const emit = defineEmits(['close', 'confirm'])
const handleConfirm = () => {
emit('confirm', true)
emit('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>

View File

@@ -0,0 +1,541 @@
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import ServerPanel from './ServerPanel.vue';
describe('ServerPanel.vue', () => {
const mockServerData = {
name: 'Test Server',
description: 'A test MCP server',
command: 'npx',
cwd: '/project',
args: '--flag value',
env: 'DEBUG=true'
};
describe('Basic Rendering', () => {
it('renders when show is true', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.side-panel').exists()).toBe(true);
});
it('does not render when show is false', () => {
const wrapper = mount(ServerPanel, {
props: {
show: false,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.side-panel').exists()).toBe(false);
});
it('shows add server title when isEditing is false', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.side-panel-title').text()).toContain('mcp.addServer');
});
it('shows edit server title when isEditing is true', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: true,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.side-panel-title').text()).toContain('mcp.editServer');
});
});
describe('Form Fields', () => {
it('has server name input', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const inputs = wrapper.findAll('.form-input');
expect(inputs.length).toBeGreaterThan(0);
});
it('has description input', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const inputs = wrapper.findAll('.form-input');
expect(inputs.length).toBeGreaterThanOrEqual(2);
});
it('has command input', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const inputs = wrapper.findAll('.form-input');
expect(inputs.length).toBeGreaterThanOrEqual(3);
});
it('has working directory input', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const inputs = wrapper.findAll('.form-input');
expect(inputs.length).toBeGreaterThanOrEqual(4);
});
it('has args textarea', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const textareas = wrapper.findAll('.form-textarea');
expect(textareas.length).toBe(2); // args and env
});
it('has env vars textarea', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const textareas = wrapper.findAll('.form-textarea');
expect(textareas.length).toBe(2);
});
it('displays correct default values', async () => {
const defaultData = {
name: '',
description: '',
command: 'npx',
cwd: '.',
args: '',
env: ''
};
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: defaultData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const commandInput = wrapper.findAll('.form-input')[2];
expect(commandInput.element.value).toBe('npx');
});
});
describe('Actions', () => {
it('has cancel and save buttons', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const buttons = wrapper.findAll('.btn');
expect(buttons.length).toBeGreaterThanOrEqual(2);
});
it('emits close when cancel button is clicked', async () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.btn-secondary').trigger('click');
expect(wrapper.emitted('close')).toBeTruthy();
});
it('emits save with localData when save button is clicked', async () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.btn-primary').trigger('click');
expect(wrapper.emitted('save')).toBeTruthy();
expect(wrapper.emitted('save')[0][0]).toEqual(mockServerData);
});
it('shows delete button only when editing', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: true,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const deleteBtn = wrapper.find('.btn-danger');
expect(deleteBtn.exists()).toBe(true);
});
it('does not show delete button when not editing', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const deleteBtn = wrapper.find('.btn-danger');
expect(deleteBtn.exists()).toBe(false);
});
it('emits delete when delete button is clicked', async () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: true,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.btn-danger').trigger('click');
expect(wrapper.emitted('delete')).toBeTruthy();
});
it('save button has correct label for add mode', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const saveBtn = wrapper.find('.btn-primary');
expect(saveBtn.text()).toContain('mcp.addServer');
});
it('save button has correct label for edit mode', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: true,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const saveBtn = wrapper.find('.btn-primary');
expect(saveBtn.text()).toContain('mcp.saveChanges');
});
});
describe('Panel Structure', () => {
it('has side panel header', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.side-panel-header').exists()).toBe(true);
});
it('has side panel body', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.side-panel-body').exists()).toBe(true);
});
it('has side panel footer', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
expect(wrapper.find('.side-panel-footer').exists()).toBe(true);
});
it('has close button', () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const closeBtn = wrapper.find('.side-panel-close');
expect(closeBtn.exists()).toBe(true);
});
it('emits close when close button is clicked', async () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.side-panel-close').trigger('click');
expect(wrapper.emitted('close')).toBeTruthy();
});
});
describe('Data Binding', () => {
it('updates localData when props data changes', async () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const newData = { ...mockServerData, name: 'New Server Name' };
await wrapper.setProps({ data: newData });
const nameInput = wrapper.findAll('.form-input')[0];
expect(nameInput.element.value).toBe('New Server Name');
});
it('saves modified data', async () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const nameInput = wrapper.findAll('.form-input')[0];
await nameInput.setValue('Modified Name');
await wrapper.find('.btn-primary').trigger('click');
const savedData = wrapper.emitted('save')[0][0];
expect(savedData.name).toBe('Modified Name');
});
});
describe('Escape Key', () => {
it('emits close on escape key', async () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.trigger('keyup.esc');
expect(wrapper.emitted('close')).toBeTruthy();
});
it('does not close when clicking on panel content', async () => {
const wrapper = mount(ServerPanel, {
props: {
show: true,
isEditing: false,
data: mockServerData
},
global: {
mocks: {
$t: (key) => key,
},
},
});
await wrapper.find('.side-panel-body').trigger('click');
expect(wrapper.emitted('close')).toBeFalsy();
});
});
});

View 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>

View File

@@ -0,0 +1,170 @@
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 five nav items', () => {
const wrapper = mount(SideBar, {
global: {
mocks: {
$t: (key) => key,
},
},
});
const navItems = wrapper.findAll('.nav-item');
expect(navItems.length).toBe(5);
});
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');
// Order: Dashboard(0), API Config(1), Basic Settings(2), MCP(3), Skills(4)
expect(navItems[0].classes('active')).toBe(false); // Dashboard
expect(navItems[1].classes('active')).toBe(true); // API Config
expect(navItems[2].classes('active')).toBe(false); // Basic Settings
expect(navItems[3].classes('active')).toBe(false); // MCP
});
it('emits navigate event when nav item is clicked', async () => {
const wrapper = mount(SideBar, {
props: {
currentSection: 'dashboard',
},
global: {
mocks: {
$t: (key) => key,
},
},
});
const navItems = wrapper.findAll('.nav-item');
// Order: Dashboard(0), API Config(1), Basic Settings(2), MCP(3), Skills(4)
await navItems[2].trigger('click'); // Click Basic Settings
expect(wrapper.emitted('navigate')).toBeTruthy();
expect(wrapper.emitted('navigate')[0][0]).toBe('general');
});
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(2); // MCP and Skills both show badges
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(2); // MCP and Skills both show badges
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');
// Order: Dashboard(0), API Config(1), Basic Settings(2), MCP(3), Skills(4)
expect(navItems[0].text()).toBe('translated-sidebar.dashboard');
expect(navItems[1].text()).toBe('translated-sidebar.apiConfig');
expect(navItems[2].text()).toBe('translated-sidebar.basicSettings');
expect(navItems[3].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);
expect(navItems[3].classes('active')).toBe(false);
});
});

196
src/components/SideBar.vue Normal file
View File

@@ -0,0 +1,196 @@
<template>
<aside class="sidebar" :class="{ collapsed }">
<div class="nav-content">
<div class="sidebar-section">
<div class="sidebar-title" v-show="!collapsed">{{ $t('sidebar.general') }}</div>
<div class="nav-item" :class="{ active: currentSection === 'dashboard' }" @click="$emit('navigate', 'dashboard')">
<Dashboard size="16" />
<span class="nav-item-text">{{ $t('sidebar.dashboard') }}</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 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>
<div class="sidebar-section">
<div class="sidebar-title" v-show="!collapsed">{{ $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" v-show="!collapsed">{{ serverCount }}</span>
</div>
<div class="nav-item" :class="{ active: currentSection === 'skills' }" @click="$emit('navigate', 'skills')">
<Star size="16" />
<span class="nav-item-text">{{ $t('sidebar.skills') }}</span>
<span class="nav-item-badge" v-show="!collapsed">{{ skillCount }}</span>
</div>
</div>
</div>
<div class="collapse-btn" @click="toggleCollapse">
<span class="collapse-arrow" :class="{ rotated: collapsed }"></span>
</div>
</aside>
</template>
<script setup>
import { ref } from 'vue'
import { Config, Key, Server, Star, Dashboard } from '@icon-park/vue-next'
defineProps({
currentSection: {
type: String,
default: 'dashboard',
},
serverCount: {
type: Number,
default: 0,
},
skillCount: {
type: Number,
default: 0,
},
})
defineEmits(['navigate'])
const collapsed = ref(false)
const toggleCollapse = () => {
collapsed.value = !collapsed.value
}
</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);
display: flex;
flex-direction: column;
flex-shrink: 0;
transition: width 0.2s ease;
&.collapsed {
width: 52px;
}
}
.nav-content {
flex: 1;
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 24px;
overflow-y: auto;
}
.collapse-btn {
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
border-top: 1px solid var(--border-light);
cursor: pointer;
color: var(--text-tertiary);
transition: all 0.15s ease;
flex-shrink: 0;
&:hover {
background: var(--control-fill);
color: var(--text-primary);
}
}
.collapse-arrow {
font-size: 18px;
font-weight: 300;
line-height: 1;
transition: transform 0.2s ease;
&.rotated {
transform: rotate(180deg);
}
}
.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;
.sidebar.collapsed & {
padding: 10px;
justify-content: center;
:deep(.iconpark-icon) {
font-size: 20px;
}
}
&: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;
.sidebar.collapsed & {
display: none;
}
}
.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;
.sidebar.collapsed & {
display: none;
}
}
</style>

View File

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

100
src/components/TitleBar.vue Normal file
View 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>

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

@@ -0,0 +1,181 @@
export default {
app: {
title: 'iFlow Settings Editor'
},
window: {
minimize: 'Minimize',
maximize: 'Maximize',
close: 'Close'
},
sidebar: {
general: 'General',
dashboard: 'Dashboard',
basicSettings: 'Basic Settings',
apiConfig: 'API Config',
advanced: 'Advanced',
mcpServers: 'MCP Servers',
skills: 'Skills'
},
dashboard: {
title: 'Dashboard',
description: 'View configuration overview',
currentApiConfig: 'Current API Config',
mcpServers: 'MCP Servers',
skills: 'Skills',
theme: 'Theme',
profiles: 'profiles',
configured: 'Configured',
installed: 'Installed',
noServers: 'Not configured',
noSkills: 'Not installed',
enabled: 'Enabled',
disabled: 'Disabled',
shown: 'Shown',
hidden: 'Hidden',
followSystem: 'Follow System',
manual: 'Manual',
edit: 'Edit',
notConfigured: 'Not configured',
quickActions: 'Quick Actions',
switchConfig: 'Switch Config',
addServer: 'Add Server',
importSkill: 'Import Skill',
recentServers: 'Recent Servers',
viewAll: 'View All'
},
general: {
title: 'Basic Settings',
description: 'Configure general application options',
language: 'Language',
languageDesc: 'Choose your preferred display language',
theme: 'Theme',
themeDesc: 'Select light, dark, or follow system',
languageInterface: 'Language & Interface',
otherSettings: 'Other Settings',
bootAnimation: 'Boot Animation',
bootAnimationShown: 'Shown',
bootAnimationNotShown: 'Not Shown',
checkpointing: 'Checkpointing',
enabled: 'Enabled',
disabled: 'Disabled',
acrylicEffect: 'Acrylic Effect',
acrylicMin: 'Opaque',
acrylicMax: 'Transparent',
autoLaunchSettings: 'Auto Start',
autoLaunch: 'Auto Start on Boot',
autoLaunchHint: 'When enabled, the application will automatically start when the system boots and run silently in the background without showing the main window.'
},
theme: {
dark: 'Dark',
light: 'Light',
system: 'System'
},
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',
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',
configSaved: 'Configuration saved',
configDeleted: 'Configuration deleted',
configCopied: 'Configuration copied as "{name}"',
switchFailed: 'Switch failed',
dragToSort: 'Drag to sort',
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'
},
skills: {
title: 'Skills',
description: 'Manage iFlow CLI skills configurations',
importLocal: 'Import Local',
importOnline: 'Import Online',
export: 'Export',
delete: 'Delete',
noSkills: 'No Skills',
addFirstSkill: 'Click the buttons above to add your first skill',
noDescription: 'No description',
url: 'Skill URL',
urlPlaceholder: 'https://github.com/user/repo/archive/refs/heads/main.zip',
skillName: 'Skill Name',
namePlaceholder: 'my-skill',
cancel: 'Cancel',
import: 'Import'
},
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': '日本語'
}
}

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

@@ -0,0 +1,181 @@
export default {
app: {
title: 'iFlow 设置编辑器'
},
window: {
minimize: '最小化',
maximize: '最大化',
close: '关闭'
},
sidebar: {
general: '常规',
dashboard: '仪表盘',
basicSettings: '基本设置',
apiConfig: 'API 配置',
advanced: '高级',
mcpServers: 'MCP 服务器',
skills: '技能'
},
dashboard: {
title: '仪表盘',
description: '查看 iFlow CLI 配置概览',
currentApiConfig: '当前 API 配置',
mcpServers: 'MCP 服务器',
skills: '技能',
theme: '主题',
edit: '编辑',
notConfigured: '未配置',
quickActions: '快速操作',
switchConfig: '切换配置',
addServer: '添加服务器',
importSkill: '导入技能',
recentServers: '最近服务器',
viewAll: '查看全部',
profiles: '个配置',
configured: '已配置',
installed: '已安装',
noServers: '暂未配置',
noSkills: '暂未安装',
enabled: '已启用',
disabled: '已禁用',
shown: '已显示',
hidden: '已隐藏',
followSystem: '跟随系统',
manual: '手动设置'
},
general: {
title: '基本设置',
description: '配置应用程序的常规选项',
language: '语言',
languageDesc: '选择界面显示语言',
theme: '主题',
themeDesc: '选择浅色、深色或跟随系统',
languageInterface: '语言与界面',
otherSettings: '其他设置',
bootAnimation: '启动动画',
bootAnimationShown: '已显示',
bootAnimationNotShown: '未显示',
checkpointing: '检查点保存',
enabled: '已启用',
disabled: '已禁用',
acrylicEffect: '亚克力效果',
acrylicMin: '不透明',
acrylicMax: '透明',
autoLaunchSettings: '开机自启动',
autoLaunch: '开机自启动',
autoLaunchHint: '启用后,应用程序将在系统启动时自动运行,并以后台模式静默启动,不显示主窗口。'
},
theme: {
dark: '深色',
light: '浅色',
system: '跟随系统'
},
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',
inUse: '使用中',
cancel: '取消',
create: '创建',
save: '保存',
edit: '编辑',
duplicate: '复制',
delete: '删除',
unconfigured: '未配置',
noBaseUrl: '未配置 Base URL',
configCreated: '配置 "{name}" 已创建',
configSaved: '配置已保存',
configDeleted: '配置已删除',
configCopied: '配置已复制为 "{name}"',
switchFailed: '切换失败',
dragToSort: '拖动排序',
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: '服务器名称已存在'
},
skills: {
title: '技能管理',
description: '管理 iFlow CLI 技能配置',
importLocal: '本地导入',
importOnline: '在线导入',
export: '导出',
delete: '删除',
noSkills: '暂无技能',
addFirstSkill: '点击上方按钮添加第一个技能',
noDescription: '无描述',
url: '技能 URL',
urlPlaceholder: 'https://github.com/user/repo/archive/refs/heads/main.zip',
skillName: '技能名称',
namePlaceholder: 'my-skill',
cancel: '取消',
import: '导入'
},
messages: {
error: '错误',
warning: '警告',
success: '成功',
info: '信息',
cannotDeleteDefault: '不能删除默认配置',
inputConfigName: '请输入配置名称',
confirmDeleteConfig: '确定要删除配置 "{name}" 吗?',
confirmDeleteServer: '确定要删除服务器 "{name}" 吗?'
},
dialog: {
confirm: '确定',
cancel: '取消'
},
footer: {
config: '配置'
},
languages: {
'zh-CN': '简体中文',
'en-US': 'English',
'ja-JP': '日本語'
}
}

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

@@ -0,0 +1,173 @@
export default {
app: {
title: 'iFlow 設定エディタ'
},
window: {
minimize: '最小化',
maximize: '最大化',
close: '閉じる'
},
sidebar: {
general: '一般',
dashboard: 'ダッシュボード',
basicSettings: '基本設定',
apiConfig: 'API 設定',
advanced: '詳細',
mcpServers: 'MCP サーバー',
skills: 'スキル'
},
dashboard: {
title: 'ダッシュボード',
description: '設定の概観を表示',
currentApiConfig: '現在の API 設定',
mcpServers: 'MCP サーバー',
skills: 'スキル',
theme: 'テーマ',
profiles: 'プロファイル',
configured: '設定済み',
installed: 'インストール済み',
noServers: '未設定',
noSkills: '未インストール',
enabled: '有効',
disabled: '無効',
shown: '表示済み',
hidden: '非表示',
followSystem: 'システムに従う',
manual: '手動'
},
general: {
title: '基本設定',
description: 'アプリケーションの一般設定を構成',
language: '言語',
languageDesc: '表示言語を選択',
theme: 'テーマ',
themeDesc: 'ライト、ダーク、システムを選択',
languageInterface: '言語とインターフェース',
otherSettings: 'その他の設定',
bootAnimation: '起動アニメーション',
bootAnimationShown: '表示済み',
bootAnimationNotShown: '未表示',
checkpointing: 'チェックポイント保存',
enabled: '有効',
disabled: '無効',
acrylicEffect: 'アクリリック効果',
acrylicMin: '不透明',
acrylicMax: '透明',
autoLaunchSettings: '自動起動',
autoLaunch: 'システム起動時に自動起動',
autoLaunchHint: '有効にすると、システム起動時にアプリケーションが自動的に起動し、バックグラウンドでサイレント実行されます。メインウィンドウは表示されません。'
},
theme: {
dark: 'ダーク',
light: 'ライト',
system: 'システム'
},
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',
inUse: '使用中',
cancel: 'キャンセル',
create: '作成',
save: '保存',
edit: '編集',
duplicate: '複製',
delete: '削除',
unconfigured: '未設定',
noBaseUrl: 'Base URL 未設定',
configCreated: 'プロファイル "{name}" を作成しました',
configSaved: 'プロファイルを保存しました',
configDeleted: 'プロファイルを削除しました',
configCopied: 'プロファイルを "{name}" に複製しました',
switchFailed: '切り替えに失敗しました',
dragToSort: 'ドラッグして並べ替え',
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: 'サーバー名は既に存在します'
},
skills: {
title: 'スキル管理',
description: 'iFlow CLI スキル設定を管理',
importLocal: 'ローカル取込',
importOnline: 'オンライン取込',
export: 'エクスポート',
delete: '削除',
noSkills: 'スキルがありません',
addFirstSkill: '上のボタンをクリックして最初のスを追加',
noDescription: '説明なし',
url: 'スキル URL',
urlPlaceholder: 'https://github.com/user/repo/archive/refs/heads/main.zip',
skillName: 'スキル名',
namePlaceholder: 'my-skill',
cancel: 'キャンセル',
import: '取込'
},
messages: {
error: 'エラー',
warning: '警告',
success: '成功',
info: '情報',
cannotDeleteDefault: 'デフォルトプロファイルは削除できません',
inputConfigName: 'プロファイル名を入力してください',
confirmDeleteConfig: 'プロファイル "{name}" を削除してもよろしいですか?',
confirmDeleteServer: 'サーバー "{name}" を削除してもよろしいですか?'
},
dialog: {
confirm: '確認',
cancel: 'キャンセル'
},
footer: {
config: '設定'
},
languages: {
'zh-CN': '简体中文',
'en-US': 'English',
'ja-JP': '日本語'
}
}

View File

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

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

@@ -0,0 +1,864 @@
// =============================================================================
// 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);
}
// =============================================================================
// 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
View File

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

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

@@ -0,0 +1,337 @@
<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="form-group">
<div class="page-actions">
<button class="btn btn-primary" @click="$emit('create-profile')">
<Add size="14" />
{{ $t('api.newProfile') }}
</button>
</div>
</div>
<div class="card">
<div class="profile-list">
<div
v-for="(profile, index) in profiles"
:key="profile.name"
class="profile-item"
:class="{ active: currentProfile === profile.name, dragging: dragIndex === index }"
draggable="true"
@dragstart="onDragStart(index)"
@dragover.prevent="onDragOver(index)"
@drop="onDrop(index)"
@dragend="onDragEnd"
@click="$emit('select-profile', profile.name)"
>
<div class="drag-handle" :title="$t('api.dragToSort')">
</div> <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 { ref } from 'vue'
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,
},
})
const emit = defineEmits(['create-profile', 'select-profile', 'edit-profile', 'duplicate-profile', 'delete-profile', 'reorder-profiles'])
const dragIndex = ref(-1)
const dragOverIndex = ref(-1)
const onDragStart = (index) => {
dragIndex.value = index
}
const onDragOver = (index) => {
dragOverIndex.value = index
}
const onDrop = (index) => {
if (dragIndex.value !== -1 && dragIndex.value !== index) {
const newProfiles = [...props.profiles]
const [removed] = newProfiles.splice(dragIndex.value, 1)
newProfiles.splice(index, 0, removed)
// 通过emit通知父组件排序变化
emit('reorder-profiles', newProfiles)
}
dragIndex.value = -1
dragOverIndex.value = -1
}
const onDragEnd = () => {
dragIndex.value = -1
dragOverIndex.value = -1
}
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>
.page-actions {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
// 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);
}
&.dragging {
opacity: 0.5;
transform: scale(1.02);
}
}
.drag-handle {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
margin-right: 4px;
color: var(--text-tertiary);
cursor: grab;
border-radius: var(--radius);
transition: all 0.15s ease;
&:hover {
color: var(--text-secondary);
background: var(--control-fill);
}
&:active {
cursor: grabbing;
}
}
.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>

258
src/views/Dashboard.vue Normal file
View File

@@ -0,0 +1,258 @@
<template>
<section>
<div class="content-header">
<h1 class="content-title">{{ $t('dashboard.title') }}</h1>
<p class="content-desc">{{ $t('dashboard.description') }}</p>
</div>
<!-- 状态卡片网格 -->
<div class="stats-grid">
<!-- API 配置 -->
<div class="stat-card card-appear" style="animation-delay: 0.02s" @click="$emit('navigate', 'api')">
<div class="stat-icon stat-icon-accent">
<Key size="28" />
</div>
<div class="stat-content">
<div class="stat-label">{{ $t('dashboard.currentApiConfig') }}</div>
<div class="stat-value">{{ currentApiProfile }}</div>
<div class="stat-sub" v-if="currentApiProfileData?.modelName">
{{ currentApiProfileData.modelName }}
</div>
</div>
<div class="stat-badge">{{ apiProfileCount }} {{ $t('dashboard.profiles') }}</div>
</div>
<!-- MCP 服务器 -->
<div class="stat-card card-appear" style="animation-delay: 0.04s" @click="$emit('navigate', 'mcp')">
<div class="stat-icon stat-icon-success">
<Server size="28" />
</div>
<div class="stat-content">
<div class="stat-label">{{ $t('dashboard.mcpServers') }}</div>
<div class="stat-value">{{ serverCount }}</div>
<div class="stat-sub" v-if="serverCount > 0">
{{ $t('dashboard.configured') }}
</div>
<div class="stat-sub stat-sub-empty" v-else>
{{ $t('dashboard.noServers') }}
</div>
</div>
</div>
<!-- 技能 -->
<div class="stat-card card-appear" style="animation-delay: 0.06s" @click="$emit('navigate', 'skills')">
<div class="stat-icon stat-icon-warning">
<Star size="28" />
</div>
<div class="stat-content">
<div class="stat-label">{{ $t('dashboard.skills') }}</div>
<div class="stat-value">{{ skillCount }}</div>
<div class="stat-sub" v-if="skillCount > 0">
{{ $t('dashboard.installed') }}
</div>
<div class="stat-sub stat-sub-empty" v-else>
{{ $t('dashboard.noSkills') }}
</div>
</div>
</div>
<!-- 主题 -->
<div class="stat-card card-appear" style="animation-delay: 0.08s" @click="$emit('navigate', 'general')">
<div class="stat-icon stat-icon-info">
<Setting size="28" />
</div>
<div class="stat-content">
<div class="stat-label">{{ $t('dashboard.theme') }}</div>
<div class="stat-value">{{ themeLabel }}</div>
<div class="stat-sub">{{ themeDescription }}</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Key, Server, Star, Setting } from '@icon-park/vue-next'
const { t } = useI18n()
const props = defineProps({
settings: {
type: Object,
required: true,
},
currentApiProfile: {
type: String,
default: 'default',
},
serverCount: {
type: Number,
default: 0,
},
skillCount: {
type: Number,
default: 0,
},
})
defineEmits(['navigate'])
const themeLabel = computed(() => {
const theme = props.settings.uiTheme
if (theme === 'Light') return t('theme.light')
if (theme === 'Dark') return t('theme.dark')
return t('theme.system')
})
const themeDescription = computed(() => {
if (props.settings.uiTheme === 'System') return t('dashboard.followSystem')
return t('dashboard.manual')
})
const currentApiProfileData = computed(() => {
if (props.settings.apiProfiles && props.settings.apiProfiles[props.currentApiProfile]) {
return props.settings.apiProfiles[props.currentApiProfile]
}
return props.settings
})
const apiProfileCount = computed(() => {
if (!props.settings.apiProfiles) return 1
return Object.keys(props.settings.apiProfiles).length
})
</script>
<style lang="less" scoped>
// Card animation
.card-appear {
animation: fadeInUp 0.3s ease backwards;
}
// Stats grid - 最大化显示2x2 网格
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-lg);
align-content: center;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
// Stat card - 最大化尺寸
.stat-card {
background: var(--bg-elevated);
border: 1px solid var(--border-light);
border-radius: var(--radius-xl);
padding: var(--space-xl);
display: flex;
align-items: center;
gap: var(--space-lg);
cursor: pointer;
transition: all 0.25s ease;
position: relative;
overflow: hidden;
min-height: 120px;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--accent);
opacity: 0;
transition: opacity 0.25s ease;
}
&:hover {
border-color: var(--accent);
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
&::before {
opacity: 1;
}
}
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.stat-icon-accent {
background: var(--accent-light);
color: var(--accent);
}
&.stat-icon-success {
background: rgba(16, 185, 129, 0.12);
color: var(--success);
}
&.stat-icon-warning {
background: rgba(245, 158, 11, 0.12);
color: var(--warning);
}
&.stat-icon-info {
background: rgba(59, 130, 246, 0.12);
color: var(--info);
}
}
.stat-content {
flex: 1;
min-width: 0;
}
.stat-label {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
margin-bottom: var(--space-sm);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.1;
}
.stat-sub {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
margin-top: var(--space-sm);
&.stat-sub-empty {
opacity: 0.5;
}
}
.stat-badge {
position: absolute;
top: var(--space-lg);
right: var(--space-lg);
background: var(--control-fill);
color: var(--text-tertiary);
padding: 4px 14px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,158 @@
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',
uiTheme: 'Light',
bootAnimationShown: true,
checkpointing: { enabled: true },
acrylicIntensity: 50,
};
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(3);
});
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(3);
expect(themeOptions[0].attributes('value')).toBe('Light');
expect(themeOptions[1].attributes('value')).toBe('Dark');
expect(themeOptions[2].attributes('value')).toBe('System');
});
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('Light');
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 three 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(3);
});
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(3);
expect(cardTitles[0].text()).toContain('general.languageInterface');
expect(cardTitles[1].text()).toContain('general.autoLaunchSettings');
expect(cardTitles[2].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('.setting-item').length).toBe(6);
expect(wrapper.findAll('.setting-label').length).toBe(6);
expect(wrapper.findAll('.form-select').length).toBe(4);
expect(wrapper.find('.switch').exists()).toBe(true);
});
});

View File

@@ -0,0 +1,379 @@
<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 card-appear" style="animation-delay: 0.02s">
<div class="card-title">
<Globe size="16" />
{{ $t('general.languageInterface') }}
</div>
<div class="settings-grid">
<div class="setting-item">
<div class="setting-info">
<label class="setting-label">{{ $t('general.language') }}</label>
<p class="setting-desc">{{ $t('general.languageDesc') || '' }}</p>
</div>
<select class="form-select setting-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="setting-item">
<div class="setting-info">
<label class="setting-label">{{ $t('general.theme') }}</label>
<p class="setting-desc">{{ $t('general.themeDesc') || '' }}</p>
</div>
<select class="form-select setting-select" v-model="localSettings.uiTheme">
<option value="Light">{{ $t('theme.light') }}</option>
<option value="Dark">{{ $t('theme.dark') }}</option>
<option value="System">{{ $t('theme.system') }}</option>
</select>
</div>
</div>
</div>
<!-- 开机自启动 -->
<div class="card card-appear" style="animation-delay: 0.05s">
<div class="card-title">
<Rocket size="16" />
{{ $t('general.autoLaunchSettings') }}
</div>
<div class="setting-item setting-item-main">
<div class="setting-info">
<label class="setting-label">{{ $t('general.autoLaunch') }}</label>
<p class="setting-desc">{{ $t('general.autoLaunchHint') }}</p>
</div>
<label class="switch">
<input type="checkbox" v-model="autoLaunchEnabled" @change="onAutoLaunchChange" />
<span class="slider"></span>
</label>
</div>
</div>
<!-- 其他设置 -->
<div class="card card-appear" style="animation-delay: 0.08s">
<div class="card-title">
<Setting size="16" />
{{ $t('general.otherSettings') }}
</div>
<div class="settings-grid">
<div class="setting-item">
<div class="setting-info">
<label class="setting-label">{{ $t('general.bootAnimation') }}</label>
</div>
<select class="form-select setting-select" v-model="localSettings.bootAnimationShown">
<option :value="true">{{ $t('general.bootAnimationShown') }}</option>
<option :value="false">{{ $t('general.bootAnimationNotShown') }}</option>
</select>
</div>
<div class="setting-item">
<div class="setting-info">
<label class="setting-label">{{ $t('general.checkpointing') }}</label>
</div>
<select class="form-select setting-select" v-model="localSettings.checkpointing.enabled">
<option :value="true">{{ $t('general.enabled') }}</option>
<option :value="false">{{ $t('general.disabled') }}</option>
</select>
</div>
</div>
<div class="setting-divider"></div>
<div class="setting-item setting-item-full" v-if="supportsAcrylic">
<div class="setting-info">
<label class="setting-label">{{ $t('general.acrylicEffect') }}</label>
<p class="setting-desc">{{ localSettings.acrylicIntensity }}% {{ $t('general.acrylicMin') }} {{ $t('general.acrylicMax') }}</p>
</div>
<div class="slider-container">
<div class="slider-track">
<div class="slider-fill" :style="{ width: localSettings.acrylicIntensity + '%' }"></div>
</div>
<input
type="range"
class="form-slider"
min="0"
max="100"
:value="localSettings.acrylicIntensity"
@input="updateSliderValue"
/>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { Globe, Setting, Rocket } from '@icon-park/vue-next'
const props = defineProps({
settings: {
type: Object,
required: true,
},
})
const emit = defineEmits(['update:settings'])
import { computed, ref, onMounted, watch, nextTick } from 'vue'
const localSettings = computed({
get: () => props.settings,
set: val => emit('update:settings', val),
})
const autoLaunchEnabled = ref(false)
const systemTheme = ref('Light')
const supportsAcrylic = computed(() => {
if (typeof document === 'undefined' || !('backdropFilter' in document.documentElement.style)) return false
const effectiveTheme = props.settings.uiTheme === 'System' ? systemTheme.value : props.settings.uiTheme
return effectiveTheme !== 'Dark'
})
const sliderWrapper = ref(null)
onMounted(async () => {
// 加载系统主题
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
systemTheme.value = isDark ? 'Dark' : 'Light'
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
systemTheme.value = e.matches ? 'Dark' : 'Light'
})
// 加载自启动状态
try {
if (window.electronAPI && window.electronAPI.getAutoLaunch) {
const result = await window.electronAPI.getAutoLaunch()
if (result.success) {
autoLaunchEnabled.value = result.enabled
}
}
} catch (error) {
console.error('Failed to load auto launch status:', error)
}
})
const onAutoLaunchChange = async () => {
try {
if (window.electronAPI && window.electronAPI.setAutoLaunch) {
await window.electronAPI.setAutoLaunch(autoLaunchEnabled.value)
}
} catch (error) {
console.error('Failed to set auto launch:', error)
}
}
const updateSliderValue = e => {
const value = Number(e.target.value)
emit('update:settings', { ...props.settings, acrylicIntensity: value })
}
</script>
<style lang="less" scoped>
// Card animation
.card-appear {
animation: fadeInUp 0.3s ease backwards;
}
// Settings grid layout
.settings-grid {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
// Setting item base style
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) 0;
gap: var(--space-lg);
&.setting-item-main {
padding: var(--space-md) 0;
}
&.setting-item-full {
flex-direction: column;
align-items: stretch;
.setting-info {
margin-bottom: var(--space-sm);
}
}
}
.setting-info {
flex: 1;
min-width: 0;
}
.setting-label {
display: block;
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
letter-spacing: -0.01em;
}
.setting-desc {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
margin: 0;
line-height: 1.4;
}
.setting-select {
width: 160px;
flex-shrink: 0;
}
// Setting divider
.setting-divider {
height: 1px;
background: var(--border-light);
margin: var(--space-md) 0;
}
// Slider container
.slider-container {
position: relative;
width: 100%;
height: 20px;
}
.slider-track {
position: absolute;
width: 100%;
height: 4px;
background: var(--border);
border-radius: 2px;
top: 50%;
transform: translateY(-50%);
left: 0;
overflow: hidden;
}
.slider-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.05s ease;
}
.form-slider {
position: absolute;
width: 100%;
height: 20px;
background: transparent;
outline: none;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
margin: 0;
top: 0;
left: 0;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid white;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
transition: transform 0.1s ease;
}
&::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
&::-webkit-slider-thumb:active {
transform: scale(0.95);
}
&::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid white;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
}
}
// Switch styles
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
flex-shrink: 0;
input {
opacity: 0;
width: 0;
height: 0;
}
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--border);
transition: 0.2s;
border-radius: 22px;
&:before {
position: absolute;
content: '';
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.2s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
}
input:checked + .slider {
background-color: var(--accent);
}
input:checked + .slider:before {
transform: translateX(18px);
}
// Responsive
@media (max-width: 600px) {
.setting-item {
flex-direction: column;
align-items: flex-start;
&.setting-item-main {
flex-direction: row;
align-items: center;
}
}
.setting-select {
width: 100%;
}
}
</style>

View File

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

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

@@ -0,0 +1,174 @@
<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 class="page-actions">
<button class="btn btn-primary" @click="$emit('add-server')">
<Add size="14" />
{{ $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>
.page-actions {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
// 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>

View File

@@ -0,0 +1,277 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import SkillsView from './SkillsView.vue';
describe('SkillsView.vue', () => {
const mockSkills = [
{
name: 'Skill One',
description: '第一个技能',
folderName: 'skill-one',
size: 1024 * 50,
path: '/path/to/skill-one',
hasLicense: true
},
{
name: 'Skill Two',
description: '第二个技能',
folderName: 'skill-two',
size: 1024 * 1024 * 2.5,
path: '/path/to/skill-two',
hasLicense: false
}
];
beforeEach(() => {
global.window.electronAPI = {
listSkills: vi.fn().mockResolvedValue({ success: true, skills: mockSkills }),
importSkillLocal: vi.fn(),
importSkillOnline: vi.fn(),
exportSkill: vi.fn(),
deleteSkill: vi.fn()
};
});
it('renders correctly with skills', async () => {
const wrapper = mount(SkillsView, {
global: {
mocks: {
$t: (key) => key,
},
},
});
await flushPromises();
expect(wrapper.exists()).toBe(true);
expect(wrapper.find('.content-title').exists()).toBe(true);
expect(wrapper.find('.skill-list').exists()).toBe(true);
});
it('displays all skills', async () => {
const wrapper = mount(SkillsView, {
global: {
mocks: {
$t: (key) => key,
},
},
});
await flushPromises();
const skillItems = wrapper.findAll('.skill-item');
expect(skillItems.length).toBe(2);
});
it('shows empty state when no skills', async () => {
window.electronAPI.listSkills.mockResolvedValueOnce({ success: true, skills: [] });
const wrapper = mount(SkillsView, {
global: {
mocks: {
$t: (key) => key,
},
},
});
await flushPromises();
expect(wrapper.find('.empty-state').exists()).toBe(true);
expect(wrapper.find('.empty-state-title').text()).toBe('skills.noSkills');
});
it('displays correct skill names', async () => {
const wrapper = mount(SkillsView, {
global: {
mocks: {
$t: (key) => key,
},
},
});
await flushPromises();
const skillNames = wrapper.findAll('.skill-name');
expect(skillNames[0].text()).toBe('Skill One');
expect(skillNames[1].text()).toBe('Skill Two');
});
it('displays skill descriptions', async () => {
const wrapper = mount(SkillsView, {
global: {
mocks: {
$t: (key) => key,
},
},
});
await flushPromises();
const skillDescs = wrapper.findAll('.skill-desc');
expect(skillDescs[0].text()).toBe('第一个技能');
expect(skillDescs[1].text()).toBe('第二个技能');
});
it('displays folder names', async () => {
const wrapper = mount(SkillsView, {
global: {
mocks: {
$t: (key) => key,
},
},
});
await flushPromises();
const folderNames = wrapper.findAll('.skill-file');
expect(folderNames[0].text()).toBe('skill-one');
expect(folderNames[1].text()).toBe('skill-two');
});
it('formats file sizes correctly', async () => {
const wrapper = mount(SkillsView, {
global: {
mocks: {
$t: (key) => key,
},
},
});
await flushPromises();
const sizeTexts = wrapper.findAll('.skill-size');
expect(sizeTexts[0].text()).toBe('50.0 KB');
expect(sizeTexts[1].text()).toBe('2.5 MB');
});
it('selects skill when clicked', async () => {
const wrapper = mount(SkillsView, {
global: {
mocks: {
$t: (key) => key,
},
},
});
await flushPromises();
const skillItems = wrapper.findAll('.skill-item');
await skillItems[0].trigger('click');
expect(skillItems[0].classes('selected')).toBe(true);
});
it('calls importSkillLocal when import local button is clicked', async () => {
const wrapper = mount(SkillsView, {
global: {
mocks: {
$t: (key) => key,
},
},
});
await flushPromises();
const importBtn = wrapper.find('.btn-primary');
await importBtn.trigger('click');
expect(window.electronAPI.importSkillLocal).toHaveBeenCalledOnce();
});
it('opens online import dialog when import online button is clicked', async () => {
const wrapper = mount(SkillsView, {
global: {
mocks: {
$t: (key) => key,
},
},
});
await flushPromises();
const onlineBtn = wrapper.find('.btn-secondary');
await onlineBtn.trigger('click');
expect(wrapper.find('.dialog').exists()).toBe(true);
});
it('closes online import dialog', async () => {
const wrapper = mount(SkillsView, {
global: {
mocks: {
$t: (key) => key,
},
},
});
await flushPromises();
// Open dialog first
await wrapper.find('.btn-secondary').trigger('click');
// Close dialog
await wrapper.find('.dialog .btn-secondary').trigger('click');
expect(wrapper.find('.dialog').exists()).toBe(false);
});
it('has two action buttons in skill item', async () => {
const wrapper = mount(SkillsView, {
global: {
mocks: {
$t: (key) => key,
},
},
});
await flushPromises();
const skillItem = wrapper.find('.skill-item');
const actionBtns = skillItem.findAll('.btn-icon');
expect(actionBtns.length).toBe(2);
});
it('handles empty description gracefully', async () => {
window.electronAPI.listSkills.mockResolvedValueOnce({
success: true,
skills: [{ name: 'NoDesc', description: '', folderName: 'nodesc', size: 100 }]
});
const wrapper = mount(SkillsView, {
global: {
mocks: {
$t: (key) => key,
},
},
});
await flushPromises();
const skillDesc = wrapper.find('.skill-desc');
expect(skillDesc.text()).toBe('skills.noDescription');
});
it('handles null size gracefully', async () => {
window.electronAPI.listSkills.mockResolvedValueOnce({
success: true,
skills: [{ name: 'NullSize', description: 'test', folderName: 'nullsize', size: null }]
});
const wrapper = mount(SkillsView, {
global: {
mocks: {
$t: (key) => key,
},
},
});
await flushPromises();
const sizeText = wrapper.find('.skill-size');
expect(sizeText.text()).toBe('');
});
it('shows export and delete buttons on hover', async () => {
const wrapper = mount(SkillsView, {
global: {
mocks: {
$t: (key) => key,
},
},
});
await flushPromises();
const skillItem = wrapper.find('.skill-item');
await skillItem.trigger('mouseenter');
const exportBtn = wrapper.find('.skill-export');
const deleteBtn = wrapper.find('.skill-delete');
expect(exportBtn.exists()).toBe(true);
expect(deleteBtn.exists()).toBe(true);
});
});

341
src/views/SkillsView.vue Normal file
View File

@@ -0,0 +1,341 @@
<template>
<section>
<div class="content-header">
<h1 class="content-title">{{ $t('skills.title') }}</h1>
<p class="content-desc">{{ $t('skills.description') }}</p>
</div>
<div class="form-group">
<div class="skills-actions">
<button class="btn btn-primary" @click="importLocal">
<FolderOpen size="14" />
{{ $t('skills.importLocal') }}
</button>
<button class="btn btn-secondary" @click="importOnline">
<Download size="14" />
{{ $t('skills.importOnline') }}
</button>
</div>
<div class="skill-list">
<template v-if="skills.length > 0">
<div
v-for="(skill, index) in skills"
:key="skill.name"
class="skill-item"
:class="{ selected: selectedSkill === skill.name }"
@click="selectSkill(skill)"
>
<div class="skill-icon">
<Star size="20" />
</div>
<div class="skill-info">
<div class="skill-name">{{ skill.name }}</div>
<div class="skill-desc">{{ skill.description || $t('skills.noDescription') }}</div>
<div class="skill-meta">
<span class="skill-file">{{ skill.folderName }}</span>
<span class="skill-size">{{ formatFileSize(skill.size) }}</span>
</div>
</div>
<button class="btn btn-icon skill-export" @click.stop="exportSkill(skill)" :title="$t('skills.export')">
<Upload size="14" />
</button>
<button class="btn btn-icon skill-delete" @click.stop="deleteSkill(skill)" :title="$t('skills.delete')">
<Delete size="14" />
</button>
</div>
</template>
<div v-else class="empty-state">
<Star size="48" class="empty-state-icon" />
<div class="empty-state-title">{{ $t('skills.noSkills') }}</div>
<div class="empty-state-desc">{{ $t('skills.addFirstSkill') }}</div>
</div>
</div>
</div>
<!-- Online Import Dialog -->
<div v-if="showOnlineDialog" class="dialog-overlay" @click.self="closeOnlineDialog">
<div class="dialog">
<div class="dialog-title">{{ $t('skills.importOnline') }}</div>
<div class="dialog-body">
<div class="form-group">
<label class="form-label">{{ $t('skills.url') }}</label>
<input
v-model="onlineUrl"
type="text"
class="form-input"
:placeholder="$t('skills.urlPlaceholder')"
/>
</div>
<div class="form-group">
<label class="form-label">{{ $t('skills.skillName') }}</label>
<input
v-model="onlineName"
type="text"
class="form-input"
:placeholder="$t('skills.namePlaceholder')"
/>
</div>
</div>
<div class="dialog-actions">
<button class="btn btn-secondary" @click="closeOnlineDialog">{{ $t('skills.cancel') }}</button>
<button class="btn btn-primary" @click="confirmOnlineImport" :disabled="!onlineUrl || !onlineName">{{ $t('skills.import') }}</button>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Star, FolderOpen, Download, Upload, Delete } from '@icon-park/vue-next'
const emit = defineEmits(['show-message', 'skills-changed', 'show-input-dialog'])
const skills = ref([])
const selectedSkill = ref(null)
const showOnlineDialog = ref(false)
const onlineUrl = ref('')
const onlineName = ref('')
const loadSkills = async () => {
try {
const result = await window.electronAPI.listSkills()
if (result.success) {
skills.value = result.skills || []
emit('skills-changed', skills.value.length)
} else {
emit('show-message', { type: 'error', title: 'Error', message: result.error })
}
} catch (error) {
console.error('Failed to load skills:', error)
}
}
const selectSkill = (skill) => {
selectedSkill.value = skill.name
}
const formatFileSize = (bytes) => {
if (!bytes) return ''
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const importLocal = async () => {
try {
const result = await window.electronAPI.importSkillLocal()
if (result.success) {
await loadSkills()
emit('show-message', { type: 'success', title: 'Success', message: result.message })
} else if (result.cancelled) {
// User cancelled
} else {
emit('show-message', { type: 'error', title: 'Error', message: result.error })
}
} catch (error) {
emit('show-message', { type: 'error', title: 'Error', message: error.message })
}
}
const importOnline = () => {
onlineUrl.value = ''
onlineName.value = ''
showOnlineDialog.value = true
}
const closeOnlineDialog = () => {
showOnlineDialog.value = false
}
const confirmOnlineImport = async () => {
if (!onlineUrl.value || !onlineName.value) return
try {
const result = await window.electronAPI.importSkillOnline(onlineUrl.value, onlineName.value)
if (result.success) {
showOnlineDialog.value = false
await loadSkills()
emit('show-message', { type: 'success', title: 'Success', message: result.message })
} else {
emit('show-message', { type: 'error', title: 'Error', message: result.error })
}
} catch (error) {
emit('show-message', { type: 'error', title: 'Error', message: error.message })
}
}
const exportSkill = async (skill) => {
const targetSkill = skill || skills.value.find(s => s.name === selectedSkill.value)
if (!targetSkill) return
try {
const result = await window.electronAPI.exportSkill(targetSkill.name, targetSkill.folderName)
if (result.success) {
emit('show-message', { type: 'success', title: 'Success', message: result.message })
} else if (result.cancelled) {
// User cancelled
} else {
emit('show-message', { type: 'error', title: 'Error', message: result.error })
}
} catch (error) {
emit('show-message', { type: 'error', title: 'Error', message: error.message })
}
}
const deleteSkill = (skill) => {
const folderToDelete = skill.folderName || skill.name
new Promise(resolve => {
emit('show-input-dialog', {
type: 'confirm',
title: 'Confirm Delete',
placeholder: `确定要删除技能 "${skill.name}" 吗?`,
callback: resolve,
isConfirm: true
})
}).then(confirmed => {
if (!confirmed) return
window.electronAPI.deleteSkill(folderToDelete).then(result => {
if (result.success) {
if (selectedSkill.value === skill.name) {
selectedSkill.value = null
}
loadSkills()
emit('show-message', { type: 'success', title: 'Success', message: result.message })
} else {
emit('show-message', { type: 'error', title: 'Error', message: result.error })
}
})
})
}
onMounted(() => {
loadSkills()
})
</script>
<style lang="less" scoped>
.skills-actions {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.skill-list {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
background: var(--bg-secondary);
}
.skill-item {
display: flex;
align-items: center;
padding: 14px 16px;
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; }
&:last-child {
border-bottom: none;
}
&:hover {
background: var(--control-fill);
.skill-export,
.skill-delete {
opacity: 1;
}
}
&.selected {
background: var(--accent-light);
border-left: 3px solid var(--accent);
padding-left: 13px;
}
}
.skill-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--accent-light);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
margin-right: 14px;
flex-shrink: 0;
}
.skill-info {
flex: 1;
min-width: 0;
}
.skill-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
}
.skill-desc {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-meta {
display: flex;
gap: 12px;
font-size: 11px;
color: var(--text-tertiary);
}
.skill-file {
font-family: var(--font-mono);
}
.skill-export {
opacity: 0;
transition: opacity 0.15s ease;
color: var(--accent);
margin-right: 8px;
&:hover {
background: var(--accent-light);
}
}
.skill-delete {
opacity: 0;
margin-left: 12px;
transition: opacity 0.15s ease;
color: var(--danger);
&:hover {
background: var(--danger-bg);
}
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

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

29
vitest.config.js Normal file
View File

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