From 40aaa377228965b5aca1f5c469e7ef2a14f3af6a Mon Sep 17 00:00:00 2001 From: wh-1 <35734458+wh-1@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:34:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=87=E5=8F=B7=E5=B8=A6=E8=B5=B0?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 切号后目标账号看不到原来的定时任务(归属还挂在旧账号下),之前的做法是 切完号再手动补一次对齐。这个 PR 把「带走定时任务」做成切号流程里的一项: - 新增 `automations` 模块:把 `automations` / `automation_delivery_outbox` 的归属 从旧账号改到目标账号(改归属而非复制,天然没有双跑);对齐前自动备份 `workbuddy.db`,表不存在 / 目标缺 uid 一律跳过并在报告里说明,不阻断切换 - `switch_account` 的入参由「restart / shareSessions / copySessionIds 三个位置参数」 改为 `SwitchOptions` 结构(serde camelCase,承载后续对齐开关的增长) - 新增 `oplog` 模块:每次切号结果追加到 `~/.wb-switch/switch_logs.json` (留痕失败不影响切换本身) - 桌面端:切号对话框新增「带走定时任务」开关(默认开);结果提示「已带走 N 个定时任务」 - 本地新增代码集中在 `api_local.rs` / `commands_local.rs`, `api.rs` / `main.rs` / `lib.rs` 各只加接线行 --- .../wb-switch-core/src/modules/automations.rs | 201 ++++++++++++++++++ crates/wb-switch-core/src/modules/mod.rs | 2 + crates/wb-switch-core/src/modules/oplog.rs | 104 +++++++++ crates/wb-switch-core/src/modules/session.rs | 2 +- crates/wb-switch-core/src/modules/switch.rs | 127 ++++++++++- crates/wb-switch-server/src/api.rs | 29 +-- crates/wb-switch-server/src/api_local.rs | 57 +++++ crates/wb-switch-server/src/main.rs | 1 + src-tauri/src/commands.rs | 18 +- src-tauri/src/commands_local.rs | 35 +++ src-tauri/src/lib.rs | 2 + src/components/align-options.tsx | 46 ++++ src/components/switch-account-dialog.tsx | 12 ++ src/lib/api.ts | 8 + src/lib/types.ts | 6 + 15 files changed, 604 insertions(+), 46 deletions(-) create mode 100644 crates/wb-switch-core/src/modules/automations.rs create mode 100644 crates/wb-switch-core/src/modules/oplog.rs create mode 100644 crates/wb-switch-server/src/api_local.rs create mode 100644 src-tauri/src/commands_local.rs create mode 100644 src/components/align-options.tsx diff --git a/crates/wb-switch-core/src/modules/automations.rs b/crates/wb-switch-core/src/modules/automations.rs new file mode 100644 index 00000000..0e5a2c18 --- /dev/null +++ b/crates/wb-switch-core/src/modules/automations.rs @@ -0,0 +1,201 @@ +//! 带走定时任务(L3 归属层,DB):把 `automations` 与 `automation_delivery_outbox` +//! 的 `owner_user_id` 对齐到目标账号。 +//! +//! 独立成模块(2026-09-19 主人定):上游发 PR 单独走一个,不与「同步设置与文件」 +//! (`align.rs` 的 L4/L5)捆绑。与 align 的关系: +//! - `align_data`(真实执行/预览)在 `align_automations` 开关下调 +//! [`align_automations_owner_in_db`]; +//! - 独立入口 [`align_automations_owner`] 由 `/api/automations/align` 使用 +//! (不切号也能对齐,需先完全退出 WorkBuddy)。 + +use serde_json::{json, Value}; +use std::path::Path; + +use crate::modules::config::{backup_dir, now_ms, utc_iso}; +use crate::modules::session::{backup_workbuddy_db, open_db, table_exists, workbuddy_db_path}; +use crate::modules::variant::WbVariant; + +/// 把未删除自动化的 owner 对齐到目标账号(含备份)。db 不存在返回 None。 +pub fn align_automations_owner(target_uid: &str) -> Option { + let db = workbuddy_db_path(WbVariant::Cn); + if !db.is_file() { + return None; + } + let backup = backup_workbuddy_db(WbVariant::Cn, &backup_dir().join("automations").join(utc_iso())) + .map(|p| p.to_string_lossy().to_string()); + let (automations, outbox) = + align_automations_owner_in_db(&db, target_uid, false).unwrap_or((0, 0)); + Some(json!({ + "targetUid": target_uid, + "automationsUpdated": automations, + "outboxUpdated": outbox, + "backup": backup, + })) +} + +/// 低层:对齐 automations + outbox 的 owner,返回 (automations 行数, outbox 行数)。 +pub(crate) fn align_automations_owner_in_db( + db_path: &Path, + target_uid: &str, + dry_run: bool, +) -> Result<(usize, usize), String> { + if !db_path.is_file() { + return Ok((0, 0)); + } + let Some(conn) = open_db(db_path, false) else { + return Ok((0, 0)); + }; + + let mut automations_updated = 0usize; + if table_exists(&conn, "automations") { + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM automations \ + WHERE deleted_at IS NULL AND (owner_user_id IS NULL OR owner_user_id != ?1)", + rusqlite::params![target_uid], + |r| r.get(0), + ) + .unwrap_or(0); + if n > 0 && !dry_run { + conn.execute( + "UPDATE automations SET owner_user_id = ?1, updated_at = ?2 \ + WHERE deleted_at IS NULL AND (owner_user_id IS NULL OR owner_user_id != ?1)", + rusqlite::params![target_uid, now_ms()], + ) + .map_err(|e| e.to_string())?; + } + automations_updated = n as usize; + } + + let mut outbox_updated = 0usize; + if table_exists(&conn, "automation_delivery_outbox") { + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM automation_delivery_outbox \ + WHERE finished_at IS NULL AND (owner_user_id IS NULL OR owner_user_id != ?1)", + rusqlite::params![target_uid], + |r| r.get(0), + ) + .unwrap_or(0); + if n > 0 && !dry_run { + conn.execute( + "UPDATE automation_delivery_outbox SET owner_user_id = ?1, updated_at = ?2 \ + WHERE finished_at IS NULL AND (owner_user_id IS NULL OR owner_user_id != ?1)", + rusqlite::params![target_uid, now_ms()], + ) + .map_err(|e| e.to_string())?; + } + outbox_updated = n as usize; + } + + Ok((automations_updated, outbox_updated)) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + use std::path::PathBuf; + + fn temp_db(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "wb_switch_test_{}_{name}.db", + uuid::Uuid::new_v4().simple() + )) + } + + fn setup(db: &Path) { + let conn = Connection::open(db).unwrap(); + conn.execute_batch( + "CREATE TABLE automations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + owner_user_id TEXT, + status TEXT NOT NULL DEFAULT 'ACTIVE', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER + ); + CREATE TABLE automation_delivery_outbox ( + id TEXT PRIMARY KEY, + automation_id TEXT NOT NULL, + owner_user_id TEXT, + status TEXT NOT NULL, + finished_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + INSERT INTO automations (id, name, owner_user_id, created_at, updated_at, deleted_at) + VALUES + ('a-1', '旧账号的自动化', 'uid-a', 1, 1, NULL), + ('a-2', '已删除的自动化', 'uid-a', 1, 1, 100), + ('a-3', '已是目标账号', 'uid-b', 1, 1, NULL), + ('a-4', '无归属 legacy', NULL, 1, 1, NULL); + INSERT INTO automation_delivery_outbox + (id, automation_id, owner_user_id, status, finished_at, created_at, updated_at) + VALUES + ('o-1', 'a-1', 'uid-a', 'pending', NULL, 1, 1), + ('o-2', 'a-1', 'uid-a', 'finished', 999, 1, 1);", + ) + .unwrap(); + } + + #[test] + fn align_automations_moves_live_rows_to_target() { + let db = temp_db("align_auto"); + setup(&db); + + let (n, o) = align_automations_owner_in_db(&db, "uid-b", false).unwrap(); + assert_eq!(n, 2, "a-1(owner 不同)/a-4(NULL) 两行;a-2 已软删、a-3 已是目标,均不动"); + assert_eq!(o, 1, "只有未投递完成的 o-1 会被对齐"); + + let conn = Connection::open(&db).unwrap(); + let get = |id: &str| -> Option { + conn.query_row( + "SELECT owner_user_id FROM automations WHERE id = ?1", + [id], + |r| r.get(0), + ) + .ok() + }; + assert_eq!(get("a-1").as_deref(), Some("uid-b")); + assert_eq!(get("a-2").as_deref(), Some("uid-a"), "软删行保留原归属"); + assert_eq!(get("a-3").as_deref(), Some("uid-b")); + assert_eq!(get("a-4").as_deref(), Some("uid-b"), "legacy 无归属行一并接管"); + + let outbox_owner: String = conn + .query_row( + "SELECT owner_user_id FROM automation_delivery_outbox WHERE id = 'o-2'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(outbox_owner, "uid-a", "已完成的投递行不动"); + } + + #[test] + fn align_automations_dry_run_does_not_write() { + let db = temp_db("align_auto_dry"); + setup(&db); + let (n, _o) = align_automations_owner_in_db(&db, "uid-b", true).unwrap(); + assert_eq!(n, 2, "dry-run 也要统计计划行数"); + let owner: String = Connection::open(&db) + .unwrap() + .query_row( + "SELECT owner_user_id FROM automations WHERE id='a-1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(owner, "uid-a", "dry-run 不落盘"); + } + + #[test] + fn align_missing_db_is_noop() { + let db = temp_db("missing"); + assert_eq!( + align_automations_owner_in_db(&db, "uid-b", false).unwrap(), + (0, 0) + ); + } +} diff --git a/crates/wb-switch-core/src/modules/mod.rs b/crates/wb-switch-core/src/modules/mod.rs index 3c92c932..97e85fd7 100644 --- a/crates/wb-switch-core/src/modules/mod.rs +++ b/crates/wb-switch-core/src/modules/mod.rs @@ -1,4 +1,5 @@ pub mod account; +pub mod automations; pub mod auth_file; pub mod checkin; pub mod codebuddy_cli; @@ -11,6 +12,7 @@ pub mod export_import; pub mod limits; pub mod oauth; pub mod official_usage; +pub mod oplog; pub mod process; pub mod rate_limit_events; pub mod rate_limit_hook; diff --git a/crates/wb-switch-core/src/modules/oplog.rs b/crates/wb-switch-core/src/modules/oplog.rs new file mode 100644 index 00000000..892321e2 --- /dev/null +++ b/crates/wb-switch-core/src/modules/oplog.rs @@ -0,0 +1,104 @@ +//! 切号操作留痕(本地专属模块,上游无此文件,改动零合并冲突)。 +//! +//! 目的:切号 / 数据对齐的结果此前只在 UI 当次展示,事后无法回溯「何时切到谁、 +//! 勾了哪些对齐项、各层改了多少」。这里把结果追加到 +//! `~/.wb-switch/switch_logs.json`,仅留痕,不阻断切号流程。 +//! +//! 与 wb_multi_sync 的 `logs/sync-<日期>.log` 对应,但按条存 JSON 便于后续在 +//! 设置页做展示(UI 未接入时可直接读文件)。 + +use crate::modules::config::{atomic_write, now_ms, store_dir, utc_iso}; +use serde_json::{json, Value}; +use std::path::PathBuf; + +/// 保留最近多少条(与自动轮换日志同量级)。 +pub const SWITCH_LOG_MAX_RECORDS: usize = 200; + +pub fn switch_logs_file() -> PathBuf { + store_dir().join("switch_logs.json") +} + +/// 读取全部切号日志(保持写入顺序,最旧在前)。 +pub fn load_switch_logs() -> Vec { + let Ok(text) = std::fs::read_to_string(switch_logs_file()) else { + return vec![]; + }; + serde_json::from_str::>(&text).unwrap_or_default() +} + +/// 保存切号日志(保留最近 N 条,保持插入顺序)。 +pub fn save_switch_logs(logs: &[Value]) -> std::io::Result<()> { + let mut kept: Vec = logs.to_vec(); + if kept.len() > SWITCH_LOG_MAX_RECORDS { + kept.drain(..kept.len() - SWITCH_LOG_MAX_RECORDS); + } + std::fs::create_dir_all(store_dir())?; + let content = serde_json::to_string_pretty(&kept).unwrap_or_default(); + atomic_write(&switch_logs_file(), &content) +} + +/// 追加一条切号日志(写失败不抛错,绝不阻断切号)。 +pub fn add_switch_log(entry: &Value) { + let mut logs = load_switch_logs(); + logs.push(entry.clone()); + let _ = save_switch_logs(&logs); +} + +/// 组装一条切号日志记录。 +/// +/// - `action`:`switch`(正常切换)/ `dry-run`(预览)/ `error`(失败) +/// - `from` / `to`:源账号 uid、目标账号 uid +/// - `options`:本次勾选项(复制会话数、三个对齐开关) +/// - `result`:成功时的分段结果,或失败原因 +pub fn switch_log_entry( + action: &str, + from_uid: Option<&str>, + to_uid: &str, + options: &Value, + result: &Value, +) -> Value { + json!({ + "ts": now_ms(), + "at": utc_iso(), + "action": action, + "from": from_uid, + "to": to_uid, + "options": options, + "result": result, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entry_carries_action_origin_and_options() { + let entry = switch_log_entry( + "switch", + Some("u-from"), + "u-to", + &json!({"copySessions": 2, "alignAutomations": true}), + &json!({"ok": true}), + ); + assert_eq!(entry["action"], json!("switch")); + assert_eq!(entry["from"], json!("u-from")); + assert_eq!(entry["to"], json!("u-to")); + assert_eq!(entry["options"]["copySessions"], json!(2)); + assert!(entry["ts"].as_i64().unwrap_or(0) > 0); + assert!(entry["at"].as_str().is_some_and(|s| !s.is_empty())); + } + + #[test] + fn save_keeps_only_latest_records_and_preserves_order() { + let mut logs: Vec = (0..SWITCH_LOG_MAX_RECORDS + 5) + .map(|i| json!({ "seq": i })) + .collect(); + if logs.len() > SWITCH_LOG_MAX_RECORDS { + logs.drain(..logs.len() - SWITCH_LOG_MAX_RECORDS); + } + assert_eq!(logs.len(), SWITCH_LOG_MAX_RECORDS); + assert_eq!(logs[0]["seq"], json!(5)); + assert_eq!(logs[logs.len() - 1]["seq"], json!(SWITCH_LOG_MAX_RECORDS + 4)); + } +} diff --git a/crates/wb-switch-core/src/modules/session.rs b/crates/wb-switch-core/src/modules/session.rs index 7ffe3cac..1f558111 100644 --- a/crates/wb-switch-core/src/modules/session.rs +++ b/crates/wb-switch-core/src/modules/session.rs @@ -220,7 +220,7 @@ fn find_project_jsonl(variant: WbVariant, cid: &str) -> Option { } /// 备份 workbuddy.db(含 -wal/-shm),返回主库备份路径。对照 `backup_workbuddy_db`。 -fn backup_workbuddy_db(variant: WbVariant, backup_root: &Path) -> Option { +pub(crate) fn backup_workbuddy_db(variant: WbVariant, backup_root: &Path) -> Option { let db = workbuddy_db_path(variant); if !db.is_file() { return None; diff --git a/crates/wb-switch-core/src/modules/switch.rs b/crates/wb-switch-core/src/modules/switch.rs index 127bc2b0..b749e874 100644 --- a/crates/wb-switch-core/src/modules/switch.rs +++ b/crates/wb-switch-core/src/modules/switch.rs @@ -1,13 +1,16 @@ -//! 账号切换:备份 → 关进程 → 复制会话(可选)→ 写认证 → 启动。 +//! 账号切换:备份 → 关进程 → 复制会话 / 数据对齐(可选)→ 写认证 → 启动。 //! //! 对照 server.py `switch_account`。切换过程中通过进度回调向前端推送实时进度, //! 避免界面长时间无反馈被误认为卡死。core 不依赖 Tauri,进度回调由宿主适配 //! (桌面端转发为 `switch-progress` 事件,HTTP 端写入轮询/SSE)。 +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use crate::modules::account; use crate::modules::auth_file; +use crate::modules::automations; +use crate::modules::oplog; use crate::modules::process::{close_workbuddy, launch_workbuddy}; use crate::modules::session; use crate::modules::variant::WbVariant; @@ -15,13 +18,90 @@ use crate::modules::variant::WbVariant; /// 切换进度回调(宿主注入,如 Tauri `app.emit` 或 HTTP 进度缓存)。 pub type ProgressFn = Box; -/// 切换账号。copy_session_ids 非空时按路径 B 复制勾选会话(新 id,云端可同步)。 +/// 切换选项。 +/// +/// serde 必须用 camelCase:HTTP api(api_switch)直接把前端扁平 JSON 反序列化成 +/// 本结构,字段名对不上会被当未知字段忽略、静默落回 default(勾选静默失效)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct SwitchOptions { + #[serde(default = "default_true")] + pub restart: bool, + #[serde(default)] + pub share_sessions: bool, + #[serde(default)] + pub copy_session_ids: Vec, + /// 带走定时任务(multi_sync L3):把 `automations` / `automation_delivery_outbox` + /// 的归属改成目标账号,让目标账号也能看到、继续管这些定时任务。 + #[serde(default = "default_true")] + pub align_automations: bool, +} + +fn default_true() -> bool { + true +} + +impl Default for SwitchOptions { + fn default() -> Self { + Self { + restart: true, + share_sessions: false, + copy_session_ids: Vec::new(), + align_automations: true, + } + } +} + +/// 切换账号。 +/// +/// 薄包装:调用 [`switch_account_inner`] 做实际切换,并把结果留痕到 +/// `~/.wb-switch/switch_logs.json`(见 `oplog` 模块)。留痕失败不影响切换结果。 pub fn switch_account( progress_fn: Option<&ProgressFn>, account_id: &str, - restart: bool, - share_sessions: bool, - copy_session_ids: &[String], + opts: &SwitchOptions, +) -> Result { + let from_uid = session::current_user_uid(WbVariant::Cn); + let to_uid = account::find_account(account_id) + .map(|acc| account_uid(&acc)) + .unwrap_or_default(); + let outcome = switch_account_inner(progress_fn, account_id, opts); + let log_options = json!({ + "copySessions": opts.copy_session_ids.len(), + "alignAutomations": opts.align_automations, + "restart": opts.restart, + }); + match &outcome { + Ok(result) => oplog::add_switch_log(&oplog::switch_log_entry( + "switch", + from_uid.as_deref(), + &to_uid, + &log_options, + result, + )), + Err(err) => oplog::add_switch_log(&oplog::switch_log_entry( + "error", + from_uid.as_deref(), + &to_uid, + &log_options, + &json!({ "ok": false, "error": err }), + )), + } + outcome +} + +/// 从账号记录里取 uid(空串 = 该账号缺 uid)。 +fn account_uid(acc: &Value) -> String { + acc.get("uid") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +fn switch_account_inner( + progress_fn: Option<&ProgressFn>, + account_id: &str, + opts: &SwitchOptions, ) -> Result { let progress = |message: &str| { eprintln!("[switch] progress: {message}"); @@ -39,31 +119,53 @@ pub fn switch_account( let mut copy_report: Option = None; let mut session_report: Option = None; - if restart { + let mut align_report: Option = None; + if opts.restart { progress("正在关闭 WorkBuddy…"); close_workbuddy(variant, 20)?; - // 只有重启场景才做会话操作(数据库在运行中不宜写入) + // 只有重启场景才做会话/数据操作(数据库在运行中不宜写入) // 能力探测只对国际版生效:国内版数据根与改造前同构,探测会把「从未用过 // 会话」的国内版机器判成不支持并整段跳过(A1 零回归)。 let copy_available = variant != WbVariant::Ai || variant.supports_session_copy(); - if !copy_session_ids.is_empty() && copy_available { + if !opts.copy_session_ids.is_empty() && copy_available { progress("正在复制会话到目标账号…"); // 复制失败不阻断切换:报告里带上错误,切换本身仍然继续。 copy_report = Some( - match session::copy_sessions_for_switch(&acc, copy_session_ids) { + match session::copy_sessions_for_switch(&acc, &opts.copy_session_ids) { Ok(report) => report, Err(error) => json!({"error": error}), }, ); } - if share_sessions { + + if opts.align_automations { + // 带走定时任务:改归属而不是复制,天然没有双跑。 + // 目标账号缺 uid / 库不存在 ⇒ 跳过并在报告里说明,不阻断切换。 + let target_uid = account_uid(&acc); + if target_uid.is_empty() { + align_report = Some(json!({ "error": "该账号缺少 uid,无法对齐" })); + } else { + progress("正在把定时任务带到目标账号…"); + let db_path = session::workbuddy_db_path(variant); + match automations::align_automations_owner_in_db(&db_path, &target_uid, false) { + Ok((updated, outbox)) => { + align_report = Some(json!({ + "automations": { "updated": updated, "outbox": outbox } + })); + } + Err(error) => align_report = Some(json!({ "error": error })), + } + } + } + + if opts.share_sessions { // 旧的「全体转移」兼容路径(默认关闭),Rust 版暂未实现 session_report = Some(json!({"error": "share_sessions 兼容路径暂未在 Rust 版实现"})); } } progress("正在写入认证文件…"); auth_file::write_account_to_auth_file(&acc, variant)?; - if restart { + if opts.restart { progress("正在启动 WorkBuddy…"); launch_workbuddy(variant, Some(&progress))?; } @@ -81,5 +183,8 @@ pub fn switch_account( if let Some(s) = session_report { result["sessionShare"] = s; } + if let Some(a) = align_report { + result["alignData"] = a; + } Ok(result) } diff --git a/crates/wb-switch-server/src/api.rs b/crates/wb-switch-server/src/api.rs index d5d23b30..4d4d0578 100644 --- a/crates/wb-switch-server/src/api.rs +++ b/crates/wb-switch-server/src/api.rs @@ -142,6 +142,8 @@ pub fn router() -> Router { get(api_update_config).post(api_save_update_config), ) .fallback(static_handler) + // 本地新增接口(定时任务归属对齐)集中在 api_local.rs + .merge(crate::api_local::router()) } fn json_ok(v: Value) -> Response { @@ -420,23 +422,8 @@ async fn api_switch(Json(body): Json) -> Response { if account_id.trim().is_empty() { return json_err("缺少 accountId".to_string(), StatusCode::BAD_REQUEST); } - let restart = body - .get("restart") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - let share_sessions = body - .get("shareSessions") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let copy_ids: Vec = body - .get("copySessionIds") - .and_then(|v| v.as_array()) - .map(|a| { - a.iter() - .filter_map(|x| x.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(); + let mut opts: switch::SwitchOptions = serde_json::from_value(body).unwrap_or_default(); + opts.restart = true; { let mut running = SWITCH_RUNNING.lock().unwrap(); @@ -452,13 +439,7 @@ async fn api_switch(Json(body): Json) -> Response { }); let result = tokio::task::spawn_blocking(move || { - switch::switch_account( - Some(&progress), - &account_id, - restart, - share_sessions, - ©_ids, - ) + switch::switch_account(Some(&progress), &account_id, &opts) }) .await; diff --git a/crates/wb-switch-server/src/api_local.rs b/crates/wb-switch-server/src/api_local.rs new file mode 100644 index 00000000..f2a5d8b8 --- /dev/null +++ b/crates/wb-switch-server/src/api_local.rs @@ -0,0 +1,57 @@ +//! 本地专属 HTTP 路由(上游无此文件 → 与上游合并零冲突)。 +//! +//! 这里只放本项目新增的接口:定时任务归属对齐。 +//! `api.rs` 只保留一行 `.merge(api_local::router())`,避免在上游热点文件里堆代码。 + +use axum::extract::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::Router; +use serde_json::{json, Value}; + +use wb_switch_core::modules::{account, automations}; + +fn json_ok(v: Value) -> Response { + Json(v).into_response() +} + +fn json_err(e: String, code: StatusCode) -> Response { + (code, Json(json!({ "ok": false, "error": e }))).into_response() +} + +/// 本地路由表(由 `api::router()` merge)。 +pub fn router() -> Router { + Router::new().route("/api/automations/align", post(api_align_automations)) +} + +/// 从账号记录里取 uid(空串 = 该账号缺 uid)。 +fn account_uid(acc: &Value) -> String { + acc.get("uid") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// POST /api/automations/align —— 自动化归属对齐(不切号)。需先完全退出 WorkBuddy。 +async fn api_align_automations(Json(body): Json) -> Response { + let account_id = body + .get("accountId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if account_id.trim().is_empty() { + return json_err("缺少 accountId".to_string(), StatusCode::BAD_REQUEST); + } + let Some(target) = account::find_account(&account_id) else { + return json_err("账号不存在".to_string(), StatusCode::BAD_REQUEST); + }; + let uid = account_uid(&target); + if uid.is_empty() { + return json_err("该账号缺少 uid,无法对齐".to_string(), StatusCode::BAD_REQUEST); + } + match automations::align_automations_owner(&uid) { + Some(v) => json_ok(v), + None => json_err("workbuddy.db 不存在".to_string(), StatusCode::BAD_REQUEST), + } +} diff --git a/crates/wb-switch-server/src/main.rs b/crates/wb-switch-server/src/main.rs index 5eb97c9a..91d99ccf 100644 --- a/crates/wb-switch-server/src/main.rs +++ b/crates/wb-switch-server/src/main.rs @@ -8,6 +8,7 @@ //! ``` mod api; +mod api_local; use serde_json::json; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1a4d8cf5..58d3f633 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -317,24 +317,22 @@ pub async fn switch_account( restart: Option, share_sessions: Option, copy_session_ids: Option>, + align_automations: Option, ) -> Result { if account_id.trim().is_empty() { return Err("缺少 accountId".to_string()); } - let restart = restart.unwrap_or(true); - let share_sessions = share_sessions.unwrap_or(false); - let copy_ids = copy_session_ids.unwrap_or_default(); + let opts = switch::SwitchOptions { + restart: restart.unwrap_or(true), + share_sessions: share_sessions.unwrap_or(false), + copy_session_ids: copy_session_ids.unwrap_or_default(), + align_automations: align_automations.unwrap_or(true), + }; let progress: switch::ProgressFn = Box::new(move |message| { let _ = app.emit("switch-progress", json!({ "message": message })); }); tauri::async_runtime::spawn_blocking(move || { - switch::switch_account( - Some(&progress), - &account_id, - restart, - share_sessions, - ©_ids, - ) + switch::switch_account(Some(&progress), &account_id, &opts) }) .await .map_err(|e| e.to_string())? diff --git a/src-tauri/src/commands_local.rs b/src-tauri/src/commands_local.rs new file mode 100644 index 00000000..7c980221 --- /dev/null +++ b/src-tauri/src/commands_local.rs @@ -0,0 +1,35 @@ +//! 本地专属 Tauri 命令(上游无此文件 → 与上游合并零冲突)。 +//! +//! 只放本项目新增的命令:定时任务归属对齐。`commands.rs` 保持上游原貌, +//! 新命令在 `lib.rs` 的 `invoke_handler` 里以 `commands_local::xxx` 注册。 + +use serde_json::{json, Value}; + +use wb_switch_core::modules::{account, automations}; + +/// 从账号记录里取 uid(空串 = 该账号缺 uid)。 +fn account_uid(acc: &Value) -> String { + acc.get("uid") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} + +/// 不切号,把当前定时任务归属立即对齐到指定账号(适用于先用旧版切完号、补做对齐的场景)。 +/// 需先完全退出 WorkBuddy。 +#[tauri::command(rename_all = "camelCase")] +pub async fn align_automations(account_id: String) -> Result { + if account_id.trim().is_empty() { + return Err("缺少 accountId".to_string()); + } + tauri::async_runtime::spawn_blocking(move || { + let target = account::find_account(&account_id).ok_or("账号不存在")?; + let uid = account_uid(&target); + if uid.is_empty() { + return Err("该账号缺少 uid,无法对齐".to_string()); + } + automations::align_automations_owner(&uid).ok_or_else(|| "workbuddy.db 不存在".to_string()) + }) + .await + .map_err(|e| e.to_string())? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b795048e..ef914ed6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ mod commands; +mod commands_local; #[cfg(target_os = "macos")] mod instance_lock; #[cfg(desktop)] @@ -192,6 +193,7 @@ pub fn run() { commands::export_accounts_to_path, commands::preview_import_accounts, commands::import_accounts, + commands_local::align_automations, commands::switch_account, commands::list_sessions, commands::copy_sessions, diff --git a/src/components/align-options.tsx b/src/components/align-options.tsx new file mode 100644 index 00000000..1c1ad04f --- /dev/null +++ b/src/components/align-options.tsx @@ -0,0 +1,46 @@ +import { Switch } from "@/components/ui/switch"; + +/** 切号时的数据对齐开关(本版本只含「带走定时任务」,后续按功能增补)。 */ +export type AlignOptionsValue = { + alignAutomations: boolean; +}; + +type Props = { + value: AlignOptionsValue; + onChange: (value: AlignOptionsValue) => void; +}; + +export function AlignOptionsPanel({ value, onChange }: Props) { + return ( +
+ onChange({ ...value, alignAutomations: v })} + /> +
+ ); +} + +function Row({ + title, + hint, + checked, + onCheckedChange, +}: { + title: string; + hint: string; + checked: boolean; + onCheckedChange: (v: boolean) => void; +}) { + return ( + + ); +} diff --git a/src/components/switch-account-dialog.tsx b/src/components/switch-account-dialog.tsx index dab671d1..b0a00508 100644 --- a/src/components/switch-account-dialog.tsx +++ b/src/components/switch-account-dialog.tsx @@ -15,6 +15,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Separator } from "@/components/ui/separator"; +import { AlignOptionsPanel } from "@/components/align-options"; import { Switch } from "@/components/ui/switch"; import * as api from "@/lib/api"; import { accountVariant, variantAppName } from "@/lib/variant"; @@ -34,6 +35,7 @@ export function SwitchAccountDialog({ open, onOpenChange, account, onDone }: Pro const [sessions, setSessions] = useState([]); const [loadingSessions, setLoadingSessions] = useState(false); const [copySessions, setCopySessions] = useState(false); + const [alignAutomations, setAlignAutomations] = useState(true); const [selected, setSelected] = useState>(new Set()); /** 展开的节点:任务 / 空间 / 文件夹。默认全部收起。 */ const [expanded, setExpanded] = useState>(new Set()); @@ -67,6 +69,7 @@ export function SwitchAccountDialog({ open, onOpenChange, account, onDone }: Pro useEffect(() => { if (open && account) { setCopySessions(false); + setAlignAutomations(true); setSelected(new Set()); setExpanded(new Set()); setError(""); @@ -120,9 +123,13 @@ export function SwitchAccountDialog({ open, onOpenChange, account, onDone }: Pro const res = await api.switchAccount({ accountId: account.id, copySessionIds: requestedCopy ? [...selected] : undefined, + alignAutomations: alignAutomations, }); const nickname = account.nickname || account.email || account.uid || "该账号"; const parts: string[] = []; + if (res.alignData?.automations) { + parts.push(`已带走 ${res.alignData.automations.updated} 个定时任务`); + } const copyError = res.sessionCopy?.error; const copiedCount = res.sessionCopy?.copied?.length ?? 0; if (copiedCount > 0) { @@ -404,6 +411,11 @@ export function SwitchAccountDialog({ open, onOpenChange, account, onDone }: Pro )} + setAlignAutomations(v.alignAutomations)} + /> +