feat: 系统托盘 (#12)

* feat: 系统托盘

1. 添加系统托盘
2. 托盘添加切换供应商功能
3. 整理组件目录

* feat: 优化系统托盘菜单结构

- 扁平化Claude和Codex的菜单结构,直接将所有供应商添加到主菜单,简化用户交互。
- 添加无供应商时的提示信息,提升用户体验。
- 更新分隔符文本以增强可读性。

* feat: integrate Tailwind CSS and Lucide icons

- Added Tailwind CSS for styling and layout improvements.
- Integrated Lucide icons for enhanced UI elements.
- Updated project structure by removing unused CSS files and components.
- Refactored configuration files to support new styling and component structure.
- Introduced new components for managing providers with improved UI interactions.   

* fix: 修复类型声明和分隔符实现问题

- 修复 updateTrayMenu 返回类型不一致(Promise<void> -> Promise<boolean>)
- 添加缺失的 UnlistenFn 类型导入
- 使用 MenuBuilder.separator() 替代文本分隔符

---------

Co-authored-by: farion1231 <farion1231@gmail.c
This commit is contained in:
TinsFox
2025-09-06 16:21:21 +08:00
committed by GitHub
parent 07b870488d
commit 5af476d376
21 changed files with 1222 additions and 1193 deletions

View File

@@ -1,268 +0,0 @@
.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: 10px;
padding: 0;
width: 90%;
max-width: 640px;
max-height: 90vh;
overflow: hidden; /* 由 body 滚动,标题栏固定 */
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
position: relative;
z-index: 1001;
display: flex; /* 纵向布局,便于底栏固定 */
flex-direction: column;
}
/* 模拟窗口标题栏 */
.modal-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 3rem; /* 与主窗口标题栏一致 */
padding: 0 12px; /* 接近主头部的水平留白 */
background: #3498db; /* 与 .app-header 相同 */
color: #fff;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
/* 左侧占位以保证标题居中(与右侧关闭按钮宽度相当) */
.modal-spacer {
width: 32px;
flex: 0 0 32px;
}
.modal-title {
flex: 1;
text-align: center;
color: #fff;
font-weight: 600;
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.modal-close-btn {
background: transparent;
border: none;
color: #fff;
font-size: 20px;
line-height: 1;
padding: 2px 6px;
border-radius: 6px;
cursor: pointer;
}
.modal-close-btn:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.modal-form {
/* 表单外层包裹 body + footer */
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0; /* 允许子元素正确计算高度 */
}
.modal-body {
padding: 1.25rem 1.5rem 1.5rem;
overflow: auto; /* 仅内容区滚动 */
flex: 1 1 auto;
min-height: 0;
}
.error-message {
background: #fee;
color: #c33;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
border: 1px solid #fcc;
font-size: 0.9rem;
}
.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;
flex-wrap: wrap;
}
.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,
.preset-btn.selected {
background: #3498db;
color: white;
}
/* 官方按钮橙色主题Anthropic 风格) */
.preset-btn.official {
border: 1px solid #d97706;
color: #d97706;
}
.preset-btn.official:hover,
.preset-btn.official.selected {
background: #d97706;
color: white;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #555;
font-weight: 500;
}
/* API Key 输入框容器 - 预留空间避免抖动 */
.form-group.api-key-group {
min-height: 88px; /* 固定高度label + input + 间距 */
transition: opacity 0.2s ease;
}
.form-group.api-key-group.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.625rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.95rem;
transition: border-color 0.2s;
background: white;
box-sizing: border-box;
}
.form-group textarea {
resize: vertical;
min-height: 200px;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #3498db;
}
.modal-footer {
/* 固定在弹窗底部(非滚动区) */
display: flex;
gap: 1rem;
justify-content: flex-end;
padding: 0.75rem 1.5rem;
border-top: 1px solid #ecf0f1;
background: #fff;
}
.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;
}
.field-hint {
display: block;
margin-top: 0.25rem;
color: #7f8c8d;
font-size: 0.8rem;
line-height: 1.3;
}
/* 添加标签和选择框的样式 */
.label-with-checkbox {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.label-with-checkbox label:first-child {
margin-bottom: 0;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.85rem;
color: #666;
font-weight: normal;
margin-bottom: 0;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
margin: 2px;
cursor: pointer;
transform: translateY(2px);
}

View File

@@ -1,73 +0,0 @@
/* 药丸式切换按钮 */
.switcher-pills {
display: inline-flex;
align-items: center;
gap: 12px;
background: rgba(255, 255, 255, 0.08);
padding: 6px 8px;
border-radius: 50px;
backdrop-filter: blur(10px);
}
.switcher-pill {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
background: transparent;
border: none;
border-radius: 50px;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 200ms ease;
min-width: 120px;
}
.switcher-pill:hover:not(.active) {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.05);
}
.switcher-pill.active {
background: rgba(255, 255, 255, 0.15);
color: white;
box-shadow:
inset 0 1px 3px rgba(0, 0, 0, 0.1),
0 1px 0 rgba(255, 255, 255, 0.1);
}
.pill-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
transition: all 200ms ease;
}
.switcher-pill.active .pill-dot {
opacity: 1;
box-shadow: 0 0 8px currentColor;
animation: pulse 2s infinite;
}
.pills-divider {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.2);
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.8;
}
}

