diff --git a/package-lock.json b/package-lock.json
index 1de734b81..b2ca695bd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "proxycast",
- "version": "0.12.3",
+ "version": "0.12.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxycast",
- "version": "0.12.3",
+ "version": "0.12.5",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
diff --git a/package.json b/package.json
index 9de4a769a..d62fe425b 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
- "version": "0.12.5",
+ "version": "0.13.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/scripts/generate-tray-icons.mjs b/scripts/generate-tray-icons.mjs
new file mode 100644
index 000000000..31f5040cd
--- /dev/null
+++ b/scripts/generate-tray-icons.mjs
@@ -0,0 +1,124 @@
+/**
+ * 生成托盘图标脚本
+ *
+ * 创建四种状态的托盘图标:
+ * - tray-running.png: 绿色圆形(正常运行)
+ * - tray-warning.png: 黄色圆形(警告状态)
+ * - tray-error.png: 红色圆形(错误状态)
+ * - tray-stopped.png: 灰色圆形(停止状态)
+ *
+ * 对于 macOS,还会生成模板图标以适应深色/浅色模式
+ */
+
+import sharp from 'sharp';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import { mkdirSync, existsSync } from 'fs';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const ICON_SIZE = 32;
+const OUTPUT_DIR = join(__dirname, '..', 'src-tauri', 'icons', 'tray');
+
+// 确保输出目录存在
+if (!existsSync(OUTPUT_DIR)) {
+ mkdirSync(OUTPUT_DIR, { recursive: true });
+}
+
+/**
+ * 创建圆形图标的 SVG
+ * @param {string} color - 填充颜色
+ * @param {boolean} isTemplate - 是否为模板图标(macOS 深色/浅色模式)
+ */
+function createCircleSvg(color, isTemplate = false) {
+ const fillColor = isTemplate ? '#000000' : color;
+ const opacity = isTemplate ? '1' : '1';
+
+ return `
+
+ `.trim();
+}
+
+/**
+ * 创建带边框的圆形图标 SVG(更好的可见性)
+ * @param {string} fillColor - 填充颜色
+ * @param {string} strokeColor - 边框颜色
+ */
+function createCircleWithBorderSvg(fillColor, strokeColor) {
+ return `
+
+ `.trim();
+}
+
+/**
+ * 生成图标文件
+ * @param {string} name - 文件名(不含扩展名)
+ * @param {string} svg - SVG 内容
+ */
+async function generateIcon(name, svg) {
+ const outputPath = join(OUTPUT_DIR, `${name}.png`);
+
+ await sharp(Buffer.from(svg))
+ .resize(ICON_SIZE, ICON_SIZE)
+ .png()
+ .toFile(outputPath);
+
+ console.log(`✓ 生成图标: ${outputPath}`);
+}
+
+/**
+ * 生成 macOS 模板图标(@2x 版本)
+ * @param {string} name - 文件名(不含扩展名)
+ * @param {string} svg - SVG 内容
+ */
+async function generateTemplateIcon(name, svg) {
+ // 标准尺寸
+ const outputPath = join(OUTPUT_DIR, `${name}Template.png`);
+ await sharp(Buffer.from(svg))
+ .resize(ICON_SIZE, ICON_SIZE)
+ .png()
+ .toFile(outputPath);
+ console.log(`✓ 生成模板图标: ${outputPath}`);
+
+ // @2x 版本
+ const output2xPath = join(OUTPUT_DIR, `${name}Template@2x.png`);
+ await sharp(Buffer.from(svg))
+ .resize(ICON_SIZE * 2, ICON_SIZE * 2)
+ .png()
+ .toFile(output2xPath);
+ console.log(`✓ 生成模板图标 @2x: ${output2xPath}`);
+}
+
+async function main() {
+ console.log('开始生成托盘图标...\n');
+
+ // 定义图标颜色
+ const icons = [
+ { name: 'tray-running', fill: '#22c55e', stroke: '#16a34a' }, // 绿色
+ { name: 'tray-warning', fill: '#eab308', stroke: '#ca8a04' }, // 黄色
+ { name: 'tray-error', fill: '#ef4444', stroke: '#dc2626' }, // 红色
+ { name: 'tray-stopped', fill: '#9ca3af', stroke: '#6b7280' }, // 灰色
+ ];
+
+ // 生成彩色图标
+ for (const icon of icons) {
+ const svg = createCircleWithBorderSvg(icon.fill, icon.stroke);
+ await generateIcon(icon.name, svg);
+ }
+
+ console.log('\n生成 macOS 模板图标...\n');
+
+ // 生成 macOS 模板图标(黑色,系统会自动适应深色/浅色模式)
+ const templateSvg = createCircleSvg('#000000', true);
+ await generateTemplateIcon('tray', templateSvg);
+
+ console.log('\n✅ 所有图标生成完成!');
+ console.log(`📁 输出目录: ${OUTPUT_DIR}`);
+}
+
+main().catch(console.error);
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index ebfd84398..12123f63c 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -432,6 +432,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
[[package]]
name = "bytes"
version = "1.11.0"
@@ -2066,6 +2072,18 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "image"
+version = "0.25.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "num-traits",
+ "png",
+]
+
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -3349,7 +3367,7 @@ dependencies = [
[[package]]
name = "proxycast"
-version = "0.12.5"
+version = "0.13.0"
dependencies = [
"anyhow",
"async-stream",
@@ -4534,6 +4552,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
+ "image",
"jni",
"libc",
"log",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 08a7a30ac..c28fa7a9c 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "proxycast"
-version = "0.12.5"
+version = "0.13.0"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
@@ -13,7 +13,7 @@ crate-type = ["lib", "cdylib", "staticlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
-tauri = { version = "2", features = [] }
+tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-shell = "2"
tauri-plugin-autostart = "2"
tauri-plugin-dialog = "2"
diff --git a/src-tauri/icons/tray/tray-error.png b/src-tauri/icons/tray/tray-error.png
new file mode 100644
index 000000000..b33cbfb70
Binary files /dev/null and b/src-tauri/icons/tray/tray-error.png differ
diff --git a/src-tauri/icons/tray/tray-running.png b/src-tauri/icons/tray/tray-running.png
new file mode 100644
index 000000000..856fbd780
Binary files /dev/null and b/src-tauri/icons/tray/tray-running.png differ
diff --git a/src-tauri/icons/tray/tray-stopped.png b/src-tauri/icons/tray/tray-stopped.png
new file mode 100644
index 000000000..ef7949a70
Binary files /dev/null and b/src-tauri/icons/tray/tray-stopped.png differ
diff --git a/src-tauri/icons/tray/tray-warning.png b/src-tauri/icons/tray/tray-warning.png
new file mode 100644
index 000000000..225717887
Binary files /dev/null and b/src-tauri/icons/tray/tray-warning.png differ
diff --git a/src-tauri/icons/tray/trayTemplate.png b/src-tauri/icons/tray/trayTemplate.png
new file mode 100644
index 000000000..8a22bf131
Binary files /dev/null and b/src-tauri/icons/tray/trayTemplate.png differ
diff --git a/src-tauri/icons/tray/trayTemplate@2x.png b/src-tauri/icons/tray/trayTemplate@2x.png
new file mode 100644
index 000000000..0a5c853e0
Binary files /dev/null and b/src-tauri/icons/tray/trayTemplate@2x.png differ
diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index 656d134da..e92085258 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -11,5 +11,6 @@ pub mod router_cmd;
pub mod skill_cmd;
pub mod switch_cmd;
pub mod telemetry_cmd;
+pub mod tray_cmd;
pub mod usage_cmd;
pub mod websocket_cmd;
diff --git a/src-tauri/src/commands/provider_pool_cmd.rs b/src-tauri/src/commands/provider_pool_cmd.rs
index 257169fcf..574008582 100644
--- a/src-tauri/src/commands/provider_pool_cmd.rs
+++ b/src-tauri/src/commands/provider_pool_cmd.rs
@@ -87,8 +87,9 @@ fn copy_and_rename_credential_file(
let mut creds: serde_json::Value =
serde_json::from_str(&content).map_err(|e| format!("解析凭证文件失败: {}", e))?;
- // 检测 refreshToken 是否被截断
+ // 检测 refreshToken 是否被截断(仅记录警告,不阻止添加)
// 正常的 refreshToken 长度应该在 500+ 字符,如果小于 100 字符则可能被截断
+ // 注意:即使 refreshToken 被截断,也允许添加凭证,在刷新时才会提示错误
if let Some(refresh_token) = creds.get("refreshToken").and_then(|v| v.as_str()) {
let token_len = refresh_token.len();
@@ -97,19 +98,16 @@ fn copy_and_rename_credential_file(
token_len < 100 || refresh_token.ends_with("...") || refresh_token.contains("...");
if is_truncated {
- tracing::error!(
- "[KIRO] 检测到 refreshToken 被截断!长度: {}, 内容: {}",
+ tracing::warn!(
+ "[KIRO] 检测到 refreshToken 可能被截断!长度: {}, 内容: {}... (仍允许添加,刷新时会提示)",
token_len,
&refresh_token[..std::cmp::min(50, token_len)]
);
- return Err(format!(
- "凭证文件中的 refreshToken 已被截断(长度: {} 字符)。\n\n⚠️ 这通常是 Kiro IDE 为了防止凭证被第三方工具使用而故意截断的。\n\n💡 解决方案:\n1. 使用 Kir-Manager 工具获取完整的凭证\n2. 或者使用其他方式获取未截断的凭证文件\n3. 正常的 refreshToken 长度应该在 500+ 字符\n\n当前 refreshToken: {}...",
- token_len,
- &refresh_token[..std::cmp::min(30, token_len)]
- ));
+ // 不再阻止添加,只记录警告
+ // 在刷新 Token 时会检测并提示用户
+ } else {
+ tracing::info!("[KIRO] refreshToken 长度检查通过: {} 字符", token_len);
}
-
- tracing::info!("[KIRO] refreshToken 长度检查通过: {} 字符", token_len);
} else {
tracing::warn!("[KIRO] 凭证文件中没有 refreshToken 字段");
}
diff --git a/src-tauri/src/commands/tray_cmd.rs b/src-tauri/src/commands/tray_cmd.rs
new file mode 100644
index 000000000..e00e23d28
--- /dev/null
+++ b/src-tauri/src/commands/tray_cmd.rs
@@ -0,0 +1,263 @@
+//! 托盘相关命令
+//!
+//! 提供托盘状态同步和更新的 Tauri 命令
+//!
+//! # Requirements
+//! - 7.1: API 服务器状态变化时在 1 秒内更新托盘图标
+//! - 7.2: 凭证健康状态变化时在 1 秒内更新托盘图标
+//! - 7.3: 托盘菜单打开时获取并显示最新信息
+
+use crate::tray::{CredentialHealth, TrayIconStatus, TrayStateSnapshot};
+use crate::TrayManagerState;
+use tauri::State;
+use tracing::{debug, error, info};
+
+/// 同步托盘状态
+///
+/// 从前端或其他模块调用,更新托盘的完整状态
+///
+/// # Requirements
+/// - 7.1: API 服务器状态变化时更新托盘图标
+/// - 7.2: 凭证健康状态变化时更新托盘图标
+#[tauri::command]
+pub async fn sync_tray_state(
+ tray_state: State<'_, TrayManagerState>,
+ server_running: bool,
+ server_address: String,
+ available_credentials: usize,
+ total_credentials: usize,
+ today_requests: u64,
+ auto_start_enabled: bool,
+) -> Result<(), String> {
+ let tray_guard = tray_state.0.read().await;
+ let tray_manager = tray_guard
+ .as_ref()
+ .ok_or_else(|| "托盘管理器未初始化".to_string())?;
+
+ // 计算图标状态
+ let icon_status = if !server_running {
+ TrayIconStatus::Stopped
+ } else if available_credentials == 0 && total_credentials > 0 {
+ TrayIconStatus::Error
+ } else if available_credentials < total_credentials {
+ TrayIconStatus::Warning
+ } else {
+ TrayIconStatus::Running
+ };
+
+ let snapshot = TrayStateSnapshot {
+ icon_status,
+ server_running,
+ server_address,
+ available_credentials,
+ total_credentials,
+ today_requests,
+ auto_start_enabled,
+ };
+
+ tray_manager
+ .update_state(snapshot)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ debug!(
+ "托盘状态已同步: server_running={}, icon_status={:?}",
+ server_running, icon_status
+ );
+
+ Ok(())
+}
+
+/// 更新托盘服务器状态
+///
+/// 仅更新服务器运行状态
+///
+/// # Requirements
+/// - 7.1: API 服务器状态变化时在 1 秒内更新托盘图标
+#[tauri::command]
+pub async fn update_tray_server_status(
+ tray_state: State<'_, TrayManagerState>,
+ server_running: bool,
+ server_host: String,
+ server_port: u16,
+) -> Result<(), String> {
+ let tray_guard = tray_state.0.read().await;
+ let tray_manager = tray_guard
+ .as_ref()
+ .ok_or_else(|| "托盘管理器未初始化".to_string())?;
+
+ // 获取当前状态
+ let mut current_state = tray_manager.get_state().await;
+
+ // 更新服务器相关字段
+ current_state.server_running = server_running;
+ current_state.server_address = if server_running {
+ format!("{}:{}", server_host, server_port)
+ } else {
+ String::new()
+ };
+
+ // 重新计算图标状态
+ if !server_running {
+ current_state.icon_status = TrayIconStatus::Stopped;
+ } else if current_state.icon_status == TrayIconStatus::Stopped {
+ current_state.icon_status = TrayIconStatus::Running;
+ }
+
+ tray_manager
+ .update_state(current_state)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ info!(
+ "托盘服务器状态已更新: running={}, address={}:{}",
+ server_running, server_host, server_port
+ );
+
+ Ok(())
+}
+
+/// 更新托盘凭证状态
+///
+/// 仅更新凭证健康状态
+///
+/// # Requirements
+/// - 7.2: 凭证健康状态变化时在 1 秒内更新托盘图标
+#[tauri::command]
+pub async fn update_tray_credential_status(
+ tray_state: State<'_, TrayManagerState>,
+ available_credentials: usize,
+ total_credentials: usize,
+ has_warning: bool,
+) -> Result<(), String> {
+ let tray_guard = tray_state.0.read().await;
+ let tray_manager = tray_guard
+ .as_ref()
+ .ok_or_else(|| "托盘管理器未初始化".to_string())?;
+
+ // 获取当前状态
+ let mut current_state = tray_manager.get_state().await;
+
+ // 更新凭证相关字段
+ current_state.available_credentials = available_credentials;
+ current_state.total_credentials = total_credentials;
+
+ // 重新计算图标状态
+ if current_state.server_running {
+ if available_credentials == 0 && total_credentials > 0 {
+ current_state.icon_status = TrayIconStatus::Error;
+ } else if has_warning || available_credentials < total_credentials {
+ current_state.icon_status = TrayIconStatus::Warning;
+ } else {
+ current_state.icon_status = TrayIconStatus::Running;
+ }
+ }
+
+ tray_manager
+ .update_state(current_state)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ info!(
+ "托盘凭证状态已更新: available={}/{}, has_warning={}",
+ available_credentials, total_credentials, has_warning
+ );
+
+ Ok(())
+}
+
+/// 获取托盘当前状态
+///
+/// 返回托盘的当前状态快照
+#[tauri::command]
+pub async fn get_tray_state(
+ tray_state: State<'_, TrayManagerState>,
+) -> Result {
+ let tray_guard = tray_state.0.read().await;
+ let tray_manager = tray_guard
+ .as_ref()
+ .ok_or_else(|| "托盘管理器未初始化".to_string())?;
+
+ Ok(tray_manager.get_state().await)
+}
+
+/// 刷新托盘菜单
+///
+/// 强制刷新托盘菜单内容
+///
+/// # Requirements
+/// - 7.3: 托盘菜单打开时获取并显示最新信息
+#[tauri::command]
+pub async fn refresh_tray_menu(
+ tray_state: State<'_, TrayManagerState>,
+) -> Result<(), String> {
+ let tray_guard = tray_state.0.read().await;
+ let tray_manager = tray_guard
+ .as_ref()
+ .ok_or_else(|| "托盘管理器未初始化".to_string())?;
+
+ tray_manager
+ .refresh_menu()
+ .await
+ .map_err(|e| e.to_string())?;
+
+ debug!("托盘菜单已刷新");
+
+ Ok(())
+}
+
+/// 刷新托盘菜单并更新统计数据
+///
+/// 在菜单打开时调用,获取最新的统计数据并刷新菜单
+///
+/// # Requirements
+/// - 7.3: 托盘菜单打开时获取并显示最新信息
+#[tauri::command]
+pub async fn refresh_tray_with_stats(
+ tray_state: State<'_, TrayManagerState>,
+ server_running: bool,
+ server_address: String,
+ available_credentials: usize,
+ total_credentials: usize,
+ today_requests: u64,
+ auto_start_enabled: bool,
+) -> Result<(), String> {
+ let tray_guard = tray_state.0.read().await;
+ let tray_manager = tray_guard
+ .as_ref()
+ .ok_or_else(|| "托盘管理器未初始化".to_string())?;
+
+ // 计算图标状态
+ let icon_status = if !server_running {
+ TrayIconStatus::Stopped
+ } else if available_credentials == 0 && total_credentials > 0 {
+ TrayIconStatus::Error
+ } else if available_credentials < total_credentials {
+ TrayIconStatus::Warning
+ } else {
+ TrayIconStatus::Running
+ };
+
+ let snapshot = TrayStateSnapshot {
+ icon_status,
+ server_running,
+ server_address,
+ available_credentials,
+ total_credentials,
+ today_requests,
+ auto_start_enabled,
+ };
+
+ // 更新状态并刷新菜单
+ tray_manager
+ .update_state(snapshot)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ debug!(
+ "托盘菜单已刷新: server_running={}, requests={}, credentials={}/{}",
+ server_running, today_requests, available_credentials, total_credentials
+ );
+
+ Ok(())
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index b738d9f2c..cc0073800 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -16,10 +16,12 @@ pub mod router;
mod server;
mod services;
pub mod telemetry;
+pub mod tray;
pub mod websocket;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
+use tauri::{Manager, Runtime};
use tokio::sync::RwLock;
use commands::provider_pool_cmd::{CredentialSyncServiceState, ProviderPoolServiceState};
@@ -29,10 +31,18 @@ use commands::skill_cmd::SkillServiceState;
use services::provider_pool_service::ProviderPoolService;
use services::skill_service::SkillService;
use services::token_cache_service::TokenCacheService;
+use tray::{
+ calculate_icon_status, CredentialHealth, TrayIconStatus, TrayManager, TrayStateSnapshot,
+};
/// TokenCacheService 状态封装
pub struct TokenCacheServiceState(pub Arc);
+/// TrayManager 状态封装
+///
+/// 用于在 Tauri 状态管理中存储托盘管理器
+pub struct TrayManagerState(pub Arc>>>);
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProviderType {
@@ -1417,7 +1427,25 @@ pub fn run() {
.manage(router_config_state)
.manage(resilience_config_state)
.manage(telemetry_state)
- .setup(move |_app| {
+ .setup(move |app| {
+ // 初始化托盘管理器
+ // Requirements 1.4: 应用启动时显示停止状态图标
+ match TrayManager::new(app.handle()) {
+ Ok(tray_manager) => {
+ tracing::info!("[启动] 托盘管理器初始化成功");
+ // 将托盘管理器存储到应用状态中
+ let tray_state: TrayManagerState =
+ TrayManagerState(Arc::new(tokio::sync::RwLock::new(Some(tray_manager))));
+ app.manage(tray_state);
+ }
+ Err(e) => {
+ tracing::error!("[启动] 托盘管理器初始化失败: {}", e);
+ // 即使托盘初始化失败,应用仍然可以运行
+ let tray_state: TrayManagerState =
+ TrayManagerState(Arc::new(tokio::sync::RwLock::new(None)));
+ app.manage(tray_state);
+ }
+ }
// 自动启动服务器
let state = state_clone.clone();
let logs = logs_clone.clone();
@@ -1427,6 +1455,7 @@ pub fn run() {
let shared_stats = shared_stats_clone.clone();
let shared_tokens = shared_tokens_clone.clone();
let shared_logger = shared_logger_clone.clone();
+ let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
// 先加载凭证
{
@@ -1440,6 +1469,8 @@ pub fn run() {
}
}
// 启动服务器(使用共享的遥测实例)
+ let server_started;
+ let server_address;
{
let mut s = state.write().await;
logs.write()
@@ -1463,11 +1494,46 @@ pub fn run() {
logs.write()
.await
.add("info", &format!("[启动] 服务器已启动: {host}:{port}"));
+ server_started = true;
+ server_address = format!("{}:{}", host, port);
}
Err(e) => {
logs.write()
.await
.add("error", &format!("[启动] 服务器启动失败: {e}"));
+ server_started = false;
+ server_address = String::new();
+ }
+ }
+ }
+
+ // 更新托盘状态
+ // Requirements 7.1: API 服务器状态变化时更新托盘图标
+ if let Some(tray_state) = app_handle.try_state::>() {
+ let tray_guard = tray_state.0.read().await;
+ if let Some(tray_manager) = tray_guard.as_ref() {
+ // 计算初始图标状态
+ // 服务器刚启动时,假设凭证健康(后续会通过状态同步更新)
+ let icon_status = if server_started {
+ TrayIconStatus::Running
+ } else {
+ TrayIconStatus::Stopped
+ };
+
+ let snapshot = TrayStateSnapshot {
+ icon_status,
+ server_running: server_started,
+ server_address,
+ available_credentials: 0, // 初始值,后续通过状态同步更新
+ total_credentials: 0,
+ today_requests: 0,
+ auto_start_enabled: false, // 后续通过状态同步更新
+ };
+
+ if let Err(e) = tray_manager.update_state(snapshot).await {
+ tracing::error!("[启动] 更新托盘状态失败: {}", e);
+ } else {
+ tracing::info!("[启动] 托盘状态已更新");
}
}
}
@@ -1650,6 +1716,13 @@ pub fn run() {
commands::injection_cmd::update_injection_rule,
// Usage commands
commands::usage_cmd::get_kiro_usage,
+ // Tray commands
+ commands::tray_cmd::sync_tray_state,
+ commands::tray_cmd::update_tray_server_status,
+ commands::tray_cmd::update_tray_credential_status,
+ commands::tray_cmd::get_tray_state,
+ commands::tray_cmd::refresh_tray_menu,
+ commands::tray_cmd::refresh_tray_with_stats,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
diff --git a/src-tauri/src/tray/events.rs b/src-tauri/src/tray/events.rs
new file mode 100644
index 000000000..50b581586
--- /dev/null
+++ b/src-tauri/src/tray/events.rs
@@ -0,0 +1,141 @@
+//! 托盘事件处理模块
+//!
+//! 处理托盘图标的点击事件
+//!
+//! # Requirements
+//! - 6.1: 单击托盘图标切换主窗口可见性
+//! - 6.2: 双击托盘图标显示并聚焦主窗口
+//! - 7.3: 托盘菜单打开时获取并显示最新信息
+
+use tauri::{
+ tray::{MouseButton, MouseButtonState, TrayIconEvent},
+ AppHandle, Emitter, Manager, Runtime,
+};
+use tracing::{debug, info};
+
+/// 托盘菜单即将打开的事件名称
+pub const TRAY_MENU_WILL_OPEN_EVENT: &str = "tray-menu-will-open";
+
+/// 处理托盘图标事件
+///
+/// # Requirements
+/// - 6.1: 单击切换主窗口可见性
+/// - 6.2: 双击显示并聚焦主窗口
+/// - 7.3: 右键点击时触发菜单刷新
+pub fn handle_tray_icon_event(app: &AppHandle, event: TrayIconEvent) {
+ match event {
+ // 单击事件 - 切换主窗口可见性
+ // Requirements 6.1: WHEN 用户单击托盘图标 THEN 系统托盘 SHALL 切换主应用程序窗口的可见性
+ TrayIconEvent::Click {
+ button: MouseButton::Left,
+ button_state: MouseButtonState::Up,
+ ..
+ } => {
+ debug!("托盘图标单击事件");
+ toggle_main_window_visibility(app);
+ }
+
+ // 右键点击事件 - 菜单即将打开,触发数据刷新
+ // Requirements 7.3: WHEN 托盘菜单被打开时 THEN 系统托盘 SHALL 获取并显示最新的统计信息和状态信息
+ TrayIconEvent::Click {
+ button: MouseButton::Right,
+ button_state: MouseButtonState::Up,
+ ..
+ } => {
+ debug!("托盘图标右键点击事件 - 菜单即将打开");
+ // 发送事件通知前端刷新托盘数据
+ if let Err(e) = app.emit(TRAY_MENU_WILL_OPEN_EVENT, ()) {
+ tracing::error!("发送托盘菜单打开事件失败: {}", e);
+ }
+ }
+
+ // 双击事件 - 显示并聚焦主窗口
+ // Requirements 6.2: WHEN 用户双击托盘图标 THEN 系统托盘 SHALL 显示并聚焦主应用程序窗口
+ TrayIconEvent::DoubleClick {
+ button: MouseButton::Left,
+ ..
+ } => {
+ debug!("托盘图标双击事件");
+ show_and_focus_main_window(app);
+ }
+
+ // 其他事件忽略
+ _ => {}
+ }
+}
+
+/// 切换主窗口可见性
+///
+/// 如果窗口可见则隐藏,如果隐藏则显示并聚焦
+///
+/// # Requirements
+/// - 6.1: 单击托盘图标切换主窗口可见性
+fn toggle_main_window_visibility(app: &AppHandle) {
+ if let Some(window) = app.get_webview_window("main") {
+ match window.is_visible() {
+ Ok(true) => {
+ // 窗口可见,隐藏它
+ if let Err(e) = window.hide() {
+ tracing::error!("隐藏主窗口失败: {}", e);
+ } else {
+ info!("主窗口已隐藏");
+ }
+ }
+ Ok(false) => {
+ // 窗口隐藏,显示并聚焦
+ show_and_focus_window(&window);
+ }
+ Err(e) => {
+ tracing::error!("获取窗口可见性失败: {}", e);
+ // 尝试显示窗口
+ show_and_focus_window(&window);
+ }
+ }
+ } else {
+ tracing::warn!("未找到主窗口");
+ }
+}
+
+/// 显示并聚焦主窗口
+///
+/// # Requirements
+/// - 6.2: 双击托盘图标显示并聚焦主窗口
+fn show_and_focus_main_window(app: &AppHandle) {
+ if let Some(window) = app.get_webview_window("main") {
+ show_and_focus_window(&window);
+ } else {
+ tracing::warn!("未找到主窗口");
+ }
+}
+
+/// 显示并聚焦窗口的辅助函数
+fn show_and_focus_window(window: &tauri::WebviewWindow) {
+ // 取消最小化
+ if let Err(e) = window.unminimize() {
+ tracing::error!("取消最小化窗口失败: {}", e);
+ }
+
+ // 显示窗口
+ if let Err(e) = window.show() {
+ tracing::error!("显示主窗口失败: {}", e);
+ } else {
+ info!("主窗口已显示");
+ }
+
+ // 聚焦窗口
+ if let Err(e) = window.set_focus() {
+ tracing::error!("聚焦主窗口失败: {}", e);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ // 由于这些函数依赖 Tauri 运行时,单元测试需要模拟环境
+ // 这里只测试基本的模块结构
+
+ #[test]
+ fn test_module_compiles() {
+ // 确保模块可以编译
+ assert!(true);
+ }
+}
diff --git a/src-tauri/src/tray/format.rs b/src-tauri/src/tray/format.rs
new file mode 100644
index 000000000..bf7d27175
--- /dev/null
+++ b/src-tauri/src/tray/format.rs
@@ -0,0 +1,120 @@
+//! 菜单文本格式化模块
+//!
+//! 提供托盘菜单文本的格式化函数
+
+/// 格式化服务器状态文本
+///
+/// # 示例输出
+/// - "🟢 API 服务器: 运行中 (127.0.0.1:8080)"
+/// - "⚪ API 服务器: 已停止"
+pub fn format_server_status(running: bool, host: &str, port: u16) -> String {
+ if running {
+ format!("🟢 API 服务器: 运行中 ({host}:{port})")
+ } else {
+ "⚪ API 服务器: 已停止".to_string()
+ }
+}
+
+/// 格式化凭证状态文本
+///
+/// # 示例输出
+/// - "🔑 可用凭证: 3/5"
+pub fn format_credential_status(available: usize, total: usize) -> String {
+ format!("🔑 可用凭证: {available}/{total}")
+}
+
+/// 格式化请求统计文本
+///
+/// # 示例输出
+/// - "📊 今日请求: 128 次"
+pub fn format_request_count(count: u64) -> String {
+ format!("📊 今日请求: {count} 次")
+}
+
+/// 格式化 API 地址
+///
+/// # 示例输出
+/// - "http://127.0.0.1:8080"
+pub fn format_api_address(host: &str, port: u16) -> String {
+ format!("http://{host}:{port}")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use proptest::prelude::*;
+
+ proptest! {
+ /// **Feature: system-tray, Property 2: 菜单内容格式化正确性**
+ /// **Validates: Requirements 2.2, 2.3, 2.4**
+ #[test]
+ fn prop_menu_content_formatting(
+ host in "[a-z0-9.]{1,50}",
+ port in 1024u16..65535,
+ available in 0usize..100,
+ total in 0usize..100,
+ requests in 0u64..1000000
+ ) {
+ // 测试服务器状态格式化 - 运行中
+ let running_status = format_server_status(true, &host, port);
+ prop_assert!(running_status.contains(&host), "运行状态应包含 host");
+ prop_assert!(running_status.contains(&port.to_string()), "运行状态应包含 port");
+ prop_assert!(running_status.contains("运行中"), "运行状态应包含'运行中'");
+
+ // 测试服务器状态格式化 - 已停止
+ let stopped_status = format_server_status(false, &host, port);
+ prop_assert!(stopped_status.contains("已停止"), "停止状态应包含'已停止'");
+
+ // 测试凭证状态格式化
+ let cred_status = format_credential_status(available, total);
+ prop_assert!(cred_status.contains(&available.to_string()), "凭证状态应包含可用数");
+ prop_assert!(cred_status.contains(&total.to_string()), "凭证状态应包含总数");
+
+ // 测试请求统计格式化
+ let req_status = format_request_count(requests);
+ prop_assert!(req_status.contains(&requests.to_string()), "请求统计应包含请求次数");
+ }
+
+ /// **Feature: system-tray, Property 4: API 地址格式化正确性**
+ /// **Validates: Requirements 4.2**
+ #[test]
+ fn prop_api_address_formatting(
+ host in "[a-z0-9.]{1,50}",
+ port in 1024u16..65535
+ ) {
+ let address = format_api_address(&host, port);
+ let expected = format!("http://{host}:{port}");
+ prop_assert_eq!(address, expected, "API 地址格式应为 http://{{host}}:{{port}}");
+ }
+ }
+
+ #[test]
+ fn test_format_server_status_running() {
+ let status = format_server_status(true, "127.0.0.1", 8080);
+ assert_eq!(status, "🟢 API 服务器: 运行中 (127.0.0.1:8080)");
+ }
+
+ #[test]
+ fn test_format_server_status_stopped() {
+ let status = format_server_status(false, "127.0.0.1", 8080);
+ assert_eq!(status, "⚪ API 服务器: 已停止");
+ }
+
+ #[test]
+ fn test_format_credential_status() {
+ let status = format_credential_status(3, 5);
+ assert_eq!(status, "🔑 可用凭证: 3/5");
+ }
+
+ #[test]
+ fn test_format_request_count() {
+ let status = format_request_count(128);
+ assert_eq!(status, "📊 今日请求: 128 次");
+ }
+
+ #[test]
+ fn test_format_api_address() {
+ let address = format_api_address("127.0.0.1", 8080);
+ assert_eq!(address, "http://127.0.0.1:8080");
+ }
+}
diff --git a/src-tauri/src/tray/manager.rs b/src-tauri/src/tray/manager.rs
new file mode 100644
index 000000000..ee5c9338f
--- /dev/null
+++ b/src-tauri/src/tray/manager.rs
@@ -0,0 +1,445 @@
+//! 托盘管理器模块
+//!
+//! 提供托盘图标和菜单的管理功能
+//!
+//! # Requirements
+//! - 1.1: 服务器运行且凭证健康时显示正常状态图标
+//! - 1.4: 应用启动时显示停止状态图标
+//! - 7.1, 7.2, 7.3: 状态变化时更新托盘
+
+use super::events::handle_tray_icon_event;
+use super::menu::build_tray_menu;
+use super::menu_handler::handle_menu_event;
+use super::state::{TrayIconStatus, TrayStateSnapshot};
+use std::path::PathBuf;
+use std::sync::Arc;
+use tauri::{
+ image::Image,
+ tray::{TrayIcon, TrayIconBuilder},
+ AppHandle, Manager, Runtime,
+};
+use tokio::sync::RwLock;
+use tracing::{info, warn};
+
+/// 托盘初始化错误
+#[derive(Debug, thiserror::Error)]
+pub enum TrayError {
+ #[error("无法加载托盘图标: {0}")]
+ IconLoadError(String),
+
+ #[error("无法创建托盘菜单: {0}")]
+ MenuBuildError(String),
+
+ #[error("无法创建托盘图标: {0}")]
+ TrayBuildError(String),
+
+ #[error("状态更新失败: {0}")]
+ StateUpdateError(String),
+
+ #[error("Tauri 错误: {0}")]
+ TauriError(String),
+}
+
+impl From for TrayError {
+ fn from(e: tauri::Error) -> Self {
+ TrayError::TauriError(e.to_string())
+ }
+}
+
+/// 托盘图标资源
+///
+/// 存储不同状态对应的图标
+pub struct TrayIcons {
+ /// 正常运行状态图标(绿色)
+ pub running: Option>,
+ /// 警告状态图标(黄色)
+ pub warning: Option>,
+ /// 错误状态图标(红色)
+ pub error: Option>,
+ /// 停止状态图标(灰色)
+ pub stopped: Option>,
+ /// 默认图标(后备)
+ pub default: Image<'static>,
+}
+
+impl TrayIcons {
+ /// 加载托盘图标资源
+ ///
+ /// 尝试从 `src-tauri/icons/tray/` 目录加载状态图标,
+ /// 如果加载失败则使用默认应用图标作为后备
+ pub fn load(app: &AppHandle) -> Self {
+ let resource_path = app
+ .path()
+ .resource_dir()
+ .unwrap_or_else(|_| PathBuf::from("."));
+
+ // 加载默认图标(必须成功)
+ let default = Self::load_default_icon(app);
+
+ // 尝试加载各状态图标
+ let tray_icons_dir = resource_path.join("icons").join("tray");
+ info!("托盘图标目录: {:?}", tray_icons_dir);
+
+ let running = Self::load_png_file(&tray_icons_dir.join("tray-running.png"));
+ let warning = Self::load_png_file(&tray_icons_dir.join("tray-warning.png"));
+ let error = Self::load_png_file(&tray_icons_dir.join("tray-error.png"));
+ let stopped = Self::load_png_file(&tray_icons_dir.join("tray-stopped.png"));
+
+ if running.is_none() || warning.is_none() || error.is_none() || stopped.is_none() {
+ warn!("部分托盘图标加载失败,将使用默认图标作为后备");
+ // 尝试从内嵌资源加载
+ return Self::load_embedded();
+ }
+
+ Self {
+ running,
+ warning,
+ error,
+ stopped,
+ default,
+ }
+ }
+
+ /// 从内嵌资源加载图标
+ fn load_embedded() -> Self {
+ info!("从内嵌资源加载托盘图标");
+
+ let default = Image::from_bytes(include_bytes!("../../icons/32x32.png"))
+ .expect("内嵌默认图标加载失败");
+
+ // 尝试加载内嵌的托盘图标
+ let running = Image::from_bytes(include_bytes!("../../icons/tray/tray-running.png")).ok();
+ let warning = Image::from_bytes(include_bytes!("../../icons/tray/tray-warning.png")).ok();
+ let error = Image::from_bytes(include_bytes!("../../icons/tray/tray-error.png")).ok();
+ let stopped = Image::from_bytes(include_bytes!("../../icons/tray/tray-stopped.png")).ok();
+
+ Self {
+ running,
+ warning,
+ error,
+ stopped,
+ default,
+ }
+ }
+
+ /// 加载默认应用图标
+ fn load_default_icon(app: &AppHandle) -> Image<'static> {
+ // 尝试从资源目录加载
+ let resource_path = app
+ .path()
+ .resource_dir()
+ .unwrap_or_else(|_| PathBuf::from("."));
+
+ let icon_path = resource_path.join("icons").join("32x32.png");
+
+ if let Some(icon) = Self::load_png_file(&icon_path) {
+ return icon;
+ }
+
+ // 使用内嵌的默认图标(PNG 格式)
+ info!("使用内嵌默认图标");
+ Image::from_bytes(include_bytes!("../../icons/32x32.png")).expect("内嵌默认图标加载失败")
+ }
+
+ /// 从 PNG 文件加载图标
+ fn load_png_file(path: &PathBuf) -> Option> {
+ match Image::from_path(path) {
+ Ok(image) => {
+ info!("成功加载图标: {:?}", path);
+ Some(image)
+ }
+ Err(e) => {
+ // 文件不存在是正常情况(图标尚未创建)
+ warn!("无法加载图标文件 {:?}: {}", path, e);
+ None
+ }
+ }
+ }
+
+ /// 根据状态获取对应的图标
+ pub fn get_icon_for_status(&self, status: TrayIconStatus) -> &Image<'static> {
+ match status {
+ TrayIconStatus::Running => self.running.as_ref().unwrap_or(&self.default),
+ TrayIconStatus::Warning => self.warning.as_ref().unwrap_or(&self.default),
+ TrayIconStatus::Error => self.error.as_ref().unwrap_or(&self.default),
+ TrayIconStatus::Stopped => self.stopped.as_ref().unwrap_or(&self.default),
+ }
+ }
+}
+
+/// 托盘管理器
+///
+/// 管理系统托盘图标和菜单的生命周期
+///
+/// # Requirements
+/// - 1.1, 1.4: 托盘图标状态管理
+/// - 7.1, 7.2, 7.3: 状态同步和更新
+pub struct TrayManager {
+ /// Tauri 托盘图标句柄
+ tray: TrayIcon,
+ /// 当前状态
+ state: Arc>,
+ /// 图标资源
+ icons: TrayIcons,
+ /// AppHandle 引用
+ app: AppHandle,
+}
+
+impl TrayManager {
+ /// 创建托盘管理器
+ ///
+ /// 初始化托盘图标,设置初始状态为 Stopped
+ ///
+ /// # Requirements
+ /// - 1.4: 应用启动时显示停止状态图标
+ pub fn new(app: &AppHandle) -> Result {
+ info!("初始化托盘管理器...");
+
+ // 加载图标资源
+ let icons = TrayIcons::load(app);
+
+ // 创建初始状态
+ let initial_state = TrayStateSnapshot::default();
+
+ // 构建初始菜单
+ let menu = build_tray_menu(app, &initial_state)
+ .map_err(|e| TrayError::MenuBuildError(e.to_string()))?;
+
+ // 获取初始图标
+ let initial_icon = icons.get_icon_for_status(TrayIconStatus::Stopped);
+
+ // 创建托盘图标
+ // Requirements 6.1, 6.2: 注册托盘图标点击事件处理器
+ // Requirements 3.1-3.4, 4.1-4.4, 5.1-5.2: 注册菜单事件处理器
+ let tray = TrayIconBuilder::with_id("main-tray")
+ .icon(initial_icon.clone())
+ .menu(&menu)
+ .show_menu_on_left_click(false)
+ .tooltip("ProxyCast - AI API 代理")
+ .on_tray_icon_event(|tray, event| {
+ let app = tray.app_handle();
+ handle_tray_icon_event(&app, event);
+ })
+ .on_menu_event(|app, event| {
+ handle_menu_event(app, event.id().as_ref());
+ })
+ .build(app)
+ .map_err(|e| TrayError::TrayBuildError(e.to_string()))?;
+
+ info!("托盘管理器初始化完成");
+
+ Ok(Self {
+ tray,
+ state: Arc::new(RwLock::new(initial_state)),
+ icons,
+ app: app.clone(),
+ })
+ }
+
+ /// 获取当前状态快照
+ pub async fn get_state(&self) -> TrayStateSnapshot {
+ self.state.read().await.clone()
+ }
+
+ /// 获取当前图标状态
+ pub async fn get_icon_status(&self) -> TrayIconStatus {
+ self.state.read().await.icon_status
+ }
+
+ /// 更新托盘状态
+ ///
+ /// 更新内部状态并刷新图标和菜单
+ ///
+ /// # Requirements
+ /// - 7.1: API 服务器状态变化时更新托盘图标
+ /// - 7.2: 凭证健康状态变化时更新托盘图标
+ pub async fn update_state(&self, snapshot: TrayStateSnapshot) -> Result<(), TrayError> {
+ let old_status = {
+ let state = self.state.read().await;
+ state.icon_status
+ };
+
+ // 更新内部状态
+ {
+ let mut state = self.state.write().await;
+ *state = snapshot.clone();
+ }
+
+ // 如果图标状态变化,更新图标
+ if old_status != snapshot.icon_status {
+ self.set_icon(snapshot.icon_status)?;
+ info!(
+ "托盘图标状态更新: {:?} -> {:?}",
+ old_status, snapshot.icon_status
+ );
+ }
+
+ // 刷新菜单
+ self.refresh_menu().await?;
+
+ Ok(())
+ }
+
+ /// 刷新菜单内容
+ ///
+ /// 根据当前状态重新构建菜单
+ ///
+ /// # Requirements
+ /// - 7.3: 托盘菜单打开时获取并显示最新信息
+ pub async fn refresh_menu(&self) -> Result<(), TrayError> {
+ let state = self.state.read().await;
+
+ let menu = build_tray_menu(&self.app, &state)
+ .map_err(|e| TrayError::MenuBuildError(e.to_string()))?;
+
+ self.tray
+ .set_menu(Some(menu))
+ .map_err(|e| TrayError::StateUpdateError(e.to_string()))?;
+
+ Ok(())
+ }
+
+ /// 设置托盘图标
+ ///
+ /// 根据状态切换图标
+ ///
+ /// # Requirements
+ /// - 1.1: 正常状态显示绿色图标
+ /// - 1.2: 警告状态显示黄色图标
+ /// - 1.3: 错误状态显示红色图标
+ /// - 1.4: 停止状态显示灰色图标
+ pub fn set_icon(&self, status: TrayIconStatus) -> Result<(), TrayError> {
+ let icon = self.icons.get_icon_for_status(status);
+
+ self.tray
+ .set_icon(Some(icon.clone()))
+ .map_err(|e| TrayError::StateUpdateError(e.to_string()))?;
+
+ Ok(())
+ }
+
+ /// 设置托盘提示文本
+ pub fn set_tooltip(&self, tooltip: &str) -> Result<(), TrayError> {
+ self.tray
+ .set_tooltip(Some(tooltip))
+ .map_err(|e| TrayError::StateUpdateError(e.to_string()))?;
+ Ok(())
+ }
+
+ /// 获取托盘图标 ID
+ pub fn id(&self) -> &str {
+ "main-tray"
+ }
+
+ /// 获取 Tauri TrayIcon 引用
+ pub fn tray_icon(&self) -> &TrayIcon {
+ &self.tray
+ }
+}
+
+/// 简化版托盘管理器(用于测试和无 Tauri 环境)
+pub struct SimpleTrayManager {
+ /// 当前状态
+ state: Arc>,
+}
+
+impl SimpleTrayManager {
+ /// 创建简化版托盘管理器
+ pub fn new() -> Self {
+ Self {
+ state: Arc::new(RwLock::new(TrayStateSnapshot::default())),
+ }
+ }
+
+ /// 获取当前状态快照
+ pub async fn get_state(&self) -> TrayStateSnapshot {
+ self.state.read().await.clone()
+ }
+
+ /// 更新托盘状态
+ pub async fn update_state(&self, snapshot: TrayStateSnapshot) {
+ let mut state = self.state.write().await;
+ *state = snapshot;
+ }
+
+ /// 获取当前图标状态
+ pub async fn get_icon_status(&self) -> TrayIconStatus {
+ self.state.read().await.icon_status
+ }
+}
+
+impl Default for SimpleTrayManager {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn test_simple_tray_manager_new() {
+ let manager = SimpleTrayManager::new();
+ let state = manager.get_state().await;
+ assert_eq!(state.icon_status, TrayIconStatus::Stopped);
+ assert!(!state.server_running);
+ }
+
+ #[tokio::test]
+ async fn test_simple_tray_manager_update_state() {
+ let manager = SimpleTrayManager::new();
+
+ let new_state = TrayStateSnapshot {
+ icon_status: TrayIconStatus::Running,
+ server_running: true,
+ server_address: "127.0.0.1:8080".to_string(),
+ available_credentials: 3,
+ total_credentials: 5,
+ today_requests: 100,
+ auto_start_enabled: true,
+ };
+
+ manager.update_state(new_state.clone()).await;
+
+ let state = manager.get_state().await;
+ assert_eq!(state.icon_status, TrayIconStatus::Running);
+ assert!(state.server_running);
+ assert_eq!(state.server_address, "127.0.0.1:8080");
+ assert_eq!(state.available_credentials, 3);
+ assert_eq!(state.total_credentials, 5);
+ assert_eq!(state.today_requests, 100);
+ assert!(state.auto_start_enabled);
+ }
+
+ #[tokio::test]
+ async fn test_simple_tray_manager_get_icon_status() {
+ let manager = SimpleTrayManager::new();
+
+ // 初始状态应该是 Stopped
+ assert_eq!(manager.get_icon_status().await, TrayIconStatus::Stopped);
+
+ // 更新状态后应该反映新状态
+ let new_state = TrayStateSnapshot {
+ icon_status: TrayIconStatus::Warning,
+ ..Default::default()
+ };
+ manager.update_state(new_state).await;
+ assert_eq!(manager.get_icon_status().await, TrayIconStatus::Warning);
+ }
+
+ #[test]
+ fn test_tray_error_display() {
+ let err = TrayError::IconLoadError("test error".to_string());
+ assert!(err.to_string().contains("无法加载托盘图标"));
+
+ let err = TrayError::MenuBuildError("menu error".to_string());
+ assert!(err.to_string().contains("无法创建托盘菜单"));
+
+ let err = TrayError::TrayBuildError("tray error".to_string());
+ assert!(err.to_string().contains("无法创建托盘图标"));
+
+ let err = TrayError::StateUpdateError("state error".to_string());
+ assert!(err.to_string().contains("状态更新失败"));
+ }
+}
diff --git a/src-tauri/src/tray/menu.rs b/src-tauri/src/tray/menu.rs
new file mode 100644
index 000000000..d4d4827c7
--- /dev/null
+++ b/src-tauri/src/tray/menu.rs
@@ -0,0 +1,410 @@
+//! 托盘菜单模块
+//!
+//! 定义菜单项 ID 和菜单构建函数
+
+use super::format::{format_credential_status, format_request_count, format_server_status};
+use super::state::TrayStateSnapshot;
+use tauri::{
+ menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem},
+ AppHandle, Runtime,
+};
+
+/// 菜单项 ID 常量
+pub mod menu_ids {
+ /// 状态信息
+ pub const STATUS_INFO: &str = "status_info";
+ /// 凭证信息
+ pub const CREDENTIAL_INFO: &str = "credential_info";
+ /// 请求信息
+ pub const REQUEST_INFO: &str = "request_info";
+ /// 分隔符 1
+ pub const SEPARATOR_1: &str = "sep_1";
+ /// 启动服务器
+ pub const START_SERVER: &str = "start_server";
+ /// 停止服务器
+ pub const STOP_SERVER: &str = "stop_server";
+ /// 刷新所有 Token
+ pub const REFRESH_TOKENS: &str = "refresh_tokens";
+ /// 健康检查
+ pub const HEALTH_CHECK: &str = "health_check";
+ /// 分隔符 2
+ pub const SEPARATOR_2: &str = "sep_2";
+ /// 打开主窗口
+ pub const OPEN_WINDOW: &str = "open_window";
+ /// 复制 API 地址
+ pub const COPY_API_ADDRESS: &str = "copy_api_address";
+ /// 打开日志目录
+ pub const OPEN_LOG_DIR: &str = "open_log_dir";
+ /// 分隔符 3
+ pub const SEPARATOR_3: &str = "sep_3";
+ /// 开机自启
+ pub const AUTO_START: &str = "auto_start";
+ /// 分隔符 4
+ pub const SEPARATOR_4: &str = "sep_4";
+ /// 退出
+ pub const QUIT: &str = "quit";
+
+ /// 获取所有必需的菜单项 ID 列表
+ pub fn all_required_ids() -> Vec<&'static str> {
+ vec![
+ STATUS_INFO,
+ CREDENTIAL_INFO,
+ REQUEST_INFO,
+ START_SERVER,
+ STOP_SERVER,
+ REFRESH_TOKENS,
+ HEALTH_CHECK,
+ OPEN_WINDOW,
+ COPY_API_ADDRESS,
+ OPEN_LOG_DIR,
+ AUTO_START,
+ QUIT,
+ ]
+ }
+}
+
+/// 托盘菜单构建错误
+#[derive(Debug, thiserror::Error)]
+pub enum MenuBuildError {
+ #[error("无法创建菜单项: {0}")]
+ MenuItemError(String),
+ #[error("无法创建菜单: {0}")]
+ MenuError(String),
+}
+
+/// 构建托盘菜单
+///
+/// 根据当前状态快照构建完整的托盘菜单,包含:
+/// - 状态信息(服务器状态、凭证状态、请求统计)
+/// - 服务器控制(启动/停止、刷新 Token、健康检查)
+/// - 快捷工具(打开主窗口、复制 API 地址、打开日志目录)
+/// - 设置(开机自启)
+/// - 退出
+///
+/// # Requirements
+/// - 2.1: 右键点击托盘图标显示包含所有可用操作的托盘菜单
+/// - 2.2: 显示当前服务器状态,包括运行状态和端口号
+/// - 2.3: 显示凭证池状态,包括可用凭证数和总凭证数
+/// - 2.4: 显示今日请求次数
+/// - 3.1, 3.2, 3.3, 3.4: 服务器控制菜单项
+/// - 4.1, 4.2, 4.3, 4.4: 快捷工具菜单项
+/// - 5.1, 5.2: 开机自启设置
+pub fn build_tray_menu(
+ app: &AppHandle,
+ state: &TrayStateSnapshot,
+) -> Result