mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
9f7642e29c
Since switching projects automatically saves the current configuration back to the project being left, the per-project "update from current" button is redundant and even harmful: it allowed overwriting another project's snapshot with the current state, breaking the invariant that a project holds the state you last left it in. - Remove the resnapshot button and confirm dialog from the manage dialog; drop the now-unused scope prop and PROFILE_SCOPE_LABELS - Add a footer Close button to the manage dialog (overlay click is disabled app-wide, so it previously could only be closed via Esc) - Update manageDescription in all four locales to describe the autosave behavior; remove the two dead i18n keys - Fix a shadowed `warnings` vec in ProfileService::apply that silently dropped autosave-failure warnings before they reached the frontend - Mention auto-capture on next switch in the "no snapshot for this scope yet" warning and doc comments The backend update command keeps its resnapshot parameter; it is now only exercised by the pre-switch autosave path.
196 lines
6.5 KiB
Rust
196 lines
6.5 KiB
Rust
//! 项目 Profile 管理命令
|
||
|
||
use serde::Serialize;
|
||
use tauri::{Emitter, Manager, State};
|
||
|
||
use crate::database::Profile;
|
||
use crate::services::profile::{ProfilePayload, ProfileScope, ProfileService};
|
||
use crate::store::AppState;
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ProfileDto {
|
||
pub id: String,
|
||
pub name: String,
|
||
pub payload: ProfilePayload,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub created_at: Option<i64>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub updated_at: Option<i64>,
|
||
}
|
||
|
||
impl From<Profile> for ProfileDto {
|
||
fn from(profile: Profile) -> Self {
|
||
// 单条 payload 损坏不应拖垮整个列表:降级为默认值并记日志
|
||
let payload = serde_json::from_str(&profile.payload).unwrap_or_else(|e| {
|
||
log::warn!(
|
||
"解析 profile '{}' payload 失败,使用默认值: {e}",
|
||
profile.id
|
||
);
|
||
ProfilePayload::default()
|
||
});
|
||
Self {
|
||
id: profile.id,
|
||
name: profile.name,
|
||
payload,
|
||
created_at: profile.created_at,
|
||
updated_at: profile.updated_at,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 每个分组当前激活的项目 id(未使用项目时为 null)
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct CurrentProfileIds {
|
||
pub claude: Option<String>,
|
||
pub claude_desktop: Option<String>,
|
||
pub codex: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ProfilesResponse {
|
||
pub profiles: Vec<ProfileDto>,
|
||
pub current_ids: CurrentProfileIds,
|
||
}
|
||
|
||
/// Profile 应用完成后的统一收尾:发事件 + 重建托盘菜单
|
||
///
|
||
/// 只对项目所属分组内的应用发 provider-switched。UI 与托盘两个入口必须
|
||
/// 共用此函数,保证事件 payload 形状一致(前端 App.tsx 的
|
||
/// provider-switched 监听依赖该形状)。
|
||
pub fn emit_profile_apply_events(
|
||
app: &tauri::AppHandle,
|
||
state: &AppState,
|
||
profile_id: &str,
|
||
scope: ProfileScope,
|
||
) {
|
||
for app_type in scope.apps().iter() {
|
||
let app_str = app_type.as_str();
|
||
let (proxy_enabled, auto_failover_enabled) = state.db.get_proxy_flags_sync(app_str);
|
||
let provider_id = crate::settings::get_effective_current_provider(&state.db, app_type)
|
||
.ok()
|
||
.flatten()
|
||
.unwrap_or_default();
|
||
let event_data = serde_json::json!({
|
||
"appType": app_str,
|
||
"proxyEnabled": proxy_enabled,
|
||
"autoFailoverEnabled": auto_failover_enabled,
|
||
"providerId": provider_id,
|
||
});
|
||
if let Err(e) = app.emit("provider-switched", event_data) {
|
||
log::error!("发射 provider-switched 事件失败: {e}");
|
||
}
|
||
}
|
||
if let Err(e) = app.emit(
|
||
"profile-applied",
|
||
serde_json::json!({ "profileId": profile_id, "scope": scope.as_str() }),
|
||
) {
|
||
log::error!("发射 profile-applied 事件失败: {e}");
|
||
}
|
||
crate::tray::refresh_tray_menu(app);
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn list_profiles(state: State<'_, AppState>) -> Result<ProfilesResponse, String> {
|
||
let profiles = ProfileService::list(&state).map_err(|e| e.to_string())?;
|
||
let current_ids = CurrentProfileIds {
|
||
claude: state
|
||
.db
|
||
.get_current_profile_id(ProfileScope::Claude.as_str())
|
||
.map_err(|e| e.to_string())?,
|
||
claude_desktop: state
|
||
.db
|
||
.get_current_profile_id(ProfileScope::ClaudeDesktop.as_str())
|
||
.map_err(|e| e.to_string())?,
|
||
codex: state
|
||
.db
|
||
.get_current_profile_id(ProfileScope::Codex.as_str())
|
||
.map_err(|e| e.to_string())?,
|
||
};
|
||
Ok(ProfilesResponse {
|
||
profiles: profiles.into_iter().map(ProfileDto::from).collect(),
|
||
current_ids,
|
||
})
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn create_profile(
|
||
state: State<'_, AppState>,
|
||
name: String,
|
||
scope: String,
|
||
) -> Result<ProfileDto, String> {
|
||
let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?;
|
||
ProfileService::create(&state, &name, scope)
|
||
.map(ProfileDto::from)
|
||
.map_err(|e| e.to_string())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn update_profile(
|
||
state: State<'_, AppState>,
|
||
id: String,
|
||
name: Option<String>,
|
||
resnapshot: Option<bool>,
|
||
scope: Option<String>,
|
||
) -> Result<ProfileDto, String> {
|
||
let scope = scope
|
||
.map(|s| ProfileScope::parse(&s))
|
||
.transpose()
|
||
.map_err(|e| e.to_string())?;
|
||
ProfileService::update(&state, &id, name, resnapshot.unwrap_or(false), scope)
|
||
.map(ProfileDto::from)
|
||
.map_err(|e| e.to_string())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn delete_profile(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||
ProfileService::delete(&state, &id).map_err(|e| e.to_string())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn clear_current_profile(state: State<'_, AppState>, scope: String) -> Result<(), String> {
|
||
let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?;
|
||
state
|
||
.db
|
||
.set_current_profile_id(scope.as_str(), None)
|
||
.map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// 应用项目快照(只作用于发起页所属分组内的应用)。
|
||
///
|
||
/// 注意:必须保持同步命令(跑在 Tauri 线程池)——`ProviderService::switch`
|
||
/// 内部使用 block_on 获取切换锁,放进 async 命令会在运行时线程上 panic。
|
||
#[tauri::command]
|
||
pub fn apply_profile(
|
||
app: tauri::AppHandle,
|
||
state: State<'_, AppState>,
|
||
id: String,
|
||
scope: String,
|
||
) -> Result<Vec<String>, String> {
|
||
let scope = ProfileScope::parse(&scope).map_err(|e| e.to_string())?;
|
||
let (warnings, should_stop_proxy) =
|
||
ProfileService::apply(&state, &id, scope).map_err(|e| e.to_string())?;
|
||
|
||
if should_stop_proxy {
|
||
// sync 命令线程没有 Tokio runtime,无法直接 await stop();
|
||
// 把停止服务放到 Tauri async runtime,停止后再补发事件刷新 UI。
|
||
let app_handle = app.clone();
|
||
let profile_id = id.clone();
|
||
let proxy_service = state.proxy_service.clone();
|
||
tauri::async_runtime::spawn(async move {
|
||
if let Err(e) = proxy_service.stop().await {
|
||
log::warn!("切换项目后停止代理服务失败: {e}");
|
||
}
|
||
if let Some(app_state) = app_handle.try_state::<AppState>() {
|
||
emit_profile_apply_events(&app_handle, app_state.inner(), &profile_id, scope);
|
||
}
|
||
});
|
||
} else {
|
||
emit_profile_apply_events(&app, &state, &id, scope);
|
||
}
|
||
|
||
Ok(warnings)
|
||
}
|