View File

@@ -1,5 +1,5 @@
import { AppType } from "../lib/tauri-api";
import "./AppSwitcher.css";
import { Terminal, Code2 } from "lucide-react";
interface AppSwitcherProps {
activeApp: AppType;
@@ -13,22 +13,30 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
};
return (
<div className="switcher-pills">
<div className="inline-flex bg-[var(--color-bg-tertiary)] rounded-lg p-1 gap-1">
<button
type="button"
className={`switcher-pill ${activeApp === "claude" ? "active" : ""}`}
onClick={() => handleSwitch("claude")}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "claude"
? "bg-white text-[var(--color-text-primary)] shadow-sm"
: "text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/50"
}`}
>
<span className="pill-dot" />
<Code2 size={16} />
<span>Claude Code</span>
</button>
<div className="pills-divider" />
<button
type="button"
className={`switcher-pill ${activeApp === "codex" ? "active" : ""}`}
onClick={() => handleSwitch("codex")}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
activeApp === "codex"
? "bg-white text-[var(--color-text-primary)] shadow-sm"
: "text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white/50"
}`}
>
<span className="pill-dot" />
<Terminal size={16} />
<span>Codex</span>
</button>
</div>

View File

@@ -1,107 +0,0 @@
.confirm-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;
backdrop-filter: blur(2px);
}
.confirm-dialog {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
min-width: 300px;
max-width: 400px;
animation: confirmSlideIn 0.2s ease-out;
}
@keyframes confirmSlideIn {
from {
opacity: 0;
transform: scale(0.9) translateY(-20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.confirm-header {
padding: 1.5rem 1.5rem 1rem;
border-bottom: 1px solid #eee;
}
.confirm-header h3 {
margin: 0;
font-size: 1.1rem;
color: #333;
font-weight: 600;
}
.confirm-content {
padding: 1rem 1.5rem;
}
.confirm-content p {
margin: 0;
color: #666;
line-height: 1.5;
}
.confirm-actions {
display: flex;
gap: 0.75rem;
padding: 1rem 1.5rem 1.5rem;
justify-content: flex-end;
}
.confirm-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition:
background-color 0.2s,
transform 0.1s;
min-width: 70px;
}
.confirm-btn:hover {
transform: translateY(-1px);
}
.confirm-btn:active {
transform: translateY(0);
}
.cancel-btn {
background: #f8f9fa;
color: #6c757d;
border: 1px solid #dee2e6;
}
.cancel-btn:hover {
background: #e9ecef;
}
.confirm-btn-primary {
background: #dc3545;
color: white;
}
.confirm-btn-primary:hover {
background: #c82333;
}
.confirm-btn:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
}

View File

@@ -1,5 +1,5 @@
import React from "react";
import "./ConfirmDialog.css";
import { AlertTriangle, X } from "lucide-react";
interface ConfirmDialogProps {
isOpen: boolean;
@@ -23,25 +23,52 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
if (!isOpen) return null;
return (
<div className="confirm-overlay">
<div className="confirm-dialog">
<div className="confirm-header">
<h3>{title}</h3>
</div>
<div className="confirm-content">
<p>{message}</p>
</div>
<div className="confirm-actions">
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onCancel}
/>
{/* Dialog */}
<div className="relative bg-white rounded-xl shadow-lg max-w-md w-full mx-4 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[var(--color-border)]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[var(--color-error-light)] rounded-full flex items-center justify-center">
<AlertTriangle size={20} className="text-[var(--color-error)]" />
</div>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
{title}
</h3>
</div>
<button
className="confirm-btn cancel-btn"
onClick={onCancel}
className="p-1 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-tertiary)] rounded-md transition-colors"
>
<X size={18} />
</button>
</div>
{/* Content */}
<div className="p-6">
<p className="text-[var(--color-text-secondary)] leading-relaxed">
{message}
</p>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-[var(--color-border)] bg-[var(--color-bg-tertiary)]">
<button
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white rounded-md transition-colors"
autoFocus
>
{cancelText}
</button>
<button
className="confirm-btn confirm-btn-primary"
onClick={onConfirm}
className="px-4 py-2 text-sm font-medium bg-[var(--color-error)] text-white hover:bg-[var(--color-error)]/90 rounded-md transition-colors"
>
{confirmText}
</button>

View File

@@ -10,8 +10,8 @@ import {
} from "../utils/providerConfigUtils";
import { providerPresets } from "../config/providerPresets";
import { codexProviderPresets } from "../config/codexProviderPresets";
import "./AddProviderModal.css";
import JsonEditor from "./JsonEditor";
import { X, AlertCircle, Save, Zap } from "lucide-react";
interface ProviderFormProps {
appType?: AppType;
@@ -49,7 +49,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const [codexApiKey, setCodexApiKey] = useState("");
// -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedCodexPreset, setSelectedCodexPreset] = useState<number | null>(
showPresets && isCodex ? -1 : null,
showPresets && isCodex ? -1 : null
);
// 初始化 Codex 配置
@@ -74,7 +74,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const [disableCoAuthored, setDisableCoAuthored] = useState(false);
// -1 表示自定义null 表示未选择,>= 0 表示预设索引
const [selectedPreset, setSelectedPreset] = useState<number | null>(
showPresets ? -1 : null,
showPresets ? -1 : null
);
const [apiKey, setApiKey] = useState("");
@@ -155,7 +155,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
@@ -188,7 +188,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// 更新JSON配置
const updatedConfig = updateCoAuthoredSetting(
formData.settingsConfig,
checked,
checked
);
setFormData({
...formData,
@@ -231,7 +231,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
// Codex: 应用预设
const applyCodexPreset = (
preset: (typeof codexProviderPresets)[0],
index: number,
index: number
) => {
const authString = JSON.stringify(preset.auth || {}, null, 2);
setCodexAuth(authString);
@@ -269,7 +269,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
const configString = setApiKeyInConfig(
formData.settingsConfig,
key.trim(),
{ createIfMissing: selectedPreset !== null && selectedPreset !== -1 },
{ createIfMissing: selectedPreset !== null && selectedPreset !== -1 }
);
// 更新表单配置
@@ -329,7 +329,7 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
useEffect(() => {
if (initialData) {
const parsedKey = getApiKeyFromConfig(
JSON.stringify(initialData.settingsConfig),
JSON.stringify(initialData.settingsConfig)
);
if (parsedKey) setApiKey(parsedKey);
}
@@ -350,130 +350,156 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
return (
<div
className="modal-overlay"
className="fixed inset-0 z-50 flex items-center justify-center"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="modal-content">
<div className="modal-titlebar">
<div className="modal-spacer" />
<div className="modal-title" title={title}>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Modal */}
<div className="relative bg-white rounded-xl shadow-lg max-w-3xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[var(--color-border)]">
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
{title}
</div>
</h2>
<button
type="button"
className="modal-close-btn"
aria-label="关闭"
onClick={onClose}
title="关闭"
className="p-1 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-tertiary)] rounded-md transition-colors"
aria-label="关闭"
>
×
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="modal-form">
<div className="modal-body">
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<div className="flex-1 overflow-auto p-6 space-y-6">
{error && (
<div className="flex items-center gap-3 p-4 bg-[var(--color-error-light)] border border-[var(--color-error)]/20 rounded-lg">
<AlertCircle
size={20}
className="text-[var(--color-error)] flex-shrink-0"
/>
<p className="text-[var(--color-error)] text-sm font-medium">
{error}
</p>
</div>
)}
{showPresets && !isCodex && (
<div className="presets">
<label></label>
<div className="preset-buttons">
<button
type="button"
className={`preset-btn ${
selectedPreset === -1 ? "selected" : ""
}`}
onClick={handleCustomClick}
>
</button>
{providerPresets.map((preset, index) => {
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-3">
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPreset === -1
? "bg-[var(--color-primary)] text-white"
: "bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-border)]"
}`}
onClick={handleCustomClick}
>
</button>
{providerPresets.map((preset, index) => (
<button
key={index}
type="button"
className={`preset-btn ${
selectedPreset === index ? "selected" : ""
} ${preset.isOfficial ? "official" : ""}`}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedPreset === index
? preset.isOfficial
? "bg-[var(--color-warning)] text-white"
: "bg-[var(--color-primary)] text-white"
: "bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-border)]"
}`}
onClick={() => applyPreset(preset, index)}
>
{preset.isOfficial && <Zap size={14} />}
{preset.name}
</button>
);
})}
))}
</div>
</div>
{selectedPreset === -1 && (
<small
className="field-hint"
style={{ marginTop: "8px", display: "block" }}
>
<p className="text-sm text-[var(--color-text-secondary)]">
</small>
</p>
)}
{selectedPreset !== -1 && selectedPreset !== null && (
<small
className="field-hint"
style={{ marginTop: "8px", display: "block" }}
>
<p className="text-sm text-[var(--color-text-secondary)]">
{isOfficialPreset
? "Claude 官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key"}
</small>
</p>
)}
</div>
)}
{showPresets && isCodex && (
<div className="presets">
<label></label>
<div className="preset-buttons">
<button
type="button"
className={`preset-btn ${
selectedCodexPreset === -1 ? "selected" : ""
}`}
onClick={handleCodexCustomClick}
>
</button>
{codexProviderPresets.map((preset, index) => (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-3">
</label>
<div className="flex flex-wrap gap-2">
<button
key={index}
type="button"
className={`preset-btn ${
selectedCodexPreset === index ? "selected" : ""
} ${preset.isOfficial ? "official" : ""}`}
onClick={() => applyCodexPreset(preset, index)}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedCodexPreset === -1
? "bg-[var(--color-primary)] text-white"
: "bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-border)]"
}`}
onClick={handleCodexCustomClick}
>
{preset.name}
</button>
))}
{codexProviderPresets.map((preset, index) => (
<button
key={index}
type="button"
className={`inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedCodexPreset === index
? preset.isOfficial
? "bg-[var(--color-warning)] text-white"
: "bg-[var(--color-primary)] text-white"
: "bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:bg-[var(--color-border)]"
}`}
onClick={() => applyCodexPreset(preset, index)}
>
{preset.isOfficial && <Zap size={14} />}
{preset.name}
</button>
))}
</div>
</div>
{selectedCodexPreset === -1 && (
<small
className="field-hint"
style={{ marginTop: "8px", display: "block" }}
>
<p className="text-sm text-[var(--color-text-secondary)]">
</small>
</p>
)}
{selectedCodexPreset !== -1 && selectedCodexPreset !== null && (
<small
className="field-hint"
style={{ marginTop: "8px", display: "block" }}
>
<p className="text-sm text-[var(--color-text-secondary)]">
{isCodexOfficialPreset
? "Codex 官方登录,不需要填写 API Key"
: "使用预设配置,只需填写 API Key"}
</small>
</p>
)}
</div>
)}
<div className="form-group">
<label htmlFor="name"> *</label>
<div className="space-y-2">
<label
htmlFor="name"
className="block text-sm font-medium text-[var(--color-text-primary)]"
>
*
</label>
<input
type="text"
id="name"
@@ -483,14 +509,18 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
placeholder="例如Anthropic 官方"
required
autoComplete="off"
className="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)] transition-colors"
/>
</div>
{!isCodex && (
<div
className={`form-group api-key-group ${!showApiKey ? "hidden" : ""}`}
>
<label htmlFor="apiKey">API Key *</label>
{!isCodex && showApiKey && (
<div className="space-y-2">
<label
htmlFor="apiKey"
className="block text-sm font-medium text-[var(--color-text-primary)]"
>
API Key *
</label>
<input
type="text"
id="apiKey"
@@ -503,24 +533,23 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}
disabled={isOfficialPreset}
autoComplete="off"
style={
className={`w-full px-3 py-2 border rounded-lg text-sm transition-colors ${
isOfficialPreset
? {
backgroundColor: "#f5f5f5",
cursor: "not-allowed",
color: "#999",
}
: {}
}
? "bg-[var(--color-bg-tertiary)] border-[var(--color-border)] text-[var(--color-text-tertiary)] cursor-not-allowed"
: "border-[var(--color-border)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
}`}
/>
</div>
)}
{isCodex && (
<div
className={`form-group api-key-group ${!showCodexApiKey ? "hidden" : ""}`}
>
<label htmlFor="codexApiKey">API Key *</label>
{isCodex && showCodexApiKey && (
<div className="space-y-2">
<label
htmlFor="codexApiKey"
className="block text-sm font-medium text-[var(--color-text-primary)]"
>
API Key *
</label>
<input
type="text"
id="codexApiKey"
@@ -538,21 +567,22 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
!isCodexOfficialPreset
}
autoComplete="off"
style={
className={`w-full px-3 py-2 border rounded-lg text-sm transition-colors ${
isCodexOfficialPreset
? {
backgroundColor: "#f5f5f5",
cursor: "not-allowed",
color: "#999",
}
: {}
}
? "bg-[var(--color-bg-tertiary)] border-[var(--color-border)] text-[var(--color-text-tertiary)] cursor-not-allowed"
: "border-[var(--color-border)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]"
}`}
/>
</div>
)}
<div className="form-group">
<label htmlFor="websiteUrl"></label>
<div className="space-y-2">
<label
htmlFor="websiteUrl"
className="block text-sm font-medium text-[var(--color-text-primary)]"
>
</label>
<input
type="url"
id="websiteUrl"
@@ -561,15 +591,21 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
onChange={handleChange}
placeholder="https://example.com可选"
autoComplete="off"
className="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)] transition-colors"
/>
</div>
{/* Claude 或 Codex 的配置部分 */}
{isCodex ? (
// Codex: 双编辑器
<>
<div className="form-group">
<label htmlFor="codexAuth">auth.json (JSON) *</label>
<div className="space-y-6">
<div className="space-y-2">
<label
htmlFor="codexAuth"
className="block text-sm font-medium text-[var(--color-text-primary)]"
>
auth.json (JSON) *
</label>
<textarea
id="codexAuth"
value={codexAuth}
@@ -591,47 +627,61 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
"OPENAI_API_KEY": "sk-your-api-key-here"
}`}
rows={6}
style={{ fontFamily: "monospace", fontSize: "14px" }}
required
className="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)] transition-colors resize-y min-h-[8rem]"
/>
<small className="field-hint">Codex auth.json </small>
<p className="text-xs text-[var(--color-text-secondary)]">
Codex auth.json
</p>
</div>
<div className="form-group">
<label htmlFor="codexConfig">config.toml (TOML)</label>
<div className="space-y-2">
<label
htmlFor="codexConfig"
className="block text-sm font-medium text-[var(--color-text-primary)]"
>
config.toml (TOML)
</label>
<textarea
id="codexConfig"
value={codexConfig}
onChange={(e) => setCodexConfig(e.target.value)}
placeholder={``}
placeholder=""
rows={8}
className="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)] transition-colors resize-y min-h-[10rem]"
/>
<small className="field-hint">
<p className="text-xs text-[var(--color-text-secondary)]">
Codex config.toml
</small>
</p>
</div>
</>
</div>
) : (
// Claude: 原有的单编辑器
<div className="form-group">
<div className="label-with-checkbox">
<label htmlFor="settingsConfig">
<div className="space-y-2">
<div className="flex items-center justify-between">
<label
htmlFor="settingsConfig"
className="block text-sm font-medium text-[var(--color-text-primary)]"
>
Claude Code (JSON) *
</label>
<label className="checkbox-label">
<label className="inline-flex items-center gap-2 text-sm text-[var(--color-text-secondary)] cursor-pointer">
<input
type="checkbox"
checked={disableCoAuthored}
onChange={(e) => handleCoAuthoredToggle(e.target.checked)}
className="w-4 h-4 text-[var(--color-primary)] bg-white border-[var(--color-border)] rounded focus:ring-[var(--color-primary)] focus:ring-2"
/>
Claude Code
</label>
</div>
<JsonEditor
value={formData.settingsConfig}
onChange={(value) => handleChange({
target: { name: "settingsConfig", value }
} as React.ChangeEvent<HTMLTextAreaElement>)}
onChange={(value) =>
handleChange({
target: { name: "settingsConfig", value },
} as React.ChangeEvent<HTMLTextAreaElement>)
}
placeholder={`{
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
@@ -640,18 +690,27 @@ const ProviderForm: React.FC<ProviderFormProps> = ({
}`}
rows={12}
/>
<small className="field-hint">
<p className="text-xs text-[var(--color-text-secondary)]">
Claude Code settings.json
</small>
</p>
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="cancel-btn" onClick={onClose}>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-[var(--color-border)] bg-[var(--color-bg-tertiary)]">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-white rounded-lg transition-colors"
>
</button>
<button type="submit" className="submit-btn">
<button
type="submit"
className="inline-flex items-center gap-2 px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-hover)] transition-colors text-sm font-medium"
>
<Save size={16} />
{submitText}
</button>
</div>

View File

@@ -1,206 +0,0 @@
.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;
}
.url-link {
color: #3498db;
text-decoration: none;
cursor: pointer;
transition: color 0.2s;
}
.url-link:hover {
color: #2980b9;
text-decoration: underline;
}
.api-url {
color: #7f8c8d;
}
.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;
}
.check-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #f39c12;
background: white;
color: #f39c12;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.check-btn:hover:not(:disabled) {
background: #f39c12;
color: white;
}
.check-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.enable-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #27ae60;
background: white;
color: #27ae60;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.enable-btn:hover:not(:disabled) {
background: #27ae60;
color: white;
}
.enable-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.edit-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;
}
.edit-btn:hover:not(:disabled) {
background: #3498db;
color: white;
}
.edit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.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;
}

