From 74f67bc1ee2f87a24e6f30fae57d34d660948f28 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Sat, 17 Jan 2026 03:21:40 +0800 Subject: [PATCH] feat(settings): add log config management Fixes #612 Fixes #514 --- src-tauri/src/commands/settings.rs | 27 +++++ src-tauri/src/database/dao/settings.rs | 18 ++++ src-tauri/src/lib.rs | 33 ++++-- src-tauri/src/panic_hook.rs | 45 -------- src-tauri/src/proxy/providers/streaming.rs | 1 - src-tauri/src/proxy/response_processor.rs | 45 ++++++-- src-tauri/src/proxy/types.rs | 100 +++++++++++++++++ src/components/settings/LogConfigPanel.tsx | 119 +++++++++++++++++++++ src/components/settings/SettingsPage.tsx | 24 +++++ src/i18n/locales/en.json | 23 ++++ src/i18n/locales/ja.json | 23 ++++ src/i18n/locales/zh.json | 23 ++++ src/lib/api/settings.ts | 13 +++ 13 files changed, 432 insertions(+), 62 deletions(-) create mode 100644 src/components/settings/LogConfigPanel.tsx diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 12cd64fff..2a6ca5039 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -80,3 +80,30 @@ pub async fn set_rectifier_config( .map_err(|e| e.to_string())?; Ok(true) } + +/// 获取日志配置 +#[tauri::command] +pub async fn get_log_config( + state: tauri::State<'_, crate::AppState>, +) -> Result { + state.db.get_log_config().map_err(|e| e.to_string()) +} + +/// 设置日志配置 +#[tauri::command] +pub async fn set_log_config( + state: tauri::State<'_, crate::AppState>, + config: crate::proxy::types::LogConfig, +) -> Result { + state + .db + .set_log_config(&config) + .map_err(|e| e.to_string())?; + log::set_max_level(config.to_level_filter()); + log::info!( + "日志配置已更新: enabled={}, level={}", + config.enabled, + config.level + ); + Ok(true) +} diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 57ecc720a..109e972dd 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -186,4 +186,22 @@ impl Database { .map_err(|e| AppError::Database(format!("序列化整流器配置失败: {e}")))?; self.set_setting("rectifier_config", &json) } + + // --- 日志配置 --- + + /// 获取日志配置 + pub fn get_log_config(&self) -> Result { + match self.get_setting("log_config")? { + Some(json) => serde_json::from_str(&json) + .map_err(|e| AppError::Database(format!("解析日志配置失败: {e}"))), + None => Ok(crate::proxy::types::LogConfig::default()), + } + } + + /// 更新日志配置 + pub fn set_log_config(&self, config: &crate::proxy::types::LogConfig) -> Result<(), AppError> { + let json = serde_json::to_string(config) + .map_err(|e| AppError::Database(format!("序列化日志配置失败: {e}")))?; + self.set_setting("log_config", &json) + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 48abd4f50..ce12eba2d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -266,33 +266,31 @@ pub fn run() { log::warn!("初始化 Updater 插件失败,已跳过:{e}"); } } - // 初始化日志(Debug 和 Release 模式都启用 Info 级别) - // 日志同时输出到控制台和文件(/logs/;若设置了覆盖则使用覆盖目录) + // 初始化日志(单文件输出到 /logs/cc-switch.log) { use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy}; let log_dir = panic_hook::get_log_dir(); + // 确保日志目录存在 + if let Err(e) = std::fs::create_dir_all(&log_dir) { + eprintln!("创建日志目录失败: {e}"); + } + app.handle().plugin( tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) + .level(log::LevelFilter::Info) // 初始化为 Info,后续从数据库加载配置 .targets([ - // 输出到控制台 Target::new(TargetKind::Stdout), - // 输出到日志文件 Target::new(TargetKind::Folder { path: log_dir, file_name: Some("cc-switch".into()), }), ]) - .rotation_strategy(RotationStrategy::KeepAll) - .max_file_size(5_000_000) // 5MB 单文件上限 + .rotation_strategy(RotationStrategy::KeepAll) // 追加模式,不清空日志 .timezone_strategy(TimezoneStrategy::UseLocal) .build(), )?; - - // 清理旧日志文件,只保留最近 2 个 - panic_hook::cleanup_old_logs(); } // 初始化数据库 @@ -660,6 +658,19 @@ pub fn run() { // 将同一个实例注入到全局状态,避免重复创建导致的不一致 app.manage(app_state); + // 从数据库加载日志配置并应用 + { + let db = &app.state::().db; + if let Ok(log_config) = db.get_log_config() { + log::set_max_level(log_config.to_level_filter()); + log::info!( + "已加载日志配置: enabled={}, level={}", + log_config.enabled, + log_config.level + ); + } + } + // 初始化 SkillService let skill_service = SkillService::new(); app.manage(commands::skill::SkillServiceState(Arc::new(skill_service))); @@ -757,6 +768,8 @@ pub fn run() { commands::save_settings, commands::get_rectifier_config, commands::set_rectifier_config, + commands::get_log_config, + commands::set_log_config, commands::restart_app, commands::check_for_updates, commands::is_portable_mode, diff --git a/src-tauri/src/panic_hook.rs b/src-tauri/src/panic_hook.rs index b157e3bf1..dacbd54e8 100644 --- a/src-tauri/src/panic_hook.rs +++ b/src-tauri/src/panic_hook.rs @@ -12,9 +12,6 @@ use std::sync::OnceLock; /// 应用版本号(从 Cargo.toml 读取) const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); -/// 日志文件保留数量 -const LOG_FILES_TO_KEEP: usize = 2; - static APP_CONFIG_DIR: OnceLock = OnceLock::new(); pub fn init_app_config_dir(dir: PathBuf) { @@ -46,48 +43,6 @@ pub fn get_log_dir() -> PathBuf { get_app_config_dir().join("logs") } -/// 清理旧日志文件,只保留最近 N 个 -/// -/// 在应用启动时调用,确保日志文件不会无限增长。 -pub fn cleanup_old_logs() { - let log_dir = get_log_dir(); - - if !log_dir.exists() { - return; - } - - // 读取目录中的所有 .log 文件 - let mut log_files: Vec<_> = match std::fs::read_dir(&log_dir) { - Ok(entries) => entries - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .filter(|p| p.extension().map(|ext| ext == "log").unwrap_or(false)) - .collect(), - Err(_) => return, - }; - - // 如果文件数量不超过保留数量,无需清理 - if log_files.len() <= LOG_FILES_TO_KEEP { - return; - } - - // 按修改时间排序(最新的在前) - log_files.sort_by(|a, b| { - let time_a = a.metadata().and_then(|m| m.modified()).ok(); - let time_b = b.metadata().and_then(|m| m.modified()).ok(); - time_b.cmp(&time_a) // 降序 - }); - - // 删除多余的旧文件 - for old_file in log_files.into_iter().skip(LOG_FILES_TO_KEEP) { - if let Err(e) = std::fs::remove_file(&old_file) { - log::warn!("清理旧日志文件失败 {}: {e}", old_file.display()); - } else { - log::info!("已清理旧日志文件: {}", old_file.display()); - } - } -} - /// 安全获取环境信息(不会 panic) fn get_system_info() -> String { let os = std::env::consts::OS; diff --git a/src-tauri/src/proxy/providers/streaming.rs b/src-tauri/src/proxy/providers/streaming.rs index 30e261301..a3fd590c2 100644 --- a/src-tauri/src/proxy/providers/streaming.rs +++ b/src-tauri/src/proxy/providers/streaming.rs @@ -104,7 +104,6 @@ pub fn create_anthropic_sse_stream( } if let Ok(chunk) = serde_json::from_str::(data) { - // 仅在 DEBUG 级别简短记录 SSE 事件 log::debug!("[Claude/OpenRouter] <<< SSE chunk received"); if message_id.is_none() { diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index f6a2eeb8a..b7af43524 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -12,6 +12,7 @@ use super::{ use axum::response::{IntoResponse, Response}; use bytes::Bytes; use futures::stream::{Stream, StreamExt}; +use reqwest::header::HeaderMap; use rust_decimal::Decimal; use serde_json::Value; use std::{ @@ -47,6 +48,12 @@ pub async fn handle_streaming( parser_config: &UsageParserConfig, ) -> Response { let status = response.status(); + log::debug!( + "[{}] 已接收上游流式响应: status={}, headers={}", + ctx.tag, + status.as_u16(), + format_headers(response.headers()) + ); let mut builder = axum::response::Response::builder().status(status); // 复制响应头 @@ -94,6 +101,19 @@ pub async fn handle_non_streaming( log::error!("[{}] 读取响应失败: {e}", ctx.tag); ProxyError::ForwardFailed(format!("Failed to read response body: {e}")) })?; + log::debug!( + "[{}] 已接收上游响应体: status={}, bytes={}, headers={}", + ctx.tag, + status.as_u16(), + body_bytes.len(), + format_headers(&response_headers) + ); + + log::debug!( + "[{}] 上游响应体内容: {}", + ctx.tag, + String::from_utf8_lossy(&body_bytes) + ); // 解析并记录使用量 if let Ok(json_value) = serde_json::from_slice::(&body_bytes) { @@ -470,6 +490,12 @@ pub fn create_logged_passthrough_stream( match chunk_result { Some(Ok(bytes)) => { + if is_first_chunk { + log::debug!( + "[{tag}] 已接收上游流式首包: bytes={}", + bytes.len() + ); + } is_first_chunk = false; let text = String::from_utf8_lossy(&bytes); buffer.push_str(&text); @@ -488,13 +514,9 @@ pub fn create_logged_passthrough_stream( if let Some(c) = &collector { c.push(json_value.clone()).await; } - log::debug!( - "[{}] <<< SSE 事件: {}", - tag, - data.chars().take(100).collect::() - ); + log::debug!("[{tag}] <<< SSE 事件: {data}"); } else { - log::debug!("[{tag}] <<< SSE 数据: {}", data.chars().take(100).collect::()); + log::debug!("[{tag}] <<< SSE 数据: {data}"); } } else { log::debug!("[{tag}] <<< SSE: [DONE]"); @@ -523,3 +545,14 @@ pub fn create_logged_passthrough_stream( } } } + +fn format_headers(headers: &HeaderMap) -> String { + headers + .iter() + .map(|(key, value)| { + let value_str = value.to_str().unwrap_or(""); + format!("{key}={value_str}") + }) + .collect::>() + .join(", ") +} diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index f49963927..4af7c76b8 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -221,6 +221,50 @@ fn default_true() -> bool { true } +fn default_log_level() -> String { + "info".to_string() +} + +/// 日志配置 +/// +/// 存储在 settings 表的 log_config 字段中(JSON 格式) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LogConfig { + /// 总开关:是否启用日志 + #[serde(default = "default_true")] + pub enabled: bool, + /// 日志级别: error, warn, info, debug, trace + #[serde(default = "default_log_level")] + pub level: String, +} + +impl Default for LogConfig { + fn default() -> Self { + Self { + enabled: true, + level: "info".to_string(), + } + } +} + +impl LogConfig { + /// 将配置转换为 log::LevelFilter + pub fn to_level_filter(&self) -> log::LevelFilter { + if !self.enabled { + return log::LevelFilter::Off; + } + match self.level.to_lowercase().as_str() { + "error" => log::LevelFilter::Error, + "warn" => log::LevelFilter::Warn, + "info" => log::LevelFilter::Info, + "debug" => log::LevelFilter::Debug, + "trace" => log::LevelFilter::Trace, + _ => log::LevelFilter::Info, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -254,4 +298,60 @@ mod tests { assert!(!config.enabled); assert!(!config.request_thinking_signature); } + + #[test] + fn test_log_config_default() { + let config = LogConfig::default(); + assert!(config.enabled); + assert_eq!(config.level, "info"); + } + + #[test] + fn test_log_config_serde_default() { + let json = "{}"; + let config: LogConfig = serde_json::from_str(json).unwrap(); + assert!(config.enabled); + assert_eq!(config.level, "info"); + } + + #[test] + fn test_log_config_to_level_filter() { + let mut config = LogConfig::default(); + + config.level = "error".to_string(); + assert_eq!(config.to_level_filter(), log::LevelFilter::Error); + + config.level = "warn".to_string(); + assert_eq!(config.to_level_filter(), log::LevelFilter::Warn); + + config.level = "info".to_string(); + assert_eq!(config.to_level_filter(), log::LevelFilter::Info); + + config.level = "debug".to_string(); + assert_eq!(config.to_level_filter(), log::LevelFilter::Debug); + + config.level = "trace".to_string(); + assert_eq!(config.to_level_filter(), log::LevelFilter::Trace); + + // 无效级别回退到 info + config.level = "invalid".to_string(); + assert_eq!(config.to_level_filter(), log::LevelFilter::Info); + + // 禁用时返回 Off + config.enabled = false; + config.level = "debug".to_string(); + assert_eq!(config.to_level_filter(), log::LevelFilter::Off); + } + + #[test] + fn test_log_config_serde_roundtrip() { + let config = LogConfig { + enabled: true, + level: "debug".to_string(), + }; + let json = serde_json::to_string(&config).unwrap(); + let parsed: LogConfig = serde_json::from_str(&json).unwrap(); + assert!(parsed.enabled); + assert_eq!(parsed.level, "debug"); + } } diff --git a/src/components/settings/LogConfigPanel.tsx b/src/components/settings/LogConfigPanel.tsx new file mode 100644 index 000000000..fa71885d0 --- /dev/null +++ b/src/components/settings/LogConfigPanel.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { settingsApi, type LogConfig } from "@/lib/api/settings"; + +const LOG_LEVELS = ["error", "warn", "info", "debug", "trace"] as const; + +export function LogConfigPanel() { + const { t } = useTranslation(); + const [config, setConfig] = useState({ + enabled: true, + level: "info", + }); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + settingsApi + .getLogConfig() + .then(setConfig) + .catch((e) => console.error("Failed to load log config:", e)) + .finally(() => setIsLoading(false)); + }, []); + + const handleChange = async (updates: Partial) => { + const newConfig = { ...config, ...updates }; + setConfig(newConfig); + try { + await settingsApi.setLogConfig(newConfig); + } catch (e) { + console.error("Failed to save log config:", e); + toast.error(String(e)); + setConfig(config); + } + }; + + if (isLoading) return null; + + return ( +
+
+
+ +

