mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 00:35:32 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eaddcbedd7 | |||
| 83a5597756 | |||
| bcfc22514c | |||
| 443e23c77e | |||
| f26a01137d | |||
| 1be9c56ec5 | |||
| 2651b65b10 |
@@ -5,6 +5,64 @@ All notable changes to CC Switch will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [3.9.0-3] - 2025-12-29
|
||||||
|
|
||||||
|
### Beta Release
|
||||||
|
|
||||||
|
Third beta release with important bug fixes for Windows compatibility, UI improvements, and new features.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Universal Provider** - Support for universal provider configurations (#348)
|
||||||
|
- **Provider Search Filter** - Quick filter to find providers by name (#435)
|
||||||
|
- **Keyboard Shortcut** - Open settings with Command+comma / Ctrl+comma (#436)
|
||||||
|
- **Xiaomi MiMo Icon** - Added MiMo icon and Claude provider configuration (#470)
|
||||||
|
- **Usage Model Extraction** - Extract model info from usage statistics (#455)
|
||||||
|
- **Skip First-Run Confirmation** - Option to skip Claude Code first-run confirmation dialog
|
||||||
|
- **Exit Animations** - Added exit animation to FullScreenPanel dialogs
|
||||||
|
- **Fade Transitions** - Smooth fade transitions for app/view/panel switching
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
- Wrap npx/npm commands with `cmd /c` for MCP export
|
||||||
|
- Prevent terminal windows from appearing during version check
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
- Use .app bundle path for autostart to prevent terminal window popup
|
||||||
|
|
||||||
|
#### UI
|
||||||
|
- Resolve Dialog/Modal not opening on first click (#492)
|
||||||
|
- Improve dark mode text contrast for form labels
|
||||||
|
- Reduce header spacing and fix layout shift on view switch
|
||||||
|
- Prevent header layout shift when switching views
|
||||||
|
|
||||||
|
#### Database & Schema
|
||||||
|
- Add missing base columns migration for proxy_config
|
||||||
|
- Add backward compatibility check for proxy_config seed insert
|
||||||
|
|
||||||
|
#### Other
|
||||||
|
- Use local timezone and robust DST handling in usage stats (#500)
|
||||||
|
- Remove deprecated `sync_enabled_to_codex` call
|
||||||
|
- Gracefully handle invalid Codex config.toml during MCP sync
|
||||||
|
- Add missing translations for reasoning model and OpenRouter compat mode
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **macOS Tray** - Use macOS tray template icon
|
||||||
|
- **Header Alignment** - Remove macOS titlebar tint, align custom header
|
||||||
|
- **Shadow Removal** - Cleaner UI by removing shadow styles
|
||||||
|
- **Code Inspector** - Added code-inspector-plugin for development
|
||||||
|
- **i18n** - Complete internationalization for usage panel and settings
|
||||||
|
- **Sponsor Logos** - Made sponsor logos clickable
|
||||||
|
|
||||||
|
### Stats
|
||||||
|
|
||||||
|
- 35 commits since v3.9.0-2
|
||||||
|
- 5 files changed in test/lint fixes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [3.9.0-1] - 2025-12-18
|
## [3.9.0-1] - 2025-12-18
|
||||||
|
|
||||||
### Beta Release
|
### Beta Release
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.9.0-2",
|
"version": "3.9.0-3",
|
||||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -701,7 +701,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.9.0-2"
|
version = "3.9.0-3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.9.0-2"
|
version = "3.9.0-3"
|
||||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||||
authors = ["Jason Young"]
|
authors = ["Jason Young"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -75,8 +75,7 @@ mod tests {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_macos_app_bundle_path_valid() {
|
fn test_get_macos_app_bundle_path_valid() {
|
||||||
let exe_path =
|
let exe_path = std::path::Path::new("/Applications/CC Switch.app/Contents/MacOS/CC Switch");
|
||||||
std::path::Path::new("/Applications/CC Switch.app/Contents/MacOS/CC Switch");
|
|
||||||
let result = get_macos_app_bundle_path(exe_path);
|
let result = get_macos_app_bundle_path(exe_path);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
@@ -92,7 +91,9 @@ mod tests {
|
|||||||
let result = get_macos_app_bundle_path(exe_path);
|
let result = get_macos_app_bundle_path(exe_path);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
Some(std::path::PathBuf::from("/Users/test/My Apps/CC Switch.app"))
|
Some(std::path::PathBuf::from(
|
||||||
|
"/Users/test/My Apps/CC Switch.app"
|
||||||
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,64 @@ use std::path::{Path, PathBuf};
|
|||||||
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
|
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
/// 需要在 Windows 上用 cmd /c 包装的命令
|
||||||
|
/// 这些命令在 Windows 上实际是 .cmd 批处理文件,需要通过 cmd /c 来执行
|
||||||
|
#[cfg(windows)]
|
||||||
|
const WINDOWS_WRAP_COMMANDS: &[&str] = &["npx", "npm", "yarn", "pnpm", "node", "bun", "deno"];
|
||||||
|
|
||||||
|
/// Windows 平台:将 `npx args...` 转换为 `cmd /c npx args...`
|
||||||
|
/// 解决 Claude Code /doctor 报告的 "Windows requires 'cmd /c' wrapper to execute npx" 警告
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn wrap_command_for_windows(obj: &mut Map<String, Value>) {
|
||||||
|
// 只处理 stdio 类型(默认或显式)
|
||||||
|
let server_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
|
||||||
|
if server_type != "stdio" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 已经是 cmd 的不重复包装
|
||||||
|
if cmd.eq_ignore_ascii_case("cmd") || cmd.eq_ignore_ascii_case("cmd.exe") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取命令名(去掉 .cmd 后缀和路径)
|
||||||
|
let cmd_name = Path::new(cmd)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or(cmd);
|
||||||
|
|
||||||
|
let needs_wrap = WINDOWS_WRAP_COMMANDS
|
||||||
|
.iter()
|
||||||
|
.any(|&c| cmd_name.eq_ignore_ascii_case(c));
|
||||||
|
|
||||||
|
if !needs_wrap {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建新的 args: ["/c", "原命令", ...原args]
|
||||||
|
let original_args = obj
|
||||||
|
.get("args")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut new_args = vec![Value::String("/c".into()), Value::String(cmd.into())];
|
||||||
|
new_args.extend(original_args);
|
||||||
|
|
||||||
|
obj.insert("command".into(), Value::String("cmd".into()));
|
||||||
|
obj.insert("args".into(), Value::Array(new_args));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 非 Windows 平台无需处理
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn wrap_command_for_windows(_obj: &mut Map<String, Value>) {
|
||||||
|
// 非 Windows 平台不做任何处理
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct McpStatus {
|
pub struct McpStatus {
|
||||||
@@ -339,6 +397,9 @@ pub fn set_mcp_servers_map(
|
|||||||
obj.remove("homepage");
|
obj.remove("homepage");
|
||||||
obj.remove("docs");
|
obj.remove("docs");
|
||||||
|
|
||||||
|
// Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
out.insert(id.clone(), Value::Object(obj));
|
out.insert(id.clone(), Value::Object(obj));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,3 +413,136 @@ pub fn set_mcp_servers_map(
|
|||||||
write_json_value(&path, &root)?;
|
write_json_value(&path, &root)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// 测试 Windows 命令包装功能
|
||||||
|
/// 由于使用条件编译,在非 Windows 平台上测试的是空函数
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_command_for_windows_npx() {
|
||||||
|
let mut obj = json!({"command": "npx", "args": ["-y", "@upstash/context7-mcp"]})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
assert_eq!(obj["command"], "cmd");
|
||||||
|
assert_eq!(
|
||||||
|
obj["args"],
|
||||||
|
json!(["/c", "npx", "-y", "@upstash/context7-mcp"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
// 非 Windows 平台不做任何处理
|
||||||
|
assert_eq!(obj["command"], "npx");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_command_for_windows_npm() {
|
||||||
|
let mut obj = json!({"command": "npm", "args": ["run", "start"]})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
assert_eq!(obj["command"], "cmd");
|
||||||
|
assert_eq!(obj["args"], json!(["/c", "npm", "run", "start"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_command_for_windows_already_cmd() {
|
||||||
|
// 已经是 cmd 的不应该重复包装
|
||||||
|
let mut obj = json!({"command": "cmd", "args": ["/c", "npx", "-y", "foo"]})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
|
assert_eq!(obj["command"], "cmd");
|
||||||
|
// args 应该保持不变,不会变成 ["/c", "cmd", "/c", "npx", ...]
|
||||||
|
assert_eq!(obj["args"], json!(["/c", "npx", "-y", "foo"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_command_for_windows_http_type_skipped() {
|
||||||
|
// http 类型不应该被处理
|
||||||
|
let mut obj = json!({"type": "http", "url": "https://example.com/mcp"})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
|
assert!(!obj.contains_key("command"));
|
||||||
|
assert_eq!(obj["url"], "https://example.com/mcp");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_command_for_windows_other_command_skipped() {
|
||||||
|
// 非目标命令(如 python)不应该被包装
|
||||||
|
let mut obj = json!({"command": "python", "args": ["server.py"]})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
|
// python 不在 WINDOWS_WRAP_COMMANDS 列表中,不应该被包装
|
||||||
|
assert_eq!(obj["command"], "python");
|
||||||
|
assert_eq!(obj["args"], json!(["server.py"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_command_for_windows_no_args() {
|
||||||
|
// 没有 args 的情况
|
||||||
|
let mut obj = json!({"command": "npx"}).as_object().unwrap().clone();
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
assert_eq!(obj["command"], "cmd");
|
||||||
|
assert_eq!(obj["args"], json!(["/c", "npx"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_command_for_windows_with_cmd_suffix() {
|
||||||
|
// 处理 npx.cmd 格式
|
||||||
|
let mut obj = json!({"command": "npx.cmd", "args": ["-y", "foo"]})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
assert_eq!(obj["command"], "cmd");
|
||||||
|
assert_eq!(obj["args"], json!(["/c", "npx.cmd", "-y", "foo"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_command_for_windows_case_insensitive() {
|
||||||
|
// 大小写不敏感
|
||||||
|
let mut obj = json!({"command": "NPX", "args": ["-y", "foo"]})
|
||||||
|
.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.clone();
|
||||||
|
wrap_command_for_windows(&mut obj);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
assert_eq!(obj["command"], "cmd");
|
||||||
|
assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -248,12 +248,18 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
if tool_path.exists() {
|
if tool_path.exists() {
|
||||||
// 构建 PATH 环境变量,确保 node 可被找到
|
// 构建 PATH 环境变量,确保 node 可被找到
|
||||||
let current_path = std::env::var("PATH").unwrap_or_default();
|
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let new_path = format!("{};{}", path.display(), current_path);
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
let new_path = format!("{}:{}", path.display(), current_path);
|
let new_path = format!("{}:{}", path.display(), current_path);
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let output = {
|
let output = {
|
||||||
Command::new(&tool_path)
|
// 使用 cmd /C 包装执行,确保子进程也在隐藏的控制台中运行
|
||||||
.arg("--version")
|
Command::new("cmd")
|
||||||
|
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
|
||||||
.env("PATH", &new_path)
|
.env("PATH", &new_path)
|
||||||
.creation_flags(CREATE_NO_WINDOW)
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.output()
|
.output()
|
||||||
|
|||||||
@@ -12,22 +12,22 @@ const UNIVERSAL_PROVIDERS_KEY: &str = "universal_providers";
|
|||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
/// 获取所有统一供应商
|
/// 获取所有统一供应商
|
||||||
pub fn get_all_universal_providers(&self) -> Result<HashMap<String, UniversalProvider>, AppError> {
|
pub fn get_all_universal_providers(
|
||||||
|
&self,
|
||||||
|
) -> Result<HashMap<String, UniversalProvider>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT value FROM settings WHERE key = ?")
|
.prepare("SELECT value FROM settings WHERE key = ?")
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
let result: Option<String> = stmt
|
let result: Option<String> = stmt
|
||||||
.query_row([UNIVERSAL_PROVIDERS_KEY], |row| row.get(0))
|
.query_row([UNIVERSAL_PROVIDERS_KEY], |row| row.get(0))
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Some(json) => {
|
Some(json) => serde_json::from_str(&json)
|
||||||
serde_json::from_str(&json)
|
.map_err(|e| AppError::Database(format!("解析统一供应商数据失败: {e}"))),
|
||||||
.map_err(|e| AppError::Database(format!("解析统一供应商数据失败: {e}")))
|
|
||||||
}
|
|
||||||
None => Ok(HashMap::new()),
|
None => Ok(HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,14 +62,13 @@ impl Database {
|
|||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let json = to_json_string(providers)?;
|
let json = to_json_string(providers)?;
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||||
[UNIVERSAL_PROVIDERS_KEY, &json],
|
[UNIVERSAL_PROVIDERS_KEY, &json],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,24 @@ impl Database {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 尝试添加基础配置列到 proxy_config 表(兼容 v3.9.0-2 升级)
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE proxy_config ADD COLUMN proxy_enabled INTEGER NOT NULL DEFAULT 0",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE proxy_config ADD COLUMN listen_address TEXT NOT NULL DEFAULT '127.0.0.1'",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE proxy_config ADD COLUMN listen_port INTEGER NOT NULL DEFAULT 5000",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE proxy_config ADD COLUMN enable_logging INTEGER NOT NULL DEFAULT 1",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// 尝试添加超时配置列到 proxy_config 表
|
// 尝试添加超时配置列到 proxy_config 表
|
||||||
let _ = conn.execute(
|
let _ = conn.execute(
|
||||||
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30",
|
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30",
|
||||||
@@ -247,6 +265,14 @@ impl Database {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 兼容:若旧版 proxy_config 仍为单例结构(无 app_type),则在启动时直接转换为三行结构
|
||||||
|
// 说明:user_version=2 时不会再触发 v1->v2 迁移,但新代码查询依赖 app_type 列。
|
||||||
|
if Self::table_exists(conn, "proxy_config")?
|
||||||
|
&& !Self::has_column(conn, "proxy_config", "app_type")?
|
||||||
|
{
|
||||||
|
Self::migrate_proxy_config_to_per_app(conn)?;
|
||||||
|
}
|
||||||
|
|
||||||
// 确保 in_failover_queue 列存在(对于已存在的 v2 数据库)
|
// 确保 in_failover_queue 列存在(对于已存在的 v2 数据库)
|
||||||
Self::add_column_if_missing(
|
Self::add_column_if_missing(
|
||||||
conn,
|
conn,
|
||||||
@@ -411,6 +437,32 @@ impl Database {
|
|||||||
|
|
||||||
// 添加代理超时配置字段
|
// 添加代理超时配置字段
|
||||||
if Self::table_exists(conn, "proxy_config")? {
|
if Self::table_exists(conn, "proxy_config")? {
|
||||||
|
// 兼容旧版本缺失的基础字段
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"proxy_config",
|
||||||
|
"proxy_enabled",
|
||||||
|
"INTEGER NOT NULL DEFAULT 0",
|
||||||
|
)?;
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"proxy_config",
|
||||||
|
"listen_address",
|
||||||
|
"TEXT NOT NULL DEFAULT '127.0.0.1'",
|
||||||
|
)?;
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"proxy_config",
|
||||||
|
"listen_port",
|
||||||
|
"INTEGER NOT NULL DEFAULT 5000",
|
||||||
|
)?;
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"proxy_config",
|
||||||
|
"enable_logging",
|
||||||
|
"INTEGER NOT NULL DEFAULT 1",
|
||||||
|
)?;
|
||||||
|
|
||||||
Self::add_column_if_missing(
|
Self::add_column_if_missing(
|
||||||
conn,
|
conn,
|
||||||
"proxy_config",
|
"proxy_config",
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ const LEGACY_SCHEMA_SQL: &str = r#"
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ColumnInfo {
|
struct ColumnInfo {
|
||||||
name: String,
|
|
||||||
r#type: String,
|
r#type: String,
|
||||||
notnull: i64,
|
notnull: i64,
|
||||||
default: Option<String>,
|
default: Option<String>,
|
||||||
@@ -65,10 +64,9 @@ fn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo {
|
|||||||
.expect("prepare pragma");
|
.expect("prepare pragma");
|
||||||
let mut rows = stmt.query([]).expect("query pragma");
|
let mut rows = stmt.query([]).expect("query pragma");
|
||||||
while let Some(row) = rows.next().expect("read row") {
|
while let Some(row) = rows.next().expect("read row") {
|
||||||
let name: String = row.get(1).expect("name");
|
let column_name: String = row.get(1).expect("name");
|
||||||
if name.eq_ignore_ascii_case(column) {
|
if column_name.eq_ignore_ascii_case(column) {
|
||||||
return ColumnInfo {
|
return ColumnInfo {
|
||||||
name,
|
|
||||||
r#type: row.get::<_, String>(2).expect("type"),
|
r#type: row.get::<_, String>(2).expect("type"),
|
||||||
notnull: row.get::<_, i64>(3).expect("notnull"),
|
notnull: row.get::<_, i64>(3).expect("notnull"),
|
||||||
default: row.get::<_, Option<String>>(4).ok().flatten(),
|
default: row.get::<_, Option<String>>(4).ok().flatten(),
|
||||||
@@ -201,6 +199,53 @@ fn migration_aligns_column_defaults_and_types() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
||||||
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
|
||||||
|
// 模拟测试版 v2:user_version=2,但 proxy_config 仍是单例结构(无 app_type)
|
||||||
|
Database::set_user_version(&conn, 2).expect("set user_version");
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE proxy_config (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||||
|
listen_port INTEGER NOT NULL DEFAULT 5000,
|
||||||
|
max_retries INTEGER NOT NULL DEFAULT 3,
|
||||||
|
request_timeout INTEGER NOT NULL DEFAULT 300,
|
||||||
|
enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||||
|
target_app TEXT NOT NULL DEFAULT 'claude',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
INSERT INTO proxy_config (id, enabled) VALUES (1, 1);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("seed legacy proxy_config");
|
||||||
|
|
||||||
|
Database::create_tables_on_conn(&conn).expect("create tables should repair proxy_config");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
Database::has_column(&conn, "proxy_config", "app_type").expect("check app_type"),
|
||||||
|
"proxy_config should be migrated to per-app structure"
|
||||||
|
);
|
||||||
|
|
||||||
|
let count: i32 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
|
||||||
|
.expect("count rows");
|
||||||
|
assert_eq!(count, 3, "per-app proxy_config should have 3 rows");
|
||||||
|
|
||||||
|
// 新结构下应能按 app_type 查询
|
||||||
|
let _: i32 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'claude'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.expect("query by app_type");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dry_run_does_not_write_to_disk() {
|
fn dry_run_does_not_write_to_disk() {
|
||||||
// Create minimal valid config for migration
|
// Create minimal valid config for migration
|
||||||
@@ -249,9 +294,10 @@ fn dry_run_validates_schema_compatibility() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut manager = ProviderManager::default();
|
let manager = ProviderManager {
|
||||||
manager.providers = providers;
|
providers,
|
||||||
manager.current = "test-provider".to_string();
|
current: "test-provider".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
let mut apps = HashMap::new();
|
let mut apps = HashMap::new();
|
||||||
apps.insert("claude".to_string(), manager);
|
apps.insert("claude".to_string(), manager);
|
||||||
|
|||||||
@@ -375,7 +375,7 @@ fn test_parse_prompt_deeplink() {
|
|||||||
assert_eq!(request.name.unwrap(), "test");
|
assert_eq!(request.name.unwrap(), "test");
|
||||||
assert_eq!(request.content.unwrap(), content_b64);
|
assert_eq!(request.content.unwrap(), content_b64);
|
||||||
assert_eq!(request.description.unwrap(), "desc");
|
assert_eq!(request.description.unwrap(), "desc");
|
||||||
assert_eq!(request.enabled.unwrap(), true);
|
assert!(request.enabled.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -391,13 +391,13 @@ fn test_parse_mcp_deeplink() {
|
|||||||
assert_eq!(request.resource, "mcp");
|
assert_eq!(request.resource, "mcp");
|
||||||
assert_eq!(request.apps.unwrap(), "claude,codex");
|
assert_eq!(request.apps.unwrap(), "claude,codex");
|
||||||
assert_eq!(request.config.unwrap(), config_b64);
|
assert_eq!(request.config.unwrap(), config_b64);
|
||||||
assert_eq!(request.enabled.unwrap(), true);
|
assert!(request.enabled.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_skill_deeplink() {
|
fn test_parse_skill_deeplink() {
|
||||||
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
|
let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev";
|
||||||
let request = parse_deeplink_url(&url).unwrap();
|
let request = parse_deeplink_url(url).unwrap();
|
||||||
|
|
||||||
assert_eq!(request.resource, "skill");
|
assert_eq!(request.resource, "skill");
|
||||||
assert_eq!(request.repo.unwrap(), "owner/repo");
|
assert_eq!(request.repo.unwrap(), "owner/repo");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
use crate::database::{lock_conn, Database};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Local, TimeZone};
|
||||||
use rusqlite::{params, Connection, OptionalExtension};
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -186,8 +186,17 @@ impl Database {
|
|||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
if days <= 1 {
|
if days <= 1 {
|
||||||
|
let today = Local::now().date_naive();
|
||||||
|
let start_of_today = today.and_hms_opt(0, 0, 0).unwrap();
|
||||||
|
// 使用 earliest() 处理 DST 切换时的歧义时间,fallback 到当前时间减一天
|
||||||
|
let start_ts = Local
|
||||||
|
.from_local_datetime(&start_of_today)
|
||||||
|
.earliest()
|
||||||
|
.unwrap_or_else(|| Local::now() - Duration::days(1))
|
||||||
|
.timestamp();
|
||||||
|
|
||||||
let sql = "SELECT
|
let sql = "SELECT
|
||||||
strftime('%Y-%m-%dT%H:00:00Z', datetime(created_at, 'unixepoch')) as bucket,
|
strftime('%Y-%m-%dT%H:00:00', datetime(created_at, 'unixepoch', 'localtime')) as bucket,
|
||||||
COUNT(*) as request_count,
|
COUNT(*) as request_count,
|
||||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||||
@@ -196,12 +205,12 @@ impl Database {
|
|||||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
|
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
|
||||||
FROM proxy_request_logs
|
FROM proxy_request_logs
|
||||||
WHERE created_at >= strftime('%s', 'now', '-1 day')
|
WHERE created_at >= ?
|
||||||
GROUP BY bucket
|
GROUP BY bucket
|
||||||
ORDER BY bucket ASC";
|
ORDER BY bucket ASC";
|
||||||
|
|
||||||
let mut stmt = conn.prepare(sql)?;
|
let mut stmt = conn.prepare(sql)?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([start_ts], |row| {
|
||||||
Ok(DailyStats {
|
Ok(DailyStats {
|
||||||
date: row.get(0)?,
|
date: row.get(0)?,
|
||||||
request_count: row.get::<_, i64>(1)? as u64,
|
request_count: row.get::<_, i64>(1)? as u64,
|
||||||
@@ -221,12 +230,11 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut stats = Vec::new();
|
let mut stats = Vec::new();
|
||||||
let today = Utc::now().date_naive();
|
|
||||||
for hour in 0..24 {
|
for hour in 0..24 {
|
||||||
let bucket = today
|
let bucket = today
|
||||||
.and_hms_opt(hour, 0, 0)
|
.and_hms_opt(hour, 0, 0)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.format("%Y-%m-%dT%H:00:00Z")
|
.format("%Y-%m-%dT%H:00:00")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
if let Some(stat) = buckets.remove(&bucket) {
|
if let Some(stat) = buckets.remove(&bucket) {
|
||||||
@@ -246,8 +254,18 @@ impl Database {
|
|||||||
}
|
}
|
||||||
Ok(stats)
|
Ok(stats)
|
||||||
} else {
|
} else {
|
||||||
|
let today = Local::now().date_naive();
|
||||||
|
let start_day = today - Duration::days((days.saturating_sub(1)) as i64);
|
||||||
|
let start_of_window = start_day.and_hms_opt(0, 0, 0).unwrap();
|
||||||
|
// 使用 earliest() 处理 DST 切换时的歧义时间,fallback 到当前时间减 days 天
|
||||||
|
let start_ts = Local
|
||||||
|
.from_local_datetime(&start_of_window)
|
||||||
|
.earliest()
|
||||||
|
.unwrap_or_else(|| Local::now() - Duration::days(days as i64))
|
||||||
|
.timestamp();
|
||||||
|
|
||||||
let sql = "SELECT
|
let sql = "SELECT
|
||||||
date(created_at, 'unixepoch') as bucket,
|
strftime('%Y-%m-%dT00:00:00', datetime(created_at, 'unixepoch', 'localtime')) as bucket,
|
||||||
COUNT(*) as request_count,
|
COUNT(*) as request_count,
|
||||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
|
||||||
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
|
||||||
@@ -256,12 +274,12 @@ impl Database {
|
|||||||
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
|
||||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
|
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
|
||||||
FROM proxy_request_logs
|
FROM proxy_request_logs
|
||||||
WHERE created_at >= strftime('%s', 'now', ?)
|
WHERE created_at >= ?
|
||||||
GROUP BY bucket
|
GROUP BY bucket
|
||||||
ORDER BY bucket ASC";
|
ORDER BY bucket ASC";
|
||||||
|
|
||||||
let mut stmt = conn.prepare(sql)?;
|
let mut stmt = conn.prepare(sql)?;
|
||||||
let rows = stmt.query_map([format!("-{days} days")], |row| {
|
let rows = stmt.query_map([start_ts], |row| {
|
||||||
Ok(DailyStats {
|
Ok(DailyStats {
|
||||||
date: row.get(0)?,
|
date: row.get(0)?,
|
||||||
request_count: row.get::<_, i64>(1)? as u64,
|
request_count: row.get::<_, i64>(1)? as u64,
|
||||||
@@ -281,12 +299,10 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut stats = Vec::new();
|
let mut stats = Vec::new();
|
||||||
let start_day =
|
|
||||||
Utc::now().date_naive() - Duration::days((days.saturating_sub(1)) as i64);
|
|
||||||
|
|
||||||
for i in 0..days {
|
for i in 0..days {
|
||||||
let day = start_day + Duration::days(i as i64);
|
let day = start_day + Duration::days(i as i64);
|
||||||
let key = day.format("%Y-%m-%d").to_string();
|
let key = day.format("%Y-%m-%dT00:00:00").to_string();
|
||||||
if let Some(stat) = map.remove(&key) {
|
if let Some(stat) = map.remove(&key) {
|
||||||
stats.push(stat);
|
stats.push(stat);
|
||||||
} else {
|
} else {
|
||||||
@@ -617,7 +633,7 @@ impl Database {
|
|||||||
"SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
|
"SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
|
||||||
FROM proxy_request_logs
|
FROM proxy_request_logs
|
||||||
WHERE provider_id = ? AND app_type = ?
|
WHERE provider_id = ? AND app_type = ?
|
||||||
AND date(created_at, 'unixepoch') = date('now')",
|
AND date(datetime(created_at, 'unixepoch', 'localtime')) = date('now', 'localtime')",
|
||||||
params![provider_id, app_type],
|
params![provider_id, app_type],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
@@ -629,7 +645,7 @@ impl Database {
|
|||||||
"SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
|
"SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0)
|
||||||
FROM proxy_request_logs
|
FROM proxy_request_logs
|
||||||
WHERE provider_id = ? AND app_type = ?
|
WHERE provider_id = ? AND app_type = ?
|
||||||
AND strftime('%Y-%m', created_at, 'unixepoch') = strftime('%Y-%m', 'now')",
|
AND strftime('%Y-%m', datetime(created_at, 'unixepoch', 'localtime')) = strftime('%Y-%m', 'now', 'localtime')",
|
||||||
params![provider_id, app_type],
|
params![provider_id, app_type],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CC Switch",
|
"productName": "CC Switch",
|
||||||
"version": "3.9.0-2",
|
"version": "3.9.0-3",
|
||||||
"identifier": "com.ccswitch.desktop",
|
"identifier": "com.ccswitch.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ pub fn test_mutex() -> &'static Mutex<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 创建测试用的 AppState,包含一个空的数据库
|
/// 创建测试用的 AppState,包含一个空的数据库
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
|
pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
|
||||||
let db = Arc::new(Database::init()?);
|
let db = Arc::new(Database::init()?);
|
||||||
let proxy_service = ProxyService::new(db.clone());
|
let proxy_service = ProxyService::new(db.clone());
|
||||||
@@ -56,6 +57,7 @@ pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据
|
/// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn create_test_state_with_config(
|
pub fn create_test_state_with_config(
|
||||||
config: &MultiAppConfig,
|
config: &MultiAppConfig,
|
||||||
) -> Result<AppState, Box<dyn std::error::Error>> {
|
) -> Result<AppState, Box<dyn std::error::Error>> {
|
||||||
|
|||||||
+7
-18
@@ -26,6 +26,7 @@ import {
|
|||||||
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env";
|
||||||
import { useProviderActions } from "@/hooks/useProviderActions";
|
import { useProviderActions } from "@/hooks/useProviderActions";
|
||||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||||
|
import { useLastValidValue } from "@/hooks/useLastValidValue";
|
||||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AppSwitcher } from "@/components/AppSwitcher";
|
import { AppSwitcher } from "@/components/AppSwitcher";
|
||||||
@@ -73,21 +74,9 @@ function App() {
|
|||||||
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
||||||
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
||||||
|
|
||||||
// 保存最后一个有效的 provider,用于动画退出期间显示内容
|
// 使用 Hook 保存最后有效值,用于动画退出期间保持内容显示
|
||||||
const lastUsageProviderRef = useRef<Provider | null>(null);
|
const effectiveEditingProvider = useLastValidValue(editingProvider);
|
||||||
const lastEditingProviderRef = useRef<Provider | null>(null);
|
const effectiveUsageProvider = useLastValidValue(usageProvider);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (usageProvider) {
|
|
||||||
lastUsageProviderRef.current = usageProvider;
|
|
||||||
}
|
|
||||||
}, [usageProvider]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (editingProvider) {
|
|
||||||
lastEditingProviderRef.current = editingProvider;
|
|
||||||
}
|
|
||||||
}, [editingProvider]);
|
|
||||||
|
|
||||||
const promptPanelRef = useRef<any>(null);
|
const promptPanelRef = useRef<any>(null);
|
||||||
const mcpPanelRef = useRef<any>(null);
|
const mcpPanelRef = useRef<any>(null);
|
||||||
@@ -718,7 +707,7 @@ function App() {
|
|||||||
|
|
||||||
<EditProviderDialog
|
<EditProviderDialog
|
||||||
open={Boolean(editingProvider)}
|
open={Boolean(editingProvider)}
|
||||||
provider={lastEditingProviderRef.current}
|
provider={effectiveEditingProvider}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setEditingProvider(null);
|
setEditingProvider(null);
|
||||||
@@ -729,9 +718,9 @@ function App() {
|
|||||||
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
|
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{lastUsageProviderRef.current && (
|
{effectiveUsageProvider && (
|
||||||
<UsageScriptModal
|
<UsageScriptModal
|
||||||
provider={lastUsageProviderRef.current}
|
provider={effectiveUsageProvider}
|
||||||
appId={activeApp}
|
appId={activeApp}
|
||||||
isOpen={Boolean(usageProvider)}
|
isOpen={Boolean(usageProvider)}
|
||||||
onClose={() => setUsageProvider(null)}
|
onClose={() => setUsageProvider(null)}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存最后一个非 null/undefined 的值
|
||||||
|
* 用于 Dialog 关闭动画期间保持内容显示
|
||||||
|
*
|
||||||
|
* @param value 当前值
|
||||||
|
* @returns 当前值(如果有效)或最后一个有效值
|
||||||
|
*/
|
||||||
|
export function useLastValidValue<T>(value: T | null | undefined): T | null {
|
||||||
|
const ref = useRef<T | null>(null);
|
||||||
|
|
||||||
|
// 同步更新 ref(在渲染期间,不在 useEffect 中)
|
||||||
|
if (value != null) {
|
||||||
|
ref.current = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回当前值或最后有效值
|
||||||
|
return value ?? ref.current;
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense, type ComponentType } from "react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import App from "@/App";
|
|
||||||
import { resetProviderState } from "../msw/state";
|
import { resetProviderState } from "../msw/state";
|
||||||
import { emitTauriEvent } from "../msw/tauriMocks";
|
import { emitTauriEvent } from "../msw/tauriMocks";
|
||||||
|
|
||||||
@@ -109,20 +108,6 @@ vi.mock("@/components/ConfirmDialog", () => ({
|
|||||||
) : null,
|
) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/settings/SettingsPage", () => ({
|
|
||||||
SettingsPage: ({ open, onOpenChange, onImportSuccess }: any) =>
|
|
||||||
open ? (
|
|
||||||
<div data-testid="settings-dialog">
|
|
||||||
<button onClick={() => onImportSuccess?.()}>
|
|
||||||
trigger-import-success
|
|
||||||
</button>
|
|
||||||
<button onClick={() => onOpenChange(false)}>close-settings</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button onClick={() => onOpenChange(true)}>open-settings</button>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/AppSwitcher", () => ({
|
vi.mock("@/components/AppSwitcher", () => ({
|
||||||
AppSwitcher: ({ activeApp, onSwitch }: any) => (
|
AppSwitcher: ({ activeApp, onSwitch }: any) => (
|
||||||
<div data-testid="app-switcher">
|
<div data-testid="app-switcher">
|
||||||
@@ -150,12 +135,12 @@ vi.mock("@/components/mcp/McpPanel", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const renderApp = () => {
|
const renderApp = (AppComponent: ComponentType) => {
|
||||||
const client = new QueryClient();
|
const client = new QueryClient();
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<Suspense fallback={<div data-testid="loading">loading</div>}>
|
<Suspense fallback={<div data-testid="loading">loading</div>}>
|
||||||
<App />
|
<AppComponent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
@@ -169,7 +154,8 @@ describe("App integration with MSW", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("covers basic provider flows via real hooks", async () => {
|
it("covers basic provider flows via real hooks", async () => {
|
||||||
renderApp();
|
const { default: App } = await import("@/App");
|
||||||
|
renderApp(App);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByTestId("provider-list").textContent).toContain(
|
expect(screen.getByTestId("provider-list").textContent).toContain(
|
||||||
@@ -177,11 +163,6 @@ describe("App integration with MSW", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("update-badge"));
|
|
||||||
expect(screen.getByTestId("settings-dialog")).toBeInTheDocument();
|
|
||||||
fireEvent.click(screen.getByText("trigger-import-success"));
|
|
||||||
fireEvent.click(screen.getByText("close-settings"));
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("switch-codex"));
|
fireEvent.click(screen.getByText("switch-codex"));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByTestId("provider-list").textContent).toContain(
|
expect(screen.getByTestId("provider-list").textContent).toContain(
|
||||||
|
|||||||
Reference in New Issue
Block a user