mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 21:54:58 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e670763f65 | |||
| f079ade731 | |||
| 0398d288ae | |||
| f5f7ab7ce2 | |||
| 2c18e125dc | |||
| 74a257b565 | |||
| d894162bb4 | |||
| fec93aa6d5 | |||
| e92a167a78 | |||
| bf570b6d2a | |||
| 26c3f05daf | |||
| f6e8d656ce | |||
| d303706d51 | |||
| 97495d1550 | |||
| cabe1944ad | |||
| 64047fb3dd | |||
| 79adfc7fe8 | |||
| c9b851c5e6 | |||
| deea8455a9 | |||
| 59096d9c15 | |||
| c231c840a7 | |||
| a1537807eb | |||
| f047960a33 | |||
| ace9b38cee | |||
| 95c323c50b | |||
| 44f2722329 | |||
| f05904821c | |||
| b67cdbb18c | |||
| c4f1e90893 | |||
| ddbff070d5 | |||
| ca7cb398c2 | |||
| 2fb3b5405a | |||
| ec649e7718 | |||
| 3da5525c79 |
Generated
+56
-4
@@ -586,6 +586,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.10.1"
|
||||
@@ -2214,7 +2220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2330,6 +2336,19 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -2759,6 +2778,16 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.17.1"
|
||||
@@ -2774,7 +2803,7 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.1",
|
||||
"once_cell",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"serde",
|
||||
"thiserror 2.0.17",
|
||||
"windows-sys 0.60.2",
|
||||
@@ -3563,6 +3592,19 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
|
||||
dependencies = [
|
||||
"bitflags 2.9.4",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
@@ -3694,6 +3736,15 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
@@ -4987,6 +5038,7 @@ dependencies = [
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"http-range",
|
||||
"image",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -5055,7 +5107,7 @@ dependencies = [
|
||||
"ico",
|
||||
"json-patch",
|
||||
"plist",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
@@ -5810,7 +5862,7 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation 0.3.1",
|
||||
"once_cell",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"serde",
|
||||
"thiserror 2.0.17",
|
||||
"windows-sys 0.59.0",
|
||||
|
||||
@@ -26,7 +26,7 @@ serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] }
|
||||
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset", "image-png"] }
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-process = "2"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
@@ -105,6 +105,55 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
|
||||
Ok(Some(content))
|
||||
}
|
||||
|
||||
/// 在 ~/.claude.json 根对象写入 hasCompletedOnboarding=true(用于跳过 Claude Code 初次安装确认)
|
||||
/// 仅增量写入该字段,其他字段保持不变
|
||||
pub fn set_has_completed_onboarding() -> Result<bool, AppError> {
|
||||
let path = user_config_path();
|
||||
let mut root = if path.exists() {
|
||||
read_json_value(&path)?
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
|
||||
let obj = root
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
|
||||
|
||||
let already = obj
|
||||
.get("hasCompletedOnboarding")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if already {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
obj.insert("hasCompletedOnboarding".into(), Value::Bool(true));
|
||||
write_json_value(&path, &root)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 删除 ~/.claude.json 根对象的 hasCompletedOnboarding 字段(恢复 Claude Code 初次安装确认)
|
||||
/// 仅增量删除该字段,其他字段保持不变
|
||||
pub fn clear_has_completed_onboarding() -> Result<bool, AppError> {
|
||||
let path = user_config_path();
|
||||
if !path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut root = read_json_value(&path)?;
|
||||
let obj = root
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
|
||||
|
||||
let existed = obj.remove("hasCompletedOnboarding").is_some();
|
||||
if !existed {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
write_json_value(&path, &root)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 故障转移队列命令
|
||||
//!
|
||||
//! 管理代理模式下的故障转移队列
|
||||
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
|
||||
|
||||
use crate::database::FailoverQueueItem;
|
||||
use crate::provider::Provider;
|
||||
@@ -56,29 +56,35 @@ pub async fn remove_from_failover_queue(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 重新排序故障转移队列
|
||||
/// 获取指定应用的自动故障转移开关状态
|
||||
#[tauri::command]
|
||||
pub async fn reorder_failover_queue(
|
||||
pub async fn get_auto_failover_enabled(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
provider_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<bool, String> {
|
||||
let key = format!("auto_failover_enabled_{app_type}");
|
||||
state
|
||||
.db
|
||||
.reorder_failover_queue(&app_type, &provider_ids)
|
||||
.get_setting(&key)
|
||||
.map(|v| v.map(|s| s == "true").unwrap_or(false)) // 默认关闭
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置故障转移队列项的启用状态
|
||||
/// 设置指定应用的自动故障转移开关状态
|
||||
///
|
||||
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
|
||||
#[tauri::command]
|
||||
pub async fn set_failover_item_enabled(
|
||||
pub async fn set_auto_failover_enabled(
|
||||
state: tauri::State<'_, AppState>,
|
||||
app_type: String,
|
||||
provider_id: String,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
state
|
||||
.db
|
||||
.set_failover_item_enabled(&app_type, &provider_id, enabled)
|
||||
.map_err(|e| e.to_string())
|
||||
let key = format!("auto_failover_enabled_{app_type}");
|
||||
let value = if enabled { "true" } else { "false" };
|
||||
|
||||
log::info!(
|
||||
"[Failover] Setting auto_failover_enabled: key='{key}', value='{value}', app_type='{app_type}'"
|
||||
);
|
||||
|
||||
state.db.set_setting(&key, value).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ use crate::init_status::InitErrorPayload;
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
/// 打开外部链接
|
||||
#[tauri::command]
|
||||
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
|
||||
@@ -142,11 +148,16 @@ fn extract_version(raw: &str) -> String {
|
||||
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
use std::process::Command;
|
||||
|
||||
let output = if cfg!(target_os = "windows") {
|
||||
#[cfg(target_os = "windows")]
|
||||
let output = {
|
||||
Command::new("cmd")
|
||||
.args(["/C", &format!("{tool} --version")])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
} else {
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let output = {
|
||||
Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(format!("{tool} --version"))
|
||||
@@ -239,10 +250,22 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||
let new_path = format!("{}:{}", path.display(), current_path);
|
||||
|
||||
let output = Command::new(&tool_path)
|
||||
.arg("--version")
|
||||
.env("PATH", &new_path)
|
||||
.output();
|
||||
#[cfg(target_os = "windows")]
|
||||
let output = {
|
||||
Command::new(&tool_path)
|
||||
.arg("--version")
|
||||
.env("PATH", &new_path)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let output = {
|
||||
Command::new(&tool_path)
|
||||
.arg("--version")
|
||||
.env("PATH", &new_path)
|
||||
.output()
|
||||
};
|
||||
|
||||
if let Ok(out) = output {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
|
||||
@@ -34,3 +34,15 @@ pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String>
|
||||
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
|
||||
crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Claude Code:跳过初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding=true)
|
||||
#[tauri::command]
|
||||
pub async fn apply_claude_onboarding_skip() -> Result<bool, String> {
|
||||
crate::claude_mcp::set_has_completed_onboarding().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Claude Code:恢复初次安装确认(删除 ~/.claude.json 的 hasCompletedOnboarding 字段)
|
||||
#[tauri::command]
|
||||
pub async fn clear_claude_onboarding_skip() -> Result<bool, String> {
|
||||
crate::claude_mcp::clear_has_completed_onboarding().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -14,14 +14,6 @@ pub async fn start_proxy_server(
|
||||
state.proxy_service.start().await
|
||||
}
|
||||
|
||||
/// 启动代理服务器(带 Live 配置接管)
|
||||
#[tauri::command]
|
||||
pub async fn start_proxy_with_takeover(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<ProxyServerInfo, String> {
|
||||
state.proxy_service.start_with_takeover().await
|
||||
}
|
||||
|
||||
/// 停止代理服务器(恢复 Live 配置)
|
||||
#[tauri::command]
|
||||
pub async fn stop_proxy_with_restore(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||
@@ -111,8 +103,13 @@ pub async fn get_provider_health(
|
||||
}
|
||||
|
||||
/// 重置熔断器
|
||||
///
|
||||
/// 重置后会检查是否应该切回队列中优先级更高的供应商:
|
||||
/// 1. 检查自动故障转移是否开启
|
||||
/// 2. 如果恢复的供应商在队列中优先级更高(queue_order 更小),则自动切换
|
||||
#[tauri::command]
|
||||
pub async fn reset_circuit_breaker(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: tauri::State<'_, AppState>,
|
||||
provider_id: String,
|
||||
app_type: String,
|
||||
@@ -129,6 +126,75 @@ pub async fn reset_circuit_breaker(
|
||||
.reset_provider_circuit_breaker(&provider_id, &app_type)
|
||||
.await?;
|
||||
|
||||
// 3. 检查是否应该切回优先级更高的供应商
|
||||
let failover_key = format!("auto_failover_enabled_{app_type}");
|
||||
let auto_failover_enabled = match db.get_setting(&failover_key) {
|
||||
Ok(Some(value)) => value == "true",
|
||||
Ok(None) => {
|
||||
log::debug!(
|
||||
"[{app_type}] Failover setting '{failover_key}' not found, defaulting to disabled"
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[{app_type}] Failed to read failover setting '{failover_key}': {e}, defaulting to disabled"
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if auto_failover_enabled && state.proxy_service.is_running().await {
|
||||
// 获取当前供应商 ID
|
||||
let current_id = db
|
||||
.get_current_provider(&app_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(current_id) = current_id {
|
||||
// 获取故障转移队列
|
||||
let queue = db
|
||||
.get_failover_queue(&app_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 找到恢复的供应商和当前供应商在队列中的位置(使用 sort_index)
|
||||
let restored_order = queue
|
||||
.iter()
|
||||
.find(|item| item.provider_id == provider_id)
|
||||
.and_then(|item| item.sort_index);
|
||||
|
||||
let current_order = queue
|
||||
.iter()
|
||||
.find(|item| item.provider_id == current_id)
|
||||
.and_then(|item| item.sort_index);
|
||||
|
||||
// 如果恢复的供应商优先级更高(sort_index 更小),则切换
|
||||
if let (Some(restored), Some(current)) = (restored_order, current_order) {
|
||||
if restored < current {
|
||||
log::info!(
|
||||
"[Recovery] 供应商 {provider_id} 已恢复且优先级更高 (P{restored} vs P{current}),自动切换"
|
||||
);
|
||||
|
||||
// 获取供应商名称用于日志和事件
|
||||
let provider_name = db
|
||||
.get_all_providers(&app_type)
|
||||
.ok()
|
||||
.and_then(|providers| providers.get(&provider_id).map(|p| p.name.clone()))
|
||||
.unwrap_or_else(|| provider_id.clone());
|
||||
|
||||
// 创建故障转移切换管理器并执行切换
|
||||
let switch_manager =
|
||||
crate::proxy::failover_switch::FailoverSwitchManager::new(db.clone());
|
||||
if let Err(e) = switch_manager
|
||||
.try_switch(Some(&app_handle), &app_type, &provider_id, &provider_name)
|
||||
.await
|
||||
{
|
||||
log::error!("[Recovery] 自动切换失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -52,9 +52,7 @@ pub async fn stream_check_all_providers(
|
||||
}
|
||||
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
|
||||
for item in queue {
|
||||
if item.enabled {
|
||||
ids.insert(item.provider_id);
|
||||
}
|
||||
ids.insert(item.provider_id);
|
||||
}
|
||||
}
|
||||
Some(ids)
|
||||
|
||||
@@ -1,36 +1,32 @@
|
||||
//! 故障转移队列 DAO
|
||||
//!
|
||||
//! 管理代理模式下的故障转移队列
|
||||
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// 故障转移队列条目
|
||||
/// 故障转移队列条目(简化版,用于前端展示)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FailoverQueueItem {
|
||||
pub provider_id: String,
|
||||
pub provider_name: String,
|
||||
pub queue_order: i32,
|
||||
pub enabled: bool,
|
||||
pub created_at: i64,
|
||||
pub sort_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 获取故障转移队列(按 queue_order 排序)
|
||||
/// 获取故障转移队列(按 sort_index 排序)
|
||||
pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT fq.provider_id, p.name, fq.queue_order, fq.enabled, fq.created_at
|
||||
FROM failover_queue fq
|
||||
JOIN providers p ON fq.provider_id = p.id AND fq.app_type = p.app_type
|
||||
WHERE fq.app_type = ?1
|
||||
ORDER BY fq.queue_order ASC",
|
||||
"SELECT id, name, sort_index
|
||||
FROM providers
|
||||
WHERE app_type = ?1 AND in_failover_queue = 1
|
||||
ORDER BY COALESCE(sort_index, 999999), id ASC",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
@@ -39,9 +35,7 @@ impl Database {
|
||||
Ok(FailoverQueueItem {
|
||||
provider_id: row.get(0)?,
|
||||
provider_name: row.get(1)?,
|
||||
queue_order: row.get(2)?,
|
||||
enabled: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
sort_index: row.get(2)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?
|
||||
@@ -53,43 +47,23 @@ impl Database {
|
||||
|
||||
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
|
||||
pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
||||
let queue = self.get_failover_queue(app_type)?;
|
||||
let all_providers = self.get_all_providers(app_type)?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for item in queue {
|
||||
if item.enabled {
|
||||
if let Some(provider) = all_providers.get(&item.provider_id) {
|
||||
result.push(provider.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let result: Vec<Provider> = all_providers
|
||||
.into_values()
|
||||
.filter(|p| p.in_failover_queue)
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 添加供应商到故障转移队列末尾
|
||||
/// 添加供应商到故障转移队列
|
||||
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// 获取当前最大 queue_order
|
||||
let max_order: i32 = conn
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(queue_order), 0) FROM failover_queue WHERE app_type = ?1",
|
||||
[app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO failover_queue (app_type, provider_id, queue_order, enabled, created_at)
|
||||
VALUES (?1, ?2, ?3, 1, ?4)",
|
||||
rusqlite::params![app_type, provider_id, max_order + 1, now],
|
||||
"UPDATE providers SET in_failover_queue = 1 WHERE id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
@@ -104,90 +78,22 @@ impl Database {
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// 获取被删除项的 queue_order
|
||||
let removed_order: Option<i32> = conn
|
||||
.query_row(
|
||||
"SELECT queue_order FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
||||
[app_type, provider_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
// 删除该项
|
||||
// 1. 从队列中移除
|
||||
conn.execute(
|
||||
"DELETE FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
||||
[app_type, provider_id],
|
||||
"UPDATE providers SET in_failover_queue = 0 WHERE id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 重新排序后面的项(填补空隙)
|
||||
if let Some(order) = removed_order {
|
||||
conn.execute(
|
||||
"UPDATE failover_queue
|
||||
SET queue_order = queue_order - 1
|
||||
WHERE app_type = ?1 AND queue_order > ?2",
|
||||
rusqlite::params![app_type, order],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 重新排序故障转移队列
|
||||
/// provider_ids: 按新顺序排列的 provider_id 列表
|
||||
pub fn reorder_failover_queue(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider_ids: &[String],
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// 使用事务确保原子性
|
||||
conn.execute("BEGIN TRANSACTION", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let result = (|| {
|
||||
for (index, provider_id) in provider_ids.iter().enumerate() {
|
||||
conn.execute(
|
||||
"UPDATE failover_queue
|
||||
SET queue_order = ?3
|
||||
WHERE app_type = ?1 AND provider_id = ?2",
|
||||
rusqlite::params![app_type, provider_id, (index + 1) as i32],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
conn.execute("COMMIT", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
conn.execute("ROLLBACK", []).ok();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置故障转移队列中供应商的启用状态
|
||||
pub fn set_failover_item_enabled(
|
||||
&self,
|
||||
app_type: &str,
|
||||
provider_id: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// 2. 清除该供应商的健康状态(退出队列后不再需要健康监控)
|
||||
conn.execute(
|
||||
"UPDATE failover_queue SET enabled = ?3 WHERE app_type = ?1 AND provider_id = ?2",
|
||||
rusqlite::params![app_type, provider_id, enabled],
|
||||
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
log::info!("已从故障转移队列移除供应商 {provider_id} ({app_type}), 并清除其健康状态");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -195,8 +101,11 @@ impl Database {
|
||||
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.execute("DELETE FROM failover_queue WHERE app_type = ?1", [app_type])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"UPDATE providers SET in_failover_queue = 0 WHERE app_type = ?1",
|
||||
[app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -209,15 +118,15 @@ impl Database {
|
||||
) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
let count: i32 = conn
|
||||
let in_queue: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
||||
[app_type, provider_id],
|
||||
"SELECT in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(count > 0)
|
||||
Ok(in_queue)
|
||||
}
|
||||
|
||||
/// 获取可添加到故障转移队列的供应商(不在队列中的)
|
||||
@@ -226,14 +135,10 @@ impl Database {
|
||||
app_type: &str,
|
||||
) -> Result<Vec<Provider>, AppError> {
|
||||
let all_providers = self.get_all_providers(app_type)?;
|
||||
let queue = self.get_failover_queue(app_type)?;
|
||||
|
||||
let queue_ids: std::collections::HashSet<_> =
|
||||
queue.iter().map(|item| &item.provider_id).collect();
|
||||
|
||||
let available: Vec<Provider> = all_providers
|
||||
.into_values()
|
||||
.filter(|p| !queue_ids.contains(&p.id))
|
||||
.filter(|p| !p.in_failover_queue)
|
||||
.collect();
|
||||
|
||||
Ok(available)
|
||||
|
||||
@@ -17,7 +17,7 @@ impl Database {
|
||||
) -> Result<IndexMap<String, Provider>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
|
||||
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
|
||||
FROM providers WHERE app_type = ?1
|
||||
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
|
||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
@@ -35,6 +35,7 @@ impl Database {
|
||||
let icon: Option<String> = row.get(8)?;
|
||||
let icon_color: Option<String> = row.get(9)?;
|
||||
let meta_str: String = row.get(10)?;
|
||||
let in_failover_queue: bool = row.get(11)?;
|
||||
|
||||
let settings_config =
|
||||
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||||
@@ -54,6 +55,7 @@ impl Database {
|
||||
meta: Some(meta),
|
||||
icon,
|
||||
icon_color,
|
||||
in_failover_queue,
|
||||
},
|
||||
))
|
||||
})
|
||||
@@ -129,7 +131,7 @@ impl Database {
|
||||
) -> Result<Option<Provider>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let result = conn.query_row(
|
||||
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
|
||||
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
|
||||
FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
params![id, app_type],
|
||||
|row| {
|
||||
@@ -143,6 +145,7 @@ impl Database {
|
||||
let icon: Option<String> = row.get(7)?;
|
||||
let icon_color: Option<String> = row.get(8)?;
|
||||
let meta_str: String = row.get(9)?;
|
||||
let in_failover_queue: bool = row.get(10)?;
|
||||
|
||||
let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||||
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
|
||||
@@ -159,6 +162,7 @@ impl Database {
|
||||
meta: Some(meta),
|
||||
icon,
|
||||
icon_color,
|
||||
in_failover_queue,
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -184,17 +188,18 @@ impl Database {
|
||||
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
||||
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
||||
|
||||
// 检查是否存在(用于判断新增/更新,以及保留 is_current)
|
||||
let existing: Option<bool> = tx
|
||||
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue)
|
||||
let existing: Option<(bool, bool)> = tx
|
||||
.query_row(
|
||||
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||
params![provider.id, app_type],
|
||||
|row| row.get(0),
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.ok();
|
||||
|
||||
let is_update = existing.is_some();
|
||||
let is_current = existing.unwrap_or(false);
|
||||
let (is_current, in_failover_queue) =
|
||||
existing.unwrap_or((false, provider.in_failover_queue));
|
||||
|
||||
if is_update {
|
||||
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
||||
@@ -210,8 +215,9 @@ impl Database {
|
||||
icon = ?8,
|
||||
icon_color = ?9,
|
||||
meta = ?10,
|
||||
is_current = ?11
|
||||
WHERE id = ?12 AND app_type = ?13",
|
||||
is_current = ?11,
|
||||
in_failover_queue = ?12
|
||||
WHERE id = ?13 AND app_type = ?14",
|
||||
params![
|
||||
provider.name,
|
||||
serde_json::to_string(&provider.settings_config).unwrap(),
|
||||
@@ -224,6 +230,7 @@ impl Database {
|
||||
provider.icon_color,
|
||||
serde_json::to_string(&meta_clone).unwrap(),
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
provider.id,
|
||||
app_type,
|
||||
],
|
||||
@@ -234,8 +241,8 @@ impl Database {
|
||||
tx.execute(
|
||||
"INSERT INTO providers (
|
||||
id, app_type, name, settings_config, website_url, category,
|
||||
created_at, sort_index, notes, icon, icon_color, meta, is_current
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
created_at, sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
params![
|
||||
provider.id,
|
||||
app_type,
|
||||
@@ -250,6 +257,7 @@ impl Database {
|
||||
provider.icon_color,
|
||||
serde_json::to_string(&meta_clone).unwrap(),
|
||||
is_current,
|
||||
in_failover_queue,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
@@ -71,21 +71,27 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 设置 Live 接管状态
|
||||
/// 设置 Live 接管状态(仅更新 proxy_config 表,兼容旧逻辑)
|
||||
///
|
||||
/// 注意:此方法不会清除 settings 表中的 proxy_takeover_* 状态。
|
||||
/// settings 表的状态由 set_proxy_takeover_enabled 单独管理,用于跨重启保持状态。
|
||||
pub async fn set_live_takeover_active(&self, active: bool) -> Result<(), AppError> {
|
||||
// 仅更新 proxy_config 表(兼容旧版本)
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"UPDATE proxy_config SET live_takeover_active = ?1, updated_at = datetime('now') WHERE id = 1",
|
||||
rusqlite::params![if active { 1 } else { 0 }],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查是否处于 Live 接管模式
|
||||
///
|
||||
/// v3.8.0+:以 settings 表中的 `proxy_takeover_{app_type}` 为真实来源
|
||||
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
|
||||
// v3.7.0+:以 proxy_live_backup 是否存在作为“接管状态”的真实来源(更贴近 per-app 接管)
|
||||
self.has_any_live_backup().await
|
||||
self.has_any_proxy_takeover()
|
||||
}
|
||||
|
||||
// ==================== Provider Health ====================
|
||||
@@ -96,28 +102,45 @@ impl Database {
|
||||
provider_id: &str,
|
||||
app_type: &str,
|
||||
) -> Result<ProviderHealth, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let result = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.query_row(
|
||||
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
|
||||
last_success_at, last_failure_at, last_error, updated_at
|
||||
FROM provider_health
|
||||
WHERE provider_id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
|row| {
|
||||
Ok(ProviderHealth {
|
||||
provider_id: row.get(0)?,
|
||||
app_type: row.get(1)?,
|
||||
is_healthy: row.get::<_, i64>(2)? != 0,
|
||||
consecutive_failures: row.get::<_, i64>(3)? as u32,
|
||||
last_success_at: row.get(4)?,
|
||||
last_failure_at: row.get(5)?,
|
||||
last_error: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))
|
||||
conn.query_row(
|
||||
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
|
||||
last_success_at, last_failure_at, last_error, updated_at
|
||||
FROM provider_health
|
||||
WHERE provider_id = ?1 AND app_type = ?2",
|
||||
rusqlite::params![provider_id, app_type],
|
||||
|row| {
|
||||
Ok(ProviderHealth {
|
||||
provider_id: row.get(0)?,
|
||||
app_type: row.get(1)?,
|
||||
is_healthy: row.get::<_, i64>(2)? != 0,
|
||||
consecutive_failures: row.get::<_, i64>(3)? as u32,
|
||||
last_success_at: row.get(4)?,
|
||||
last_failure_at: row.get(5)?,
|
||||
last_error: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(health) => Ok(health),
|
||||
// 缺少记录时视为健康(关闭后清空状态,再次打开时默认正常)
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ProviderHealth {
|
||||
provider_id: provider_id.to_string(),
|
||||
app_type: app_type.to_string(),
|
||||
is_healthy: true,
|
||||
consecutive_failures: 0,
|
||||
last_success_at: None,
|
||||
last_failure_at: None,
|
||||
last_error: None,
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
}),
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新Provider健康状态
|
||||
@@ -222,6 +245,20 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清空指定应用的健康状态(关闭单个代理时使用)
|
||||
pub async fn clear_provider_health_for_app(&self, app_type: &str) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
conn.execute(
|
||||
"DELETE FROM provider_health WHERE app_type = ?1",
|
||||
[app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
log::debug!("Cleared provider health records for app {app_type}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清空所有Provider健康状态(代理停止时调用)
|
||||
pub async fn clear_all_provider_health(&self) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
@@ -62,4 +62,56 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- 代理接管状态管理 ---
|
||||
|
||||
/// 获取指定应用的代理接管状态
|
||||
///
|
||||
/// 使用 settings 表存储各应用的接管状态,key 格式: `proxy_takeover_{app_type}`
|
||||
pub fn get_proxy_takeover_enabled(&self, app_type: &str) -> Result<bool, AppError> {
|
||||
let key = format!("proxy_takeover_{app_type}");
|
||||
match self.get_setting(&key)? {
|
||||
Some(value) => Ok(value == "true"),
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置指定应用的代理接管状态
|
||||
///
|
||||
/// - `true` = 开启代理接管
|
||||
/// - `false` = 关闭代理接管
|
||||
pub fn set_proxy_takeover_enabled(
|
||||
&self,
|
||||
app_type: &str,
|
||||
enabled: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let key = format!("proxy_takeover_{app_type}");
|
||||
let value = if enabled { "true" } else { "false" };
|
||||
self.set_setting(&key, value)
|
||||
}
|
||||
|
||||
/// 检查是否有任一应用开启了代理接管
|
||||
pub fn has_any_proxy_takeover(&self) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM settings WHERE key LIKE 'proxy_takeover_%' AND value = 'true'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// 清除所有代理接管状态(将所有 proxy_takeover_* 设置为 false)
|
||||
pub fn clear_all_proxy_takeover(&self) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"UPDATE settings SET value = 'false' WHERE key LIKE 'proxy_takeover_%'",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
log::info!("已清除所有代理接管状态");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ impl Database {
|
||||
icon_color TEXT,
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
is_current BOOLEAN NOT NULL DEFAULT 0,
|
||||
in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id, app_type)
|
||||
)",
|
||||
[],
|
||||
@@ -312,29 +313,24 @@ impl Database {
|
||||
[],
|
||||
);
|
||||
|
||||
// 14. Failover Queue 表 (故障转移队列)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS failover_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
app_type TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
queue_order INTEGER NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE (app_type, provider_id),
|
||||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
// 确保 in_failover_queue 列存在(对于已存在的 v2 数据库)
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"providers",
|
||||
"in_failover_queue",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
// 为故障转移队列创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_failover_queue_order
|
||||
ON failover_queue(app_type, queue_order)",
|
||||
// 删除旧的 failover_queue 表(如果存在)
|
||||
let _ = conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", []);
|
||||
let _ = conn.execute("DROP TABLE IF EXISTS failover_queue", []);
|
||||
|
||||
// 为故障转移队列创建索引(基于 providers 表)
|
||||
let _ = conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_providers_failover
|
||||
ON providers(app_type, in_failover_queue, sort_index)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -472,6 +468,26 @@ impl Database {
|
||||
Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
|
||||
Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
|
||||
Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
|
||||
Self::add_column_if_missing(
|
||||
conn,
|
||||
"providers",
|
||||
"in_failover_queue",
|
||||
"BOOLEAN NOT NULL DEFAULT 0",
|
||||
)?;
|
||||
|
||||
// 删除旧的 failover_queue 表(如果存在)
|
||||
conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", [])
|
||||
.map_err(|e| AppError::Database(format!("删除 failover_queue 索引失败: {e}")))?;
|
||||
conn.execute("DROP TABLE IF EXISTS failover_queue", [])
|
||||
.map_err(|e| AppError::Database(format!("删除 failover_queue 表失败: {e}")))?;
|
||||
|
||||
// 创建 failover 索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_providers_failover
|
||||
ON providers(app_type, in_failover_queue, sort_index)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 failover 索引失败: {e}")))?;
|
||||
|
||||
// proxy_request_logs 表(包含所有字段)
|
||||
conn.execute(
|
||||
|
||||
@@ -245,6 +245,7 @@ fn dry_run_validates_schema_compatibility() {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ pub(crate) fn build_provider_from_request(
|
||||
meta,
|
||||
icon: request.icon.clone(),
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
};
|
||||
|
||||
Ok(provider)
|
||||
|
||||
+97
-50
@@ -48,6 +48,8 @@ use tauri_plugin_deep_link::DeepLinkExt;
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||
|
||||
use std::sync::Arc;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::image::Image;
|
||||
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::RunEvent;
|
||||
use tauri::{Emitter, Manager};
|
||||
@@ -133,6 +135,19 @@ async fn update_tray_menu(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_tray_icon() -> Option<Image<'static>> {
|
||||
const ICON_BYTES: &[u8] = include_bytes!("../icons/tray/macos/statusbar_template_3x.png");
|
||||
|
||||
match Image::from_bytes(ICON_BYTES) {
|
||||
Ok(icon) => Some(icon),
|
||||
Err(err) => {
|
||||
log::warn!("Failed to load macOS tray icon: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let mut builder = tauri::Builder::default();
|
||||
@@ -208,44 +223,6 @@ pub fn run() {
|
||||
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// 设置 macOS 标题栏背景色为主界面蓝色
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::AnyObject;
|
||||
use objc2_app_kit::NSColor;
|
||||
|
||||
match window.ns_window() {
|
||||
Ok(ns_window_ptr) => {
|
||||
if let Some(ns_window) =
|
||||
unsafe { Retained::retain(ns_window_ptr as *mut AnyObject) }
|
||||
{
|
||||
// 使用与主界面 banner 相同的蓝色 #3498db
|
||||
// #3498db = RGB(52, 152, 219)
|
||||
let bg_color = unsafe {
|
||||
NSColor::colorWithRed_green_blue_alpha(
|
||||
52.0 / 255.0, // R: 52
|
||||
152.0 / 255.0, // G: 152
|
||||
219.0 / 255.0, // B: 219
|
||||
1.0, // Alpha: 1.0
|
||||
)
|
||||
};
|
||||
|
||||
unsafe {
|
||||
use objc2::msg_send;
|
||||
let _: () =
|
||||
msg_send![&*ns_window, setBackgroundColor: &*bg_color];
|
||||
}
|
||||
} else {
|
||||
log::warn!("Failed to retain NSWindow reference");
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("Failed to get NSWindow pointer: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化日志
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
@@ -503,11 +480,26 @@ pub fn run() {
|
||||
})
|
||||
.show_menu_on_left_click(true);
|
||||
|
||||
// 统一使用应用默认图标;待托盘模板图标就绪后再启用
|
||||
if let Some(icon) = app.default_window_icon() {
|
||||
tray_builder = tray_builder.icon(icon.clone());
|
||||
} else {
|
||||
log::warn!("Failed to get default window icon for tray");
|
||||
// 使用平台对应的托盘图标(macOS 使用模板图标适配深浅色)
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(icon) = macos_tray_icon() {
|
||||
tray_builder = tray_builder.icon(icon).icon_as_template(true);
|
||||
} else if let Some(icon) = app.default_window_icon() {
|
||||
log::warn!("Falling back to default window icon for tray");
|
||||
tray_builder = tray_builder.icon(icon.clone());
|
||||
} else {
|
||||
log::warn!("Failed to load macOS tray icon for tray");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
if let Some(icon) = app.default_window_icon() {
|
||||
tray_builder = tray_builder.icon(icon.clone());
|
||||
} else {
|
||||
log::warn!("Failed to get default window icon for tray");
|
||||
}
|
||||
}
|
||||
|
||||
let _tray = tray_builder.build(app)?;
|
||||
@@ -524,7 +516,7 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 异常退出恢复
|
||||
// 异常退出恢复 + 代理状态自动恢复
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app_handle.state::<AppState>();
|
||||
@@ -548,6 +540,9 @@ pub fn run() {
|
||||
log::info!("Live 配置已恢复");
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||||
restore_proxy_state_on_startup(&state).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
@@ -585,6 +580,8 @@ pub fn run() {
|
||||
commands::read_claude_plugin_config,
|
||||
commands::apply_claude_plugin_config,
|
||||
commands::is_claude_plugin_applied,
|
||||
commands::apply_claude_onboarding_skip,
|
||||
commands::clear_claude_onboarding_skip,
|
||||
// Claude MCP management
|
||||
commands::get_claude_mcp_status,
|
||||
commands::read_claude_mcp_config,
|
||||
@@ -653,7 +650,6 @@ pub fn run() {
|
||||
commands::get_auto_launch_status,
|
||||
// Proxy server management
|
||||
commands::start_proxy_server,
|
||||
commands::start_proxy_with_takeover,
|
||||
commands::stop_proxy_with_restore,
|
||||
commands::get_proxy_takeover_status,
|
||||
commands::set_proxy_takeover_for_app,
|
||||
@@ -674,8 +670,8 @@ pub fn run() {
|
||||
commands::get_available_providers_for_failover,
|
||||
commands::add_to_failover_queue,
|
||||
commands::remove_from_failover_queue,
|
||||
commands::reorder_failover_queue,
|
||||
commands::set_failover_item_enabled,
|
||||
commands::get_auto_failover_enabled,
|
||||
commands::set_auto_failover_enabled,
|
||||
// Usage statistics
|
||||
commands::get_usage_summary,
|
||||
commands::get_usage_trends,
|
||||
@@ -807,6 +803,7 @@ pub fn run() {
|
||||
///
|
||||
/// 在应用退出前检查代理服务器状态,如果正在运行则停止代理并恢复 Live 配置。
|
||||
/// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。
|
||||
/// 使用 stop_with_restore_keep_state 保留 settings 表中的代理状态,下次启动时自动恢复。
|
||||
pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
|
||||
if let Some(state) = app_handle.try_state::<store::AppState>() {
|
||||
let proxy_service = &state.proxy_service;
|
||||
@@ -823,11 +820,12 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
|
||||
let needs_restore = has_backups || live_taken_over;
|
||||
|
||||
if needs_restore {
|
||||
log::info!("检测到接管残留,开始恢复 Live 配置...");
|
||||
if let Err(e) = proxy_service.stop_with_restore().await {
|
||||
log::info!("检测到接管残留,开始恢复 Live 配置(保留代理状态)...");
|
||||
// 使用 keep_state 版本,保留 settings 表中的代理状态
|
||||
if let Err(e) = proxy_service.stop_with_restore_keep_state().await {
|
||||
log::error!("退出时恢复 Live 配置失败: {e}");
|
||||
} else {
|
||||
log::info!("已恢复 Live 配置");
|
||||
log::info!("已恢复 Live 配置(代理状态已保留,下次启动将自动恢复)");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -843,6 +841,55 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 启动时恢复代理状态
|
||||
// ============================================================
|
||||
|
||||
/// 启动时根据 settings 表中的代理状态自动恢复代理服务
|
||||
///
|
||||
/// 检查 `proxy_takeover_claude`、`proxy_takeover_codex`、`proxy_takeover_gemini` 的值,
|
||||
/// 如果有任一应用的状态为 `true`,则自动启动代理服务并接管对应应用的 Live 配置。
|
||||
async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||
// 收集需要恢复接管的应用列表
|
||||
let apps_to_restore: Vec<&str> = ["claude", "codex", "gemini"]
|
||||
.iter()
|
||||
.filter(|app_type| {
|
||||
state
|
||||
.db
|
||||
.get_proxy_takeover_enabled(app_type)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
if apps_to_restore.is_empty() {
|
||||
log::debug!("启动时无需恢复代理状态");
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("检测到上次代理状态需要恢复,应用列表: {apps_to_restore:?}");
|
||||
|
||||
// 逐个恢复接管状态
|
||||
for app_type in apps_to_restore {
|
||||
match state
|
||||
.proxy_service
|
||||
.set_takeover_for_app(app_type, true)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
log::info!("✓ 已恢复 {app_type} 的代理接管状态");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}");
|
||||
// 失败时清除该应用的状态,避免下次启动再次尝试
|
||||
if let Err(clear_err) = state.db.set_proxy_takeover_enabled(app_type, false) {
|
||||
log::error!("清除 {app_type} 代理状态失败: {clear_err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 迁移错误对话框辅助函数
|
||||
// ============================================================
|
||||
|
||||
@@ -36,6 +36,10 @@ pub struct Provider {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "iconColor")]
|
||||
pub icon_color: Option<String>,
|
||||
/// 是否加入故障转移队列
|
||||
#[serde(default)]
|
||||
#[serde(rename = "inFailoverQueue")]
|
||||
pub in_failover_queue: bool,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
@@ -58,6 +62,7 @@ impl Provider {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,16 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub struct ForwardResult {
|
||||
pub response: Response,
|
||||
pub provider: Provider,
|
||||
}
|
||||
|
||||
pub struct ForwardError {
|
||||
pub error: ProxyError,
|
||||
pub provider: Option<Provider>,
|
||||
}
|
||||
|
||||
pub struct RequestForwarder {
|
||||
client: Client,
|
||||
/// 共享的 ProviderRouter(持有熔断器状态)
|
||||
@@ -134,13 +144,16 @@ impl RequestForwarder {
|
||||
body: Value,
|
||||
headers: axum::http::HeaderMap,
|
||||
providers: Vec<Provider>,
|
||||
) -> Result<Response, ProxyError> {
|
||||
) -> Result<ForwardResult, ForwardError> {
|
||||
// 获取适配器
|
||||
let adapter = get_adapter(app_type);
|
||||
let app_type_str = app_type.as_str();
|
||||
|
||||
if providers.is_empty() {
|
||||
return Err(ProxyError::NoAvailableProvider);
|
||||
return Err(ForwardError {
|
||||
error: ProxyError::NoAvailableProvider,
|
||||
provider: None,
|
||||
});
|
||||
}
|
||||
|
||||
log::info!(
|
||||
@@ -150,6 +163,7 @@ impl RequestForwarder {
|
||||
);
|
||||
|
||||
let mut last_error = None;
|
||||
let mut last_provider = None;
|
||||
let mut attempted_providers = 0usize;
|
||||
|
||||
// 依次尝试每个供应商
|
||||
@@ -269,7 +283,10 @@ impl RequestForwarder {
|
||||
latency
|
||||
);
|
||||
|
||||
return Ok(response);
|
||||
return Ok(ForwardResult {
|
||||
response,
|
||||
provider: provider.clone(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let latency = start.elapsed().as_millis() as u64;
|
||||
@@ -310,6 +327,7 @@ impl RequestForwarder {
|
||||
);
|
||||
|
||||
last_error = Some(e);
|
||||
last_provider = Some(provider.clone());
|
||||
// 继续尝试下一个供应商
|
||||
continue;
|
||||
}
|
||||
@@ -331,7 +349,10 @@ impl RequestForwarder {
|
||||
provider.name,
|
||||
e
|
||||
);
|
||||
return Err(e);
|
||||
return Err(ForwardError {
|
||||
error: e,
|
||||
provider: Some(provider.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,7 +370,10 @@ impl RequestForwarder {
|
||||
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
|
||||
}
|
||||
}
|
||||
return Err(ProxyError::NoAvailableProvider);
|
||||
return Err(ForwardError {
|
||||
error: ProxyError::NoAvailableProvider,
|
||||
provider: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 所有供应商都失败了
|
||||
@@ -369,7 +393,10 @@ impl RequestForwarder {
|
||||
providers.len()
|
||||
);
|
||||
|
||||
Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded))
|
||||
Err(ForwardError {
|
||||
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
|
||||
provider: last_provider,
|
||||
})
|
||||
}
|
||||
|
||||
/// 转发单个请求(使用适配器)
|
||||
|
||||
@@ -61,27 +61,16 @@ pub async fn handle_messages(
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
|
||||
|
||||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||||
let adapter = get_adapter(&AppType::Claude);
|
||||
let needs_transform = adapter.needs_transform(&ctx.provider);
|
||||
let mut ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
.and_then(|s| s.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
log::info!(
|
||||
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
|
||||
ctx.provider.name,
|
||||
needs_transform,
|
||||
is_stream
|
||||
);
|
||||
|
||||
// 转发请求
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let response = match forwarder
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Claude,
|
||||
"/v1/messages",
|
||||
@@ -91,13 +80,30 @@ pub async fn handle_messages(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
log_forward_error(&state, &ctx, is_stream, &e);
|
||||
return Err(e);
|
||||
Ok(result) => result,
|
||||
Err(mut err) => {
|
||||
if let Some(provider) = err.provider.take() {
|
||||
ctx.provider = provider;
|
||||
}
|
||||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||
return Err(err.error);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||||
let adapter = get_adapter(&AppType::Claude);
|
||||
let needs_transform = adapter.needs_transform(&ctx.provider);
|
||||
|
||||
log::info!(
|
||||
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
|
||||
ctx.provider.name,
|
||||
needs_transform,
|
||||
is_stream
|
||||
);
|
||||
|
||||
let status = response.status();
|
||||
log::info!("[Claude] 上游响应状态: {status}");
|
||||
|
||||
@@ -295,7 +301,7 @@ pub async fn handle_chat_completions(
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
|
||||
|
||||
let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -309,7 +315,7 @@ pub async fn handle_chat_completions(
|
||||
);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let response = match forwarder
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/v1/chat/completions",
|
||||
@@ -319,13 +325,19 @@ pub async fn handle_chat_completions(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
log_forward_error(&state, &ctx, is_stream, &e);
|
||||
return Err(e);
|
||||
Ok(result) => result,
|
||||
Err(mut err) => {
|
||||
if let Some(provider) = err.provider.take() {
|
||||
ctx.provider = provider;
|
||||
}
|
||||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||
return Err(err.error);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
log::info!("[Codex] 上游响应状态: {}", response.status());
|
||||
|
||||
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
|
||||
@@ -337,7 +349,7 @@ pub async fn handle_responses(
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -345,7 +357,7 @@ pub async fn handle_responses(
|
||||
.unwrap_or(false);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let response = match forwarder
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/v1/responses",
|
||||
@@ -355,13 +367,19 @@ pub async fn handle_responses(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
log_forward_error(&state, &ctx, is_stream, &e);
|
||||
return Err(e);
|
||||
Ok(result) => result,
|
||||
Err(mut err) => {
|
||||
if let Some(provider) = err.provider.take() {
|
||||
ctx.provider = provider;
|
||||
}
|
||||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||
return Err(err.error);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
log::info!("[Codex] 上游响应状态: {}", response.status());
|
||||
|
||||
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
||||
@@ -379,7 +397,7 @@ pub async fn handle_gemini(
|
||||
Json(body): Json<Value>,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
// Gemini 的模型名称在 URI 中
|
||||
let ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini")
|
||||
let mut ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini")
|
||||
.await?
|
||||
.with_model_from_uri(&uri);
|
||||
|
||||
@@ -397,7 +415,7 @@ pub async fn handle_gemini(
|
||||
.unwrap_or(false);
|
||||
|
||||
let forwarder = ctx.create_forwarder(&state);
|
||||
let response = match forwarder
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Gemini,
|
||||
endpoint,
|
||||
@@ -407,13 +425,19 @@ pub async fn handle_gemini(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
log_forward_error(&state, &ctx, is_stream, &e);
|
||||
return Err(e);
|
||||
Ok(result) => result,
|
||||
Err(mut err) => {
|
||||
if let Some(provider) = err.provider.take() {
|
||||
ctx.provider = provider;
|
||||
}
|
||||
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||
return Err(err.error);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.provider = result.provider;
|
||||
let response = result.response;
|
||||
|
||||
log::info!("[Gemini] 上游响应状态: {}", response.status());
|
||||
|
||||
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
|
||||
|
||||
@@ -30,74 +30,92 @@ impl ProviderRouter {
|
||||
/// 选择可用的供应商(支持故障转移)
|
||||
///
|
||||
/// 返回按优先级排序的可用供应商列表:
|
||||
/// 1. 当前供应商(is_current=true)始终第一位
|
||||
/// 2. 故障转移队列中的其他供应商(按 queue_order 排序)
|
||||
/// 3. 只返回熔断器未打开的供应商
|
||||
/// - 故障转移关闭时:仅返回当前供应商
|
||||
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
|
||||
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
||||
let mut result = Vec::new();
|
||||
let all_providers = self.db.get_all_providers(app_type)?;
|
||||
|
||||
// 1. 当前供应商始终第一位
|
||||
if let Some(current_id) = self.db.get_current_provider(app_type)? {
|
||||
if let Some(current) = all_providers.get(¤t_id) {
|
||||
let circuit_key = format!("{}:{}", app_type, current.id);
|
||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||
|
||||
if breaker.is_available().await {
|
||||
log::info!(
|
||||
"[{}] Current provider available: {} ({})",
|
||||
app_type,
|
||||
current.name,
|
||||
current.id
|
||||
);
|
||||
result.push(current.clone());
|
||||
} else {
|
||||
log::warn!(
|
||||
"[{}] Current provider {} circuit breaker open, checking failover queue",
|
||||
app_type,
|
||||
current.name
|
||||
);
|
||||
}
|
||||
// 检查该应用的自动故障转移开关是否开启
|
||||
let failover_key = format!("auto_failover_enabled_{app_type}");
|
||||
let auto_failover_enabled = match self.db.get_setting(&failover_key) {
|
||||
Ok(Some(value)) => {
|
||||
let enabled = value == "true";
|
||||
log::info!(
|
||||
"[{app_type}] Failover setting '{failover_key}' = '{value}', enabled: {enabled}"
|
||||
);
|
||||
enabled
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 获取故障转移队列中的供应商
|
||||
let queue = self.db.get_failover_queue(app_type)?;
|
||||
|
||||
for item in queue {
|
||||
// 跳过已添加的当前供应商
|
||||
if result.iter().any(|p| p.id == item.provider_id) {
|
||||
continue;
|
||||
Ok(None) => {
|
||||
log::warn!(
|
||||
"[{app_type}] Failover setting '{failover_key}' not found in database, defaulting to disabled"
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
// 跳过禁用的队列项
|
||||
if !item.enabled {
|
||||
continue;
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[{app_type}] Failed to read failover setting '{failover_key}': {e}, defaulting to disabled"
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
// 获取供应商信息
|
||||
if let Some(provider) = all_providers.get(&item.provider_id) {
|
||||
if auto_failover_enabled {
|
||||
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
|
||||
let failover_providers = self.db.get_failover_providers(app_type)?;
|
||||
log::info!(
|
||||
"[{}] Failover enabled, using queue order ({} items)",
|
||||
app_type,
|
||||
failover_providers.len()
|
||||
);
|
||||
|
||||
for provider in failover_providers {
|
||||
// 检查熔断器状态
|
||||
let circuit_key = format!("{}:{}", app_type, provider.id);
|
||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||
|
||||
if breaker.is_available().await {
|
||||
log::info!(
|
||||
"[{}] Failover provider available: {} ({}) at queue position {}",
|
||||
"[{}] Queue provider available: {} ({}) at sort_index {:?}",
|
||||
app_type,
|
||||
provider.name,
|
||||
provider.id,
|
||||
item.queue_order
|
||||
provider.sort_index
|
||||
);
|
||||
result.push(provider.clone());
|
||||
result.push(provider);
|
||||
} else {
|
||||
log::debug!(
|
||||
"[{}] Failover provider {} circuit breaker open, skipping",
|
||||
"[{}] Queue provider {} circuit breaker open, skipping",
|
||||
app_type,
|
||||
provider.name
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 故障转移关闭:仅使用当前供应商
|
||||
log::info!("[{app_type}] Failover disabled, using current provider only");
|
||||
|
||||
if let Some(current_id) = self.db.get_current_provider(app_type)? {
|
||||
if let Some(current) = self.db.get_provider_by_id(¤t_id, app_type)? {
|
||||
let circuit_key = format!("{}:{}", app_type, current.id);
|
||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||
|
||||
if breaker.is_available().await {
|
||||
log::info!(
|
||||
"[{}] Current provider available: {} ({})",
|
||||
app_type,
|
||||
current.name,
|
||||
current.id
|
||||
);
|
||||
result.push(current);
|
||||
} else {
|
||||
log::warn!(
|
||||
"[{}] Current provider {} circuit breaker open",
|
||||
app_type,
|
||||
current.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
@@ -107,7 +125,7 @@ impl ProviderRouter {
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[{}] Failover chain: {} provider(s) available",
|
||||
"[{}] Provider chain: {} provider(s) available",
|
||||
app_type,
|
||||
result.len()
|
||||
);
|
||||
@@ -264,25 +282,14 @@ mod tests {
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
let router = ProviderRouter::new(db);
|
||||
|
||||
// 测试创建熔断器
|
||||
let breaker = router.get_or_create_circuit_breaker("claude:test").await;
|
||||
assert!(breaker.allow_request().await.allowed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn select_providers_does_not_consume_half_open_permit() {
|
||||
async fn test_failover_disabled_uses_current_provider() {
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
|
||||
// 配置:让熔断器 Open 后立刻进入 HalfOpen(timeout_seconds=0),并用 1 次失败就打开熔断器
|
||||
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
||||
failure_threshold: 1,
|
||||
timeout_seconds: 0,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 准备 2 个 Provider:A(当前)+ B(队列)
|
||||
let provider_a =
|
||||
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||
let provider_b =
|
||||
@@ -293,19 +300,78 @@ mod tests {
|
||||
db.set_current_provider("claude", "a").unwrap();
|
||||
db.add_to_failover_queue("claude", "b").unwrap();
|
||||
|
||||
let router = ProviderRouter::new(db.clone());
|
||||
let providers = router.select_providers("claude").await.unwrap();
|
||||
|
||||
assert_eq!(providers.len(), 1);
|
||||
assert_eq!(providers[0].id, "a");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_failover_enabled_uses_queue_order() {
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
|
||||
// 设置 sort_index 来控制顺序:b=1, a=2
|
||||
let mut provider_a =
|
||||
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||
provider_a.sort_index = Some(2);
|
||||
let mut provider_b =
|
||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||
provider_b.sort_index = Some(1);
|
||||
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.save_provider("claude", &provider_b).unwrap();
|
||||
db.set_current_provider("claude", "a").unwrap();
|
||||
|
||||
db.add_to_failover_queue("claude", "b").unwrap();
|
||||
db.add_to_failover_queue("claude", "a").unwrap();
|
||||
db.set_setting("auto_failover_enabled_claude", "true")
|
||||
.unwrap();
|
||||
|
||||
let router = ProviderRouter::new(db.clone());
|
||||
let providers = router.select_providers("claude").await.unwrap();
|
||||
|
||||
assert_eq!(providers.len(), 2);
|
||||
// 按 sort_index 排序:b(1) 在前,a(2) 在后
|
||||
assert_eq!(providers[0].id, "b");
|
||||
assert_eq!(providers[1].id, "a");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_select_providers_does_not_consume_half_open_permit() {
|
||||
let db = Arc::new(Database::memory().unwrap());
|
||||
|
||||
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
||||
failure_threshold: 1,
|
||||
timeout_seconds: 0,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let provider_a =
|
||||
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||
let provider_b =
|
||||
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||
|
||||
db.save_provider("claude", &provider_a).unwrap();
|
||||
db.save_provider("claude", &provider_b).unwrap();
|
||||
|
||||
db.add_to_failover_queue("claude", "a").unwrap();
|
||||
db.add_to_failover_queue("claude", "b").unwrap();
|
||||
db.set_setting("auto_failover_enabled_claude", "true")
|
||||
.unwrap();
|
||||
|
||||
let router = ProviderRouter::new(db.clone());
|
||||
|
||||
// 让 B 进入 Open 状态(failure_threshold=1)
|
||||
router
|
||||
.record_result("b", "claude", false, false, Some("fail".to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// select_providers 只做“可用性判断”,不应占用 HalfOpen 探测名额
|
||||
let providers = router.select_providers("claude").await.unwrap();
|
||||
assert_eq!(providers.len(), 2);
|
||||
|
||||
// 如果 select_providers 错误地消耗了 HalfOpen 名额,这里会返回 false(被限流拒绝)
|
||||
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +270,7 @@ mod tests {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ mod tests {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -250,6 +250,7 @@ mod tests {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,7 @@ mod tests {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -394,6 +394,7 @@ mod tests {
|
||||
meta: None,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,7 +115,14 @@ impl ProxyService {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// 5. 启动代理服务器
|
||||
// 5. 设置 settings 表中所有应用的接管状态(用于重启后自动恢复)
|
||||
for app in ["claude", "codex", "gemini"] {
|
||||
if let Err(e) = self.db.set_proxy_takeover_enabled(app, true) {
|
||||
log::warn!("设置 {app} 接管状态失败: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 启动代理服务器
|
||||
match self.start().await {
|
||||
Ok(info) => Ok(info),
|
||||
Err(e) => {
|
||||
@@ -125,6 +132,8 @@ impl ProxyService {
|
||||
Ok(()) => {
|
||||
let _ = self.db.set_live_takeover_active(false).await;
|
||||
let _ = self.db.delete_all_live_backups().await;
|
||||
// 清除 settings 状态
|
||||
let _ = self.db.clear_all_proxy_takeover();
|
||||
}
|
||||
Err(restore_err) => {
|
||||
log::error!("恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}");
|
||||
@@ -214,7 +223,12 @@ impl ProxyService {
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// 6) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)
|
||||
// 6) 设置 settings 表中的接管状态
|
||||
self.db
|
||||
.set_proxy_takeover_enabled(app_type_str, true)
|
||||
.map_err(|e| format!("设置 {app_type_str} 接管状态失败: {e}"))?;
|
||||
|
||||
// 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)
|
||||
let _ = self.db.set_live_takeover_active(true).await;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -239,7 +253,18 @@ impl ProxyService {
|
||||
.await
|
||||
.map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?;
|
||||
|
||||
// 3) 若无其它接管,更新旧标志,并停止代理服务
|
||||
// 3) 清除 settings 表中该应用的接管状态
|
||||
self.db
|
||||
.set_proxy_takeover_enabled(app_type_str, false)
|
||||
.map_err(|e| format!("清除 {app_type_str} 接管状态失败: {e}"))?;
|
||||
|
||||
// 4) 清除该应用的健康状态(关闭代理时重置队列状态)
|
||||
self.db
|
||||
.clear_provider_health_for_app(app_type_str)
|
||||
.await
|
||||
.map_err(|e| format!("清除 {app_type_str} 健康状态失败: {e}"))?;
|
||||
|
||||
// 5) 若无其它接管,更新旧标志,并停止代理服务
|
||||
let has_any_backup = self
|
||||
.db
|
||||
.has_any_live_backup()
|
||||
@@ -484,7 +509,9 @@ impl ProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止代理服务器(恢复 Live 配置)
|
||||
/// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)
|
||||
///
|
||||
/// 会清除 settings 表中的代理状态,下次启动不会自动恢复。
|
||||
pub async fn stop_with_restore(&self) -> Result<(), String> {
|
||||
// 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)
|
||||
if let Err(e) = self.stop().await {
|
||||
@@ -494,28 +521,69 @@ impl ProxyService {
|
||||
// 2. 恢复原始 Live 配置
|
||||
self.restore_live_configs().await?;
|
||||
|
||||
// 3. 清除接管状态
|
||||
// 3. 清除 proxy_config 表中的接管状态(兼容旧版)
|
||||
self.db
|
||||
.set_live_takeover_active(false)
|
||||
.await
|
||||
.map_err(|e| format!("清除接管状态失败: {e}"))?;
|
||||
|
||||
// 4. 删除备份
|
||||
// 4. 清除 settings 表中的代理状态(用户手动关闭,不需要下次自动恢复)
|
||||
self.db
|
||||
.clear_all_proxy_takeover()
|
||||
.map_err(|e| format!("清除代理状态失败: {e}"))?;
|
||||
|
||||
// 5. 删除备份
|
||||
self.db
|
||||
.delete_all_live_backups()
|
||||
.await
|
||||
.map_err(|e| format!("删除备份失败: {e}"))?;
|
||||
|
||||
// 5. 重置健康状态(让健康徽章恢复为正常)
|
||||
// 6. 重置健康状态(让健康徽章恢复为正常)
|
||||
self.db
|
||||
.clear_all_provider_health()
|
||||
.await
|
||||
.map_err(|e| format!("重置健康状态失败: {e}"))?;
|
||||
|
||||
// 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用
|
||||
log::info!("代理已停止,Live 配置已恢复");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)
|
||||
///
|
||||
/// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复
|
||||
pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {
|
||||
// 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)
|
||||
if let Err(e) = self.stop().await {
|
||||
log::warn!("停止代理服务器失败(将继续恢复 Live 配置): {e}");
|
||||
}
|
||||
|
||||
// 2. 恢复原始 Live 配置
|
||||
self.restore_live_configs().await?;
|
||||
|
||||
// 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)
|
||||
// 注意:仅更新 proxy_config 表,不清除 settings 表中的 proxy_takeover_* 状态
|
||||
if let Ok(mut config) = self.db.get_proxy_config().await {
|
||||
config.live_takeover_active = false;
|
||||
let _ = self.db.update_proxy_config(config).await;
|
||||
}
|
||||
|
||||
// 4. 删除备份(Live 配置已恢复,备份不再需要)
|
||||
self.db
|
||||
.delete_all_live_backups()
|
||||
.await
|
||||
.map_err(|e| format!("删除备份失败: {e}"))?;
|
||||
|
||||
// 5. 重置健康状态
|
||||
self.db
|
||||
.clear_all_provider_health()
|
||||
.await
|
||||
.map_err(|e| format!("重置健康状态失败: {e}"))?;
|
||||
|
||||
log::info!("代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 备份各应用的 Live 配置
|
||||
async fn backup_live_configs(&self) -> Result<(), String> {
|
||||
// Claude
|
||||
|
||||
@@ -31,6 +31,9 @@ pub struct AppSettings {
|
||||
/// 是否启用 Claude 插件联动
|
||||
#[serde(default)]
|
||||
pub enable_claude_plugin_integration: bool,
|
||||
/// 是否跳过 Claude Code 初次安装确认
|
||||
#[serde(default = "default_true")]
|
||||
pub skip_claude_onboarding: bool,
|
||||
/// 是否开机自启
|
||||
#[serde(default)]
|
||||
pub launch_on_startup: bool,
|
||||
@@ -65,12 +68,17 @@ fn default_minimize_to_tray_on_close() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_in_tray: true,
|
||||
minimize_to_tray_on_close: true,
|
||||
enable_claude_plugin_integration: false,
|
||||
skip_claude_onboarding: true,
|
||||
launch_on_startup: false,
|
||||
language: None,
|
||||
claude_config_dir: None,
|
||||
|
||||
+150
-100
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { toast } from "sonner";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -46,6 +47,10 @@ import { Button } from "@/components/ui/button";
|
||||
|
||||
type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
|
||||
|
||||
const DRAG_BAR_HEIGHT = 28; // px
|
||||
const HEADER_HEIGHT = 64; // px
|
||||
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -67,9 +72,20 @@ function App() {
|
||||
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
|
||||
|
||||
// 获取代理服务状态
|
||||
const { isRunning: isProxyRunning, takeoverStatus } = useProxyStatus();
|
||||
const {
|
||||
isRunning: isProxyRunning,
|
||||
takeoverStatus,
|
||||
status: proxyStatus,
|
||||
} = useProxyStatus();
|
||||
// 当前应用的代理是否开启
|
||||
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
|
||||
// 当前应用代理实际使用的供应商 ID(从 active_targets 中获取)
|
||||
const activeProviderId = useMemo(() => {
|
||||
const target = proxyStatus?.active_targets?.find(
|
||||
(t) => t.app_type === activeApp,
|
||||
);
|
||||
return target?.provider_id;
|
||||
}, [proxyStatus?.active_targets, activeApp]);
|
||||
|
||||
// 获取供应商列表,当代理服务运行时自动刷新
|
||||
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
|
||||
@@ -294,79 +310,109 @@ function App() {
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (currentView) {
|
||||
case "settings":
|
||||
return (
|
||||
<SettingsPage
|
||||
open={true}
|
||||
onOpenChange={() => setCurrentView("providers")}
|
||||
onImportSuccess={handleImportSuccess}
|
||||
/>
|
||||
);
|
||||
case "prompts":
|
||||
return (
|
||||
<PromptPanel
|
||||
ref={promptPanelRef}
|
||||
open={true}
|
||||
onOpenChange={() => setCurrentView("providers")}
|
||||
appId={activeApp}
|
||||
/>
|
||||
);
|
||||
case "skills":
|
||||
return (
|
||||
<SkillsPage
|
||||
ref={skillsPageRef}
|
||||
onClose={() => setCurrentView("providers")}
|
||||
initialApp={activeApp}
|
||||
/>
|
||||
);
|
||||
case "mcp":
|
||||
return (
|
||||
<UnifiedMcpPanel
|
||||
ref={mcpPanelRef}
|
||||
onOpenChange={() => setCurrentView("providers")}
|
||||
/>
|
||||
);
|
||||
case "agents":
|
||||
return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />;
|
||||
default:
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
||||
<div className="space-y-4">
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
currentProviderId={currentProviderId}
|
||||
appId={activeApp}
|
||||
isLoading={isLoading}
|
||||
isProxyRunning={isProxyRunning}
|
||||
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
|
||||
onSwitch={switchProvider}
|
||||
onEdit={setEditingProvider}
|
||||
onDelete={setConfirmDelete}
|
||||
onDuplicate={handleDuplicateProvider}
|
||||
onConfigureUsage={setUsageProvider}
|
||||
onOpenWebsite={handleOpenWebsite}
|
||||
onCreate={() => setIsAddOpen(true)}
|
||||
/>
|
||||
const content = (() => {
|
||||
switch (currentView) {
|
||||
case "settings":
|
||||
return (
|
||||
<SettingsPage
|
||||
open={true}
|
||||
onOpenChange={() => setCurrentView("providers")}
|
||||
onImportSuccess={handleImportSuccess}
|
||||
/>
|
||||
);
|
||||
case "prompts":
|
||||
return (
|
||||
<PromptPanel
|
||||
ref={promptPanelRef}
|
||||
open={true}
|
||||
onOpenChange={() => setCurrentView("providers")}
|
||||
appId={activeApp}
|
||||
/>
|
||||
);
|
||||
case "skills":
|
||||
return (
|
||||
<SkillsPage
|
||||
ref={skillsPageRef}
|
||||
onClose={() => setCurrentView("providers")}
|
||||
initialApp={activeApp}
|
||||
/>
|
||||
);
|
||||
case "mcp":
|
||||
return (
|
||||
<UnifiedMcpPanel
|
||||
ref={mcpPanelRef}
|
||||
onOpenChange={() => setCurrentView("providers")}
|
||||
/>
|
||||
);
|
||||
case "agents":
|
||||
return (
|
||||
<AgentsPanel onOpenChange={() => setCurrentView("providers")} />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeApp}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<ProviderList
|
||||
providers={providers}
|
||||
currentProviderId={currentProviderId}
|
||||
appId={activeApp}
|
||||
isLoading={isLoading}
|
||||
isProxyRunning={isProxyRunning}
|
||||
isProxyTakeover={
|
||||
isProxyRunning && isCurrentAppTakeoverActive
|
||||
}
|
||||
activeProviderId={activeProviderId}
|
||||
onSwitch={switchProvider}
|
||||
onEdit={setEditingProvider}
|
||||
onDelete={setConfirmDelete}
|
||||
onDuplicate={handleDuplicateProvider}
|
||||
onConfigureUsage={setUsageProvider}
|
||||
onOpenWebsite={handleOpenWebsite}
|
||||
onCreate={() => setIsAddOpen(true)}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentView}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen flex-col bg-background text-foreground selection:bg-primary/30"
|
||||
style={{ overflowX: "hidden" }}
|
||||
className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30"
|
||||
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
|
||||
>
|
||||
{/* 全局拖拽区域(顶部 4px),避免上边框无法拖动 */}
|
||||
{/* 全局拖拽区域(顶部 28px),避免上边框无法拖动 */}
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 h-4 z-[60]"
|
||||
className="fixed top-0 left-0 right-0 z-[60]"
|
||||
data-tauri-drag-region
|
||||
style={{ WebkitAppRegion: "drag" } as any}
|
||||
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
|
||||
/>
|
||||
{/* 环境变量警告横幅 */}
|
||||
{showEnvBanner && envConflicts.length > 0 && (
|
||||
@@ -396,13 +442,18 @@ function App() {
|
||||
)}
|
||||
|
||||
<header
|
||||
className="fixed top-0 z-50 w-full py-3 bg-background/80 backdrop-blur-md transition-all duration-300"
|
||||
className="fixed z-50 w-full transition-all duration-300 bg-background/80 backdrop-blur-md"
|
||||
data-tauri-drag-region
|
||||
style={{ WebkitAppRegion: "drag" } as any}
|
||||
style={
|
||||
{
|
||||
WebkitAppRegion: "drag",
|
||||
top: DRAG_BAR_HEIGHT,
|
||||
height: HEADER_HEIGHT,
|
||||
} as any
|
||||
}
|
||||
>
|
||||
<div className="h-4 w-full" aria-hidden data-tauri-drag-region />
|
||||
<div
|
||||
className="mx-auto max-w-[56rem] px-6 flex flex-wrap items-center justify-between gap-2"
|
||||
className="mx-auto flex h-full max-w-[56rem] flex-wrap items-center justify-between gap-2 px-6"
|
||||
data-tauri-drag-region
|
||||
style={{ WebkitAppRegion: "drag" } as any}
|
||||
>
|
||||
@@ -418,7 +469,7 @@ function App() {
|
||||
onClick={() => setCurrentView("providers")}
|
||||
className="mr-2 rounded-lg"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-semibold">
|
||||
{currentView === "settings" && t("settings.title")}
|
||||
@@ -452,7 +503,7 @@ function App() {
|
||||
title={t("common.settings")}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<UpdateBadge onClick={() => setCurrentView("settings")} />
|
||||
@@ -461,27 +512,27 @@ function App() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
className="flex items-center gap-2 min-h-[40px]"
|
||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||
>
|
||||
{currentView === "prompts" && (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => promptPanelRef.current?.openAdd()}
|
||||
className={addActionButtonClass}
|
||||
className={`ml-auto ${addActionButtonClass}`}
|
||||
title={t("prompts.add")}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
{currentView === "mcp" && (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={() => mcpPanelRef.current?.openAdd()}
|
||||
className={addActionButtonClass}
|
||||
className={`ml-auto ${addActionButtonClass}`}
|
||||
title={t("mcp.unifiedPanel.addServer")}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
{currentView === "skills" && (
|
||||
@@ -492,7 +543,7 @@ function App() {
|
||||
onClick={() => skillsPageRef.current?.refresh()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t("skills.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -501,7 +552,7 @@ function App() {
|
||||
onClick={() => skillsPageRef.current?.openRepoManager()}
|
||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
{t("skills.repoManager")}
|
||||
</Button>
|
||||
</>
|
||||
@@ -512,7 +563,7 @@ function App() {
|
||||
|
||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
||||
|
||||
<div className="bg-muted p-1 rounded-xl flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -526,20 +577,20 @@ function App() {
|
||||
)}
|
||||
title={t("skills.manage")}
|
||||
>
|
||||
<Wrench className="h-4 w-4 flex-shrink-0" />
|
||||
<Wrench className="flex-shrink-0 w-4 h-4" />
|
||||
</Button>
|
||||
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
|
||||
{/* {isClaudeApp && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("agents")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title="Agents"
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
</Button>
|
||||
)} */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCurrentView("agents")}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title="Agents"
|
||||
>
|
||||
<Bot className="w-4 h-4" />
|
||||
</Button>
|
||||
)} */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -547,7 +598,7 @@ function App() {
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("prompts.manage")}
|
||||
>
|
||||
<Book className="h-4 w-4" />
|
||||
<Book className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -556,7 +607,7 @@ function App() {
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||
title={t("mcp.title")}
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<Server className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -565,7 +616,7 @@ function App() {
|
||||
size="icon"
|
||||
className={`ml-2 ${addActionButtonClass}`}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -573,13 +624,12 @@ function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
className={`flex-1 overflow-y-auto pb-12 animate-fade-in scroll-overlay ${
|
||||
currentView === "providers" ? "pt-24" : "pt-20"
|
||||
}`}
|
||||
style={{ overflowX: "hidden" }}
|
||||
>
|
||||
{renderContent()}
|
||||
<main className="flex-1 pb-12 animate-fade-in ">
|
||||
<div
|
||||
className={cn("pb-12", currentView === "providers" ? "pt-6" : "pt-4")}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<AddProviderDialog
|
||||
|
||||
@@ -56,7 +56,7 @@ export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
||||
"
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="w-3 h-3 text-gray-400 dark:text-gray-500" />
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -114,7 +114,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
||||
{/* 第一行:更新时间和刷新按钮 */}
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
{/* 上次查询时间 */}
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
|
||||
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{lastQueriedAt
|
||||
? formatRelativeTime(lastQueriedAt, now, t)
|
||||
@@ -128,7 +128,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
||||
refetch();
|
||||
}}
|
||||
disabled={loading}
|
||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0 text-gray-400 dark:text-gray-500"
|
||||
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0 text-muted-foreground"
|
||||
title={t("usage.refreshUsage")}
|
||||
>
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
@@ -191,7 +191,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 自动查询时间提示 */}
|
||||
{lastQueriedAt && (
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
|
||||
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{formatRelativeTime(lastQueriedAt, now, t)}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -35,7 +36,10 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-[60] flex flex-col"
|
||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
||||
>
|
||||
@@ -71,7 +75,7 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
</motion.div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -198,7 +198,7 @@ export function EnvWarningBanner({
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 break-all">
|
||||
{t("env.field.value")}: {conflict.varValue}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("env.field.source")}:{" "}
|
||||
{getSourceDescription(conflict)}
|
||||
</p>
|
||||
|
||||
@@ -129,18 +129,18 @@ const UnifiedMcpPanel = React.forwardRef<
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{t("mcp.loading")}
|
||||
</div>
|
||||
) : serverEntries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<Server size={24} className="text-gray-400 dark:text-gray-500" />
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
|
||||
<Server size={24} className="text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{t("mcp.unifiedPanel.noServers")}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("mcp.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -237,9 +237,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
{/* 左侧:服务器信息 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{name}
|
||||
</h3>
|
||||
<h3 className="font-medium text-foreground">{name}</h3>
|
||||
{docsUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -253,12 +251,12 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{!description && tags && tags.length > 0 && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
|
||||
<p className="text-xs text-muted-foreground/70 truncate">
|
||||
{tags.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
@@ -269,7 +267,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-claude`}
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.claude")}
|
||||
</label>
|
||||
@@ -285,7 +283,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-codex`}
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.codex")}
|
||||
</label>
|
||||
@@ -301,7 +299,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label
|
||||
htmlFor={`${id}-gemini`}
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||
className="text-sm text-foreground/80 cursor-pointer"
|
||||
>
|
||||
{t("mcp.unifiedPanel.apps.gemini")}
|
||||
</label>
|
||||
|
||||
@@ -36,11 +36,9 @@ const PromptListItem: React.FC<PromptListItemProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
|
||||
{prompt.name}
|
||||
</h3>
|
||||
<h3 className="font-medium text-foreground mb-1">{prompt.name}</h3>
|
||||
{prompt.description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{prompt.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -108,21 +108,18 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
||||
|
||||
<div className="flex-1 overflow-y-auto pb-16">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{t("prompts.loading")}
|
||||
</div>
|
||||
) : promptEntries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<FileText
|
||||
size={24}
|
||||
className="text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
|
||||
<FileText size={24} className="text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{t("prompts.empty")}
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("prompts.emptyDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface FailoverPriorityBadgeProps {
|
||||
priority: number; // 1, 2, 3, ...
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 故障转移优先级徽章
|
||||
* 显示供应商在故障转移队列中的优先级顺序
|
||||
*/
|
||||
export function FailoverPriorityBadge({
|
||||
priority,
|
||||
className,
|
||||
}: FailoverPriorityBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold",
|
||||
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
className,
|
||||
)}
|
||||
title={t("failover.priority.tooltip", {
|
||||
priority,
|
||||
defaultValue: `故障转移优先级 ${priority}`,
|
||||
})}
|
||||
>
|
||||
P{priority}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { HealthStatus } from "@/lib/api/model-test";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface HealthStatusIndicatorProps {
|
||||
status: HealthStatus;
|
||||
@@ -11,17 +12,20 @@ interface HealthStatusIndicatorProps {
|
||||
const statusConfig = {
|
||||
operational: {
|
||||
color: "bg-emerald-500",
|
||||
label: "正常",
|
||||
labelKey: "health.operational",
|
||||
labelFallback: "正常",
|
||||
textColor: "text-emerald-600 dark:text-emerald-400",
|
||||
},
|
||||
degraded: {
|
||||
color: "bg-yellow-500",
|
||||
label: "降级",
|
||||
labelKey: "health.degraded",
|
||||
labelFallback: "降级",
|
||||
textColor: "text-yellow-600 dark:text-yellow-400",
|
||||
},
|
||||
failed: {
|
||||
color: "bg-red-500",
|
||||
label: "失败",
|
||||
labelKey: "health.failed",
|
||||
labelFallback: "失败",
|
||||
textColor: "text-red-600 dark:text-red-400",
|
||||
},
|
||||
};
|
||||
@@ -31,13 +35,15 @@ export const HealthStatusIndicator: React.FC<HealthStatusIndicatorProps> = ({
|
||||
responseTimeMs,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const config = statusConfig[status];
|
||||
const label = t(config.labelKey, { defaultValue: config.labelFallback });
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className={cn("w-2 h-2 rounded-full", config.color)} />
|
||||
<span className={cn("text-xs font-medium", config.textColor)}>
|
||||
{config.label}
|
||||
{label}
|
||||
{responseTimeMs !== undefined && ` (${responseTimeMs}ms)`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Edit,
|
||||
Loader2,
|
||||
Play,
|
||||
Plus,
|
||||
TestTube2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@@ -22,6 +23,10 @@ interface ProviderActionsProps {
|
||||
onTest?: () => void;
|
||||
onConfigureUsage: () => void;
|
||||
onDelete: () => void;
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled?: boolean;
|
||||
isInFailoverQueue?: boolean;
|
||||
onToggleFailover?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function ProviderActions({
|
||||
@@ -34,38 +39,88 @@ export function ProviderActions({
|
||||
onTest,
|
||||
onConfigureUsage,
|
||||
onDelete,
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled = false,
|
||||
isInFailoverQueue = false,
|
||||
onToggleFailover,
|
||||
}: ProviderActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const iconButtonClass = "h-8 w-8 p-1";
|
||||
|
||||
// 故障转移模式下的按钮逻辑
|
||||
const isFailoverMode = isAutoFailoverEnabled && onToggleFailover;
|
||||
|
||||
// 处理主按钮点击
|
||||
const handleMainButtonClick = () => {
|
||||
if (isFailoverMode) {
|
||||
// 故障转移模式:切换队列状态
|
||||
onToggleFailover(!isInFailoverQueue);
|
||||
} else {
|
||||
// 普通模式:切换供应商
|
||||
onSwitch();
|
||||
}
|
||||
};
|
||||
|
||||
// 主按钮的状态和样式
|
||||
const getMainButtonState = () => {
|
||||
if (isFailoverMode) {
|
||||
// 故障转移模式
|
||||
if (isInFailoverQueue) {
|
||||
return {
|
||||
disabled: false,
|
||||
variant: "secondary" as const,
|
||||
className:
|
||||
"bg-blue-100 text-blue-600 hover:bg-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:hover:bg-blue-900/70",
|
||||
icon: <Check className="h-4 w-4" />,
|
||||
text: t("failover.inQueue", { defaultValue: "已加入" }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
disabled: false,
|
||||
variant: "default" as const,
|
||||
className:
|
||||
"bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||||
icon: <Plus className="h-4 w-4" />,
|
||||
text: t("failover.addQueue", { defaultValue: "加入" }),
|
||||
};
|
||||
}
|
||||
|
||||
// 普通模式
|
||||
if (isCurrent) {
|
||||
return {
|
||||
disabled: true,
|
||||
variant: "secondary" as const,
|
||||
className:
|
||||
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
||||
icon: <Check className="h-4 w-4" />,
|
||||
text: t("provider.inUse"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
disabled: false,
|
||||
variant: "default" as const,
|
||||
className: isProxyTakeover
|
||||
? "bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
|
||||
: "",
|
||||
icon: <Play className="h-4 w-4" />,
|
||||
text: t("provider.enable"),
|
||||
};
|
||||
};
|
||||
|
||||
const buttonState = getMainButtonState();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isCurrent ? "secondary" : "default"}
|
||||
onClick={onSwitch}
|
||||
disabled={isCurrent}
|
||||
className={cn(
|
||||
"w-[4.5rem] px-2.5",
|
||||
isCurrent &&
|
||||
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
||||
// 代理接管模式下启用按钮使用绿色
|
||||
!isCurrent &&
|
||||
isProxyTakeover &&
|
||||
"bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
|
||||
)}
|
||||
variant={buttonState.variant}
|
||||
onClick={handleMainButtonClick}
|
||||
disabled={buttonState.disabled}
|
||||
className={cn("w-[4.5rem] px-2.5", buttonState.className)}
|
||||
>
|
||||
{isCurrent ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
{t("provider.inUse")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
{t("provider.enable")}
|
||||
</>
|
||||
)}
|
||||
{buttonState.icon}
|
||||
{buttonState.text}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ProviderActions } from "@/components/providers/ProviderActions";
|
||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||
import UsageFooter from "@/components/UsageFooter";
|
||||
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
||||
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
|
||||
import { useProviderHealth } from "@/lib/query/failover";
|
||||
import { useUsageQuery } from "@/lib/query/queries";
|
||||
|
||||
@@ -36,6 +37,12 @@ interface ProviderCardProps {
|
||||
isProxyRunning: boolean;
|
||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
||||
dragHandleProps?: DragHandleProps;
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移
|
||||
failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...)
|
||||
isInFailoverQueue?: boolean; // 是否在故障转移队列中
|
||||
onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列
|
||||
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||
}
|
||||
|
||||
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||
@@ -88,6 +95,12 @@ export function ProviderCard({
|
||||
isProxyRunning,
|
||||
isProxyTakeover = false,
|
||||
dragHandleProps,
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled = false,
|
||||
failoverPriority,
|
||||
isInFailoverQueue = false,
|
||||
onToggleFailover,
|
||||
activeProviderId,
|
||||
}: ProviderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -148,21 +161,32 @@ export function ProviderCard({
|
||||
onOpenWebsite(displayUrl);
|
||||
};
|
||||
|
||||
// 判断是否是"当前使用中"的供应商
|
||||
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||
// - 代理接管模式(非故障转移):isCurrent
|
||||
// - 普通模式:isCurrent
|
||||
const isActiveProvider = isAutoFailoverEnabled
|
||||
? activeProviderId === provider.id
|
||||
: isCurrent;
|
||||
|
||||
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
|
||||
const shouldUseGreen = isProxyTakeover && isActiveProvider;
|
||||
const shouldUseBlue = !isProxyTakeover && isActiveProvider;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
|
||||
"bg-card text-card-foreground group",
|
||||
// 代理接管模式下 hover 使用绿色边框,否则使用蓝色
|
||||
isProxyTakeover
|
||||
// hover 时的边框效果
|
||||
isAutoFailoverEnabled || isProxyTakeover
|
||||
? "hover:border-emerald-500/50"
|
||||
: "hover:border-border-active",
|
||||
// 代理接管模式下当前供应商使用绿色边框
|
||||
isProxyTakeover && isCurrent
|
||||
? "border-emerald-500/60 shadow-sm shadow-emerald-500/10"
|
||||
: isCurrent
|
||||
? "border-primary/50 shadow-sm"
|
||||
: "hover:shadow-sm",
|
||||
// 当前激活的供应商边框样式
|
||||
shouldUseGreen &&
|
||||
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
|
||||
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
|
||||
!isActiveProvider && "hover:shadow-sm",
|
||||
dragHandleProps?.isDragging &&
|
||||
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
||||
)}
|
||||
@@ -170,11 +194,11 @@ export function ProviderCard({
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
|
||||
// 代理接管模式下使用绿色渐变,否则使用蓝色主色调
|
||||
isProxyTakeover && isCurrent
|
||||
? "from-emerald-500/10"
|
||||
: "from-primary/10",
|
||||
isCurrent ? "opacity-100" : "opacity-0",
|
||||
// 代理接管模式使用绿色渐变,普通模式使用蓝色渐变
|
||||
shouldUseGreen && "from-emerald-500/10",
|
||||
shouldUseBlue && "from-blue-500/10",
|
||||
!isActiveProvider && "from-primary/10",
|
||||
isActiveProvider ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -209,13 +233,20 @@ export function ProviderCard({
|
||||
{provider.name}
|
||||
</h3>
|
||||
|
||||
{/* 健康状态徽章和优先级 */}
|
||||
{isProxyRunning && health && (
|
||||
{/* 健康状态徽章 */}
|
||||
{isProxyRunning && isInFailoverQueue && health && (
|
||||
<ProviderHealthBadge
|
||||
consecutiveFailures={health.consecutive_failures}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 故障转移优先级徽章 */}
|
||||
{isAutoFailoverEnabled &&
|
||||
isInFailoverQueue &&
|
||||
failoverPriority && (
|
||||
<FailoverPriorityBadge priority={failoverPriority} />
|
||||
)}
|
||||
|
||||
{provider.category === "third_party" &&
|
||||
provider.meta?.isPartner && (
|
||||
<span
|
||||
@@ -308,6 +339,10 @@ export function ProviderCard({
|
||||
onTest={onTest ? () => onTest(provider) : undefined}
|
||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||
onDelete={() => onDelete(provider)}
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||
isInFailoverQueue={isInFailoverQueue}
|
||||
onToggleFailover={onToggleFailover}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProviderHealthStatus } from "@/types/proxy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ProviderHealthBadgeProps {
|
||||
consecutiveFailures: number;
|
||||
@@ -14,11 +15,14 @@ export function ProviderHealthBadge({
|
||||
consecutiveFailures,
|
||||
className,
|
||||
}: ProviderHealthBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 根据失败次数计算状态
|
||||
const getStatus = () => {
|
||||
if (consecutiveFailures === 0) {
|
||||
return {
|
||||
label: "正常",
|
||||
labelKey: "health.operational",
|
||||
labelFallback: "正常",
|
||||
status: ProviderHealthStatus.Healthy,
|
||||
color: "bg-green-500",
|
||||
// 使用更深/柔和的背景色,去除可能的白色内容感
|
||||
@@ -27,7 +31,8 @@ export function ProviderHealthBadge({
|
||||
};
|
||||
} else if (consecutiveFailures < 5) {
|
||||
return {
|
||||
label: "降级",
|
||||
labelKey: "health.degraded",
|
||||
labelFallback: "降级",
|
||||
status: ProviderHealthStatus.Degraded,
|
||||
color: "bg-yellow-500",
|
||||
bgColor: "bg-yellow-500/10",
|
||||
@@ -35,7 +40,8 @@ export function ProviderHealthBadge({
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
label: "熔断",
|
||||
labelKey: "health.circuitOpen",
|
||||
labelFallback: "熔断",
|
||||
status: ProviderHealthStatus.Failed,
|
||||
color: "bg-red-500",
|
||||
bgColor: "bg-red-500/10",
|
||||
@@ -45,6 +51,9 @@ export function ProviderHealthBadge({
|
||||
};
|
||||
|
||||
const statusConfig = getStatus();
|
||||
const label = t(statusConfig.labelKey, {
|
||||
defaultValue: statusConfig.labelFallback,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -54,10 +63,13 @@ export function ProviderHealthBadge({
|
||||
statusConfig.textColor,
|
||||
className,
|
||||
)}
|
||||
title={`连续失败 ${consecutiveFailures} 次`}
|
||||
title={t("health.consecutiveFailures", {
|
||||
count: consecutiveFailures,
|
||||
defaultValue: `连续失败 ${consecutiveFailures} 次`,
|
||||
})}
|
||||
>
|
||||
<div className={cn("w-2 h-2 rounded-full", statusConfig.color)} />
|
||||
<span>{statusConfig.label}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,13 +5,31 @@ import {
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import type { CSSProperties } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Provider } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { useDragSort } from "@/hooks/useDragSort";
|
||||
import { useStreamCheck } from "@/hooks/useStreamCheck";
|
||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||
import {
|
||||
useAutoFailoverEnabled,
|
||||
useFailoverQueue,
|
||||
useAddToFailoverQueue,
|
||||
useRemoveFromFailoverQueue,
|
||||
} from "@/lib/query/failover";
|
||||
import { useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ProviderListProps {
|
||||
providers: Record<string, Provider>;
|
||||
@@ -27,6 +45,7 @@ interface ProviderListProps {
|
||||
isLoading?: boolean;
|
||||
isProxyRunning?: boolean; // 代理服务运行状态
|
||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
|
||||
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||
}
|
||||
|
||||
export function ProviderList({
|
||||
@@ -41,9 +60,11 @@ export function ProviderList({
|
||||
onOpenWebsite,
|
||||
onCreate,
|
||||
isLoading = false,
|
||||
isProxyRunning = false, // 默认值为 false
|
||||
isProxyTakeover = false, // 默认值为 false
|
||||
isProxyRunning = false,
|
||||
isProxyTakeover = false,
|
||||
activeProviderId,
|
||||
}: ProviderListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
||||
providers,
|
||||
appId,
|
||||
@@ -52,17 +73,103 @@ export function ProviderList({
|
||||
// 流式健康检查
|
||||
const { checkProvider, isChecking } = useStreamCheck(appId);
|
||||
|
||||
// 故障转移相关
|
||||
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
|
||||
const { data: failoverQueue } = useFailoverQueue(appId);
|
||||
const addToQueue = useAddToFailoverQueue();
|
||||
const removeFromQueue = useRemoveFromFailoverQueue();
|
||||
|
||||
// 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式
|
||||
const isFailoverModeActive =
|
||||
isProxyTakeover === true && isAutoFailoverEnabled === true;
|
||||
|
||||
// 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序)
|
||||
const getFailoverPriority = useCallback(
|
||||
(providerId: string): number | undefined => {
|
||||
if (!isFailoverModeActive || !failoverQueue) return undefined;
|
||||
const index = failoverQueue.findIndex(
|
||||
(item) => item.providerId === providerId,
|
||||
);
|
||||
return index >= 0 ? index + 1 : undefined;
|
||||
},
|
||||
[isFailoverModeActive, failoverQueue],
|
||||
);
|
||||
|
||||
// 判断供应商是否在故障转移队列中
|
||||
const isInFailoverQueue = useCallback(
|
||||
(providerId: string): boolean => {
|
||||
if (!isFailoverModeActive || !failoverQueue) return false;
|
||||
return failoverQueue.some((item) => item.providerId === providerId);
|
||||
},
|
||||
[isFailoverModeActive, failoverQueue],
|
||||
);
|
||||
|
||||
// 切换供应商的故障转移队列状态
|
||||
const handleToggleFailover = useCallback(
|
||||
(providerId: string, enabled: boolean) => {
|
||||
if (enabled) {
|
||||
addToQueue.mutate({ appType: appId, providerId });
|
||||
} else {
|
||||
removeFromQueue.mutate({ appType: appId, providerId });
|
||||
}
|
||||
},
|
||||
[appId, addToQueue, removeFromQueue],
|
||||
);
|
||||
|
||||
const handleTest = (provider: Provider) => {
|
||||
checkProvider(provider.id, provider.name);
|
||||
};
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const key = event.key.toLowerCase();
|
||||
if ((event.metaKey || event.ctrlKey) && key === "f") {
|
||||
event.preventDefault();
|
||||
setIsSearchOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "escape") {
|
||||
setIsSearchOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearchOpen) {
|
||||
const frame = requestAnimationFrame(() => {
|
||||
searchInputRef.current?.focus();
|
||||
searchInputRef.current?.select();
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}
|
||||
}, [isSearchOpen]);
|
||||
|
||||
const filteredProviders = useMemo(() => {
|
||||
const keyword = searchTerm.trim().toLowerCase();
|
||||
if (!keyword) return sortedProviders;
|
||||
return sortedProviders.filter((provider) => {
|
||||
const fields = [provider.name, provider.notes, provider.websiteUrl];
|
||||
return fields.some((field) =>
|
||||
field?.toString().toLowerCase().includes(keyword),
|
||||
);
|
||||
});
|
||||
}, [searchTerm, sortedProviders]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-28 w-full rounded-lg border border-dashed border-muted-foreground/40 bg-muted/40"
|
||||
className="w-full border border-dashed rounded-lg h-28 border-muted-foreground/40 bg-muted/40"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -73,21 +180,18 @@ export function ProviderList({
|
||||
return <ProviderEmptyState onCreate={onCreate} />;
|
||||
}
|
||||
|
||||
return (
|
||||
const renderProviderList = () => (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortedProviders.map((provider) => provider.id)}
|
||||
items={filteredProviders.map((provider) => provider.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div
|
||||
className="space-y-3 animate-slide-up"
|
||||
style={{ animationDelay: "0.1s" }}
|
||||
>
|
||||
{sortedProviders.map((provider) => (
|
||||
<div className="space-y-3">
|
||||
{filteredProviders.map((provider) => (
|
||||
<SortableProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
@@ -103,12 +207,98 @@ export function ProviderList({
|
||||
isTesting={isChecking(provider.id)}
|
||||
isProxyRunning={isProxyRunning}
|
||||
isProxyTakeover={isProxyTakeover}
|
||||
// 故障转移相关:联动状态
|
||||
isAutoFailoverEnabled={isFailoverModeActive}
|
||||
failoverPriority={getFailoverPriority(provider.id)}
|
||||
isInFailoverQueue={isInFailoverQueue(provider.id)}
|
||||
onToggleFailover={(enabled) =>
|
||||
handleToggleFailover(provider.id, enabled)
|
||||
}
|
||||
activeProviderId={activeProviderId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<AnimatePresence>
|
||||
{isSearchOpen && (
|
||||
<motion.div
|
||||
key="provider-search"
|
||||
initial={{ opacity: 0, y: -8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.98 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
className="fixed left-1/2 top-[6.5rem] z-40 w-[min(90vw,26rem)] -translate-x-1/2 sm:right-6 sm:left-auto sm:translate-x-0"
|
||||
>
|
||||
<div className="p-4 space-y-3 border shadow-md rounded-2xl border-white/10 bg-background/95 shadow-black/20 backdrop-blur-md">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Search className="absolute w-4 h-4 -translate-y-1/2 pointer-events-none left-3 top-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder={t("provider.searchPlaceholder", {
|
||||
defaultValue: "Search name, notes, or URL...",
|
||||
})}
|
||||
aria-label={t("provider.searchAriaLabel", {
|
||||
defaultValue: "Search providers",
|
||||
})}
|
||||
className="pr-16 pl-9"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute text-xs -translate-y-1/2 right-11 top-1/2"
|
||||
onClick={() => setSearchTerm("")}
|
||||
>
|
||||
{t("common.clear", { defaultValue: "Clear" })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto"
|
||||
onClick={() => setIsSearchOpen(false)}
|
||||
aria-label={t("provider.searchCloseAriaLabel", {
|
||||
defaultValue: "Close provider search",
|
||||
})}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{t("provider.searchScopeHint", {
|
||||
defaultValue: "Matches provider name, notes, and URL.",
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t("provider.searchCloseHint", {
|
||||
defaultValue: "Press Esc to close",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{filteredProviders.length === 0 ? (
|
||||
<div className="px-6 py-8 text-sm text-center border border-dashed rounded-lg border-border text-muted-foreground">
|
||||
{t("provider.noSearchResults", {
|
||||
defaultValue: "No providers match your search.",
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
renderProviderList()
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableProviderCardProps {
|
||||
@@ -125,6 +315,12 @@ interface SortableProviderCardProps {
|
||||
isTesting: boolean;
|
||||
isProxyRunning: boolean;
|
||||
isProxyTakeover: boolean;
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled: boolean;
|
||||
failoverPriority?: number;
|
||||
isInFailoverQueue: boolean;
|
||||
onToggleFailover: (enabled: boolean) => void;
|
||||
activeProviderId?: string;
|
||||
}
|
||||
|
||||
function SortableProviderCard({
|
||||
@@ -141,6 +337,11 @@ function SortableProviderCard({
|
||||
isTesting,
|
||||
isProxyRunning,
|
||||
isProxyTakeover,
|
||||
isAutoFailoverEnabled,
|
||||
failoverPriority,
|
||||
isInFailoverQueue,
|
||||
onToggleFailover,
|
||||
activeProviderId,
|
||||
}: SortableProviderCardProps) {
|
||||
const {
|
||||
setNodeRef,
|
||||
@@ -179,6 +380,12 @@ function SortableProviderCard({
|
||||
listeners,
|
||||
isDragging,
|
||||
}}
|
||||
// 故障转移相关
|
||||
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||
failoverPriority={failoverPriority}
|
||||
isInFailoverQueue={isInFailoverQueue}
|
||||
onToggleFailover={onToggleFailover}
|
||||
activeProviderId={activeProviderId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
||||
|
||||
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
|
||||
disabled
|
||||
? "bg-gray-100 dark:bg-gray-800 border-border-default text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
||||
? "bg-muted border-border-default text-muted-foreground cursor-not-allowed"
|
||||
: "border-border-default dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20"
|
||||
}`;
|
||||
|
||||
|
||||
@@ -52,7 +52,15 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="w-20 h-20 p-3 rounded-xl border-2 border-muted hover:border-primary transition-colors cursor-pointer bg-muted/30 hover:bg-muted/50 flex items-center justify-center"
|
||||
title={currentIcon ? "点击更换图标" : "点击选择图标"}
|
||||
title={
|
||||
currentIcon
|
||||
? t("providerIcon.clickToChange", {
|
||||
defaultValue: "点击更换图标",
|
||||
})
|
||||
: t("providerIcon.clickToSelect", {
|
||||
defaultValue: "点击选择图标",
|
||||
})
|
||||
}
|
||||
>
|
||||
<ProviderIcon
|
||||
icon={currentIcon}
|
||||
@@ -145,7 +153,10 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
||||
<FormItem>
|
||||
<FormLabel>{t("provider.websiteUrl")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://" />
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t("providerForm.websiteUrlPlaceholder")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -149,10 +149,7 @@ export function ProviderPresetSelector({
|
||||
className={`${getPresetButtonClass(isSelected, entry.preset)} relative`}
|
||||
style={getPresetButtonStyle(isSelected, entry.preset)}
|
||||
title={
|
||||
presetCategoryLabels[category] ??
|
||||
t("providerPreset.categoryOther", {
|
||||
defaultValue: "其他",
|
||||
})
|
||||
presetCategoryLabels[category] ?? t("providerPreset.other")
|
||||
}
|
||||
>
|
||||
{renderPresetIcon(entry.preset)}
|
||||
|
||||
@@ -12,14 +12,14 @@ import {
|
||||
} from "@/lib/query/failover";
|
||||
|
||||
export interface AutoFailoverConfigPanelProps {
|
||||
enabled: boolean;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
enabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function AutoFailoverConfigPanel({
|
||||
enabled,
|
||||
enabled = true,
|
||||
onEnabledChange: _onEnabledChange,
|
||||
}: AutoFailoverConfigPanelProps) {
|
||||
}: AutoFailoverConfigPanelProps = {}) {
|
||||
// Note: onEnabledChange is currently unused but kept in the interface
|
||||
// for potential future use by parent components
|
||||
void _onEnabledChange;
|
||||
|
||||
@@ -2,37 +2,14 @@
|
||||
* 故障转移队列管理组件
|
||||
*
|
||||
* 允许用户管理代理模式下的故障转移队列,支持:
|
||||
* - 拖拽排序
|
||||
* - 添加/移除供应商
|
||||
* - 启用/禁用队列项
|
||||
* - 队列顺序基于首页供应商列表的 sort_index
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { DndContext, closestCenter } from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
GripVertical,
|
||||
Plus,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Info,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Plus, Trash2, Loader2, Info, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
@@ -51,8 +28,8 @@ import {
|
||||
useAvailableProvidersForFailover,
|
||||
useAddToFailoverQueue,
|
||||
useRemoveFromFailoverQueue,
|
||||
useReorderFailoverQueue,
|
||||
useSetFailoverItemEnabled,
|
||||
useAutoFailoverEnabled,
|
||||
useSetAutoFailoverEnabled,
|
||||
} from "@/lib/query/failover";
|
||||
|
||||
interface FailoverQueueManagerProps {
|
||||
@@ -67,6 +44,10 @@ export function FailoverQueueManager({
|
||||
const { t } = useTranslation();
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string>("");
|
||||
|
||||
// 故障转移开关状态(每个应用独立)
|
||||
const { data: isFailoverEnabled = false } = useAutoFailoverEnabled(appType);
|
||||
const setFailoverEnabled = useSetAutoFailoverEnabled();
|
||||
|
||||
// 查询数据
|
||||
const {
|
||||
data: queue,
|
||||
@@ -79,59 +60,11 @@ export function FailoverQueueManager({
|
||||
// Mutations
|
||||
const addToQueue = useAddToFailoverQueue();
|
||||
const removeFromQueue = useRemoveFromFailoverQueue();
|
||||
const reorderQueue = useReorderFailoverQueue();
|
||||
const setItemEnabled = useSetFailoverItemEnabled();
|
||||
|
||||
// 拖拽配置
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
// 排序后的队列
|
||||
const sortedQueue = useMemo(() => {
|
||||
if (!queue) return [];
|
||||
return [...queue].sort((a, b) => a.queueOrder - b.queueOrder);
|
||||
}, [queue]);
|
||||
|
||||
// 处理拖拽结束
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id || !sortedQueue) return;
|
||||
|
||||
const oldIndex = sortedQueue.findIndex(
|
||||
(item) => item.providerId === active.id,
|
||||
);
|
||||
const newIndex = sortedQueue.findIndex(
|
||||
(item) => item.providerId === over.id,
|
||||
);
|
||||
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
const reordered = arrayMove(sortedQueue, oldIndex, newIndex);
|
||||
const providerIds = reordered.map((item) => item.providerId);
|
||||
|
||||
try {
|
||||
await reorderQueue.mutateAsync({ appType, providerIds });
|
||||
toast.success(
|
||||
t("proxy.failoverQueue.reorderSuccess", "队列顺序已更新"),
|
||||
{ closeButton: true },
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("proxy.failoverQueue.reorderFailed", "更新顺序失败") +
|
||||
": " +
|
||||
String(error),
|
||||
);
|
||||
}
|
||||
},
|
||||
[sortedQueue, appType, reorderQueue, t],
|
||||
);
|
||||
// 切换故障转移开关
|
||||
const handleToggleFailover = (enabled: boolean) => {
|
||||
setFailoverEnabled.mutate({ appType, enabled });
|
||||
};
|
||||
|
||||
// 添加供应商到队列
|
||||
const handleAddProvider = async () => {
|
||||
@@ -171,19 +104,6 @@ export function FailoverQueueManager({
|
||||
}
|
||||
};
|
||||
|
||||
// 切换启用状态
|
||||
const handleToggleEnabled = async (providerId: string, enabled: boolean) => {
|
||||
try {
|
||||
await setItemEnabled.mutateAsync({ appType, providerId, enabled });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("proxy.failoverQueue.toggleFailed", "状态更新失败") +
|
||||
": " +
|
||||
String(error),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (isQueueLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
@@ -203,13 +123,41 @@ export function FailoverQueueManager({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 自动故障转移开关 */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50 border border-border/50">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t("proxy.failover.autoSwitch", {
|
||||
defaultValue: "自动故障转移",
|
||||
})}
|
||||
</span>
|
||||
{isFailoverEnabled && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-emerald-500/20 text-emerald-600 dark:text-emerald-400">
|
||||
{t("common.enabled", { defaultValue: "已开启" })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.failover.autoSwitchDescription", {
|
||||
defaultValue: "开启后,请求失败时自动切换到队列中的下一个供应商",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isFailoverEnabled}
|
||||
onCheckedChange={handleToggleFailover}
|
||||
disabled={disabled || setFailoverEnabled.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 说明信息 */}
|
||||
<Alert className="border-blue-500/40 bg-blue-500/10">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
{t(
|
||||
"proxy.failoverQueue.info",
|
||||
"当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。",
|
||||
"队列顺序与首页供应商列表顺序一致。当请求失败时,系统会按顺序依次尝试队列中的供应商。",
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -260,7 +208,7 @@ export function FailoverQueueManager({
|
||||
</div>
|
||||
|
||||
{/* 队列列表 */}
|
||||
{sortedQueue.length === 0 ? (
|
||||
{!queue || queue.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
@@ -270,39 +218,26 @@ export function FailoverQueueManager({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortedQueue.map((item) => item.providerId)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{sortedQueue.map((item, index) => (
|
||||
<SortableQueueItem
|
||||
key={item.providerId}
|
||||
item={item}
|
||||
index={index}
|
||||
disabled={disabled}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
onRemove={handleRemoveProvider}
|
||||
isRemoving={removeFromQueue.isPending}
|
||||
isToggling={setItemEnabled.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<div className="space-y-2">
|
||||
{queue.map((item, index) => (
|
||||
<QueueItem
|
||||
key={item.providerId}
|
||||
item={item}
|
||||
index={index}
|
||||
disabled={disabled}
|
||||
onRemove={handleRemoveProvider}
|
||||
isRemoving={removeFromQueue.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 队列说明 */}
|
||||
{sortedQueue.length > 0 && (
|
||||
{queue && queue.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"proxy.failoverQueue.dragHint",
|
||||
"拖拽供应商可调整故障转移顺序,序号越小优先级越高。",
|
||||
"proxy.failoverQueue.orderHint",
|
||||
"队列顺序与首页供应商列表顺序一致,可在首页拖拽调整顺序。",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
@@ -310,65 +245,29 @@ export function FailoverQueueManager({
|
||||
);
|
||||
}
|
||||
|
||||
interface SortableQueueItemProps {
|
||||
interface QueueItemProps {
|
||||
item: FailoverQueueItem;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
onToggleEnabled: (providerId: string, enabled: boolean) => void;
|
||||
onRemove: (providerId: string) => void;
|
||||
isRemoving: boolean;
|
||||
isToggling: boolean;
|
||||
}
|
||||
|
||||
function SortableQueueItem({
|
||||
function QueueItem({
|
||||
item,
|
||||
index,
|
||||
disabled,
|
||||
onToggleEnabled,
|
||||
onRemove,
|
||||
isRemoving,
|
||||
isToggling,
|
||||
}: SortableQueueItemProps) {
|
||||
}: QueueItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
setNodeRef,
|
||||
attributes,
|
||||
listeners,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: item.providerId, disabled });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border bg-card p-3 transition-colors",
|
||||
isDragging && "opacity-50 shadow-lg",
|
||||
!item.enabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-grab touch-none text-muted-foreground hover:text-foreground",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
disabled={disabled}
|
||||
aria-label={t("provider.dragHandle", "拖拽排序")}
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* 序号 */}
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium">
|
||||
{index + 1}
|
||||
@@ -376,24 +275,11 @@ function SortableQueueItem({
|
||||
|
||||
{/* 供应商名称 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium truncate block",
|
||||
!item.enabled && "text-muted-foreground line-through",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium truncate block">
|
||||
{item.providerName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onCheckedChange={(checked) => onToggleEnabled(item.providerId, checked)}
|
||||
disabled={disabled || isToggling}
|
||||
aria-label={t("proxy.failoverQueue.toggleEnabled", "启用/禁用")}
|
||||
/>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -15,8 +15,10 @@ import { useFailoverQueue } from "@/lib/query/failover";
|
||||
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
||||
import { useProviderHealth } from "@/lib/query/failover";
|
||||
import type { ProxyStatus } from "@/types/proxy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function ProxyPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { status, isRunning } = useProxyStatus();
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
@@ -48,7 +50,11 @@ export function ProxyPanel() {
|
||||
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-muted-foreground">服务地址</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("proxy.panel.serviceAddress", {
|
||||
defaultValue: "服务地址",
|
||||
})}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -56,7 +62,7 @@ export function ProxyPanel() {
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
配置
|
||||
{t("common.settings")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
@@ -70,16 +76,23 @@ export function ProxyPanel() {
|
||||
navigator.clipboard.writeText(
|
||||
`http://${status.address}:${status.port}`,
|
||||
);
|
||||
toast.success("地址已复制", { closeButton: true });
|
||||
toast.success(
|
||||
t("proxy.panel.addressCopied", {
|
||||
defaultValue: "地址已复制",
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
复制
|
||||
{t("common.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-border space-y-2">
|
||||
<p className="text-xs text-muted-foreground">使用中</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("provider.inUse")}
|
||||
</p>
|
||||
{status.active_targets && status.active_targets.length > 0 ? (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{status.active_targets.map((target) => (
|
||||
@@ -101,14 +114,18 @@ export function ProxyPanel() {
|
||||
</div>
|
||||
) : status.current_provider ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
当前 Provider:{" "}
|
||||
{t("proxy.panel.currentProvider", {
|
||||
defaultValue: "当前 Provider:",
|
||||
})}{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{status.current_provider}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
当前 Provider:等待首次请求…
|
||||
{t("proxy.panel.waitingFirstRequest", {
|
||||
defaultValue: "当前 Provider:等待首次请求…",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -121,7 +138,7 @@ export function ProxyPanel() {
|
||||
<div className="flex items-center gap-2">
|
||||
<ListOrdered className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
故障转移队列
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -130,13 +147,10 @@ export function ProxyPanel() {
|
||||
<ProviderQueueGroup
|
||||
appType="claude"
|
||||
appLabel="Claude"
|
||||
targets={claudeQueue
|
||||
.filter((item) => item.enabled)
|
||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
||||
.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
targets={claudeQueue.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
@@ -146,13 +160,10 @@ export function ProxyPanel() {
|
||||
<ProviderQueueGroup
|
||||
appType="codex"
|
||||
appLabel="Codex"
|
||||
targets={codexQueue
|
||||
.filter((item) => item.enabled)
|
||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
||||
.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
targets={codexQueue.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
@@ -162,13 +173,10 @@ export function ProxyPanel() {
|
||||
<ProviderQueueGroup
|
||||
appType="gemini"
|
||||
appLabel="Gemini"
|
||||
targets={geminiQueue
|
||||
.filter((item) => item.enabled)
|
||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
||||
.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
targets={geminiQueue.map((item) => ({
|
||||
id: item.providerId,
|
||||
name: item.providerName,
|
||||
}))}
|
||||
status={status}
|
||||
/>
|
||||
)}
|
||||
@@ -179,23 +187,31 @@ export function ProxyPanel() {
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<StatCard
|
||||
icon={<Activity className="h-4 w-4" />}
|
||||
label="活跃连接"
|
||||
label={t("proxy.panel.stats.activeConnections", {
|
||||
defaultValue: "活跃连接",
|
||||
})}
|
||||
value={status.active_connections}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<TrendingUp className="h-4 w-4" />}
|
||||
label="总请求数"
|
||||
label={t("proxy.panel.stats.totalRequests", {
|
||||
defaultValue: "总请求数",
|
||||
})}
|
||||
value={status.total_requests}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
label="成功率"
|
||||
label={t("proxy.panel.stats.successRate", {
|
||||
defaultValue: "成功率",
|
||||
})}
|
||||
value={`${status.success_rate.toFixed(1)}%`}
|
||||
variant={status.success_rate > 90 ? "success" : "warning"}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
label="运行时间"
|
||||
label={t("proxy.panel.stats.uptime", {
|
||||
defaultValue: "运行时间",
|
||||
})}
|
||||
value={formatUptime(status.uptime_seconds)}
|
||||
/>
|
||||
</div>
|
||||
@@ -206,10 +222,14 @@ export function ProxyPanel() {
|
||||
<Server className="h-8 w-8" />
|
||||
</div>
|
||||
<p className="text-base font-medium text-foreground mb-1">
|
||||
代理服务已停止
|
||||
{t("proxy.panel.stoppedTitle", {
|
||||
defaultValue: "代理服务已停止",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
使用右上角开关即可启动服务
|
||||
{t("proxy.panel.stoppedDescription", {
|
||||
defaultValue: "使用右上角开关即可启动服务",
|
||||
})}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -218,7 +238,9 @@ export function ProxyPanel() {
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
配置代理服务
|
||||
{t("proxy.panel.openSettings", {
|
||||
defaultValue: "配置代理服务",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -319,6 +341,7 @@ function ProviderQueueItem({
|
||||
appType,
|
||||
isCurrent,
|
||||
}: ProviderQueueItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: health } = useProviderHealth(provider.id, appType);
|
||||
|
||||
return (
|
||||
@@ -344,7 +367,7 @@ function ProviderQueueItem({
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/20 text-primary">
|
||||
使用中
|
||||
{t("provider.inUse")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,11 @@ import type { ProxyConfig } from "@/types/proxy";
|
||||
// 表单数据类型(仅包含可编辑字段)
|
||||
type ProxyConfigForm = Pick<
|
||||
ProxyConfig,
|
||||
"listen_address" | "listen_port" | "max_retries" | "request_timeout" | "enable_logging"
|
||||
| "listen_address"
|
||||
| "listen_port"
|
||||
| "max_retries"
|
||||
| "request_timeout"
|
||||
| "enable_logging"
|
||||
>;
|
||||
|
||||
const createProxyConfigSchema = (t: TFunction) => {
|
||||
|
||||
@@ -49,10 +49,7 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"p-1 rounded-xl transition-all",
|
||||
className,
|
||||
)}
|
||||
className={cn("p-1 rounded-xl transition-all", className)}
|
||||
title={tooltipText}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-2 h-8 rounded-md cursor-default">
|
||||
|
||||
@@ -93,7 +93,7 @@ export function ImportExportSection({
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="absolute -top-2 -right-2 h-6 w-6 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center shadow-lg transition-colors z-10"
|
||||
aria-label="Clear selection"
|
||||
aria-label={t("common.clear")}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Loader2,
|
||||
Save,
|
||||
@@ -181,7 +182,6 @@ export function SettingsPage({
|
||||
stopWithRestore,
|
||||
isPending: isProxyPending,
|
||||
} = useProxyStatus();
|
||||
const [failoverEnabled, setFailoverEnabled] = useState(true);
|
||||
|
||||
const handleToggleProxy = async (checked: boolean) => {
|
||||
try {
|
||||
@@ -214,16 +214,19 @@ export function SettingsPage({
|
||||
<TabsTrigger value="advanced">
|
||||
{t("settings.tabAdvanced")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="usage">
|
||||
{t("usage.title", "使用统计")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="usage">{t("usage.title")}</TabsTrigger>
|
||||
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-2">
|
||||
<TabsContent value="general" className="space-y-6 mt-0">
|
||||
{settings ? (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<LanguageSettings
|
||||
value={settings.language}
|
||||
onChange={(lang) => handleAutoSave({ language: lang })}
|
||||
@@ -233,13 +236,18 @@ export function SettingsPage({
|
||||
settings={settings}
|
||||
onChange={handleAutoSave}
|
||||
/>
|
||||
</>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-6 mt-0 pb-6">
|
||||
{settings ? (
|
||||
<div className="space-y-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={[]}
|
||||
@@ -254,10 +262,10 @@ export function SettingsPage({
|
||||
<FolderSearch className="h-5 w-5 text-primary" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
配置文件目录
|
||||
{t("settings.advanced.configDir.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
管理 Claude、Codex 和 Gemini 的配置存储路径
|
||||
{t("settings.advanced.configDir.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -289,10 +297,10 @@ export function SettingsPage({
|
||||
<Server className="h-5 w-5 text-green-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
本地代理
|
||||
{t("settings.advanced.proxy.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
控制代理服务开关、查看状态与端口信息
|
||||
{t("settings.advanced.proxy.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -307,7 +315,9 @@ export function SettingsPage({
|
||||
<Activity
|
||||
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
{isRunning ? "运行中" : "已停止"}
|
||||
{isRunning
|
||||
? t("settings.advanced.proxy.running")
|
||||
: t("settings.advanced.proxy.stopped")}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={isRunning}
|
||||
@@ -330,10 +340,10 @@ export function SettingsPage({
|
||||
<Activity className="h-5 w-5 text-indigo-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
模型测试配置
|
||||
{t("settings.advanced.modelTest.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
配置模型测试使用的默认模型和提示词
|
||||
{t("settings.advanced.modelTest.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -345,44 +355,43 @@ export function SettingsPage({
|
||||
|
||||
<AccordionItem
|
||||
value="failover"
|
||||
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
|
||||
className="rounded-xl glass-card overflow-hidden"
|
||||
>
|
||||
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
|
||||
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-orange-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
自动故障转移
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
配置故障转移队列和熔断策略
|
||||
</p>
|
||||
</div>
|
||||
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-orange-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
{t("settings.advanced.failover.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
{t("settings.advanced.failover.description")}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<Switch
|
||||
checked={failoverEnabled}
|
||||
onCheckedChange={setFailoverEnabled}
|
||||
/>
|
||||
</div>
|
||||
</AccordionPrimitive.Header>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||
<div className="space-y-6">
|
||||
{/* 故障转移队列管理 */}
|
||||
{/* 代理未运行时的提示 */}
|
||||
{!isRunning && (
|
||||
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
{t("proxy.failover.proxyRequired", {
|
||||
defaultValue:
|
||||
"需要先启动代理服务才能配置故障转移",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 故障转移队列管理 - 每个应用独立 */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.failoverQueue.title", "故障转移队列")}
|
||||
{t("proxy.failoverQueue.title")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"proxy.failoverQueue.description",
|
||||
"管理各应用的供应商故障转移顺序",
|
||||
)}
|
||||
{t("proxy.failoverQueue.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Tabs defaultValue="claude" className="w-full">
|
||||
@@ -394,30 +403,27 @@ export function SettingsPage({
|
||||
<TabsContent value="claude" className="mt-4">
|
||||
<FailoverQueueManager
|
||||
appType="claude"
|
||||
disabled={!failoverEnabled}
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="codex" className="mt-4">
|
||||
<FailoverQueueManager
|
||||
appType="codex"
|
||||
disabled={!failoverEnabled}
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="gemini" className="mt-4">
|
||||
<FailoverQueueManager
|
||||
appType="gemini"
|
||||
disabled={!failoverEnabled}
|
||||
disabled={!isRunning}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 熔断器配置 */}
|
||||
{/* 熔断器配置 - 全局共享 */}
|
||||
<div className="border-t border-border/50 pt-6">
|
||||
<AutoFailoverConfigPanel
|
||||
enabled={failoverEnabled}
|
||||
onEnabledChange={setFailoverEnabled}
|
||||
/>
|
||||
<AutoFailoverConfigPanel />
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
@@ -432,10 +438,10 @@ export function SettingsPage({
|
||||
<Coins className="h-5 w-5 text-yellow-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
成本定价
|
||||
{t("settings.advanced.pricing.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
管理各模型 Token 计费规则
|
||||
{t("settings.advanced.pricing.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -454,10 +460,10 @@ export function SettingsPage({
|
||||
<Database className="h-5 w-5 text-blue-500" />
|
||||
<div className="text-left">
|
||||
<h3 className="text-base font-semibold">
|
||||
数据管理
|
||||
{t("settings.advanced.data.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
导入导出配置与备份恢复
|
||||
{t("settings.advanced.data.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -497,7 +503,7 @@ export function SettingsPage({
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
|
||||
onChange({ enableClaudePluginIntegration: value })
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
icon={<MonitorUp className="h-4 w-4 text-cyan-500" />}
|
||||
title={t("settings.skipClaudeOnboarding")}
|
||||
description={t("settings.skipClaudeOnboardingDescription")}
|
||||
checked={!!settings.skipClaudeOnboarding}
|
||||
onCheckedChange={(value) => onChange({ skipClaudeOnboarding: value })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -9,14 +9,14 @@ const Switch = React.forwardRef<
|
||||
<SwitchPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-emerald-500 dark:data-[state=checked]:bg-emerald-600 data-[state=unchecked]:bg-gray-300 dark:data-[state=unchecked]:bg-gray-700",
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-emerald-500 dark:data-[state=checked]:bg-emerald-600 data-[state=unchecked]:bg-gray-200 dark:data-[state=unchecked]:bg-gray-900",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-white dark:bg-gray-400 shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
@@ -47,13 +47,11 @@ export function ModelTestConfigPanel() {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await saveStreamCheckConfig(config);
|
||||
toast.success(t("streamCheck.configSaved", "健康检查配置已保存"), {
|
||||
toast.success(t("streamCheck.configSaved"), {
|
||||
closeButton: true,
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("streamCheck.configSaveFailed", "保存失败") + ": " + String(e),
|
||||
);
|
||||
toast.error(t("streamCheck.configSaveFailed") + ": " + String(e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -78,13 +76,11 @@ export function ModelTestConfigPanel() {
|
||||
{/* 测试模型配置 */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{t("streamCheck.testModels", "测试模型")}
|
||||
{t("streamCheck.testModels")}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="claudeModel">
|
||||
{t("streamCheck.claudeModel", "Claude 模型")}
|
||||
</Label>
|
||||
<Label htmlFor="claudeModel">{t("streamCheck.claudeModel")}</Label>
|
||||
<Input
|
||||
id="claudeModel"
|
||||
value={config.claudeModel}
|
||||
@@ -96,9 +92,7 @@ export function ModelTestConfigPanel() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="codexModel">
|
||||
{t("streamCheck.codexModel", "Codex 模型")}
|
||||
</Label>
|
||||
<Label htmlFor="codexModel">{t("streamCheck.codexModel")}</Label>
|
||||
<Input
|
||||
id="codexModel"
|
||||
value={config.codexModel}
|
||||
@@ -110,9 +104,7 @@ export function ModelTestConfigPanel() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="geminiModel">
|
||||
{t("streamCheck.geminiModel", "Gemini 模型")}
|
||||
</Label>
|
||||
<Label htmlFor="geminiModel">{t("streamCheck.geminiModel")}</Label>
|
||||
<Input
|
||||
id="geminiModel"
|
||||
value={config.geminiModel}
|
||||
@@ -128,13 +120,11 @@ export function ModelTestConfigPanel() {
|
||||
{/* 检查参数配置 */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{t("streamCheck.checkParams", "检查参数")}
|
||||
{t("streamCheck.checkParams")}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timeoutSecs">
|
||||
{t("streamCheck.timeout", "超时时间(秒)")}
|
||||
</Label>
|
||||
<Label htmlFor="timeoutSecs">{t("streamCheck.timeout")}</Label>
|
||||
<Input
|
||||
id="timeoutSecs"
|
||||
type="number"
|
||||
@@ -151,9 +141,7 @@ export function ModelTestConfigPanel() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxRetries">
|
||||
{t("streamCheck.maxRetries", "最大重试次数")}
|
||||
</Label>
|
||||
<Label htmlFor="maxRetries">{t("streamCheck.maxRetries")}</Label>
|
||||
<Input
|
||||
id="maxRetries"
|
||||
type="number"
|
||||
@@ -171,7 +159,7 @@ export function ModelTestConfigPanel() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="degradedThresholdMs">
|
||||
{t("streamCheck.degradedThreshold", "降级阈值(毫秒)")}
|
||||
{t("streamCheck.degradedThreshold")}
|
||||
</Label>
|
||||
<Input
|
||||
id="degradedThresholdMs"
|
||||
@@ -196,12 +184,12 @@ export function ModelTestConfigPanel() {
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("common.saving", "保存中...")}
|
||||
{t("common.saving")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t("common.save", "保存")}
|
||||
{t("common.save")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -63,7 +63,7 @@ export function PricingConfigPanel() {
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<CardTitle className="text-base">
|
||||
{t("usage.modelPricing", "模型定价")}
|
||||
{t("usage.modelPricing")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -85,7 +85,7 @@ export function PricingConfigPanel() {
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<CardTitle className="text-base">
|
||||
{t("usage.modelPricing", "模型定价")}
|
||||
{t("usage.modelPricing")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -93,8 +93,7 @@ export function PricingConfigPanel() {
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("usage.loadPricingError", "加载定价数据失败")}:{" "}
|
||||
{String(error)}
|
||||
{t("usage.loadPricingError")}: {String(error)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
@@ -107,7 +106,7 @@ export function PricingConfigPanel() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{t("usage.modelPricingDesc", "配置各模型的 Token 成本")} (每百万)
|
||||
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
|
||||
</h4>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
@@ -117,41 +116,36 @@ export function PricingConfigPanel() {
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t("common.add", "新增")}
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!pricing || pricing.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"usage.noPricingData",
|
||||
'暂无定价数据。点击"新增"添加模型定价配置。',
|
||||
)}
|
||||
</AlertDescription>
|
||||
<AlertDescription>{t("usage.noPricingData")}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="rounded-md bg-card/60 shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("usage.model", "模型")}</TableHead>
|
||||
<TableHead>{t("usage.displayName", "显示名称")}</TableHead>
|
||||
<TableHead>{t("usage.model")}</TableHead>
|
||||
<TableHead>{t("usage.displayName")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.inputCost", "输入成本")}
|
||||
{t("usage.inputCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.outputCost", "输出成本")}
|
||||
{t("usage.outputCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheReadCost", "缓存读取")}
|
||||
{t("usage.cacheReadCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheWriteCost", "缓存写入")}
|
||||
{t("usage.cacheWriteCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions", "操作")}
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -183,7 +177,7 @@ export function PricingConfigPanel() {
|
||||
setIsAddingNew(false);
|
||||
setEditingModel(model);
|
||||
}}
|
||||
title={t("common.edit", "编辑")}
|
||||
title={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -191,7 +185,7 @@ export function PricingConfigPanel() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteConfirm(model.modelId)}
|
||||
title={t("common.delete", "删除")}
|
||||
title={t("common.delete")}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -223,19 +217,14 @@ export function PricingConfigPanel() {
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("usage.deleteConfirmTitle", "确认删除")}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{t("usage.deleteConfirmTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"usage.deleteConfirmDesc",
|
||||
"确定要删除此模型定价配置吗?此操作无法撤销。",
|
||||
)}
|
||||
{t("usage.deleteConfirmDesc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||
{t("common.cancel", "取消")}
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -243,8 +232,8 @@ export function PricingConfigPanel() {
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending
|
||||
? t("common.deleting", "删除中...")
|
||||
: t("common.delete", "删除")}
|
||||
? t("common.deleting")
|
||||
: t("common.delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -106,7 +106,9 @@ export function PricingEditModal({
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, modelId: e.target.value })
|
||||
}
|
||||
placeholder="例如: claude-3-5-sonnet-20241022"
|
||||
placeholder={t("usage.modelIdPlaceholder", {
|
||||
defaultValue: "例如: claude-3-5-sonnet-20241022",
|
||||
})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -122,7 +124,9 @@ export function PricingEditModal({
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, displayName: e.target.value })
|
||||
}
|
||||
placeholder="例如: Claude 3.5 Sonnet"
|
||||
placeholder={t("usage.displayNamePlaceholder", {
|
||||
defaultValue: "例如: Claude 3.5 Sonnet",
|
||||
})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,14 @@ export function RequestDetailPanel({
|
||||
requestId,
|
||||
onClose,
|
||||
}: RequestDetailPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { data: request, isLoading } = useRequestDetail(requestId);
|
||||
const dateLocale =
|
||||
i18n.language === "zh"
|
||||
? "zh-CN"
|
||||
: i18n.language === "ja"
|
||||
? "ja-JP"
|
||||
: "en-US";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -69,7 +75,9 @@ export function RequestDetailPanel({
|
||||
{t("usage.time", "时间")}
|
||||
</dt>
|
||||
<dd>
|
||||
{new Date(request.createdAt * 1000).toLocaleString("zh-CN")}
|
||||
{new Date(request.createdAt * 1000).toLocaleString(
|
||||
dateLocale,
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { LogFilters } from "@/types/usage";
|
||||
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
|
||||
|
||||
export function RequestLogTable() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 默认时间范围:过去24小时
|
||||
@@ -62,6 +62,13 @@ export function RequestLogTable() {
|
||||
});
|
||||
};
|
||||
|
||||
const dateLocale =
|
||||
i18n.language === "zh"
|
||||
? "zh-CN"
|
||||
: i18n.language === "ja"
|
||||
? "ja-JP"
|
||||
: "en-US";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 筛选栏 */}
|
||||
@@ -77,10 +84,10 @@ export function RequestLogTable() {
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[130px] bg-background">
|
||||
<SelectValue placeholder={t("usage.endpoint", "端点")} />
|
||||
<SelectValue placeholder={t("usage.appType")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("common.all", "全部端点")}</SelectItem>
|
||||
<SelectItem value="all">{t("usage.allApps")}</SelectItem>
|
||||
<SelectItem value="claude">Claude</SelectItem>
|
||||
<SelectItem value="codex">Codex</SelectItem>
|
||||
<SelectItem value="gemini">Gemini</SelectItem>
|
||||
@@ -97,10 +104,10 @@ export function RequestLogTable() {
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[130px] bg-background">
|
||||
<SelectValue placeholder={t("usage.status", "状态码")} />
|
||||
<SelectValue placeholder={t("usage.statusCode")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("common.all", "全部状态")}</SelectItem>
|
||||
<SelectItem value="all">{t("common.all")}</SelectItem>
|
||||
<SelectItem value="200">200 OK</SelectItem>
|
||||
<SelectItem value="400">400 Bad Request</SelectItem>
|
||||
<SelectItem value="401">401 Unauthorized</SelectItem>
|
||||
@@ -113,7 +120,7 @@ export function RequestLogTable() {
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("usage.provider", "搜索供应商...")}
|
||||
placeholder={t("usage.searchProviderPlaceholder")}
|
||||
className="pl-9 bg-background"
|
||||
value={tempFilters.providerName || ""}
|
||||
onChange={(e) =>
|
||||
@@ -125,7 +132,7 @@ export function RequestLogTable() {
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t("usage.model", "搜索模型...")}
|
||||
placeholder={t("usage.searchModelPlaceholder")}
|
||||
className="w-[180px] bg-background"
|
||||
value={tempFilters.model || ""}
|
||||
onChange={(e) =>
|
||||
@@ -140,7 +147,7 @@ export function RequestLogTable() {
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="whitespace-nowrap">时间范围:</span>
|
||||
<span className="whitespace-nowrap">{t("usage.timeRange")}:</span>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
className="h-8 w-[200px] bg-background"
|
||||
@@ -190,7 +197,7 @@ export function RequestLogTable() {
|
||||
className="h-8"
|
||||
>
|
||||
<Search className="mr-2 h-3.5 w-3.5" />
|
||||
{t("common.search", "查询")}
|
||||
{t("common.search")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -199,7 +206,7 @@ export function RequestLogTable() {
|
||||
className="h-8"
|
||||
>
|
||||
<X className="mr-2 h-3.5 w-3.5" />
|
||||
{t("common.reset", "重置")}
|
||||
{t("common.reset")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -222,34 +229,34 @@ export function RequestLogTable() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("usage.time", "时间")}
|
||||
{t("usage.time")}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("usage.provider", "供应商")}
|
||||
{t("usage.provider")}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[280px] whitespace-nowrap">
|
||||
{t("usage.billingModel", "计费模型")}
|
||||
{t("usage.billingModel")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("usage.inputTokens", "输入")}
|
||||
{t("usage.inputTokens")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("usage.outputTokens", "输出")}
|
||||
{t("usage.outputTokens")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
||||
{t("usage.cacheReadTokens", "缓存读取")}
|
||||
{t("usage.cacheReadTokens")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
||||
{t("usage.cacheCreationTokens", "缓存写入")}
|
||||
{t("usage.cacheCreationTokens")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("usage.totalCost", "成本")}
|
||||
{t("usage.totalCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-center min-w-[140px] whitespace-nowrap">
|
||||
{t("usage.timingInfo", "用时/首字")}
|
||||
{t("usage.timingInfo")}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("usage.status", "状态")}
|
||||
{t("usage.status")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -260,18 +267,19 @@ export function RequestLogTable() {
|
||||
colSpan={10}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("usage.noData", "暂无数据")}
|
||||
{t("usage.noData")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.requestId}>
|
||||
<TableCell>
|
||||
{new Date(log.createdAt * 1000).toLocaleString("zh-CN")}
|
||||
{new Date(log.createdAt * 1000).toLocaleString(
|
||||
dateLocale,
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.providerName ||
|
||||
t("usage.unknownProvider", "未知供应商")}
|
||||
{log.providerName || t("usage.unknownProvider")}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="font-mono text-sm max-w-[280px] truncate"
|
||||
@@ -339,8 +347,8 @@ export function RequestLogTable() {
|
||||
}`}
|
||||
>
|
||||
{log.isStreaming
|
||||
? t("usage.stream", "流")
|
||||
: t("usage.nonStream", "非流")}
|
||||
? t("usage.stream")
|
||||
: t("usage.nonStream")}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -366,7 +374,7 @@ export function RequestLogTable() {
|
||||
{total > 0 && (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("usage.totalRecords", "共 {{total}} 条记录", { total })}
|
||||
{t("usage.totalRecords", { total })}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
|
||||
@@ -25,10 +25,8 @@ export function UsageDashboard() {
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold">{t("usage.title", "使用统计")}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("usage.subtitle", "查看 AI 模型的使用情况和成本统计")}
|
||||
</p>
|
||||
<h2 className="text-2xl font-bold">{t("usage.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t("usage.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
@@ -41,19 +39,19 @@ export function UsageDashboard() {
|
||||
value="1d"
|
||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||
>
|
||||
{t("usage.today", "24小时")}
|
||||
{t("usage.today")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="7d"
|
||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||
>
|
||||
{t("usage.last7days", "7天")}
|
||||
{t("usage.last7days")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="30d"
|
||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||
>
|
||||
{t("usage.last30days", "30天")}
|
||||
{t("usage.last30days")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
@@ -69,15 +67,15 @@ export function UsageDashboard() {
|
||||
<TabsList className="bg-muted/50">
|
||||
<TabsTrigger value="logs" className="gap-2">
|
||||
<ListFilter className="h-4 w-4" />
|
||||
{t("usage.requestLogs", "请求日志")}
|
||||
{t("usage.requestLogs")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="providers" className="gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
{t("usage.providerStats", "Provider 统计")}
|
||||
{t("usage.providerStats")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models" className="gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
{t("usage.modelStats", "模型统计")}
|
||||
{t("usage.modelStats")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
||||
|
||||
return [
|
||||
{
|
||||
title: t("usage.totalRequests", "总请求数"),
|
||||
title: t("usage.totalRequests"),
|
||||
value: totalRequests.toLocaleString(),
|
||||
icon: Activity,
|
||||
color: "text-blue-500",
|
||||
@@ -42,7 +42,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
||||
subValue: null,
|
||||
},
|
||||
{
|
||||
title: t("usage.totalCost", "总成本"),
|
||||
title: t("usage.totalCost"),
|
||||
value: `$${totalCost.toFixed(4)}`,
|
||||
icon: DollarSign,
|
||||
color: "text-green-500",
|
||||
@@ -50,7 +50,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
||||
subValue: null,
|
||||
},
|
||||
{
|
||||
title: t("usage.totalTokens", "总 Token 数"),
|
||||
title: t("usage.totalTokens"),
|
||||
value: totalTokens.toLocaleString(),
|
||||
icon: Layers,
|
||||
color: "text-purple-500",
|
||||
@@ -58,13 +58,13 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
||||
subValue: (
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>Input</span>
|
||||
<span>{t("usage.input")}</span>
|
||||
<span className="text-foreground/80">
|
||||
{(inputTokens / 1000).toFixed(1)}k
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span>Output</span>
|
||||
<span>{t("usage.output")}</span>
|
||||
<span className="text-foreground/80">
|
||||
{(outputTokens / 1000).toFixed(1)}k
|
||||
</span>
|
||||
@@ -73,7 +73,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("usage.cacheTokens", "缓存 Token"),
|
||||
title: t("usage.cacheTokens"),
|
||||
value: totalCacheTokens.toLocaleString(),
|
||||
icon: Database,
|
||||
color: "text-orange-500",
|
||||
@@ -81,13 +81,13 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
||||
subValue: (
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>Write</span>
|
||||
<span>{t("usage.cacheWrite")}</span>
|
||||
<span className="text-foreground/80">
|
||||
{(cacheWriteTokens / 1000).toFixed(1)}k
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span>Read</span>
|
||||
<span>{t("usage.cacheRead")}</span>
|
||||
<span className="text-foreground/80">
|
||||
{(cacheReadTokens / 1000).toFixed(1)}k
|
||||
</span>
|
||||
|
||||
@@ -17,7 +17,7 @@ interface UsageTrendChartProps {
|
||||
}
|
||||
|
||||
export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { data: trends, isLoading } = useUsageTrends(days);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -29,14 +29,20 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
||||
}
|
||||
|
||||
const isToday = days === 1;
|
||||
const dateLocale =
|
||||
i18n.language === "zh"
|
||||
? "zh-CN"
|
||||
: i18n.language === "ja"
|
||||
? "ja-JP"
|
||||
: "en-US";
|
||||
const chartData =
|
||||
trends?.map((stat) => {
|
||||
const pointDate = new Date(stat.date);
|
||||
return {
|
||||
rawDate: stat.date,
|
||||
label: isToday
|
||||
? pointDate.toLocaleTimeString("zh-CN", { hour: "2-digit" })
|
||||
: pointDate.toLocaleDateString("zh-CN", {
|
||||
? pointDate.toLocaleTimeString(dateLocale, { hour: "2-digit" })
|
||||
: pointDate.toLocaleDateString(dateLocale, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}),
|
||||
|
||||
@@ -75,6 +75,11 @@ export function useDragSort(providers: Record<string, Provider>, appId: AppId) {
|
||||
queryKey: ["providers", appId],
|
||||
});
|
||||
|
||||
// 刷新故障转移队列(因为队列顺序依赖 sort_index)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["failoverQueue", appId],
|
||||
});
|
||||
|
||||
// 更新托盘菜单以反映新的排序(失败不影响主操作)
|
||||
try {
|
||||
await providersApi.updateTrayMenu();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ProxyConfig } from "@/types/proxy";
|
||||
|
||||
/**
|
||||
@@ -12,6 +13,7 @@ import type { ProxyConfig } from "@/types/proxy";
|
||||
*/
|
||||
export function useProxyConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 查询配置
|
||||
const { data: config, isLoading } = useQuery({
|
||||
@@ -24,12 +26,16 @@ export function useProxyConfig() {
|
||||
mutationFn: (newConfig: ProxyConfig) =>
|
||||
invoke("update_proxy_config", { config: newConfig }),
|
||||
onSuccess: () => {
|
||||
toast.success("代理配置已保存", { closeButton: true });
|
||||
toast.success(t("proxy.settings.toast.saved"), { closeButton: true });
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`保存失败: ${error.message}`);
|
||||
toast.error(
|
||||
t("proxy.settings.toast.saveFailed", {
|
||||
error: error.message,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@ export function useProxyStatus() {
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const detail = extractErrorMessage(error) || "未知错误";
|
||||
const detail =
|
||||
extractErrorMessage(error) ||
|
||||
t("common.unknown", { defaultValue: "未知错误" });
|
||||
toast.error(
|
||||
t("proxy.server.startFailed", {
|
||||
defaultValue: `启动代理服务失败: ${detail}`,
|
||||
@@ -71,11 +73,16 @@ export function useProxyStatus() {
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
|
||||
// 清除所有供应商健康状态缓存(后端已清空数据库记录)
|
||||
queryClient.invalidateQueries({ queryKey: ["providerHealth"] });
|
||||
// 彻底删除所有供应商健康状态缓存(后端已清空数据库记录)
|
||||
queryClient.removeQueries({ queryKey: ["providerHealth"] });
|
||||
// 彻底删除所有熔断器统计缓存(代理停止后熔断器状态已重置)
|
||||
queryClient.removeQueries({ queryKey: ["circuitBreakerStats"] });
|
||||
// 注意:故障转移队列和开关状态会保留,不需要刷新
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const detail = extractErrorMessage(error) || "未知错误";
|
||||
const detail =
|
||||
extractErrorMessage(error) ||
|
||||
t("common.unknown", { defaultValue: "未知错误" });
|
||||
toast.error(
|
||||
t("proxy.stopWithRestoreFailed", {
|
||||
defaultValue: `停止失败: ${detail}`,
|
||||
@@ -111,7 +118,9 @@ export function useProxyStatus() {
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const detail = extractErrorMessage(error) || "未知错误";
|
||||
const detail =
|
||||
extractErrorMessage(error) ||
|
||||
t("common.unknown", { defaultValue: "未知错误" });
|
||||
toast.error(
|
||||
t("proxy.takeover.failed", {
|
||||
defaultValue: `操作失败: ${detail}`,
|
||||
@@ -133,8 +142,15 @@ export function useProxyStatus() {
|
||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const detail = extractErrorMessage(error) || "未知错误";
|
||||
toast.error(`切换失败: ${detail}`);
|
||||
const detail =
|
||||
extractErrorMessage(error) ||
|
||||
t("common.unknown", { defaultValue: "未知错误" });
|
||||
toast.error(
|
||||
t("proxy.switchFailed", {
|
||||
error: detail,
|
||||
defaultValue: `切换失败: ${detail}`,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -160,6 +160,36 @@ export function useSettings(): UseSettingsResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Claude Code 初次安装确认:开=写入 hasCompletedOnboarding=true;关=删除该字段
|
||||
// 仅在本次更新包含 skipClaudeOnboarding 时触发,避免其它自动保存误触发
|
||||
const nextSkipClaudeOnboarding = updates.skipClaudeOnboarding;
|
||||
if (
|
||||
nextSkipClaudeOnboarding !== undefined &&
|
||||
nextSkipClaudeOnboarding !== (data?.skipClaudeOnboarding ?? false)
|
||||
) {
|
||||
try {
|
||||
if (nextSkipClaudeOnboarding) {
|
||||
await settingsApi.applyClaudeOnboardingSkip();
|
||||
} else {
|
||||
await settingsApi.clearClaudeOnboardingSkip();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[useSettings] Failed to sync Claude onboarding skip",
|
||||
error,
|
||||
);
|
||||
toast.error(
|
||||
nextSkipClaudeOnboarding
|
||||
? t("notifications.skipClaudeOnboardingFailed", {
|
||||
defaultValue: "跳过 Claude Code 初次安装确认失败",
|
||||
})
|
||||
: t("notifications.clearClaudeOnboardingSkipFailed", {
|
||||
defaultValue: "恢复 Claude Code 初次安装确认失败",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 持久化语言偏好
|
||||
try {
|
||||
if (typeof window !== "undefined" && updates.language) {
|
||||
@@ -242,6 +272,33 @@ export function useSettings(): UseSettingsResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Claude Code 初次安装确认:开=写入 hasCompletedOnboarding=true;关=删除该字段
|
||||
const prevSkipClaudeOnboarding = data?.skipClaudeOnboarding ?? false;
|
||||
const nextSkipClaudeOnboarding = payload.skipClaudeOnboarding ?? false;
|
||||
if (nextSkipClaudeOnboarding !== prevSkipClaudeOnboarding) {
|
||||
try {
|
||||
if (nextSkipClaudeOnboarding) {
|
||||
await settingsApi.applyClaudeOnboardingSkip();
|
||||
} else {
|
||||
await settingsApi.clearClaudeOnboardingSkip();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[useSettings] Failed to sync Claude onboarding skip",
|
||||
error,
|
||||
);
|
||||
toast.error(
|
||||
nextSkipClaudeOnboarding
|
||||
? t("notifications.skipClaudeOnboardingFailed", {
|
||||
defaultValue: "跳过 Claude Code 初次安装确认失败",
|
||||
})
|
||||
: t("notifications.clearClaudeOnboardingSkipFailed", {
|
||||
defaultValue: "恢复 Claude Code 初次安装确认失败",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 只在 Claude 插件集成状态真正改变时调用系统 API
|
||||
if (
|
||||
payload.enableClaudePluginIntegration !== undefined &&
|
||||
|
||||
@@ -83,6 +83,7 @@ export function useSettingsForm(): UseSettingsFormResult {
|
||||
minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,
|
||||
enableClaudePluginIntegration:
|
||||
data.enableClaudePluginIntegration ?? false,
|
||||
skipClaudeOnboarding: data.skipClaudeOnboarding ?? true,
|
||||
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
|
||||
codexConfigDir: sanitizeDir(data.codexConfigDir),
|
||||
language: normalizedLanguage,
|
||||
@@ -102,6 +103,7 @@ export function useSettingsForm(): UseSettingsFormResult {
|
||||
showInTray: true,
|
||||
minimizeToTrayOnClose: true,
|
||||
enableClaudePluginIntegration: false,
|
||||
skipClaudeOnboarding: true,
|
||||
language: readPersistedLanguage(),
|
||||
} as SettingsFormState);
|
||||
|
||||
@@ -136,6 +138,7 @@ export function useSettingsForm(): UseSettingsFormResult {
|
||||
minimizeToTrayOnClose: serverData.minimizeToTrayOnClose ?? true,
|
||||
enableClaudePluginIntegration:
|
||||
serverData.enableClaudePluginIntegration ?? false,
|
||||
skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? true,
|
||||
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
|
||||
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
|
||||
language: normalizedLanguage,
|
||||
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
type StreamCheckResult,
|
||||
} from "@/lib/api/model-test";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { useResetCircuitBreaker } from "@/lib/query/failover";
|
||||
|
||||
export function useStreamCheck(appId: AppId) {
|
||||
const { t } = useTranslation();
|
||||
const [checkingIds, setCheckingIds] = useState<Set<string>>(new Set());
|
||||
const resetCircuitBreaker = useResetCircuitBreaker();
|
||||
|
||||
const checkProvider = useCallback(
|
||||
async (
|
||||
@@ -30,6 +32,9 @@ export function useStreamCheck(appId: AppId) {
|
||||
}),
|
||||
{ closeButton: true },
|
||||
);
|
||||
|
||||
// 测试通过后重置熔断器状态
|
||||
resetCircuitBreaker.mutate({ providerId, appType: appId });
|
||||
} else if (result.status === "degraded") {
|
||||
toast.warning(
|
||||
t("streamCheck.degraded", {
|
||||
@@ -38,6 +43,9 @@ export function useStreamCheck(appId: AppId) {
|
||||
defaultValue: `${providerName} 响应较慢 (${result.responseTimeMs}ms)`,
|
||||
}),
|
||||
);
|
||||
|
||||
// 降级状态也重置熔断器,因为至少能通信
|
||||
resetCircuitBreaker.mutate({ providerId, appType: appId });
|
||||
} else {
|
||||
toast.error(
|
||||
t("streamCheck.failed", {
|
||||
@@ -66,7 +74,7 @@ export function useStreamCheck(appId: AppId) {
|
||||
});
|
||||
}
|
||||
},
|
||||
[appId, t],
|
||||
[appId, t, resetCircuitBreaker],
|
||||
);
|
||||
|
||||
const isChecking = useCallback(
|
||||
|
||||
+147
-4
@@ -32,7 +32,11 @@
|
||||
"back": "Back",
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Refreshing...",
|
||||
"notInstalled": "Not installed"
|
||||
"all": "All",
|
||||
"search": "Search",
|
||||
"reset": "Reset",
|
||||
"actions": "Actions",
|
||||
"deleting": "Deleting..."
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "Enter API Key",
|
||||
@@ -83,6 +87,12 @@
|
||||
"removeFromClaudePlugin": "Remove from Claude plugin",
|
||||
"dragToReorder": "Drag to reorder",
|
||||
"dragHandle": "Drag to reorder",
|
||||
"searchPlaceholder": "Search name, notes, or URL...",
|
||||
"searchAriaLabel": "Search providers",
|
||||
"searchScopeHint": "Matches provider name, notes, and URL.",
|
||||
"searchCloseHint": "Press Esc to close",
|
||||
"searchCloseAriaLabel": "Close provider search",
|
||||
"noSearchResults": "No providers match your search.",
|
||||
"duplicate": "Duplicate",
|
||||
"sortUpdateFailed": "Failed to update sort order",
|
||||
"configureUsage": "Configure usage query",
|
||||
@@ -126,6 +136,8 @@
|
||||
"appliedToClaudePlugin": "Applied to Claude plugin",
|
||||
"removedFromClaudePlugin": "Removed from Claude plugin",
|
||||
"syncClaudePluginFailed": "Sync Claude plugin failed",
|
||||
"skipClaudeOnboardingFailed": "Failed to skip Claude Code first-run confirmation",
|
||||
"clearClaudeOnboardingSkipFailed": "Failed to restore Claude Code first-run confirmation",
|
||||
"updateSuccess": "Provider updated successfully",
|
||||
"updateFailed": "Failed to update provider: {{error}}",
|
||||
"deleteSuccess": "Provider deleted",
|
||||
@@ -142,6 +154,34 @@
|
||||
"general": "General",
|
||||
"tabGeneral": "General",
|
||||
"tabAdvanced": "Advanced",
|
||||
"advanced": {
|
||||
"configDir": {
|
||||
"title": "Configuration Directory",
|
||||
"description": "Manage storage paths for Claude, Codex and Gemini configurations"
|
||||
},
|
||||
"proxy": {
|
||||
"title": "Local Proxy",
|
||||
"description": "Control proxy service toggle, view status and port info",
|
||||
"running": "Running",
|
||||
"stopped": "Stopped"
|
||||
},
|
||||
"modelTest": {
|
||||
"title": "Model Test Config",
|
||||
"description": "Configure default models and prompts for model testing"
|
||||
},
|
||||
"failover": {
|
||||
"title": "Auto Failover",
|
||||
"description": "Configure failover queue and circuit breaker strategy"
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Cost Pricing",
|
||||
"description": "Manage token pricing rules for each model"
|
||||
},
|
||||
"data": {
|
||||
"title": "Data Management",
|
||||
"description": "Import/export configurations and backup/restore"
|
||||
}
|
||||
},
|
||||
"language": "Language",
|
||||
"languageHint": "Preview interface language immediately after switching, takes effect permanently after saving.",
|
||||
"theme": "Theme",
|
||||
@@ -179,6 +219,8 @@
|
||||
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
|
||||
"enableClaudePluginIntegration": "Apply to Claude Code extension",
|
||||
"enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app",
|
||||
"skipClaudeOnboarding": "Skip Claude Code first-run confirmation",
|
||||
"skipClaudeOnboardingDescription": "When enabled, Claude Code will skip the first-run confirmation",
|
||||
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
|
||||
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory to the one in WSL to keep provider data consistent with the main environment.",
|
||||
"appConfigDir": "CC Switch Configuration Directory",
|
||||
@@ -365,6 +407,50 @@
|
||||
"hint": "You can continue to adjust the fields below after selecting a preset."
|
||||
},
|
||||
"usage": {
|
||||
"title": "Usage Statistics",
|
||||
"subtitle": "View AI model usage and cost statistics",
|
||||
"today": "24 Hours",
|
||||
"last7days": "7 Days",
|
||||
"last30days": "30 Days",
|
||||
"totalRequests": "Total Requests",
|
||||
"totalCost": "Total Cost",
|
||||
"cost": "Cost",
|
||||
"perMillion": "(per million)",
|
||||
"trends": "Usage Trends",
|
||||
"rangeToday": "Today (hourly)",
|
||||
"rangeLast7Days": "Last 7 days",
|
||||
"rangeLast30Days": "Last 30 days",
|
||||
"totalTokens": "Total Tokens",
|
||||
"cacheTokens": "Cache Tokens",
|
||||
"requestLogs": "Request Logs",
|
||||
"providerStats": "Provider Stats",
|
||||
"modelStats": "Model Stats",
|
||||
"time": "Time",
|
||||
"provider": "Provider",
|
||||
"billingModel": "Billing Model",
|
||||
"inputTokens": "Input",
|
||||
"outputTokens": "Output",
|
||||
"cacheReadTokens": "Cache Read",
|
||||
"cacheCreationTokens": "Cache Write",
|
||||
"timingInfo": "Duration/TTFT",
|
||||
"status": "Status",
|
||||
"noData": "No data",
|
||||
"unknownProvider": "Unknown Provider",
|
||||
"stream": "Stream",
|
||||
"nonStream": "Non-stream",
|
||||
"totalRecords": "{{total}} records total",
|
||||
"modelPricing": "Model Pricing",
|
||||
"loadPricingError": "Failed to load pricing data",
|
||||
"modelPricingDesc": "Configure token costs for each model",
|
||||
"noPricingData": "No pricing data. Click \"Add\" to add model pricing configuration.",
|
||||
"model": "Model",
|
||||
"displayName": "Display Name",
|
||||
"inputCost": "Input Cost",
|
||||
"outputCost": "Output Cost",
|
||||
"cacheReadCost": "Cache Read",
|
||||
"cacheWriteCost": "Cache Write",
|
||||
"deleteConfirmTitle": "Confirm Delete",
|
||||
"deleteConfirmDesc": "Are you sure you want to delete this model pricing? This action cannot be undone.",
|
||||
"queryFailed": "Query failed",
|
||||
"refreshUsage": "Refresh usage",
|
||||
"planUsage": "Plan usage",
|
||||
@@ -378,7 +464,19 @@
|
||||
"daysAgo": "{{count}} day ago",
|
||||
"multiplePlans": "{{count}} plans",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse"
|
||||
"collapse": "Collapse",
|
||||
"modelIdPlaceholder": "e.g., claude-3-5-sonnet-20241022",
|
||||
"displayNamePlaceholder": "e.g., Claude 3.5 Sonnet",
|
||||
"appType": "App Type",
|
||||
"allApps": "All Apps",
|
||||
"statusCode": "Status Code",
|
||||
"searchProviderPlaceholder": "Search provider...",
|
||||
"searchModelPlaceholder": "Search model...",
|
||||
"timeRange": "Time Range",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "Write",
|
||||
"cacheRead": "Read"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "Configure Usage Query",
|
||||
@@ -448,7 +546,9 @@
|
||||
"tip3": "• Entire config must be wrapped in () to form object literal expression"
|
||||
},
|
||||
"errors": {
|
||||
"usage_query_failed": "Usage query failed"
|
||||
"usage_query_failed": "Usage query failed",
|
||||
"configLoadFailedTitle": "Configuration Load Failed",
|
||||
"configLoadFailedMessage": "Unable to read configuration file:\n{{path}}\n\nError details:\n{{detail}}\n\nPlease check if the JSON is valid, or restore from a backup file (e.g., config.json.bak) in the same directory.\n\nThe app will exit so you can fix this."
|
||||
},
|
||||
"presetSelector": {
|
||||
"title": "Select Configuration Type",
|
||||
@@ -847,7 +947,9 @@
|
||||
"label": "Icon",
|
||||
"colorLabel": "Icon Color",
|
||||
"selectIcon": "Select Icon",
|
||||
"preview": "Preview"
|
||||
"preview": "Preview",
|
||||
"clickToChange": "Click to change icon",
|
||||
"clickToSelect": "Click to select icon"
|
||||
},
|
||||
"migration": {
|
||||
"success": "Configuration migrated successfully"
|
||||
@@ -855,7 +957,36 @@
|
||||
"agents": {
|
||||
"title": "Agents"
|
||||
},
|
||||
"health": {
|
||||
"operational": "Operational",
|
||||
"degraded": "Degraded",
|
||||
"failed": "Failed",
|
||||
"circuitOpen": "Circuit Open",
|
||||
"consecutiveFailures": "{{count}} consecutive failures"
|
||||
},
|
||||
"proxy": {
|
||||
"panel": {
|
||||
"serviceAddress": "Service Address",
|
||||
"addressCopied": "Address copied",
|
||||
"currentProvider": "Current Provider:",
|
||||
"waitingFirstRequest": "Current Provider: Waiting for first request...",
|
||||
"stoppedTitle": "Proxy Service Stopped",
|
||||
"stoppedDescription": "Use the toggle in the top right to start the service",
|
||||
"openSettings": "Configure Proxy Service",
|
||||
"stats": {
|
||||
"activeConnections": "Active Connections",
|
||||
"totalRequests": "Total Requests",
|
||||
"successRate": "Success Rate",
|
||||
"uptime": "Uptime"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"toast": {
|
||||
"saved": "Proxy configuration saved",
|
||||
"saveFailed": "Save failed: {{error}}"
|
||||
}
|
||||
},
|
||||
"switchFailed": "Switch failed: {{error}}",
|
||||
"failoverQueue": {
|
||||
"title": "Failover Queue",
|
||||
"description": "Manage failover order for each app's providers",
|
||||
@@ -899,5 +1030,17 @@
|
||||
"errorRateLabel": "Error Rate Threshold",
|
||||
"errorRateExplain": "Open circuit breaker when error rate exceeds this value, even if failure threshold not reached"
|
||||
}
|
||||
},
|
||||
"streamCheck": {
|
||||
"configSaved": "Health check config saved",
|
||||
"configSaveFailed": "Save failed",
|
||||
"testModels": "Test Models",
|
||||
"claudeModel": "Claude Model",
|
||||
"codexModel": "Codex Model",
|
||||
"geminiModel": "Gemini Model",
|
||||
"checkParams": "Check Parameters",
|
||||
"timeout": "Timeout (seconds)",
|
||||
"maxRetries": "Max Retries",
|
||||
"degradedThreshold": "Degraded Threshold (ms)"
|
||||
}
|
||||
}
|
||||
|
||||
+192
-4
@@ -32,7 +32,11 @@
|
||||
"back": "戻る",
|
||||
"refresh": "更新",
|
||||
"refreshing": "更新中...",
|
||||
"notInstalled": "未インストール"
|
||||
"all": "すべて",
|
||||
"search": "検索",
|
||||
"reset": "リセット",
|
||||
"actions": "操作",
|
||||
"deleting": "削除中..."
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "API Key を入力",
|
||||
@@ -83,6 +87,12 @@
|
||||
"removeFromClaudePlugin": "Claude プラグインから解除",
|
||||
"dragToReorder": "ドラッグで並べ替え",
|
||||
"dragHandle": "ドラッグで並べ替え",
|
||||
"searchPlaceholder": "名前・メモ・URLで検索...",
|
||||
"searchAriaLabel": "プロバイダーを検索",
|
||||
"searchScopeHint": "名前・メモ・URL を対象に検索します。",
|
||||
"searchCloseHint": "Esc で閉じる",
|
||||
"searchCloseAriaLabel": "検索を閉じる",
|
||||
"noSearchResults": "一致するプロバイダーがありません。",
|
||||
"duplicate": "複製",
|
||||
"sortUpdateFailed": "並び順の更新に失敗しました",
|
||||
"configureUsage": "利用状況を設定",
|
||||
@@ -126,6 +136,8 @@
|
||||
"appliedToClaudePlugin": "Claude プラグインに適用しました",
|
||||
"removedFromClaudePlugin": "Claude プラグインから削除しました",
|
||||
"syncClaudePluginFailed": "Claude プラグインとの同期に失敗しました",
|
||||
"skipClaudeOnboardingFailed": "Claude Code の初回確認スキップに失敗しました",
|
||||
"clearClaudeOnboardingSkipFailed": "Claude Code の初回確認の復元に失敗しました",
|
||||
"updateSuccess": "プロバイダーを更新しました",
|
||||
"updateFailed": "プロバイダーの更新に失敗しました: {{error}}",
|
||||
"deleteSuccess": "プロバイダーを削除しました",
|
||||
@@ -142,6 +154,34 @@
|
||||
"general": "一般",
|
||||
"tabGeneral": "一般",
|
||||
"tabAdvanced": "詳細",
|
||||
"advanced": {
|
||||
"configDir": {
|
||||
"title": "設定ディレクトリ",
|
||||
"description": "Claude、Codex、Gemini の設定保存パスを管理"
|
||||
},
|
||||
"proxy": {
|
||||
"title": "ローカルプロキシ",
|
||||
"description": "プロキシサービスの切り替え、ステータスとポート情報を表示",
|
||||
"running": "実行中",
|
||||
"stopped": "停止中"
|
||||
},
|
||||
"modelTest": {
|
||||
"title": "モデルテスト設定",
|
||||
"description": "モデルテストで使用するデフォルトモデルとプロンプトを設定"
|
||||
},
|
||||
"failover": {
|
||||
"title": "自動フェイルオーバー",
|
||||
"description": "フェイルオーバーキューとサーキットブレーカー戦略を設定"
|
||||
},
|
||||
"pricing": {
|
||||
"title": "コスト計算",
|
||||
"description": "各モデルのトークン料金ルールを管理"
|
||||
},
|
||||
"data": {
|
||||
"title": "データ管理",
|
||||
"description": "設定のインポート/エクスポートとバックアップ/復元"
|
||||
}
|
||||
},
|
||||
"language": "言語",
|
||||
"languageHint": "切り替えるとすぐにプレビューされ、保存後に永続化されます。",
|
||||
"theme": "テーマ",
|
||||
@@ -179,6 +219,8 @@
|
||||
"minimizeToTrayDescription": "チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。",
|
||||
"enableClaudePluginIntegration": "Claude Code 拡張に適用",
|
||||
"enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します",
|
||||
"skipClaudeOnboarding": "Claude Code の初回確認をスキップ",
|
||||
"skipClaudeOnboardingDescription": "オンにすると Claude Code の初回インストール確認をスキップします",
|
||||
"configDirectoryOverride": "設定ディレクトリの上書き(詳細)",
|
||||
"configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。",
|
||||
"appConfigDir": "CC Switch 設定ディレクトリ",
|
||||
@@ -365,6 +407,50 @@
|
||||
"hint": "プリセットを選んだ後でも、下のフィールドで調整できます。"
|
||||
},
|
||||
"usage": {
|
||||
"title": "利用統計",
|
||||
"subtitle": "AI モデルの利用状況とコスト統計を表示",
|
||||
"today": "24時間",
|
||||
"last7days": "7日間",
|
||||
"last30days": "30日間",
|
||||
"totalRequests": "総リクエスト数",
|
||||
"totalCost": "総コスト",
|
||||
"cost": "コスト",
|
||||
"perMillion": "(100万あたり)",
|
||||
"trends": "利用トレンド",
|
||||
"rangeToday": "今日 (時間別)",
|
||||
"rangeLast7Days": "過去7日間",
|
||||
"rangeLast30Days": "過去30日間",
|
||||
"totalTokens": "総トークン数",
|
||||
"cacheTokens": "キャッシュトークン",
|
||||
"requestLogs": "リクエストログ",
|
||||
"providerStats": "プロバイダー統計",
|
||||
"modelStats": "モデル統計",
|
||||
"time": "時間",
|
||||
"provider": "プロバイダー",
|
||||
"billingModel": "課金モデル",
|
||||
"inputTokens": "入力",
|
||||
"outputTokens": "出力",
|
||||
"cacheReadTokens": "キャッシュ読取",
|
||||
"cacheCreationTokens": "キャッシュ書込",
|
||||
"timingInfo": "応答時間/TTFT",
|
||||
"status": "ステータス",
|
||||
"noData": "データなし",
|
||||
"unknownProvider": "不明なプロバイダー",
|
||||
"stream": "ストリーム",
|
||||
"nonStream": "非ストリーム",
|
||||
"totalRecords": "全 {{total}} 件",
|
||||
"modelPricing": "モデル料金",
|
||||
"loadPricingError": "料金データの読み込みに失敗しました",
|
||||
"modelPricingDesc": "各モデルのトークンコストを設定",
|
||||
"noPricingData": "料金データがありません。「追加」をクリックしてモデル料金を設定してください。",
|
||||
"model": "モデル",
|
||||
"displayName": "表示名",
|
||||
"inputCost": "入力コスト",
|
||||
"outputCost": "出力コスト",
|
||||
"cacheReadCost": "キャッシュ読取",
|
||||
"cacheWriteCost": "キャッシュ書込",
|
||||
"deleteConfirmTitle": "削除の確認",
|
||||
"deleteConfirmDesc": "このモデル料金を削除しますか?この操作は元に戻せません。",
|
||||
"queryFailed": "照会に失敗しました",
|
||||
"refreshUsage": "利用状況を更新",
|
||||
"planUsage": "プラン利用状況",
|
||||
@@ -378,7 +464,19 @@
|
||||
"daysAgo": "{{count}} 日前",
|
||||
"multiplePlans": "{{count}} プラン",
|
||||
"expand": "展開",
|
||||
"collapse": "折りたたむ"
|
||||
"collapse": "折りたたむ",
|
||||
"modelIdPlaceholder": "例: claude-3-5-sonnet-20241022",
|
||||
"displayNamePlaceholder": "例: Claude 3.5 Sonnet",
|
||||
"appType": "アプリ種別",
|
||||
"allApps": "すべてのアプリ",
|
||||
"statusCode": "ステータスコード",
|
||||
"searchProviderPlaceholder": "プロバイダーを検索...",
|
||||
"searchModelPlaceholder": "モデルを検索...",
|
||||
"timeRange": "期間",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "Write",
|
||||
"cacheRead": "Read"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "利用状況を設定",
|
||||
@@ -448,7 +546,9 @@
|
||||
"tip3": "• 全体を () で囲み、オブジェクトリテラル式にしてください"
|
||||
},
|
||||
"errors": {
|
||||
"usage_query_failed": "利用状況の取得に失敗しました"
|
||||
"usage_query_failed": "利用状況の取得に失敗しました",
|
||||
"configLoadFailedTitle": "設定の読み込みに失敗しました",
|
||||
"configLoadFailedMessage": "設定ファイルを読み込めません:\n{{path}}\n\nエラー詳細:\n{{detail}}\n\nJSON が正しいか確認するか、同じディレクトリのバックアップファイル(config.json.bak など)から復元してください。\n\nアプリを終了して修正してください。"
|
||||
},
|
||||
"presetSelector": {
|
||||
"title": "設定タイプを選択",
|
||||
@@ -847,12 +947,100 @@
|
||||
"label": "アイコン",
|
||||
"colorLabel": "アイコンカラー",
|
||||
"selectIcon": "アイコンを選択",
|
||||
"preview": "プレビュー"
|
||||
"preview": "プレビュー",
|
||||
"clickToChange": "クリックでアイコンを変更",
|
||||
"clickToSelect": "クリックでアイコンを選択"
|
||||
},
|
||||
"migration": {
|
||||
"success": "設定の移行が完了しました"
|
||||
},
|
||||
"agents": {
|
||||
"title": "エージェント"
|
||||
},
|
||||
"health": {
|
||||
"operational": "正常",
|
||||
"degraded": "低下",
|
||||
"failed": "失敗",
|
||||
"circuitOpen": "サーキットオープン",
|
||||
"consecutiveFailures": "{{count}} 回連続失敗"
|
||||
},
|
||||
"proxy": {
|
||||
"panel": {
|
||||
"serviceAddress": "サービスアドレス",
|
||||
"addressCopied": "アドレスをコピーしました",
|
||||
"currentProvider": "現在のプロバイダー:",
|
||||
"waitingFirstRequest": "現在のプロバイダー: 最初のリクエスト待ち...",
|
||||
"stoppedTitle": "プロキシサービス停止中",
|
||||
"stoppedDescription": "右上のトグルでサービスを開始できます",
|
||||
"openSettings": "プロキシサービスを設定",
|
||||
"stats": {
|
||||
"activeConnections": "アクティブ接続",
|
||||
"totalRequests": "総リクエスト数",
|
||||
"successRate": "成功率",
|
||||
"uptime": "稼働時間"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"toast": {
|
||||
"saved": "プロキシ設定を保存しました",
|
||||
"saveFailed": "保存に失敗しました: {{error}}"
|
||||
}
|
||||
},
|
||||
"switchFailed": "切り替えに失敗しました: {{error}}",
|
||||
"failoverQueue": {
|
||||
"title": "フェイルオーバーキュー",
|
||||
"description": "各アプリのプロバイダーのフェイルオーバー順序を管理します",
|
||||
"info": "現在アクティブなプロバイダーが常に優先されます。リクエストが失敗すると、システムはキュー順に他のプロバイダーを試行します。",
|
||||
"selectProvider": "キューに追加するプロバイダーを選択",
|
||||
"noAvailableProviders": "追加できるプロバイダーがありません",
|
||||
"empty": "フェイルオーバーキューが空です。自動フェイルオーバーを有効にするにはプロバイダーを追加してください。",
|
||||
"dragHint": "ドラッグでフェイルオーバー順序を調整します。番号が小さいほど優先度が高くなります。",
|
||||
"toggleEnabled": "有効/無効",
|
||||
"addSuccess": "フェイルオーバーキューに追加しました",
|
||||
"addFailed": "追加に失敗しました",
|
||||
"removeSuccess": "フェイルオーバーキューから削除しました",
|
||||
"removeFailed": "削除に失敗しました",
|
||||
"reorderSuccess": "キュー順序を更新しました",
|
||||
"reorderFailed": "順序の更新に失敗しました",
|
||||
"toggleFailed": "状態の更新に失敗しました"
|
||||
},
|
||||
"autoFailover": {
|
||||
"info": "フェイルオーバーキューに複数のプロバイダーが設定されている場合、リクエストが失敗すると優先度順に試行します。プロバイダーが連続失敗のしきい値に達すると、サーキットブレーカーが開き、一時的にスキップされます。",
|
||||
"configSaved": "自動フェイルオーバー設定を保存しました",
|
||||
"configSaveFailed": "保存に失敗しました",
|
||||
"retrySettings": "リトライとタイムアウト設定",
|
||||
"failureThreshold": "失敗しきい値",
|
||||
"failureThresholdHint": "この回数連続で失敗するとサーキットブレーカーが開きます(推奨: 3-10)",
|
||||
"timeout": "回復待ち時間(秒)",
|
||||
"timeoutHint": "サーキットが開いた後、回復を試みるまでの待ち時間(推奨: 30-120)",
|
||||
"circuitBreakerSettings": "サーキットブレーカー詳細設定",
|
||||
"successThreshold": "回復成功しきい値",
|
||||
"successThresholdHint": "半開状態でこの回数成功するとサーキットブレーカーが閉じます",
|
||||
"errorRate": "エラー率しきい値 (%)",
|
||||
"errorRateHint": "この値を超えるとサーキットブレーカーが開きます",
|
||||
"minRequests": "最小リクエスト数",
|
||||
"minRequestsHint": "エラー率を計算する前の最小リクエスト数",
|
||||
"explanationTitle": "仕組み",
|
||||
"failureThresholdLabel": "失敗しきい値",
|
||||
"failureThresholdExplain": "この回数連続で失敗すると、サーキットブレーカーが開き、プロバイダーは一時的に利用不可になります",
|
||||
"timeoutLabel": "回復待ち時間",
|
||||
"timeoutExplain": "サーキットが開いた後、半開状態を試みるまでの待ち時間",
|
||||
"successThresholdLabel": "回復成功しきい値",
|
||||
"successThresholdExplain": "半開状態でこの回数成功するとサーキットブレーカーが閉じ、プロバイダーが再び利用可能になります",
|
||||
"errorRateLabel": "エラー率しきい値",
|
||||
"errorRateExplain": "失敗しきい値に達していなくても、エラー率がこの値を超えるとサーキットブレーカーが開きます"
|
||||
}
|
||||
},
|
||||
"streamCheck": {
|
||||
"configSaved": "ヘルスチェック設定を保存しました",
|
||||
"configSaveFailed": "保存に失敗しました",
|
||||
"testModels": "テストモデル",
|
||||
"claudeModel": "Claude モデル",
|
||||
"codexModel": "Codex モデル",
|
||||
"geminiModel": "Gemini モデル",
|
||||
"checkParams": "チェックパラメーター",
|
||||
"timeout": "タイムアウト(秒)",
|
||||
"maxRetries": "最大リトライ回数",
|
||||
"degradedThreshold": "劣化しきい値(ミリ秒)"
|
||||
}
|
||||
}
|
||||
|
||||
+147
-4
@@ -32,7 +32,11 @@
|
||||
"back": "返回",
|
||||
"refresh": "刷新",
|
||||
"refreshing": "刷新中...",
|
||||
"notInstalled": "未安装"
|
||||
"all": "全部",
|
||||
"search": "查询",
|
||||
"reset": "重置",
|
||||
"actions": "操作",
|
||||
"deleting": "删除中..."
|
||||
},
|
||||
"apiKeyInput": {
|
||||
"placeholder": "请输入API Key",
|
||||
@@ -83,6 +87,12 @@
|
||||
"removeFromClaudePlugin": "从 Claude 插件移除",
|
||||
"dragToReorder": "拖拽以重新排序",
|
||||
"dragHandle": "拖拽排序",
|
||||
"searchPlaceholder": "按名称/备注/网址搜索供应商...",
|
||||
"searchAriaLabel": "搜索供应商",
|
||||
"searchScopeHint": "根据名称、备注和官网链接匹配结果。",
|
||||
"searchCloseHint": "按 Esc 关闭",
|
||||
"searchCloseAriaLabel": "关闭供应商搜索",
|
||||
"noSearchResults": "没有符合搜索条件的供应商。",
|
||||
"duplicate": "复制",
|
||||
"sortUpdateFailed": "排序更新失败",
|
||||
"configureUsage": "配置用量查询",
|
||||
@@ -126,6 +136,8 @@
|
||||
"appliedToClaudePlugin": "已应用到 Claude 插件",
|
||||
"removedFromClaudePlugin": "已从 Claude 插件移除",
|
||||
"syncClaudePluginFailed": "同步 Claude 插件失败",
|
||||
"skipClaudeOnboardingFailed": "跳过 Claude Code 初次安装确认失败",
|
||||
"clearClaudeOnboardingSkipFailed": "恢复 Claude Code 初次安装确认失败",
|
||||
"updateSuccess": "供应商更新成功",
|
||||
"updateFailed": "更新供应商失败:{{error}}",
|
||||
"deleteSuccess": "供应商已删除",
|
||||
@@ -142,6 +154,34 @@
|
||||
"general": "通用",
|
||||
"tabGeneral": "通用",
|
||||
"tabAdvanced": "高级",
|
||||
"advanced": {
|
||||
"configDir": {
|
||||
"title": "配置文件目录",
|
||||
"description": "管理 Claude、Codex 和 Gemini 的配置存储路径"
|
||||
},
|
||||
"proxy": {
|
||||
"title": "本地代理",
|
||||
"description": "控制代理服务开关、查看状态与端口信息",
|
||||
"running": "运行中",
|
||||
"stopped": "已停止"
|
||||
},
|
||||
"modelTest": {
|
||||
"title": "模型测试配置",
|
||||
"description": "配置模型测试使用的默认模型和提示词"
|
||||
},
|
||||
"failover": {
|
||||
"title": "自动故障转移",
|
||||
"description": "配置故障转移队列和熔断策略"
|
||||
},
|
||||
"pricing": {
|
||||
"title": "成本定价",
|
||||
"description": "管理各模型 Token 计费规则"
|
||||
},
|
||||
"data": {
|
||||
"title": "数据管理",
|
||||
"description": "导入导出配置与备份恢复"
|
||||
}
|
||||
},
|
||||
"language": "界面语言",
|
||||
"languageHint": "切换后立即预览界面语言,保存后永久生效。",
|
||||
"theme": "外观主题",
|
||||
@@ -179,6 +219,8 @@
|
||||
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
|
||||
"enableClaudePluginIntegration": "应用到 Claude Code 插件",
|
||||
"enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换",
|
||||
"skipClaudeOnboarding": "跳过 Claude Code 初次安装确认",
|
||||
"skipClaudeOnboardingDescription": "开启后跳过 Claude Code 初次安装确认",
|
||||
"configDirectoryOverride": "配置目录覆盖(高级)",
|
||||
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。",
|
||||
"appConfigDir": "CC Switch 配置目录",
|
||||
@@ -365,6 +407,50 @@
|
||||
"hint": "选择预设后可继续调整下方字段。"
|
||||
},
|
||||
"usage": {
|
||||
"title": "使用统计",
|
||||
"subtitle": "查看 AI 模型的使用情况和成本统计",
|
||||
"today": "24小时",
|
||||
"last7days": "7天",
|
||||
"last30days": "30天",
|
||||
"totalRequests": "总请求数",
|
||||
"totalCost": "总成本",
|
||||
"cost": "成本",
|
||||
"perMillion": "(每百万)",
|
||||
"trends": "使用趋势",
|
||||
"rangeToday": "今天 (按小时)",
|
||||
"rangeLast7Days": "过去 7 天",
|
||||
"rangeLast30Days": "过去 30 天",
|
||||
"totalTokens": "总 Token 数",
|
||||
"cacheTokens": "缓存 Token",
|
||||
"requestLogs": "请求日志",
|
||||
"providerStats": "Provider 统计",
|
||||
"modelStats": "模型统计",
|
||||
"time": "时间",
|
||||
"provider": "供应商",
|
||||
"billingModel": "计费模型",
|
||||
"inputTokens": "输入",
|
||||
"outputTokens": "输出",
|
||||
"cacheReadTokens": "缓存读取",
|
||||
"cacheCreationTokens": "缓存写入",
|
||||
"timingInfo": "用时/首字",
|
||||
"status": "状态",
|
||||
"noData": "暂无数据",
|
||||
"unknownProvider": "未知供应商",
|
||||
"stream": "流",
|
||||
"nonStream": "非流",
|
||||
"totalRecords": "共 {{total}} 条记录",
|
||||
"modelPricing": "模型定价",
|
||||
"loadPricingError": "加载定价数据失败",
|
||||
"modelPricingDesc": "配置各模型的 Token 成本",
|
||||
"noPricingData": "暂无定价数据。点击\"新增\"添加模型定价配置。",
|
||||
"model": "模型",
|
||||
"displayName": "显示名称",
|
||||
"inputCost": "输入成本",
|
||||
"outputCost": "输出成本",
|
||||
"cacheReadCost": "缓存读取",
|
||||
"cacheWriteCost": "缓存写入",
|
||||
"deleteConfirmTitle": "确认删除",
|
||||
"deleteConfirmDesc": "确定要删除此模型定价配置吗?此操作无法撤销。",
|
||||
"queryFailed": "查询失败",
|
||||
"refreshUsage": "刷新用量",
|
||||
"planUsage": "套餐用量",
|
||||
@@ -378,7 +464,19 @@
|
||||
"daysAgo": "{{count}} 天前",
|
||||
"multiplePlans": "{{count}} 个套餐",
|
||||
"expand": "展开",
|
||||
"collapse": "收起"
|
||||
"collapse": "收起",
|
||||
"modelIdPlaceholder": "例如: claude-3-5-sonnet-20241022",
|
||||
"displayNamePlaceholder": "例如: Claude 3.5 Sonnet",
|
||||
"appType": "应用类型",
|
||||
"allApps": "全部应用",
|
||||
"statusCode": "状态码",
|
||||
"searchProviderPlaceholder": "搜索供应商...",
|
||||
"searchModelPlaceholder": "搜索模型...",
|
||||
"timeRange": "时间范围",
|
||||
"input": "Input",
|
||||
"output": "Output",
|
||||
"cacheWrite": "Write",
|
||||
"cacheRead": "Read"
|
||||
},
|
||||
"usageScript": {
|
||||
"title": "配置用量查询",
|
||||
@@ -448,7 +546,9 @@
|
||||
"tip3": "• 整个配置必须用 () 包裹,形成对象字面量表达式"
|
||||
},
|
||||
"errors": {
|
||||
"usage_query_failed": "用量查询失败"
|
||||
"usage_query_failed": "用量查询失败",
|
||||
"configLoadFailedTitle": "配置加载失败",
|
||||
"configLoadFailedMessage": "无法读取配置文件:\n{{path}}\n\n错误详情:\n{{detail}}\n\n请手动检查 JSON 是否有效,或从同目录的备份文件(如 config.json.bak)恢复。\n\n应用将退出以便您进行修复。"
|
||||
},
|
||||
"presetSelector": {
|
||||
"title": "选择配置类型",
|
||||
@@ -847,7 +947,9 @@
|
||||
"label": "图标",
|
||||
"colorLabel": "图标颜色",
|
||||
"selectIcon": "选择图标",
|
||||
"preview": "预览"
|
||||
"preview": "预览",
|
||||
"clickToChange": "点击更换图标",
|
||||
"clickToSelect": "点击选择图标"
|
||||
},
|
||||
"migration": {
|
||||
"success": "配置迁移成功"
|
||||
@@ -855,7 +957,36 @@
|
||||
"agents": {
|
||||
"title": "智能体"
|
||||
},
|
||||
"health": {
|
||||
"operational": "正常",
|
||||
"degraded": "降级",
|
||||
"failed": "失败",
|
||||
"circuitOpen": "熔断",
|
||||
"consecutiveFailures": "连续失败 {{count}} 次"
|
||||
},
|
||||
"proxy": {
|
||||
"panel": {
|
||||
"serviceAddress": "服务地址",
|
||||
"addressCopied": "地址已复制",
|
||||
"currentProvider": "当前 Provider:",
|
||||
"waitingFirstRequest": "当前 Provider:等待首次请求…",
|
||||
"stoppedTitle": "代理服务已停止",
|
||||
"stoppedDescription": "使用右上角开关即可启动服务",
|
||||
"openSettings": "配置代理服务",
|
||||
"stats": {
|
||||
"activeConnections": "活跃连接",
|
||||
"totalRequests": "总请求数",
|
||||
"successRate": "成功率",
|
||||
"uptime": "运行时间"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"toast": {
|
||||
"saved": "代理配置已保存",
|
||||
"saveFailed": "保存失败: {{error}}"
|
||||
}
|
||||
},
|
||||
"switchFailed": "切换失败: {{error}}",
|
||||
"failoverQueue": {
|
||||
"title": "故障转移队列",
|
||||
"description": "管理各应用的供应商故障转移顺序",
|
||||
@@ -899,5 +1030,17 @@
|
||||
"errorRateLabel": "错误率阈值",
|
||||
"errorRateExplain": "错误率超过此值时,即使未达到失败阈值也会打开熔断器"
|
||||
}
|
||||
},
|
||||
"streamCheck": {
|
||||
"configSaved": "健康检查配置已保存",
|
||||
"configSaveFailed": "保存失败",
|
||||
"testModels": "测试模型",
|
||||
"claudeModel": "Claude 模型",
|
||||
"codexModel": "Codex 模型",
|
||||
"geminiModel": "Gemini 模型",
|
||||
"checkParams": "检查参数",
|
||||
"timeout": "超时时间(秒)",
|
||||
"maxRetries": "最大重试次数",
|
||||
"degradedThreshold": "降级阈值(毫秒)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,9 +88,6 @@
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.dark .glass-card {
|
||||
@@ -107,13 +104,11 @@
|
||||
.glass-card-active {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border: 1px solid rgba(59, 130, 246, 0.4);
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.dark .glass-card-active {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
box-shadow: 0 0 24px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.glass-header {
|
||||
|
||||
+6
-14
@@ -84,24 +84,16 @@ export const failoverApi = {
|
||||
return invoke("remove_from_failover_queue", { appType, providerId });
|
||||
},
|
||||
|
||||
// 重新排序故障转移队列
|
||||
async reorderFailoverQueue(
|
||||
appType: string,
|
||||
providerIds: string[],
|
||||
): Promise<void> {
|
||||
return invoke("reorder_failover_queue", { appType, providerIds });
|
||||
// 获取指定应用的自动故障转移开关状态
|
||||
async getAutoFailoverEnabled(appType: string): Promise<boolean> {
|
||||
return invoke("get_auto_failover_enabled", { appType });
|
||||
},
|
||||
|
||||
// 设置故障转移队列项的启用状态
|
||||
async setFailoverItemEnabled(
|
||||
// 设置指定应用的自动故障转移开关状态
|
||||
async setAutoFailoverEnabled(
|
||||
appType: string,
|
||||
providerId: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
return invoke("set_failover_item_enabled", {
|
||||
appType,
|
||||
providerId,
|
||||
enabled,
|
||||
});
|
||||
return invoke("set_auto_failover_enabled", { appType, enabled });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,6 +69,14 @@ export const settingsApi = {
|
||||
return await invoke("apply_claude_plugin_config", { official });
|
||||
},
|
||||
|
||||
async applyClaudeOnboardingSkip(): Promise<boolean> {
|
||||
return await invoke("apply_claude_onboarding_skip");
|
||||
},
|
||||
|
||||
async clearClaudeOnboardingSkip(): Promise<boolean> {
|
||||
return await invoke("clear_claude_onboarding_skip");
|
||||
},
|
||||
|
||||
async saveFileDialog(defaultName: string): Promise<string | null> {
|
||||
return await invoke("save_file_dialog", { defaultName });
|
||||
},
|
||||
|
||||
+74
-33
@@ -18,6 +18,9 @@ export function useProviderHealth(providerId: string, appType: string) {
|
||||
|
||||
/**
|
||||
* 重置熔断器
|
||||
*
|
||||
* 重置后后端会检查是否应该切回优先级更高的供应商,
|
||||
* 因此需要同时刷新供应商列表和代理状态。
|
||||
*/
|
||||
export function useResetCircuitBreaker() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -35,6 +38,14 @@ export function useResetCircuitBreaker() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["providerHealth", variables.providerId, variables.appType],
|
||||
});
|
||||
// 刷新供应商列表(因为可能发生了自动恢复切换)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["providers", variables.appType],
|
||||
});
|
||||
// 刷新代理状态(更新 active_targets)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["proxyStatus"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -120,6 +131,9 @@ export function useAddToFailoverQueue() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["availableProvidersForFailover", variables.appType],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["providers", variables.appType],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -145,51 +159,78 @@ export function useRemoveFromFailoverQueue() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["availableProvidersForFailover", variables.appType],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新排序故障转移队列
|
||||
*/
|
||||
export function useReorderFailoverQueue() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
appType,
|
||||
providerIds,
|
||||
}: {
|
||||
appType: string;
|
||||
providerIds: string[];
|
||||
}) => failoverApi.reorderFailoverQueue(appType, providerIds),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["failoverQueue", variables.appType],
|
||||
queryKey: ["providers", variables.appType],
|
||||
});
|
||||
// 清除该供应商的健康状态缓存(退出队列后不再需要健康监控)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["providerHealth", variables.providerId, variables.appType],
|
||||
});
|
||||
// 清除该供应商的熔断器统计缓存
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
"circuitBreakerStats",
|
||||
variables.providerId,
|
||||
variables.appType,
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 自动故障转移开关 Hooks ==========
|
||||
|
||||
/**
|
||||
* 设置故障转移队列项的启用状态
|
||||
* 获取指定应用的自动故障转移开关状态
|
||||
*/
|
||||
export function useSetFailoverItemEnabled() {
|
||||
export function useAutoFailoverEnabled(appType: string) {
|
||||
return useQuery({
|
||||
queryKey: ["autoFailoverEnabled", appType],
|
||||
queryFn: () => failoverApi.getAutoFailoverEnabled(appType),
|
||||
// 默认值为 false(与后端保持一致)
|
||||
placeholderData: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置指定应用的自动故障转移开关状态
|
||||
*/
|
||||
export function useSetAutoFailoverEnabled() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
appType,
|
||||
providerId,
|
||||
enabled,
|
||||
}: {
|
||||
appType: string;
|
||||
providerId: string;
|
||||
enabled: boolean;
|
||||
}) => failoverApi.setFailoverItemEnabled(appType, providerId, enabled),
|
||||
onSuccess: (_, variables) => {
|
||||
mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) =>
|
||||
failoverApi.setAutoFailoverEnabled(appType, enabled),
|
||||
|
||||
// 乐观更新
|
||||
onMutate: async ({ appType, enabled }) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: ["autoFailoverEnabled", appType],
|
||||
});
|
||||
const previousValue = queryClient.getQueryData<boolean>([
|
||||
"autoFailoverEnabled",
|
||||
appType,
|
||||
]);
|
||||
|
||||
queryClient.setQueryData(["autoFailoverEnabled", appType], enabled);
|
||||
|
||||
return { previousValue, appType };
|
||||
},
|
||||
|
||||
// 错误时回滚
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context?.previousValue !== undefined) {
|
||||
queryClient.setQueryData(
|
||||
["autoFailoverEnabled", context.appType],
|
||||
context.previousValue,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// 无论成功失败,都重新获取
|
||||
onSettled: (_, __, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["failoverQueue", variables.appType],
|
||||
queryKey: ["autoFailoverEnabled", variables.appType],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export const settingsSchema = z.object({
|
||||
showInTray: z.boolean(),
|
||||
minimizeToTrayOnClose: z.boolean(),
|
||||
enableClaudePluginIntegration: z.boolean().optional(),
|
||||
skipClaudeOnboarding: z.boolean().optional(),
|
||||
launchOnStartup: z.boolean().optional(),
|
||||
language: z.enum(["en", "zh", "ja"]).optional(),
|
||||
|
||||
|
||||
+13
-3
@@ -4,7 +4,7 @@ import App from "./App";
|
||||
import { UpdateProvider } from "./contexts/UpdateContext";
|
||||
import "./index.css";
|
||||
// 导入国际化配置
|
||||
import "./i18n";
|
||||
import i18n from "./i18n";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { queryClient } from "@/lib/query";
|
||||
@@ -43,8 +43,18 @@ async function handleConfigLoadError(
|
||||
const detail = payload?.error ?? "Unknown error";
|
||||
|
||||
await message(
|
||||
`无法读取配置文件:\n${path}\n\n错误详情:\n${detail}\n\n请手动检查 JSON 是否有效,或从同目录的备份文件(如 config.json.bak)恢复。\n\n应用将退出以便您进行修复。`,
|
||||
{ title: "配置加载失败", kind: "error" },
|
||||
i18n.t("errors.configLoadFailedMessage", {
|
||||
path,
|
||||
detail,
|
||||
defaultValue:
|
||||
"无法读取配置文件:\n{{path}}\n\n错误详情:\n{{detail}}\n\n请手动检查 JSON 是否有效,或从同目录的备份文件(如 config.json.bak)恢复。\n\n应用将退出以便您进行修复。",
|
||||
}),
|
||||
{
|
||||
title: i18n.t("errors.configLoadFailedTitle", {
|
||||
defaultValue: "配置加载失败",
|
||||
}),
|
||||
kind: "error",
|
||||
},
|
||||
);
|
||||
|
||||
await exit(1);
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface Provider {
|
||||
// 图标配置
|
||||
icon?: string; // 图标名称(如 "openai", "anthropic")
|
||||
iconColor?: string; // 图标颜色(Hex 格式,如 "#00A67E")
|
||||
// 是否加入故障转移队列
|
||||
inFailoverQueue?: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
@@ -106,6 +108,8 @@ export interface Settings {
|
||||
minimizeToTrayOnClose: boolean;
|
||||
// 启用 Claude 插件联动(写入 ~/.claude/config.json 的 primaryApiKey)
|
||||
enableClaudePluginIntegration?: boolean;
|
||||
// 跳过 Claude Code 初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding)
|
||||
skipClaudeOnboarding?: boolean;
|
||||
// 是否开机自启
|
||||
launchOnStartup?: boolean;
|
||||
// 首选语言(可选,默认中文)
|
||||
|
||||
+1
-3
@@ -103,7 +103,5 @@ export interface ProxyUsageRecord {
|
||||
export interface FailoverQueueItem {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
queueOrder: number;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
sortIndex?: number;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("ImportExportSection Component", () => {
|
||||
fireEvent.click(importButton);
|
||||
expect(baseProps.onImport).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear selection" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.clear" }));
|
||||
expect(baseProps.onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -79,6 +79,22 @@ vi.mock("@dnd-kit/sortable", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock hooks that use QueryClient
|
||||
vi.mock("@/hooks/useStreamCheck", () => ({
|
||||
useStreamCheck: () => ({
|
||||
checkProvider: vi.fn(),
|
||||
isChecking: () => false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/query/failover", () => ({
|
||||
useAutoFailoverEnabled: () => ({ data: false }),
|
||||
useFailoverQueue: () => ({ data: [] }),
|
||||
useAddToFailoverQueue: () => ({ mutate: vi.fn() }),
|
||||
useRemoveFromFailoverQueue: () => ({ mutate: vi.fn() }),
|
||||
useReorderFailoverQueue: () => ({ mutate: vi.fn() }),
|
||||
}));
|
||||
|
||||
function createProvider(overrides: Partial<Provider> = {}): Provider {
|
||||
return {
|
||||
id: overrides.id ?? "provider-1",
|
||||
@@ -235,4 +251,47 @@ describe("ProviderList Component", () => {
|
||||
"claude",
|
||||
);
|
||||
});
|
||||
|
||||
it("filters providers with the search input", () => {
|
||||
const providerAlpha = createProvider({ id: "alpha", name: "Alpha Labs" });
|
||||
const providerBeta = createProvider({ id: "beta", name: "Beta Works" });
|
||||
|
||||
useDragSortMock.mockReturnValue({
|
||||
sortedProviders: [providerAlpha, providerBeta],
|
||||
sensors: [],
|
||||
handleDragEnd: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<ProviderList
|
||||
providers={{ alpha: providerAlpha, beta: providerBeta }}
|
||||
currentProviderId=""
|
||||
appId="claude"
|
||||
onSwitch={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onDuplicate={vi.fn()}
|
||||
onOpenWebsite={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.keyDown(window, { key: "f", metaKey: true });
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
"Search name, notes, or URL...",
|
||||
);
|
||||
// Initially both providers are rendered
|
||||
expect(screen.getByTestId("provider-card-alpha")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("provider-card-beta")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "beta" } });
|
||||
expect(screen.queryByTestId("provider-card-alpha")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("provider-card-beta")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: "gamma" } });
|
||||
expect(screen.queryByTestId("provider-card-alpha")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("provider-card-beta")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("No providers match your search."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -305,7 +305,7 @@ describe("SettingsPage Component", () => {
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("settings.tabAdvanced"));
|
||||
fireEvent.click(screen.getByText("数据管理"));
|
||||
fireEvent.click(screen.getByText("settings.advanced.data.title"));
|
||||
|
||||
// 有文件时,点击导入按钮执行 importConfig
|
||||
fireEvent.click(
|
||||
@@ -319,7 +319,7 @@ describe("SettingsPage Component", () => {
|
||||
expect(importExportMock.exportConfig).toHaveBeenCalled();
|
||||
|
||||
// 清除选择按钮
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear selection" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.clear" }));
|
||||
expect(importExportMock.clearSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -412,7 +412,7 @@ describe("SettingsPage Component", () => {
|
||||
render(<SettingsPage open={true} onOpenChange={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText("settings.tabAdvanced"));
|
||||
fireEvent.click(screen.getByText("配置文件目录"));
|
||||
fireEvent.click(screen.getByText("settings.advanced.configDir.title"));
|
||||
|
||||
fireEvent.click(screen.getByText("browse-directory"));
|
||||
expect(settingsMock.browseDirectory).toHaveBeenCalledWith("claude");
|
||||
|
||||
@@ -7,6 +7,8 @@ const mutateAsyncMock = vi.fn();
|
||||
const useSettingsQueryMock = vi.fn();
|
||||
const setAppConfigDirOverrideMock = vi.fn();
|
||||
const applyClaudePluginConfigMock = vi.fn();
|
||||
const applyClaudeOnboardingSkipMock = vi.fn();
|
||||
const clearClaudeOnboardingSkipMock = vi.fn();
|
||||
const syncCurrentProvidersLiveMock = vi.fn();
|
||||
const updateTrayMenuMock = vi.fn();
|
||||
const toastErrorMock = vi.fn();
|
||||
@@ -50,6 +52,10 @@ vi.mock("@/lib/api", () => ({
|
||||
setAppConfigDirOverrideMock(...args),
|
||||
applyClaudePluginConfig: (...args: unknown[]) =>
|
||||
applyClaudePluginConfigMock(...args),
|
||||
applyClaudeOnboardingSkip: (...args: unknown[]) =>
|
||||
applyClaudeOnboardingSkipMock(...args),
|
||||
clearClaudeOnboardingSkip: (...args: unknown[]) =>
|
||||
clearClaudeOnboardingSkipMock(...args),
|
||||
syncCurrentProvidersLive: (...args: unknown[]) =>
|
||||
syncCurrentProvidersLiveMock(...args),
|
||||
},
|
||||
@@ -63,6 +69,7 @@ const createSettingsFormMock = (overrides: Record<string, unknown> = {}) => ({
|
||||
showInTray: true,
|
||||
minimizeToTrayOnClose: true,
|
||||
enableClaudePluginIntegration: false,
|
||||
skipClaudeOnboarding: true,
|
||||
claudeConfigDir: "/claude",
|
||||
codexConfigDir: "/codex",
|
||||
language: "zh",
|
||||
@@ -111,6 +118,8 @@ describe("useSettings hook", () => {
|
||||
useSettingsQueryMock.mockReset();
|
||||
setAppConfigDirOverrideMock.mockReset();
|
||||
applyClaudePluginConfigMock.mockReset();
|
||||
applyClaudeOnboardingSkipMock.mockReset();
|
||||
clearClaudeOnboardingSkipMock.mockReset();
|
||||
syncCurrentProvidersLiveMock.mockReset();
|
||||
toastErrorMock.mockReset();
|
||||
toastSuccessMock.mockReset();
|
||||
@@ -120,6 +129,7 @@ describe("useSettings hook", () => {
|
||||
showInTray: true,
|
||||
minimizeToTrayOnClose: true,
|
||||
enableClaudePluginIntegration: false,
|
||||
skipClaudeOnboarding: true,
|
||||
claudeConfigDir: "/server/claude",
|
||||
codexConfigDir: "/server/codex",
|
||||
language: "zh",
|
||||
@@ -142,6 +152,64 @@ describe("useSettings hook", () => {
|
||||
mutateAsyncMock.mockResolvedValue(true);
|
||||
setAppConfigDirOverrideMock.mockResolvedValue(true);
|
||||
applyClaudePluginConfigMock.mockResolvedValue(true);
|
||||
applyClaudeOnboardingSkipMock.mockResolvedValue(true);
|
||||
clearClaudeOnboardingSkipMock.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("auto-saves and applies Claude onboarding skip when toggled on", async () => {
|
||||
serverSettings = {
|
||||
...serverSettings,
|
||||
skipClaudeOnboarding: false,
|
||||
};
|
||||
useSettingsQueryMock.mockReturnValue({
|
||||
data: serverSettings,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
settingsFormMock = createSettingsFormMock({
|
||||
settings: {
|
||||
...serverSettings,
|
||||
language: "zh",
|
||||
skipClaudeOnboarding: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.autoSaveSettings({ skipClaudeOnboarding: true });
|
||||
});
|
||||
|
||||
expect(applyClaudeOnboardingSkipMock).toHaveBeenCalledTimes(1);
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-saves and clears Claude onboarding skip when toggled off", async () => {
|
||||
serverSettings = {
|
||||
...serverSettings,
|
||||
skipClaudeOnboarding: true,
|
||||
};
|
||||
useSettingsQueryMock.mockReturnValue({
|
||||
data: serverSettings,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
settingsFormMock = createSettingsFormMock({
|
||||
settings: {
|
||||
...serverSettings,
|
||||
language: "zh",
|
||||
skipClaudeOnboarding: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSettings());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.autoSaveSettings({ skipClaudeOnboarding: false });
|
||||
});
|
||||
|
||||
expect(clearClaudeOnboardingSkipMock).toHaveBeenCalledTimes(1);
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves settings and flags restart when app config directory changes", async () => {
|
||||
|
||||
@@ -150,7 +150,7 @@ describe("SettingsPage integration", () => {
|
||||
expect(screen.getByText("language:zh")).toBeInTheDocument(),
|
||||
);
|
||||
fireEvent.click(screen.getByText("settings.tabAdvanced"));
|
||||
fireEvent.click(screen.getByText("配置文件目录"));
|
||||
fireEvent.click(screen.getByText("settings.advanced.configDir.title"));
|
||||
const appInput = await screen.findByPlaceholderText(
|
||||
"settings.browsePlaceholderApp",
|
||||
);
|
||||
@@ -166,7 +166,7 @@ describe("SettingsPage integration", () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("settings.tabAdvanced"));
|
||||
fireEvent.click(screen.getByText("数据管理"));
|
||||
fireEvent.click(screen.getByText("settings.advanced.data.title"));
|
||||
fireEvent.click(screen.getByText("settings.selectConfigFile"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("selected-file").textContent).toContain(
|
||||
@@ -190,7 +190,7 @@ describe("SettingsPage integration", () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("settings.tabAdvanced"));
|
||||
fireEvent.click(screen.getByText("配置文件目录"));
|
||||
fireEvent.click(screen.getByText("settings.advanced.configDir.title"));
|
||||
const appInput = await screen.findByPlaceholderText(
|
||||
"settings.browsePlaceholderApp",
|
||||
);
|
||||
@@ -217,7 +217,7 @@ describe("SettingsPage integration", () => {
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("settings.tabAdvanced"));
|
||||
fireEvent.click(screen.getByText("配置文件目录"));
|
||||
fireEvent.click(screen.getByText("settings.advanced.configDir.title"));
|
||||
|
||||
const browseButtons = screen.getAllByTitle("settings.browseDirectory");
|
||||
const resetButtons = screen.getAllByTitle("settings.resetDefault");
|
||||
@@ -257,7 +257,7 @@ describe("SettingsPage integration", () => {
|
||||
expect(screen.getByText("language:zh")).toBeInTheDocument(),
|
||||
);
|
||||
fireEvent.click(screen.getByText("settings.tabAdvanced"));
|
||||
fireEvent.click(screen.getByText("数据管理"));
|
||||
fireEvent.click(screen.getByText("settings.advanced.data.title"));
|
||||
|
||||
server.use(
|
||||
http.post("http://tauri.local/save_file_dialog", () =>
|
||||
|
||||
@@ -176,6 +176,10 @@ export const handlers = [
|
||||
},
|
||||
),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/apply_claude_onboarding_skip`, () => success(true)),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/clear_claude_onboarding_skip`, () => success(true)),
|
||||
|
||||
http.post(`${TAURI_ENDPOINT}/get_config_dir`, async ({ request }) => {
|
||||
const { app } = await withJson<{ app: AppId }>(request);
|
||||
return success(app === "claude" ? "/default/claude" : "/default/codex");
|
||||
|
||||
Reference in New Issue
Block a user