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

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