View File

@@ -1,6 +1,13 @@
import React from "react";
import { Provider } from "../types";
import "./ProviderList.css";
import {
Play,
Edit3,
Trash2,
ExternalLink,
CheckCircle2,
Users,
} from "lucide-react";
interface ProviderListProps {
providers: Record<string, Provider>;
@@ -39,71 +46,104 @@ const ProviderList: React.FC<ProviderListProps> = ({
};
return (
<div className="provider-list">
<div className="space-y-4">
{Object.values(providers).length === 0 ? (
<div className="empty-state">
<p></p>
<p>"添加供应商"</p>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-[var(--color-bg-tertiary)] rounded-full flex items-center justify-center">
<Users size={24} className="text-[var(--color-text-tertiary)]" />
</div>
<h3 className="text-lg font-medium text-[var(--color-text-primary)] mb-2">
</h3>
<p className="text-[var(--color-text-secondary)] text-sm">
"添加供应商"API供应商
</p>
</div>
) : (
<div className="provider-items">
<div className="space-y-3">
{Object.values(providers).map((provider) => {
const isCurrent = provider.id === currentProviderId;
const apiUrl = getApiUrl(provider);
return (
<div
key={provider.id}
className={`provider-item ${isCurrent ? "current" : ""}`}
className={`bg-white rounded-lg border p-4 transition-all duration-200 ${
isCurrent
? "border-[var(--color-primary)] ring-1 ring-[var(--color-primary)]/20 bg-[var(--color-primary)]/5"
: "border-[var(--color-border)] hover:border-[var(--color-border-hover)] hover:shadow-sm"
}`}
>
<div className="provider-info">
<div className="provider-name">
<span>{provider.name}</span>
{isCurrent && (
<span className="current-badge">使</span>
)}
</div>
<div className="provider-url">
{provider.websiteUrl ? (
<a
href="#"
onClick={(e) => {
e.preventDefault();
handleUrlClick(provider.websiteUrl!);
}}
className="url-link"
title={`访问 ${provider.websiteUrl}`}
>
{provider.websiteUrl}
</a>
) : (
<span className="api-url" title={getApiUrl(provider)}>
{getApiUrl(provider)}
</span>
)}
</div>
</div>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-medium text-[var(--color-text-primary)]">
{provider.name}
</h3>
{isCurrent && (
<div className="inline-flex items-center gap-1 px-2 py-1 bg-[var(--color-success)]/10 text-[var(--color-success)] rounded-md text-xs font-medium">
<CheckCircle2 size={12} />
使
</div>
)}
</div>
<div className="provider-actions">
<button
className="enable-btn"
onClick={() => onSwitch(provider.id)}
disabled={isCurrent}
>
</button>
<button
className="edit-btn"
onClick={() => onEdit(provider.id)}
>
</button>
<button
className="delete-btn"
onClick={() => onDelete(provider.id)}
disabled={isCurrent}
>
</button>
<div className="flex items-center gap-2 text-sm text-[var(--color-text-secondary)]">
{provider.websiteUrl ? (
<button
onClick={(e) => {
e.preventDefault();
handleUrlClick(provider.websiteUrl!);
}}
className="inline-flex items-center gap-1 hover:text-[var(--color-primary)] transition-colors"
title={`访问 ${provider.websiteUrl}`}
>
<ExternalLink size={14} />
{provider.websiteUrl}
</button>
) : (
<span className="font-mono" title={apiUrl}>
{apiUrl}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => onSwitch(provider.id)}
disabled={isCurrent}
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
isCurrent
? "bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)] cursor-not-allowed"
: "bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)]"
}`}
>
<Play size={14} />
{isCurrent ? "使用中" : "启用"}
</button>
<button
onClick={() => onEdit(provider.id)}
className="p-1.5 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-tertiary)] rounded-md transition-colors"
title="编辑供应商"
>
<Edit3 size={16} />
</button>
<button
onClick={() => onDelete(provider.id)}
disabled={isCurrent}
className={`p-1.5 rounded-md transition-colors ${
isCurrent
? "text-[var(--color-text-tertiary)] cursor-not-allowed"
: "text-[var(--color-text-secondary)] hover:text-[var(--color-error)] hover:bg-[var(--color-error-light)]"
}`}
title="删除供应商"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
);