+ {t("settings.advanced.logConfig.enabledDescription")} +

+
+ handleChange({ enabled: checked })} + /> +
+ +
+
+ +

+ {t("settings.advanced.logConfig.levelDescription")} +

+
+ +
+ + {/* 日志级别说明 */} +
+

+ {t("settings.advanced.logConfig.levelHint")} +

+
+

+ error -{" "} + {t("settings.advanced.logConfig.levelDesc.error")} +

+

+ warn -{" "} + {t("settings.advanced.logConfig.levelDesc.warn")} +

+

+ info -{" "} + {t("settings.advanced.logConfig.levelDesc.info")} +

+

+ debug -{" "} + {t("settings.advanced.logConfig.levelDesc.debug")} +

+

+ trace -{" "} + {t("settings.advanced.logConfig.levelDesc.trace")} +

+
+
+
+ ); +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 6fd63e0b5..3f12a471d 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -11,6 +11,7 @@ import { ChevronDown, Zap, Globe, + ScrollText, } from "lucide-react"; import * as AccordionPrimitive from "@radix-ui/react-accordion"; import { toast } from "sonner"; @@ -44,6 +45,7 @@ import { AutoFailoverConfigPanel } from "@/components/proxy/AutoFailoverConfigPa import { FailoverQueueManager } from "@/components/proxy/FailoverQueueManager"; import { UsageDashboard } from "@/components/usage/UsageDashboard"; import { RectifierConfigPanel } from "@/components/settings/RectifierConfigPanel"; +import { LogConfigPanel } from "@/components/settings/LogConfigPanel"; import { useSettings } from "@/hooks/useSettings"; import { useImportExport } from "@/hooks/useImportExport"; import { useTranslation } from "react-i18next"; @@ -574,6 +576,28 @@ export function SettingsPage({ + + + +
+ +
+

