initial commit
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
45
README.md
Normal file
45
README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Claude Code 供应商切换器
|
||||
|
||||
一个用于管理和切换 Claude Code 不同供应商配置的桌面应用。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🔄 一键切换不同供应商(Anthropic、OpenRouter 等)
|
||||
- 🔍 实时监控供应商状态和响应时间
|
||||
- ⚡ 支持添加自定义供应商
|
||||
- 🎨 简洁美观的图形界面
|
||||
- 🔒 安全存储 API 密钥
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 开发模式
|
||||
npm run dev
|
||||
|
||||
# 构建应用
|
||||
npm run build
|
||||
|
||||
# 打包发布
|
||||
npm run dist
|
||||
```
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. 点击"添加供应商"添加你的 API 配置
|
||||
2. 系统会自动检测每个供应商的状态
|
||||
3. 选择要使用的供应商,点击单选按钮切换
|
||||
4. 配置会自动保存到 Claude Code 的配置文件中
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Electron
|
||||
- React
|
||||
- TypeScript
|
||||
- Vite
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
52
package.json
Normal file
52
package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "1.0.0",
|
||||
"description": "Claude Code 供应商切换工具",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"",
|
||||
"dev:main": "tsc -w -p tsconfig.main.json",
|
||||
"dev:renderer": "vite",
|
||||
"build": "npm run build:renderer && npm run build:main",
|
||||
"build:main": "tsc -p tsconfig.main.json",
|
||||
"build:renderer": "vite build",
|
||||
"start": "electron .",
|
||||
"dist": "electron-builder"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"concurrently": "^8.2.0",
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"electron-store": "^8.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.ccswitch.app",
|
||||
"productName": "CC Switch",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools"
|
||||
},
|
||||
"win": {
|
||||
"target": "nsis"
|
||||
},
|
||||
"linux": {
|
||||
"target": "AppImage"
|
||||
}
|
||||
}
|
||||
}
|
||||
94
src/main/index.ts
Normal file
94
src/main/index.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import path from 'path'
|
||||
import Store from 'electron-store'
|
||||
import { Provider, AppConfig } from '../shared/types'
|
||||
import { checkProviderStatus, switchProvider, getClaudeCodeConfig } from './services'
|
||||
|
||||
const store = new Store<AppConfig>()
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
},
|
||||
titleBarStyle: 'hiddenInset',
|
||||
autoHideMenuBar: true
|
||||
})
|
||||
|
||||
if (app.isPackaged) {
|
||||
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'))
|
||||
} else {
|
||||
mainWindow.loadURL('http://localhost:3000')
|
||||
mainWindow.webContents.openDevTools()
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
// IPC handlers
|
||||
ipcMain.handle('getProviders', () => {
|
||||
return store.get('providers', {})
|
||||
})
|
||||
|
||||
ipcMain.handle('getCurrentProvider', () => {
|
||||
return store.get('current', '')
|
||||
})
|
||||
|
||||
ipcMain.handle('addProvider', (_, provider: Provider) => {
|
||||
const providers = store.get('providers', {})
|
||||
providers[provider.id] = provider
|
||||
store.set('providers', providers)
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('deleteProvider', (_, id: string) => {
|
||||
const providers = store.get('providers', {})
|
||||
delete providers[id]
|
||||
store.set('providers', providers)
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('checkStatus', async (_, provider: Provider) => {
|
||||
return await checkProviderStatus(provider)
|
||||
})
|
||||
|
||||
ipcMain.handle('switchProvider', async (_, providerId: string) => {
|
||||
const providers = store.get('providers', {})
|
||||
const provider = providers[providerId]
|
||||
if (provider) {
|
||||
const success = await switchProvider(provider)
|
||||
if (success) {
|
||||
store.set('current', providerId)
|
||||
}
|
||||
return success
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
ipcMain.handle('getClaudeCodeConfigPath', () => {
|
||||
return getClaudeCodeConfig().path
|
||||
})
|
||||
12
src/main/preload.ts
Normal file
12
src/main/preload.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { Provider } from '../shared/types'
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getProviders: () => ipcRenderer.invoke('getProviders'),
|
||||
getCurrentProvider: () => ipcRenderer.invoke('getCurrentProvider'),
|
||||
addProvider: (provider: Provider) => ipcRenderer.invoke('addProvider', provider),
|
||||
deleteProvider: (id: string) => ipcRenderer.invoke('deleteProvider', id),
|
||||
checkStatus: (provider: Provider) => ipcRenderer.invoke('checkStatus', provider),
|
||||
switchProvider: (providerId: string) => ipcRenderer.invoke('switchProvider', providerId),
|
||||
getClaudeCodeConfigPath: () => ipcRenderer.invoke('getClaudeCodeConfigPath')
|
||||
})
|
||||
86
src/main/services.ts
Normal file
86
src/main/services.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import axios from 'axios'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { Provider, ProviderStatus } from '../shared/types'
|
||||
|
||||
export async function checkProviderStatus(provider: Provider): Promise<ProviderStatus> {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 简单的健康检查请求
|
||||
const response = await axios.post(
|
||||
`${provider.apiUrl}/v1/messages`,
|
||||
{
|
||||
model: provider.model || 'claude-3-opus-20240229',
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
max_tokens: 1
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'x-api-key': provider.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
timeout: 5000
|
||||
}
|
||||
)
|
||||
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
return {
|
||||
isOnline: true,
|
||||
responseTime,
|
||||
lastChecked: new Date()
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
isOnline: false,
|
||||
responseTime: -1,
|
||||
lastChecked: new Date(),
|
||||
error: error instanceof Error ? error.message : '未知错误'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getClaudeCodeConfig() {
|
||||
// Claude Code 配置文件路径
|
||||
const configDir = path.join(os.homedir(), '.claude')
|
||||
const configPath = path.join(configDir, 'settings.json')
|
||||
|
||||
return { path: configPath, dir: configDir }
|
||||
}
|
||||
|
||||
export async function switchProvider(provider: Provider): Promise<boolean> {
|
||||
try {
|
||||
const { path: configPath, dir: configDir } = getClaudeCodeConfig()
|
||||
|
||||
// 确保目录存在
|
||||
await fs.mkdir(configDir, { recursive: true })
|
||||
|
||||
// 读取现有配置
|
||||
let config: any = {}
|
||||
try {
|
||||
const content = await fs.readFile(configPath, 'utf-8')
|
||||
config = JSON.parse(content)
|
||||
} catch {
|
||||
// 文件不存在或解析失败,使用空配置
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
config.api = {
|
||||
...config.api,
|
||||
baseURL: provider.apiUrl,
|
||||
apiKey: provider.apiKey,
|
||||
model: provider.model
|
||||
}
|
||||
|
||||
// 写回配置文件
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2))
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('切换供应商失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
72
src/renderer/App.css
Normal file
72
src/renderer/App.css
Normal file
@@ -0,0 +1,72 @@
|
||||
.app {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.refresh-btn, .add-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.refresh-btn:hover:not(:disabled) {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: #229954;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.config-path {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
background: #ecf0f1;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
131
src/renderer/App.tsx
Normal file
131
src/renderer/App.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Provider, ProviderStatus } from '../shared/types'
|
||||
import ProviderList from './components/ProviderList'
|
||||
import AddProviderModal from './components/AddProviderModal'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [providers, setProviders] = useState<Record<string, Provider>>({})
|
||||
const [currentProviderId, setCurrentProviderId] = useState<string>('')
|
||||
const [statuses, setStatuses] = useState<Record<string, ProviderStatus>>({})
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [configPath, setConfigPath] = useState<string>('')
|
||||
|
||||
// 加载供应商列表
|
||||
useEffect(() => {
|
||||
loadProviders()
|
||||
loadConfigPath()
|
||||
}, [])
|
||||
|
||||
// 定时检查状态
|
||||
useEffect(() => {
|
||||
checkAllStatuses()
|
||||
const interval = setInterval(checkAllStatuses, 30000) // 每30秒检查一次
|
||||
return () => clearInterval(interval)
|
||||
}, [providers])
|
||||
|
||||
const loadProviders = async () => {
|
||||
const loadedProviders = await window.electronAPI.getProviders()
|
||||
const currentId = await window.electronAPI.getCurrentProvider()
|
||||
setProviders(loadedProviders)
|
||||
setCurrentProviderId(currentId)
|
||||
}
|
||||
|
||||
const loadConfigPath = async () => {
|
||||
const path = await window.electronAPI.getClaudeCodeConfigPath()
|
||||
setConfigPath(path)
|
||||
}
|
||||
|
||||
const checkAllStatuses = async () => {
|
||||
if (Object.keys(providers).length === 0) return
|
||||
|
||||
setIsRefreshing(true)
|
||||
const newStatuses: Record<string, ProviderStatus> = {}
|
||||
|
||||
await Promise.all(
|
||||
Object.values(providers).map(async (provider) => {
|
||||
const status = await window.electronAPI.checkStatus(provider)
|
||||
newStatuses[provider.id] = status
|
||||
})
|
||||
)
|
||||
|
||||
setStatuses(newStatuses)
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
|
||||
const handleAddProvider = async (provider: Omit<Provider, 'id'>) => {
|
||||
const newProvider: Provider = {
|
||||
...provider,
|
||||
id: Date.now().toString()
|
||||
}
|
||||
await window.electronAPI.addProvider(newProvider)
|
||||
await loadProviders()
|
||||
setIsAddModalOpen(false)
|
||||
}
|
||||
|
||||
const handleDeleteProvider = async (id: string) => {
|
||||
if (confirm('确定要删除这个供应商吗?')) {
|
||||
await window.electronAPI.deleteProvider(id)
|
||||
await loadProviders()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSwitchProvider = async (id: string) => {
|
||||
const success = await window.electronAPI.switchProvider(id)
|
||||
if (success) {
|
||||
setCurrentProviderId(id)
|
||||
alert('切换成功!')
|
||||
} else {
|
||||
alert('切换失败,请检查配置')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>Claude Code 供应商切换器</h1>
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="refresh-btn"
|
||||
onClick={checkAllStatuses}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? '检查中...' : '刷新状态'}
|
||||
</button>
|
||||
<button
|
||||
className="add-btn"
|
||||
onClick={() => setIsAddModalOpen(true)}
|
||||
>
|
||||
添加供应商
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
currentProviderId={currentProviderId}
|
||||
statuses={statuses}
|
||||
onSwitch={handleSwitchProvider}
|
||||
onDelete={handleDeleteProvider}
|
||||
/>
|
||||
|
||||
{configPath && (
|
||||
<div className="config-path">
|
||||
配置文件位置: {configPath}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{isAddModalOpen && (
|
||||
<AddProviderModal
|
||||
onAdd={handleAddProvider}
|
||||
onClose={() => setIsAddModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
122
src/renderer/components/AddProviderModal.css
Normal file
122
src/renderer/components/AddProviderModal.css
Normal file
@@ -0,0 +1,122 @@
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.presets {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
}
|
||||
|
||||
.presets label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.preset-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #3498db;
|
||||
background: white;
|
||||
color: #3498db;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.submit-btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #ecf0f1;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: #bdc3c7;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
background: #229954;
|
||||
}
|
||||
146
src/renderer/components/AddProviderModal.tsx
Normal file
146
src/renderer/components/AddProviderModal.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Provider } from '../../shared/types'
|
||||
import './AddProviderModal.css'
|
||||
|
||||
interface AddProviderModalProps {
|
||||
onAdd: (provider: Omit<Provider, 'id'>) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const AddProviderModal: React.FC<AddProviderModalProps> = ({ onAdd, onClose }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
apiUrl: '',
|
||||
apiKey: '',
|
||||
model: 'claude-3-opus-20240229'
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.name || !formData.apiUrl || !formData.apiKey) {
|
||||
alert('请填写所有必填字段')
|
||||
return
|
||||
}
|
||||
|
||||
onAdd(formData)
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
// 预设的供应商配置
|
||||
const presets = [
|
||||
{
|
||||
name: '官方 Anthropic',
|
||||
apiUrl: 'https://api.anthropic.com',
|
||||
model: 'claude-3-opus-20240229'
|
||||
},
|
||||
{
|
||||
name: 'OpenRouter',
|
||||
apiUrl: 'https://openrouter.ai/api/v1',
|
||||
model: 'anthropic/claude-3-opus'
|
||||
}
|
||||
]
|
||||
|
||||
const applyPreset = (preset: typeof presets[0]) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
name: preset.name,
|
||||
apiUrl: preset.apiUrl,
|
||||
model: preset.model
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>添加新供应商</h2>
|
||||
|
||||
<div className="presets">
|
||||
<label>快速选择:</label>
|
||||
<div className="preset-buttons">
|
||||
{presets.map((preset, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className="preset-btn"
|
||||
onClick={() => applyPreset(preset)}
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">供应商名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="例如:官方 Anthropic"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="apiUrl">API 地址 *</label>
|
||||
<input
|
||||
type="url"
|
||||
id="apiUrl"
|
||||
name="apiUrl"
|
||||
value={formData.apiUrl}
|
||||
onChange={handleChange}
|
||||
placeholder="https://api.anthropic.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="apiKey">API Key *</label>
|
||||
<input
|
||||
type="password"
|
||||
id="apiKey"
|
||||
name="apiKey"
|
||||
value={formData.apiKey}
|
||||
onChange={handleChange}
|
||||
placeholder="sk-ant-..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="model">模型名称</label>
|
||||
<input
|
||||
type="text"
|
||||
id="model"
|
||||
name="model"
|
||||
value={formData.model}
|
||||
onChange={handleChange}
|
||||
placeholder="claude-3-opus-20240229"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="cancel-btn" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="submit-btn">
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddProviderModal
|
||||
127
src/renderer/components/ProviderList.css
Normal file
127
src/renderer/components/ProviderList.css
Normal file
@@ -0,0 +1,127 @@
|
||||
.provider-list {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.empty-state p:first-child {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.provider-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.provider-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border: 2px solid #ecf0f1;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.provider-item:hover {
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.provider-item.current {
|
||||
border-color: #27ae60;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.provider-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.provider-name input[type="radio"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.provider-name input[type="radio"]:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.current-badge {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.provider-url {
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.provider-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: #555;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.response-time {
|
||||
color: #3498db;
|
||||
font-size: 0.85rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.provider-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #e74c3c;
|
||||
background: white;
|
||||
color: #e74c3c;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.delete-btn:hover:not(:disabled) {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.delete-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
97
src/renderer/components/ProviderList.tsx
Normal file
97
src/renderer/components/ProviderList.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react'
|
||||
import { Provider, ProviderStatus } from '../../shared/types'
|
||||
import './ProviderList.css'
|
||||
|
||||
interface ProviderListProps {
|
||||
providers: Record<string, Provider>
|
||||
currentProviderId: string
|
||||
statuses: Record<string, ProviderStatus>
|
||||
onSwitch: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
const ProviderList: React.FC<ProviderListProps> = ({
|
||||
providers,
|
||||
currentProviderId,
|
||||
statuses,
|
||||
onSwitch,
|
||||
onDelete
|
||||
}) => {
|
||||
const formatResponseTime = (time: number) => {
|
||||
if (time < 0) return '-'
|
||||
return `${time}ms`
|
||||
}
|
||||
|
||||
const getStatusIcon = (status?: ProviderStatus) => {
|
||||
if (!status) return '⏳'
|
||||
return status.isOnline ? '✅' : '❌'
|
||||
}
|
||||
|
||||
const getStatusText = (status?: ProviderStatus) => {
|
||||
if (!status) return '检查中...'
|
||||
if (status.isOnline) return '正常'
|
||||
return status.error || '连接失败'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="provider-list">
|
||||
{Object.values(providers).length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>还没有添加任何供应商</p>
|
||||
<p>点击右上角的"添加供应商"按钮开始</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="provider-items">
|
||||
{Object.values(providers).map((provider) => {
|
||||
const status = statuses[provider.id]
|
||||
const isCurrent = provider.id === currentProviderId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className={`provider-item ${isCurrent ? 'current' : ''}`}
|
||||
>
|
||||
<div className="provider-info">
|
||||
<div className="provider-name">
|
||||
<input
|
||||
type="radio"
|
||||
name="provider"
|
||||
checked={isCurrent}
|
||||
onChange={() => onSwitch(provider.id)}
|
||||
disabled={!status?.isOnline}
|
||||
/>
|
||||
<span>{provider.name}</span>
|
||||
{isCurrent && <span className="current-badge">当前使用</span>}
|
||||
</div>
|
||||
<div className="provider-url">{provider.apiUrl}</div>
|
||||
</div>
|
||||
|
||||
<div className="provider-status">
|
||||
<span className="status-icon">{getStatusIcon(status)}</span>
|
||||
<span className="status-text">{getStatusText(status)}</span>
|
||||
{status?.isOnline && (
|
||||
<span className="response-time">
|
||||
{formatResponseTime(status.responseTime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="provider-actions">
|
||||
<button
|
||||
className="delete-btn"
|
||||
onClick={() => onDelete(provider.id)}
|
||||
disabled={isCurrent}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProviderList
|
||||
21
src/renderer/index.css
Normal file
21
src/renderer/index.css
Normal file
@@ -0,0 +1,21 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
12
src/renderer/index.html
Normal file
12
src/renderer/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Claude Code 供应商切换器</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
10
src/renderer/main.tsx
Normal file
10
src/renderer/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
33
src/shared/types.ts
Normal file
33
src/shared/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
apiUrl: string
|
||||
apiKey: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface ProviderStatus {
|
||||
isOnline: boolean
|
||||
responseTime: number
|
||||
lastChecked: Date
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
providers: Record<string, Provider>
|
||||
current: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
getProviders: () => Promise<Record<string, Provider>>
|
||||
getCurrentProvider: () => Promise<string>
|
||||
addProvider: (provider: Provider) => Promise<boolean>
|
||||
deleteProvider: (id: string) => Promise<boolean>
|
||||
checkStatus: (provider: Provider) => Promise<ProviderStatus>
|
||||
switchProvider: (providerId: string) => Promise<boolean>
|
||||
getClaudeCodeConfigPath: () => Promise<string>
|
||||
}
|
||||
}
|
||||
}
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/renderer/**/*"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
9
tsconfig.main.json
Normal file
9
tsconfig.main.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src/main",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/main/**/*", "src/shared/**/*"]
|
||||
}
|
||||
15
tsconfig.node.json
Normal file
15
tsconfig.node.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"target": "ES2020",
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
vite.config.ts
Normal file
16
vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: resolve(__dirname, 'src/renderer'),
|
||||
base: './',
|
||||
build: {
|
||||
outDir: resolve(__dirname, 'dist/renderer'),
|
||||
emptyOutDir: true
|
||||
},
|
||||
server: {
|
||||
port: 3000
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user