diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs
index 1d74b4338..040e5f0c3 100644
--- a/src-tauri/src/codex_config.rs
+++ b/src-tauri/src/codex_config.rs
@@ -9,13 +9,21 @@ use serde_json::Value;
use std::fs;
use std::path::Path;
+/// 获取用户主目录,带回退和日志
+fn get_home_dir() -> PathBuf {
+ dirs::home_dir().unwrap_or_else(|| {
+ log::warn!("无法获取用户主目录,回退到当前目录");
+ PathBuf::from(".")
+ })
+}
+
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() {
return custom;
}
- dirs::home_dir().expect("无法获取用户主目录").join(".codex")
+ get_home_dir().join(".codex")
}
/// 获取 Codex auth.json 路径
diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs
index 012d10ebb..2027f1c0c 100644
--- a/src-tauri/src/commands/misc.rs
+++ b/src-tauri/src/commands/misc.rs
@@ -1,6 +1,8 @@
#![allow(non_snake_case)]
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
+use once_cell::sync::Lazy;
+use regex::Regex;
use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt;
@@ -139,11 +141,14 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op
}
}
+/// 预编译的版本号正则表达式
+static VERSION_RE: Lazy =
+ Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex"));
+
/// 从版本输出中提取纯版本号
fn extract_version(raw: &str) -> String {
- // 匹配 semver 格式: x.y.z 或 x.y.z-xxx
- let re = regex::Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex pattern");
- re.find(raw)
+ VERSION_RE
+ .find(raw)
.map(|m| m.as_str().to_string())
.unwrap_or_else(|| raw.to_string())
}
diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs
index 0b3cc9af0..6eae3a2cd 100644
--- a/src-tauri/src/config.rs
+++ b/src-tauri/src/config.rs
@@ -5,22 +5,26 @@ use std::path::{Path, PathBuf};
use crate::error::AppError;
+/// 获取用户主目录,带回退和日志
+fn get_home_dir() -> PathBuf {
+ dirs::home_dir().unwrap_or_else(|| {
+ log::warn!("无法获取用户主目录,回退到当前目录");
+ PathBuf::from(".")
+ })
+}
+
/// 获取 Claude Code 配置目录路径
pub fn get_claude_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_claude_override_dir() {
return custom;
}
- dirs::home_dir()
- .expect("无法获取用户主目录")
- .join(".claude")
+ get_home_dir().join(".claude")
}
/// 默认 Claude MCP 配置文件路径 (~/.claude.json)
pub fn get_default_claude_mcp_path() -> PathBuf {
- dirs::home_dir()
- .expect("无法获取用户主目录")
- .join(".claude.json")
+ get_home_dir().join(".claude.json")
}
fn derive_mcp_path_from_override(dir: &Path) -> Option {
diff --git a/src-tauri/src/gemini_config.rs b/src-tauri/src/gemini_config.rs
index 8e453babf..6ea476662 100644
--- a/src-tauri/src/gemini_config.rs
+++ b/src-tauri/src/gemini_config.rs
@@ -5,15 +5,21 @@ use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
+/// 获取用户主目录,带回退和日志
+fn get_home_dir() -> PathBuf {
+ dirs::home_dir().unwrap_or_else(|| {
+ log::warn!("无法获取用户主目录,回退到当前目录");
+ PathBuf::from(".")
+ })
+}
+
/// 获取 Gemini 配置目录路径(支持设置覆盖)
pub fn get_gemini_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_gemini_override_dir() {
return custom;
}
- dirs::home_dir()
- .expect("无法获取用户主目录")
- .join(".gemini")
+ get_home_dir().join(".gemini")
}
/// 获取 Gemini .env 文件路径
diff --git a/src-tauri/src/gemini_mcp.rs b/src-tauri/src/gemini_mcp.rs
index 62a959c18..72d7a86d5 100644
--- a/src-tauri/src/gemini_mcp.rs
+++ b/src-tauri/src/gemini_mcp.rs
@@ -134,6 +134,33 @@ pub fn set_mcp_servers_map(
obj.remove("homepage");
obj.remove("docs");
+ // Timeout 转换:Claude/Codex 使用 startup_timeout_sec/tool_timeout_sec
+ // Gemini CLI 只支持 timeout(单位 ms)
+ // 默认值:startup=10s, tool=60s
+ const DEFAULT_STARTUP_MS: u64 = 10_000;
+ const DEFAULT_TOOL_MS: u64 = 60_000;
+
+ let extract_timeout =
+ |obj: &mut Map, key: &str, multiplier: u64| -> Option {
+ obj.remove(key).and_then(|val| {
+ val.as_u64()
+ .map(|n| n * multiplier)
+ .or_else(|| val.as_f64().map(|f| (f * multiplier as f64) as u64))
+ })
+ };
+
+ // 分别收集 startup 和 tool timeout,未设置时使用默认值
+ let startup_ms = extract_timeout(&mut obj, "startup_timeout_sec", 1000)
+ .or_else(|| extract_timeout(&mut obj, "startup_timeout_ms", 1))
+ .unwrap_or(DEFAULT_STARTUP_MS);
+ let tool_ms = extract_timeout(&mut obj, "tool_timeout_sec", 1000)
+ .or_else(|| extract_timeout(&mut obj, "tool_timeout_ms", 1))
+ .unwrap_or(DEFAULT_TOOL_MS);
+
+ // 取最大值作为 Gemini timeout
+ let final_timeout = startup_ms.max(tool_ms);
+ obj.insert("timeout".to_string(), Value::Number(final_timeout.into()));
+
out.insert(id.clone(), Value::Object(obj));
}
diff --git a/src-tauri/src/proxy/circuit_breaker.rs b/src-tauri/src/proxy/circuit_breaker.rs
index def493442..310ce9bcb 100644
--- a/src-tauri/src/proxy/circuit_breaker.rs
+++ b/src-tauri/src/proxy/circuit_breaker.rs
@@ -2,6 +2,7 @@
//!
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
+use super::log_codes::cb as log_cb;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
@@ -126,7 +127,10 @@ impl CircuitBreaker {
if let Some(opened_at) = *self.last_opened_at.read().await {
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
drop(config); // 释放读锁再转换状态
- log::info!("[CB-001] 熔断器 Open → HalfOpen (超时恢复)");
+ log::info!(
+ "[{}] 熔断器 Open → HalfOpen (超时恢复)",
+ log_cb::OPEN_TO_HALF_OPEN
+ );
self.transition_to_half_open().await;
return true;
}
@@ -151,7 +155,10 @@ impl CircuitBreaker {
if let Some(opened_at) = *self.last_opened_at.read().await {
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
drop(config); // 释放读锁再转换状态
- log::info!("熔断器 Open → HalfOpen (超时恢复)");
+ log::info!(
+ "[{}] 熔断器 Open → HalfOpen (超时恢复)",
+ log_cb::OPEN_TO_HALF_OPEN
+ );
self.transition_to_half_open().await;
// 转换后按当前状态决定是否需要获取 HalfOpen 探测名额
@@ -197,7 +204,10 @@ impl CircuitBreaker {
if successes >= config.success_threshold {
drop(config); // 释放读锁再转换状态
- log::info!("[CB-002] 熔断器 HalfOpen → Closed (恢复正常)");
+ log::info!(
+ "[{}] 熔断器 HalfOpen → Closed (恢复正常)",
+ log_cb::HALF_OPEN_TO_CLOSED
+ );
self.transition_to_closed().await;
}
}
@@ -224,14 +234,20 @@ impl CircuitBreaker {
match state {
CircuitState::HalfOpen => {
// HalfOpen 状态下失败,立即转为 Open
- log::warn!("[CB-003] 熔断器 HalfOpen 探测失败 → Open");
+ log::warn!(
+ "[{}] 熔断器 HalfOpen 探测失败 → Open",
+ log_cb::HALF_OPEN_PROBE_FAILED
+ );
drop(config);
self.transition_to_open().await;
}
CircuitState::Closed => {
// 检查连续失败次数
if failures >= config.failure_threshold {
- log::warn!("[CB-004] 熔断器触发: 连续失败 {failures} 次 → Open");
+ log::warn!(
+ "[{}] 熔断器触发: 连续失败 {failures} 次 → Open",
+ log_cb::TRIGGERED_FAILURES
+ );
drop(config); // 释放读锁再转换状态
self.transition_to_open().await;
} else {
@@ -244,7 +260,8 @@ impl CircuitBreaker {
if error_rate >= config.error_rate_threshold {
log::warn!(
- "[CB-005] 熔断器触发: 错误率 {:.1}% → Open",
+ "[{}] 熔断器触发: 错误率 {:.1}% → Open",
+ log_cb::TRIGGERED_ERROR_RATE,
error_rate * 100.0
);
drop(config); // 释放读锁再转换状态
@@ -278,7 +295,7 @@ impl CircuitBreaker {
/// 重置熔断器(手动恢复)
#[allow(dead_code)]
pub async fn reset(&self) {
- log::info!("[CB-006] 熔断器手动重置 → Closed");
+ log::info!("[{}] 熔断器手动重置 → Closed", log_cb::MANUAL_RESET);
self.transition_to_closed().await;
}
diff --git a/src-tauri/src/proxy/error.rs b/src-tauri/src/proxy/error.rs
index 1b0531a3a..cc66d68e6 100644
--- a/src-tauri/src/proxy/error.rs
+++ b/src-tauri/src/proxy/error.rs
@@ -17,6 +17,12 @@ pub enum ProxyError {
#[error("地址绑定失败: {0}")]
BindFailed(String),
+ #[error("停止超时")]
+ StopTimeout,
+
+ #[error("停止失败: {0}")]
+ StopFailed(String),
+
#[error("请求转发失败: {0}")]
ForwardFailed(String),
@@ -113,6 +119,12 @@ impl IntoResponse for ProxyError {
ProxyError::BindFailed(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
+ ProxyError::StopTimeout => {
+ (StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
+ }
+ ProxyError::StopFailed(_) => {
+ (StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
+ }
ProxyError::ForwardFailed(_) => (StatusCode::BAD_GATEWAY, self.to_string()),
ProxyError::NoAvailableProvider => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs
index 286113dc2..664b849b4 100644
--- a/src-tauri/src/proxy/handlers.rs
+++ b/src-tauri/src/proxy/handlers.rs
@@ -456,7 +456,12 @@ async fn log_usage(
Ok(Some(p)) => {
if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier {
- Decimal::from_str(&cm).unwrap_or(Decimal::from(1))
+ Decimal::from_str(&cm).unwrap_or_else(|e| {
+ log::warn!(
+ "cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
+ );
+ Decimal::from(1)
+ })
} else {
Decimal::from(1)
}
diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs
index 87a0d85c9..04d4ec6ff 100644
--- a/src-tauri/src/proxy/providers/claude.rs
+++ b/src-tauri/src/proxy/providers/claude.rs
@@ -62,7 +62,8 @@ impl ClaudeAdapter {
let normalized = value.trim().to_lowercase();
normalized == "true" || normalized == "1"
}
- _ => true,
+ // OpenRouter now supports Claude Code compatible API, default to passthrough
+ _ => false,
}
}
@@ -465,12 +466,22 @@ mod tests {
}));
assert!(!adapter.needs_transform(&anthropic_provider));
+ // OpenRouter provider without explicit setting now defaults to passthrough (no transform)
let openrouter_provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
}
}));
- assert!(adapter.needs_transform(&openrouter_provider));
+ assert!(!adapter.needs_transform(&openrouter_provider));
+
+ // OpenRouter provider with explicit compat mode enabled should transform
+ let openrouter_enabled = create_provider(json!({
+ "env": {
+ "ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
+ },
+ "openrouter_compat_mode": true
+ }));
+ assert!(adapter.needs_transform(&openrouter_enabled));
let openrouter_disabled = create_provider(json!({
"env": {
diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs
index b393211f1..f6a2eeb8a 100644
--- a/src-tauri/src/proxy/response_processor.rs
+++ b/src-tauri/src/proxy/response_processor.rs
@@ -372,7 +372,12 @@ async fn log_usage_internal(
Ok(Some(p)) => {
if let Some(meta) = p.meta {
if let Some(cm) = meta.cost_multiplier {
- Decimal::from_str(&cm).unwrap_or(Decimal::from(1))
+ Decimal::from_str(&cm).unwrap_or_else(|e| {
+ log::warn!(
+ "cost_multiplier 解析失败 (provider_id={provider_id}): {cm} - {e}"
+ );
+ Decimal::from(1)
+ })
} else {
Decimal::from(1)
}
diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs
index 2a4b2867f..8cd742182 100644
--- a/src-tauri/src/proxy/server.rs
+++ b/src-tauri/src/proxy/server.rs
@@ -3,8 +3,8 @@
//! 基于Axum的HTTP服务器,处理代理请求
use super::{
- failover_switch::FailoverSwitchManager, handlers, provider_router::ProviderRouter, types::*,
- ProxyError,
+ failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv,
+ provider_router::ProviderRouter, types::*, ProxyError,
};
use crate::database::Database;
use axum::{
@@ -95,7 +95,7 @@ impl ProxyServer {
.await
.map_err(|e| ProxyError::BindFailed(e.to_string()))?;
- log::info!("[SRV-001] 代理服务器启动于 {addr}");
+ log::info!("[{}] 代理服务器启动于 {addr}", log_srv::STARTED);
// 保存关闭句柄
*self.shutdown_tx.write().await = Some(shutdown_tx);
@@ -146,13 +146,25 @@ impl ProxyServer {
// 2. 等待服务器任务结束(带 5 秒超时保护)
if let Some(handle) = self.server_handle.write().await.take() {
match tokio::time::timeout(std::time::Duration::from_secs(5), handle).await {
- Ok(Ok(())) => log::info!("[SRV-002] 代理服务器已完全停止"),
- Ok(Err(e)) => log::warn!("[SRV-003] 代理服务器任务异常终止: {e}"),
- Err(_) => log::warn!("[SRV-004] 代理服务器停止超时(5秒),强制继续"),
+ Ok(Ok(())) => {
+ log::info!("[{}] 代理服务器已完全停止", log_srv::STOPPED);
+ Ok(())
+ }
+ Ok(Err(e)) => {
+ log::warn!("[{}] 代理服务器任务异常终止: {e}", log_srv::TASK_ERROR);
+ Err(ProxyError::StopFailed(e.to_string()))
+ }
+ Err(_) => {
+ log::warn!(
+ "[{}] 代理服务器停止超时(5秒),强制继续",
+ log_srv::STOP_TIMEOUT
+ );
+ Err(ProxyError::StopTimeout)
+ }
}
+ } else {
+ Ok(())
}
-
- Ok(())
}
pub async fn get_status(&self) -> ProxyStatus {
diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs
index 6389827b9..0192f6ecd 100644
--- a/src-tauri/src/services/provider/live.rs
+++ b/src-tauri/src/services/provider/live.rs
@@ -148,6 +148,15 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// MCP sync
McpService::sync_all_enabled(state)?;
+
+ // Skill sync
+ for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
+ if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
+ log::warn!("同步 Skill 到 {:?} 失败: {}", app_type, e);
+ // Continue syncing other apps, don't abort
+ }
+ }
+
Ok(())
}
diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs
index 30f4c5de9..21e15d528 100644
--- a/src-tauri/src/settings.rs
+++ b/src-tauri/src/settings.rs
@@ -92,12 +92,9 @@ impl Default for AppSettings {
}
impl AppSettings {
- fn settings_path() -> PathBuf {
+ fn settings_path() -> Option {
// settings.json 保留用于旧版本迁移和无数据库场景
- dirs::home_dir()
- .expect("无法获取用户主目录")
- .join(".cc-switch")
- .join("settings.json")
+ dirs::home_dir().map(|h| h.join(".cc-switch").join("settings.json"))
}
fn normalize_paths(&mut self) {
@@ -131,7 +128,9 @@ impl AppSettings {
}
fn load_from_file() -> Self {
- let path = Self::settings_path();
+ let Some(path) = Self::settings_path() else {
+ return Self::default();
+ };
if let Ok(content) = fs::read_to_string(&path) {
match serde_json::from_str::(&content) {
Ok(mut settings) => {
@@ -156,7 +155,9 @@ impl AppSettings {
fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
let mut normalized = settings.clone();
normalized.normalize_paths();
- let path = AppSettings::settings_path();
+ let Some(path) = AppSettings::settings_path() else {
+ return Err(AppError::Config("无法获取用户主目录".to_string()));
+ };
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
@@ -193,14 +194,23 @@ fn resolve_override_path(raw: &str) -> PathBuf {
}
pub fn get_settings() -> AppSettings {
- settings_store().read().expect("读取设置锁失败").clone()
+ settings_store()
+ .read()
+ .unwrap_or_else(|e| {
+ log::warn!("设置锁已毒化,使用恢复值: {e}");
+ e.into_inner()
+ })
+ .clone()
}
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
new_settings.normalize_paths();
save_settings_file(&new_settings)?;
- let mut guard = settings_store().write().expect("写入设置锁失败");
+ let mut guard = settings_store().write().unwrap_or_else(|e| {
+ log::warn!("设置锁已毒化,使用恢复值: {e}");
+ e.into_inner()
+ });
*guard = new_settings;
Ok(())
}
@@ -209,7 +219,10 @@ pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> {
/// 用于导入配置等场景,确保内存缓存与文件同步
pub fn reload_settings() -> Result<(), AppError> {
let fresh_settings = AppSettings::load_from_file();
- let mut guard = settings_store().write().expect("写入设置锁失败");
+ let mut guard = settings_store().write().unwrap_or_else(|e| {
+ log::warn!("设置锁已毒化,使用恢复值: {e}");
+ e.into_inner()
+ });
*guard = fresh_settings;
Ok(())
}
diff --git a/src/App.tsx b/src/App.tsx
index 62bc543d5..6bbc34386 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -808,6 +808,7 @@ function App() {
{effectiveUsageProvider && (
diff --git a/src/components/providers/forms/hooks/useApiKeyState.ts b/src/components/providers/forms/hooks/useApiKeyState.ts
index 9cd5fde39..9101d2e35 100644
--- a/src/components/providers/forms/hooks/useApiKeyState.ts
+++ b/src/components/providers/forms/hooks/useApiKeyState.ts
@@ -43,11 +43,7 @@ export function useApiKeyState({
return;
}
- // 仅当配置确实包含 API Key 字段时才同步(避免无意清空用户正在输入的 key)
- if (!hasApiKeyField(initialConfig, appType)) {
- return;
- }
-
+ // 从配置中提取 API Key(如果不存在则返回空字符串)
const extracted = getApiKeyFromConfig(initialConfig, appType);
if (extracted !== apiKey) {
setApiKey(extracted);
diff --git a/src/components/providers/forms/hooks/useBaseUrlState.ts b/src/components/providers/forms/hooks/useBaseUrlState.ts
index 61b1ad997..2deffcbac 100644
--- a/src/components/providers/forms/hooks/useBaseUrlState.ts
+++ b/src/components/providers/forms/hooks/useBaseUrlState.ts
@@ -41,8 +41,9 @@ export function useBaseUrlState({
try {
const config = JSON.parse(settingsConfig || "{}");
const envUrl: unknown = config?.env?.ANTHROPIC_BASE_URL;
- if (typeof envUrl === "string" && envUrl && envUrl.trim() !== baseUrl) {
- setBaseUrl(envUrl.trim());
+ const nextUrl = typeof envUrl === "string" ? envUrl.trim() : "";
+ if (nextUrl !== baseUrl) {
+ setBaseUrl(nextUrl);
}
} catch {
// ignore
diff --git a/src/components/proxy/AutoFailoverConfigPanel.tsx b/src/components/proxy/AutoFailoverConfigPanel.tsx
index b80130fc1..4186e358e 100644
--- a/src/components/proxy/AutoFailoverConfigPanel.tsx
+++ b/src/components/proxy/AutoFailoverConfigPanel.tsx
@@ -56,29 +56,122 @@ export function AutoFailoverConfigPanel({
const handleSave = async () => {
if (!config) return;
- // 解析数字,空值使用默认值,0 是有效值
- const parseNum = (val: string, defaultVal: number) => {
- const n = parseInt(val);
- return isNaN(n) ? defaultVal : n;
+ // 解析数字,返回 NaN 表示无效输入
+ const parseNum = (val: string) => {
+ const trimmed = val.trim();
+ // 必须是纯数字
+ if (!/^-?\d+$/.test(trimmed)) return NaN;
+ return parseInt(trimmed);
};
+
+ // 定义各字段的有效范围
+ const ranges = {
+ maxRetries: { min: 0, max: 10 },
+ streamingFirstByteTimeout: { min: 0, max: 180 },
+ streamingIdleTimeout: { min: 0, max: 600 },
+ nonStreamingTimeout: { min: 0, max: 1800 },
+ circuitFailureThreshold: { min: 1, max: 20 },
+ circuitSuccessThreshold: { min: 1, max: 10 },
+ circuitTimeoutSeconds: { min: 0, max: 300 },
+ circuitErrorRateThreshold: { min: 0, max: 100 },
+ circuitMinRequests: { min: 5, max: 100 },
+ };
+
+ // 解析原始值
+ const raw = {
+ maxRetries: parseNum(formData.maxRetries),
+ streamingFirstByteTimeout: parseNum(formData.streamingFirstByteTimeout),
+ streamingIdleTimeout: parseNum(formData.streamingIdleTimeout),
+ nonStreamingTimeout: parseNum(formData.nonStreamingTimeout),
+ circuitFailureThreshold: parseNum(formData.circuitFailureThreshold),
+ circuitSuccessThreshold: parseNum(formData.circuitSuccessThreshold),
+ circuitTimeoutSeconds: parseNum(formData.circuitTimeoutSeconds),
+ circuitErrorRateThreshold: parseNum(formData.circuitErrorRateThreshold),
+ circuitMinRequests: parseNum(formData.circuitMinRequests),
+ };
+
+ // 校验是否超出范围(NaN 也视为无效)
+ const errors: string[] = [];
+ const checkRange = (
+ value: number,
+ range: { min: number; max: number },
+ label: string,
+ ) => {
+ if (isNaN(value) || value < range.min || value > range.max) {
+ errors.push(`${label}: ${range.min}-${range.max}`);
+ }
+ };
+
+ checkRange(
+ raw.maxRetries,
+ ranges.maxRetries,
+ t("proxy.autoFailover.maxRetries", "最大重试次数"),
+ );
+ checkRange(
+ raw.streamingFirstByteTimeout,
+ ranges.streamingFirstByteTimeout,
+ t("proxy.autoFailover.streamingFirstByte", "流式首字节超时"),
+ );
+ checkRange(
+ raw.streamingIdleTimeout,
+ ranges.streamingIdleTimeout,
+ t("proxy.autoFailover.streamingIdle", "流式静默超时"),
+ );
+ checkRange(
+ raw.nonStreamingTimeout,
+ ranges.nonStreamingTimeout,
+ t("proxy.autoFailover.nonStreaming", "非流式超时"),
+ );
+ checkRange(
+ raw.circuitFailureThreshold,
+ ranges.circuitFailureThreshold,
+ t("proxy.autoFailover.failureThreshold", "失败阈值"),
+ );
+ checkRange(
+ raw.circuitSuccessThreshold,
+ ranges.circuitSuccessThreshold,
+ t("proxy.autoFailover.successThreshold", "恢复成功阈值"),
+ );
+ checkRange(
+ raw.circuitTimeoutSeconds,
+ ranges.circuitTimeoutSeconds,
+ t("proxy.autoFailover.timeout", "恢复等待时间"),
+ );
+ checkRange(
+ raw.circuitErrorRateThreshold,
+ ranges.circuitErrorRateThreshold,
+ t("proxy.autoFailover.errorRate", "错误率阈值"),
+ );
+ checkRange(
+ raw.circuitMinRequests,
+ ranges.circuitMinRequests,
+ t("proxy.autoFailover.minRequests", "最小请求数"),
+ );
+
+ if (errors.length > 0) {
+ toast.error(
+ t("proxy.autoFailover.validationFailed", {
+ fields: errors.join("; "),
+ defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
+ }),
+ );
+ return;
+ }
+
try {
await updateConfig.mutateAsync({
appType,
enabled: config.enabled,
autoFailoverEnabled: formData.autoFailoverEnabled,
- maxRetries: parseNum(formData.maxRetries, 3),
- streamingFirstByteTimeout: parseNum(
- formData.streamingFirstByteTimeout,
- 30,
- ),
- streamingIdleTimeout: parseNum(formData.streamingIdleTimeout, 60),
- nonStreamingTimeout: parseNum(formData.nonStreamingTimeout, 300),
- circuitFailureThreshold: parseNum(formData.circuitFailureThreshold, 5),
- circuitSuccessThreshold: parseNum(formData.circuitSuccessThreshold, 2),
- circuitTimeoutSeconds: parseNum(formData.circuitTimeoutSeconds, 60),
- circuitErrorRateThreshold:
- parseNum(formData.circuitErrorRateThreshold, 50) / 100,
- circuitMinRequests: parseNum(formData.circuitMinRequests, 10),
+ maxRetries: raw.maxRetries,
+ streamingFirstByteTimeout: raw.streamingFirstByteTimeout,
+ streamingIdleTimeout: raw.streamingIdleTimeout,
+ nonStreamingTimeout: raw.nonStreamingTimeout,
+ circuitFailureThreshold: raw.circuitFailureThreshold,
+ circuitSuccessThreshold: raw.circuitSuccessThreshold,
+ circuitTimeoutSeconds: raw.circuitTimeoutSeconds,
+ circuitErrorRateThreshold: raw.circuitErrorRateThreshold / 100,
+ circuitMinRequests: raw.circuitMinRequests,
});
toast.success(
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
@@ -327,7 +420,7 @@ export function AutoFailoverConfigPanel({
diff --git a/src/components/proxy/CircuitBreakerConfigPanel.tsx b/src/components/proxy/CircuitBreakerConfigPanel.tsx
index 5341df331..a57767993 100644
--- a/src/components/proxy/CircuitBreakerConfigPanel.tsx
+++ b/src/components/proxy/CircuitBreakerConfigPanel.tsx
@@ -7,12 +7,14 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useState, useEffect } from "react";
import { toast } from "sonner";
+import { useTranslation } from "react-i18next";
/**
* 熔断器配置面板
* 允许用户调整熔断器参数
*/
export function CircuitBreakerConfigPanel() {
+ const { t } = useTranslation();
const { data: config, isLoading } = useCircuitBreakerConfig();
const updateConfig = useUpdateCircuitBreakerConfig();
@@ -39,23 +41,95 @@ export function CircuitBreakerConfigPanel() {
}, [config]);
const handleSave = async () => {
- // 解析数字,空值使用默认值,0 是有效值
- const parseNum = (val: string, defaultVal: number) => {
- const n = parseInt(val);
- return isNaN(n) ? defaultVal : n;
+ // 解析数字,返回 NaN 表示无效输入
+ const parseNum = (val: string) => {
+ const trimmed = val.trim();
+ // 必须是纯数字
+ if (!/^-?\d+$/.test(trimmed)) return NaN;
+ return parseInt(trimmed);
};
+
+ // 定义各字段的有效范围
+ const ranges = {
+ failureThreshold: { min: 1, max: 20 },
+ successThreshold: { min: 1, max: 10 },
+ timeoutSeconds: { min: 0, max: 300 },
+ errorRateThreshold: { min: 0, max: 100 },
+ minRequests: { min: 5, max: 100 },
+ };
+
+ // 解析原始值
+ const raw = {
+ failureThreshold: parseNum(formData.failureThreshold),
+ successThreshold: parseNum(formData.successThreshold),
+ timeoutSeconds: parseNum(formData.timeoutSeconds),
+ errorRateThreshold: parseNum(formData.errorRateThreshold),
+ minRequests: parseNum(formData.minRequests),
+ };
+
+ // 校验是否超出范围(NaN 也视为无效)
+ const errors: string[] = [];
+ const checkRange = (
+ value: number,
+ range: { min: number; max: number },
+ label: string,
+ ) => {
+ if (isNaN(value) || value < range.min || value > range.max) {
+ errors.push(`${label}: ${range.min}-${range.max}`);
+ }
+ };
+
+ checkRange(
+ raw.failureThreshold,
+ ranges.failureThreshold,
+ t("circuitBreaker.failureThreshold", "失败阈值"),
+ );
+ checkRange(
+ raw.successThreshold,
+ ranges.successThreshold,
+ t("circuitBreaker.successThreshold", "成功阈值"),
+ );
+ checkRange(
+ raw.timeoutSeconds,
+ ranges.timeoutSeconds,
+ t("circuitBreaker.timeoutSeconds", "超时时间"),
+ );
+ checkRange(
+ raw.errorRateThreshold,
+ ranges.errorRateThreshold,
+ t("circuitBreaker.errorRateThreshold", "错误率阈值"),
+ );
+ checkRange(
+ raw.minRequests,
+ ranges.minRequests,
+ t("circuitBreaker.minRequests", "最小请求数"),
+ );
+
+ if (errors.length > 0) {
+ toast.error(
+ t("circuitBreaker.validationFailed", {
+ fields: errors.join("; "),
+ defaultValue: `以下字段超出有效范围: ${errors.join("; ")}`,
+ }),
+ );
+ return;
+ }
+
try {
- const parsed = {
- failureThreshold: parseNum(formData.failureThreshold, 5),
- successThreshold: parseNum(formData.successThreshold, 2),
- timeoutSeconds: parseNum(formData.timeoutSeconds, 60),
- errorRateThreshold: parseNum(formData.errorRateThreshold, 50) / 100,
- minRequests: parseNum(formData.minRequests, 10),
- };
- await updateConfig.mutateAsync(parsed);
- toast.success("熔断器配置已保存", { closeButton: true });
+ await updateConfig.mutateAsync({
+ failureThreshold: raw.failureThreshold,
+ successThreshold: raw.successThreshold,
+ timeoutSeconds: raw.timeoutSeconds,
+ errorRateThreshold: raw.errorRateThreshold / 100,
+ minRequests: raw.minRequests,
+ });
+ toast.success(t("circuitBreaker.configSaved", "熔断器配置已保存"), {
+ closeButton: true,
+ });
} catch (error) {
- toast.error("保存失败: " + String(error));
+ toast.error(
+ t("circuitBreaker.saveFailed", "保存失败") + ": " + String(error),
+ );
}
};
@@ -111,7 +185,7 @@ export function CircuitBreakerConfigPanel() {
diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx
index 1cf88a146..5bee8590f 100644
--- a/src/components/proxy/ProxyPanel.tsx
+++ b/src/components/proxy/ProxyPanel.tsx
@@ -102,14 +102,57 @@ export function ProxyPanel() {
const handleSaveBasicConfig = async () => {
if (!globalConfig) return;
- const port = parseInt(listenPort);
- const validPort = isNaN(port) || port < 1024 || port > 65535 ? 15721 : port;
+
+ // 校验地址格式(简单的 IP 地址或 localhost 校验)
+ const addressTrimmed = listenAddress.trim();
+ const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
+ // 规范化 localhost 为 127.0.0.1
+ const normalizedAddress =
+ addressTrimmed === "localhost" ? "127.0.0.1" : addressTrimmed;
+ const isValidAddress =
+ normalizedAddress === "0.0.0.0" ||
+ (ipv4Regex.test(normalizedAddress) &&
+ normalizedAddress.split(".").every((n) => {
+ const num = parseInt(n);
+ return num >= 0 && num <= 255;
+ }));
+ if (!isValidAddress) {
+ toast.error(
+ t("proxy.settings.invalidAddress", {
+ defaultValue:
+ "地址无效,请输入有效的 IP 地址(如 127.0.0.1)或 localhost",
+ }),
+ );
+ return;
+ }
+
+ // 严格校验端口:必须是纯数字
+ const portTrimmed = listenPort.trim();
+ if (!/^\d+$/.test(portTrimmed)) {
+ toast.error(
+ t("proxy.settings.invalidPort", {
+ defaultValue: "端口无效,请输入 1024-65535 之间的数字",
+ }),
+ );
+ return;
+ }
+ const port = parseInt(portTrimmed);
+ if (isNaN(port) || port < 1024 || port > 65535) {
+ toast.error(
+ t("proxy.settings.invalidPort", {
+ defaultValue: "端口无效,请输入 1024-65535 之间的数字",
+ }),
+ );
+ return;
+ }
try {
await updateGlobalConfig.mutateAsync({
...globalConfig,
- listenAddress,
- listenPort: validPort,
+ listenAddress: normalizedAddress,
+ listenPort: port,
});
+ // 同步更新本地状态为规范化后的值
+ setListenAddress(normalizedAddress);
toast.success(
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
{ closeButton: true },
@@ -135,6 +178,13 @@ export function ProxyPanel() {
}
};
+ // 格式化地址用于 URL(IPv6 需要方括号)
+ const formatAddressForUrl = (address: string, port: number): string => {
+ const isIPv6 = address.includes(":");
+ const host = isIPv6 ? `[${address}]` : address;
+ return `http://${host}:${port}`;
+ };
+
return (
<>
@@ -149,14 +199,14 @@ export function ProxyPanel() {
- http://{status.address}:{status.port}
+ {formatAddressForUrl(status.address, status.port)}