mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 00:35:32 +08:00
feat: add project profiles for snapshot-based config switching
Add a profile feature that captures the current provider, MCP, skills and prompt state for Claude Code and Codex as a named snapshot, and re-applies it in one click from the header switcher or the tray Projects submenu. - New profiles table (schema v12) with current marker in settings - ProfileService orchestrates the four existing switch primitives (provider first, then MCP diff, skills diff, prompt enable) - Best-effort apply: dangling references become warnings, no rollback - Header combobox switcher + snapshot-style manage dialog - Tray Projects submenu shared with the UI apply/event pipeline - i18n for zh/en/ja/zh-TW under the new profiles domain - Integration tests covering roundtrip, dangling refs and clear
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
|
||||
pub mod failover;
|
||||
pub mod mcp;
|
||||
pub mod profiles;
|
||||
pub mod prompts;
|
||||
pub mod providers;
|
||||
pub mod providers_seed;
|
||||
@@ -15,5 +16,6 @@ pub mod universal_providers;
|
||||
pub mod usage_rollup;
|
||||
|
||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||
// 导出 FailoverQueueItem 供外部使用
|
||||
// 导出 FailoverQueueItem / Profile 供外部使用
|
||||
pub use failover::FailoverQueueItem;
|
||||
pub use profiles::Profile;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! 项目 Profile 数据访问对象
|
||||
//!
|
||||
//! profiles 表存放按 app 快照的配置方案(供应商/MCP/Skills/Prompt),
|
||||
//! payload 为原始 JSON 文本,解析在 service 层进行。
|
||||
//! current_profile_id 存放于 settings 表(key-value)。
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use rusqlite::params;
|
||||
|
||||
const CURRENT_PROFILE_ID_KEY: &str = "current_profile_id";
|
||||
|
||||
/// 项目 Profile 记录
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Profile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// 原始 JSON 快照文本(ProfilePayload),解析在 service 层
|
||||
pub payload: String,
|
||||
pub sort_order: Option<i64>,
|
||||
pub created_at: Option<i64>,
|
||||
pub updated_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 获取所有项目(按 sort_order 优先、created_at 兜底排序)
|
||||
pub fn get_all_profiles(&self) -> Result<Vec<Profile>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, payload, sort_order, created_at, updated_at
|
||||
FROM profiles
|
||||
ORDER BY sort_order IS NULL, sort_order, created_at, id",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Profile {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
payload: row.get(2)?,
|
||||
sort_order: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let mut profiles = Vec::new();
|
||||
for row in rows {
|
||||
profiles.push(row.map_err(|e| AppError::Database(e.to_string()))?);
|
||||
}
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// 获取单个项目
|
||||
pub fn get_profile(&self, id: &str) -> Result<Option<Profile>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, name, payload, sort_order, created_at, updated_at
|
||||
FROM profiles WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
match stmt.query_row(params![id], |row| {
|
||||
Ok(Profile {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
payload: row.get(2)?,
|
||||
sort_order: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
})
|
||||
}) {
|
||||
Ok(profile) => Ok(Some(profile)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(AppError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存项目(插入或整行替换)
|
||||
pub fn save_profile(&self, profile: &Profile) -> Result<(), AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO profiles
|
||||
(id, name, payload, sort_order, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
profile.id,
|
||||
profile.name,
|
||||
profile.payload,
|
||||
profile.sort_order,
|
||||
profile.created_at,
|
||||
profile.updated_at,
|
||||
],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除项目,返回是否实际删除了记录
|
||||
pub fn delete_profile(&self, id: &str) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
let affected = conn
|
||||
.execute("DELETE FROM profiles WHERE id = ?1", params![id])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(affected > 0)
|
||||
}
|
||||
|
||||
/// 读取当前激活的项目 id(未使用项目时为 None)
|
||||
pub fn get_current_profile_id(&self) -> Result<Option<String>, AppError> {
|
||||
self.get_setting(CURRENT_PROFILE_ID_KEY)
|
||||
}
|
||||
|
||||
/// 设置当前激活的项目 id;None 表示"不使用项目"(删除 key)
|
||||
pub fn set_current_profile_id(&self, id: Option<&str>) -> Result<(), AppError> {
|
||||
match id {
|
||||
Some(id) => self.set_setting(CURRENT_PROFILE_ID_KEY, id),
|
||||
None => {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"DELETE FROM settings WHERE key = ?1",
|
||||
params![CURRENT_PROFILE_ID_KEY],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample(id: &str, name: &str, sort_order: Option<i64>) -> Profile {
|
||||
Profile {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
payload: r#"{"providers":{"claude":null,"codex":null}}"#.to_string(),
|
||||
sort_order,
|
||||
created_at: Some(1_000),
|
||||
updated_at: Some(1_000),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_crud_roundtrip() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
db.save_profile(&sample("a", "Dev", Some(2)))?;
|
||||
db.save_profile(&sample("b", "Draw", Some(1)))?;
|
||||
db.save_profile(&sample("c", "Misc", None))?;
|
||||
|
||||
// sort_order 优先,NULL 排最后
|
||||
let all = db.get_all_profiles()?;
|
||||
assert_eq!(
|
||||
all.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["b", "a", "c"]
|
||||
);
|
||||
|
||||
let got = db.get_profile("a")?.expect("profile a exists");
|
||||
assert_eq!(got.name, "Dev");
|
||||
assert!(got.payload.contains("providers"));
|
||||
|
||||
// 整行替换更新
|
||||
let mut updated = sample("a", "Dev Renamed", Some(2));
|
||||
updated.updated_at = Some(2_000);
|
||||
db.save_profile(&updated)?;
|
||||
let got = db.get_profile("a")?.expect("profile a exists");
|
||||
assert_eq!(got.name, "Dev Renamed");
|
||||
assert_eq!(got.updated_at, Some(2_000));
|
||||
|
||||
assert!(db.delete_profile("a")?);
|
||||
assert!(!db.delete_profile("a")?);
|
||||
assert!(db.get_profile("a")?.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_current_profile_id_set_and_clear() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
|
||||
assert_eq!(db.get_current_profile_id()?, None);
|
||||
db.set_current_profile_id(Some("a"))?;
|
||||
assert_eq!(db.get_current_profile_id()?, Some("a".to_string()));
|
||||
db.set_current_profile_id(None)?;
|
||||
assert_eq!(db.get_current_profile_id()?, None);
|
||||
// 重复清除应幂等
|
||||
db.set_current_profile_id(None)?;
|
||||
assert_eq!(db.get_current_profile_id()?, None);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ pub(crate) use dao::proxy::{
|
||||
PRICING_SOURCE_RESPONSE,
|
||||
};
|
||||
pub use dao::FailoverQueueItem;
|
||||
pub use dao::Profile;
|
||||
|
||||
use crate::config::get_app_config_dir;
|
||||
use crate::error::AppError;
|
||||
@@ -49,7 +50,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 11;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 12;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
|
||||
@@ -295,6 +295,20 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 19. Profiles 表(项目配置方案:按 app 快照供应商/MCP/Skills/Prompt)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
sort_order INTEGER,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 尝试添加 live_takeover_active 列到 proxy_config 表
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
|
||||
@@ -444,6 +458,11 @@ impl Database {
|
||||
Self::migrate_v10_to_v11(conn)?;
|
||||
Self::set_user_version(conn, 11)?;
|
||||
}
|
||||
11 => {
|
||||
log::info!("迁移数据库从 v11 到 v12(添加项目 Profiles 表)");
|
||||
Self::migrate_v11_to_v12(conn)?;
|
||||
Self::set_user_version(conn, 12)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -1270,6 +1289,24 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v11 -> v12 迁移:添加项目 Profiles 表
|
||||
/// 与 create_tables_on_conn 中的建表语句保持一致(IF NOT EXISTS 保证幂等)
|
||||
fn migrate_v11_to_v12(conn: &Connection) -> Result<(), AppError> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
sort_order INTEGER,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("v11 -> v12 创建 profiles 表失败: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
|
||||
Reference in New Issue
Block a user