+ {t("settings.advanced.logConfig.title")} +

+

+ {t("settings.advanced.logConfig.description")} +

+
+
+
+ + + +
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6553c7a0b..d7dbaa729 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -208,6 +208,29 @@ "responseGroup": "Response Rectification", "thinkingSignature": "Thinking Signature Rectification", "thinkingSignatureDescription": "Automatically fix Claude API errors caused by thinking signature validation failures" + }, + "logConfig": { + "title": "Log Management", + "description": "Control log output level", + "enabled": "Enable Logging", + "enabledDescription": "Master switch, all logging will be disabled when turned off", + "level": "Log Level", + "levelDescription": "Set the minimum log level to output", + "levels": { + "error": "Error", + "warn": "Warning", + "info": "Info", + "debug": "Debug", + "trace": "Trace" + }, + "levelHint": "Log level descriptions:", + "levelDesc": { + "error": "Critical errors only", + "warn": "Errors + warnings", + "info": "General operation info (default)", + "debug": "Detailed info including SSE stream and request/response", + "trace": "All logs, most verbose" + } } }, "language": "Language", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 3e2a0abc9..a2c9a2817 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -208,6 +208,29 @@ "responseGroup": "レスポンス整流", "thinkingSignature": "Thinking 署名整流", "thinkingSignatureDescription": "Claude API の thinking 署名検証エラーを自動修正" + }, + "logConfig": { + "title": "ログ管理", + "description": "ログ出力レベルを制御", + "enabled": "ログを有効化", + "enabledDescription": "マスタースイッチ、オフにするとすべてのログが無効になります", + "level": "ログレベル", + "levelDescription": "出力する最小ログレベルを設定", + "levels": { + "error": "エラー", + "warn": "警告", + "info": "情報", + "debug": "デバッグ", + "trace": "トレース" + }, + "levelHint": "ログレベルの説明:", + "levelDesc": { + "error": "重大なエラーのみ", + "warn": "エラー + 警告", + "info": "一般的な操作情報(デフォルト)", + "debug": "SSE ストリームとリクエスト/レスポンスを含む詳細情報", + "trace": "すべてのログ、最も詳細" + } } }, "language": "言語", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 67388b1a6..89335af55 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -208,6 +208,29 @@ "responseGroup": "响应整流", "thinkingSignature": "Thinking 签名整流", "thinkingSignatureDescription": "自动修复 Claude API 中因 thinking 签名校验失败导致的请求错误" + }, + "logConfig": { + "title": "日志管理", + "description": "控制日志输出级别", + "enabled": "启用日志", + "enabledDescription": "总开关,关闭后所有日志将被禁用", + "level": "日志级别", + "levelDescription": "设置输出的最低日志级别", + "levels": { + "error": "错误", + "warn": "警告", + "info": "信息", + "debug": "调试", + "trace": "跟踪" + }, + "levelHint": "日志级别说明:", + "levelDesc": { + "error": "仅严重错误", + "warn": "错误 + 警告信息", + "info": "一般操作信息(默认)", + "debug": "详细信息,包含 SSE 流和请求/响应详情", + "trace": "全部日志,最详细" + } } }, "language": "界面语言", diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index 1f1e6e0af..43daeed37 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -142,9 +142,22 @@ export const settingsApi = { async setRectifierConfig(config: RectifierConfig): Promise { return await invoke("set_rectifier_config", { config }); }, + + async getLogConfig(): Promise { + return await invoke("get_log_config"); + }, + + async setLogConfig(config: LogConfig): Promise { + return await invoke("set_log_config", { config }); + }, }; export interface RectifierConfig { enabled: boolean; requestThinkingSignature: boolean; } + +export interface LogConfig { + enabled: boolean; + level: "error" | "warn" | "info" | "debug" | "trace"; +}