From f691f93587b8e6c29f69c9d93d788d9b16372c57 Mon Sep 17 00:00:00 2001 From: wh-1 <35734458+wh-1@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:01:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B4=A6=E5=8F=B7=E5=8F=91=E7=8E=B0?= =?UTF-8?q?=E2=80=94=E2=80=94=E8=AF=86=E5=88=AB=E6=9C=AC=E6=9C=BA=E6=9C=AA?= =?UTF-8?q?=E5=85=A5=E5=86=8C=E8=B4=A6=E5=8F=B7=E5=B9=B6=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E8=A1=A5=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增「账号发现」:以官方 auth 登录历史为主(含凭据、可补录)、storage/settings 残留为辅,比对账号库列出「本机还有哪些账号没入册」,附凭据文件数 / 备份时间 / 凭据是否可续期 - 新增「一键补录」:用该 uid 最新的 auth 历史备份构造账号记录写进账号库 (写入前备份 accounts.json) - 账号页顶部提示条:只在存在未入册账号时出现,补录后自动刷新列表 - 复用 `auth_file::imported_account_from_root`(仅提可见性为 pub(crate),无行为变化) - 本地新增代码集中在 `api_local.rs` / `commands_local.rs`, `api.rs` / `main.rs` / `lib.rs` 各只加接线行 --- .../wb-switch-core/src/modules/auth_file.rs | 3 +- crates/wb-switch-core/src/modules/discover.rs | 320 ++++++++++++++++++ crates/wb-switch-core/src/modules/mod.rs | 1 + crates/wb-switch-server/src/api.rs | 2 + crates/wb-switch-server/src/api_local.rs | 49 +++ crates/wb-switch-server/src/main.rs | 1 + src-tauri/src/commands_local.rs | 23 ++ src-tauri/src/lib.rs | 3 + src/components/discover-accounts-banner.tsx | 129 +++++++ src/lib/api.ts | 15 +- src/lib/screenshot-demo.ts | 16 + src/lib/types.ts | 16 + src/pages/AccountsPage.tsx | 3 + 13 files changed, 579 insertions(+), 2 deletions(-) create mode 100644 crates/wb-switch-core/src/modules/discover.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/discover-accounts-banner.tsx diff --git a/crates/wb-switch-core/src/modules/auth_file.rs b/crates/wb-switch-core/src/modules/auth_file.rs index 15cd533a..32bea0cd 100644 --- a/crates/wb-switch-core/src/modules/auth_file.rs +++ b/crates/wb-switch-core/src/modules/auth_file.rs @@ -255,7 +255,8 @@ pub fn import_from_auth_file(variant: WbVariant) -> Option { imported_account_from_root(read_auth_file(variant)?, variant) } -fn imported_account_from_root(root: Value, variant: WbVariant) -> Option { +/// 从认证文件 root JSON 构造账号库记录。同文件 discover 模块复用(补录用)。 +pub(crate) fn imported_account_from_root(root: Value, variant: WbVariant) -> Option { let account_obj = root .get("account") .filter(|v| v.is_object()) diff --git a/crates/wb-switch-core/src/modules/discover.rs b/crates/wb-switch-core/src/modules/discover.rs new file mode 100644 index 00000000..16099a8b --- /dev/null +++ b/crates/wb-switch-core/src/modules/discover.rs @@ -0,0 +1,320 @@ +//! 本机"曾登录账号"发现与补录。 +//! +//! 数据源(证据链): +//! 1. **官方 auth 历史**:`~/AppData/Local/CodeBuddyExtension/Data/Public/auth/` +//! `workbuddy-desktop....info` —— WorkBuddy 每次登录/切号留档, +//! 含完整 account + token,是"曾登录过"的最权威记录。 +//! 2. **数据残留**:settings.json `claw.users` 键 ∪ `storage/user-*` 目录 ∪ +//! `memory/_memory.md`(本模块 [`discover_accounts`]),无凭据,仅证明确实用过。 +//! +//! 对照在册账号库(~/.wb-switch/accounts.json)输出"识别到但未登记"的账号, +//! 供 UI 一键补录(adopt_account:凭据来自最新 auth 历史备份)。 + +use std::collections::HashMap; +use std::path::PathBuf; + +use serde_json::{json, Value}; + +use crate::modules::account::{get_str, load_accounts, save_collected_account}; +use crate::modules::auth_file::{auth_file_path, imported_account_from_root}; +use crate::modules::config::{backup_dir, home_dir, now_ms, utc_iso}; + +// --------------------------------------------------------------------------- +// 本机数据落点(`~/.workbuddy/*`)—— 对齐模块(align)也复用这几个路径 +// --------------------------------------------------------------------------- + +fn workbuddy_root() -> PathBuf { + home_dir().join(".workbuddy") +} + +/// `~/.workbuddy/settings.json`(`claw.users` 账号键的来源)。 +pub(crate) fn settings_path() -> PathBuf { + workbuddy_root().join("settings.json") +} + +/// `~/.workbuddy/memory`(画像缓存 `_memory.md`)。 +pub(crate) fn memory_dir() -> PathBuf { + workbuddy_root().join("memory") +} + +/// `~/.workbuddy/storage`(`user-*` 目录)。 +pub(crate) fn storage_dir() -> PathBuf { + workbuddy_root().join("storage") +} + +/// 账号清单 = settings `claw.users` 键 ∪ `storage/user-` 目录 ∪ 画像缓存文件。 +/// +/// 无凭据,只说明「这个 uid 在本机确实用过」⇒ 发现的 residual 来源; +/// 对齐模块的缺省源选择(`align::pick_source`)也用它。 +pub fn discover_accounts() -> Vec { + let mut accs: Vec = Vec::new(); + if let Ok(text) = std::fs::read_to_string(settings_path()) { + if let Ok(data) = serde_json::from_str::(&text) { + if let Some(users) = data + .get("claw") + .and_then(|c| c.get("users")) + .and_then(|u| u.as_object()) + { + accs.extend(users.keys().cloned()); + } + } + } + if let Ok(entries) = std::fs::read_dir(storage_dir()) { + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().to_string(); + if let Some(uid) = name.strip_prefix("user-") { + if !uid.ends_with("-personal") { + accs.push(uid.to_string()); + } + } + } + } + if let Ok(entries) = std::fs::read_dir(memory_dir()) { + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().to_string(); + if let Some(uid) = name.strip_suffix("_memory.md") { + accs.push(uid.to_string()); + } + } + } + accs.sort(); + accs.dedup(); + accs +} + +/// 扫描 auth 目录:`workbuddy-desktop.*.info`(排除当前登录文件),按 uid 保留最新。 +fn scan_auth_history() -> Vec<(i64, Value)> { + let current = auth_file_path(crate::modules::variant::WbVariant::Cn); + let Some(dir) = current.parent().map(|p| p.to_path_buf()) else { + return vec![]; + }; + scan_auth_history_in(&dir, ¤t) +} + +/// 可注入目录的实现(单测用)。 +fn scan_auth_history_in(dir: &std::path::Path, current: &std::path::Path) -> Vec<(i64, Value)> { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) => { + eprintln!("[discover] 读取 auth 目录失败: {e}"); + return vec![]; + } + }; + + let mut seen: HashMap = HashMap::new(); + for entry in entries.flatten() { + let path = entry.path(); + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if !name.starts_with("workbuddy-desktop") || !name.ends_with(".info") { + continue; + } + if path == current { + continue; // 当前登录态不算历史 + } + let Ok(text) = std::fs::read_to_string(&path) else { continue }; + let Ok(root) = serde_json::from_str::(&text) else { + continue; + }; + let mtime = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + // 新旧判据:**主判据 = 文件名内嵌的 ISO 时间戳**(`workbuddy-desktop..….info`)。 + // 理由:该时间戳由客户端写入备份时生成、天然有序;而 mtime 在快速连续写入时会落在 + // 同一毫秒,导致「同 uid 取最新」取错(2026-09-16 修此 bug,由测试 + // `scan_keeps_newest_per_uid_and_skips_current` 捕获)。 + // ISO 8601 字符串的**字典序 == 时间序**,故直接字符串比较,无需日期运算; + // 解析不到时间戳时退化为空串,实际由 mtime 决定。 + let stamp = name + .strip_prefix("workbuddy-desktop.") + .and_then(|rest| rest.split('.').next()) + .unwrap_or("") + .to_string(); + let Some(rec) = imported_account_from_root(root, crate::modules::variant::WbVariant::Cn) else { + continue; // 无 access_token 的备份无恢复价值 + }; + let uid = get_str(&rec, "uid").unwrap_or_default(); + if uid.is_empty() { + continue; + } + match seen.get(&uid) { + Some((prev_stamp, prev_mtime, _)) + if prev_stamp.as_str() > stamp.as_str() + || (prev_stamp.as_str() == stamp.as_str() && *prev_mtime >= mtime) => + { + continue + } + _ => { + seen.insert(uid, (stamp, mtime, rec)); + } + } + } + seen.into_values().map(|(_, mtime, rec)| (mtime, rec)).collect() +} + +fn backup_accounts_file() -> Option { + let src = crate::modules::config::accounts_file(); + if !src.is_file() { + return None; + } + let dir = backup_dir().join("accounts").join(utc_iso()); + std::fs::create_dir_all(&dir).ok()?; + let dst = dir.join("accounts.json"); + std::fs::copy(&src, &dst).ok()?; + Some(dst) +} + +/// 识别本机所有曾登录/留有数据的账号,对照在册账号库。 +/// +/// 返回每项: +/// ```json +/// { "uid", "nickname", "email", "source": "auth-history" | "residual", +/// "backupFiles": 0, "backedUpAt": 0, "inAccountList": false, +/// "accessTokenExpiresAt": 0, "refreshTokenExpiresAt": 0, +/// "restorable": false } +/// ``` +/// `restorable` = 有 auth 历史备份 且 refresh token 未过期(可补录并刷新)。 +pub fn discover_known_accounts() -> Value { + let in_accounts = load_accounts(); + let history = scan_auth_history(); + + let mut items: Vec = Vec::new(); + // 1) auth 历史优先(含凭据,可直接补录) + for (mtime, rec) in history { + let uid = get_str(&rec, "uid").unwrap_or_default(); + let now = now_ms(); + let access_exp = rec.get("expiresAt").and_then(|v| v.as_i64()).unwrap_or(0); + let refresh_exp = rec + .get("refreshExpiresAt") + .and_then(|v| v.as_i64()) + .unwrap_or(access_exp); + let in_list = in_accounts.iter().any(|a| { + get_str(a, "uid").as_deref() == Some(uid.as_str()) + || get_str(a, "id").as_deref() == Some(uid.as_str()) + }); + items.push(json!({ + "uid": uid, + "nickname": rec.get("nickname").cloned().unwrap_or(Value::Null), + "email": rec.get("email").cloned().unwrap_or(Value::Null), + "source": "auth-history", + "backupFiles": 1, + "backedUpAt": mtime, + "inAccountList": in_list, + "accessTokenExpiresAt": access_exp, + "refreshTokenExpiresAt": refresh_exp, + "restorable": refresh_exp == 0 || refresh_exp > now, + })); + } + + // 2) 数据残留账号(无凭据备份,仅提示"曾登录",restorable=false) + let residual_uids = discover_accounts(); + let known_uids: Vec = items.iter().filter_map(|i| get_str(i, "uid")).collect(); + for uid in residual_uids { + if known_uids.contains(&uid) { + continue; + } + let in_list = in_accounts.iter().any(|a| { + get_str(a, "uid").as_deref() == Some(uid.as_str()) + || get_str(a, "id").as_deref() == Some(uid.as_str()) + }); + items.push(json!({ + "uid": uid, + "nickname": Value::Null, + "email": Value::Null, + "source": "residual", + "backupFiles": 0, + "backedUpAt": 0, + "inAccountList": in_list, + "accessTokenExpiresAt": 0, + "refreshTokenExpiresAt": 0, + "restorable": false, + })); + } + + items.sort_by_key(|i| { + ( + i.get("inAccountList") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + std::cmp::Reverse(i.get("backedUpAt").and_then(|v| v.as_i64()).unwrap_or(0)), + ) + }); + json!({ "accounts": items }) +} + +/// 补录:用 uid 对应的最新 auth 历史备份构造账号记录并写入账号库。 +/// +/// 写前先备份 accounts.json 到 `~/.wb-switch/backups/accounts//`。 +pub fn adopt_account(uid: &str) -> Result { + let history = scan_auth_history(); + let mut newest: Option<(i64, Value)> = None; + for (mtime, rec) in history { + if get_str(&rec, "uid").as_deref() == Some(uid) + && newest.as_ref().is_none_or(|(m, _)| *m < mtime) + { + newest = Some((mtime, rec)); + } + } + let Some((_, rec)) = newest else { + return Err(format!("uid {uid} 无 auth 历史备份,无法补录")); + }; + backup_accounts_file(); + let saved = save_collected_account(rec).map_err(|e| e.to_string())?; + Ok(crate::modules::account::account_meta(&saved)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// 在临时 auth 目录构造两个账号的历史备份,验证扫描去重取最新 + 排除当前登录。 + #[test] + fn scan_keeps_newest_per_uid_and_skips_current() { + let tmp = std::env::temp_dir().join(format!("wb-discover-test-{}", now_ms())); + std::fs::create_dir_all(&tmp).unwrap(); + let write = |name: &str, uid: &str, nick: &str, tok: &str| { + let root = json!({ + "account": {"uid": uid, "nickname": nick}, + "auth": {"accessToken": tok, "refreshToken": "RT", + "tokenType": "Bearer", "domain": "d", "expiresAt": 1} + }); + std::fs::write(tmp.join(name), serde_json::to_string(&root).unwrap()).unwrap(); + }; + // 旧备份 u-1 → 新备份 u-1(应留新);u-2 一份;当前登录文件应跳过 + write("workbuddy-desktop.2026-09-01T00-00-00-000Z.1.aaaa.info", "u-1", "一号", "AT-1-old"); + write("workbuddy-desktop.2026-09-02T00-00-00-000Z.1.bbbb.info", "u-1", "一号", "AT-1-new"); + write("workbuddy-desktop.2026-09-03T00-00-00-000Z.1.cccc.info", "u-2", "二号", "AT-2"); + write("workbuddy-desktop.info", "u-cur", "当前", "AT-cur"); // 应被排除 + // 非 workbuddy 前缀文件应被忽略 + write("other.info", "u-x", "杂鱼", "AT-x"); + + let hits = scan_auth_history_in(&tmp, &tmp.join("workbuddy-desktop.info")); + let mut by_uid: HashMap = HashMap::new(); + for (_, rec) in &hits { + by_uid.insert( + get_str(rec, "uid").unwrap(), + get_str(rec, "access_token").unwrap(), + ); + } + assert_eq!(by_uid.len(), 2, "应识别 u-1 + u-2,共两个历史账号"); + assert_eq!(by_uid.get("u-1").unwrap(), "AT-1-new", "同 uid 应取最新备份"); + assert_eq!(by_uid.get("u-2").unwrap(), "AT-2"); + assert!(!by_uid.contains_key("u-cur"), "当前登录文件应被排除"); + assert!(!by_uid.contains_key("u-x"), "非 workbuddy 前缀应被忽略"); + let _ = std::fs::remove_dir_all(&tmp); + } + + /// 无 token 的备份应被忽略(imported_account_from_root 返回 None)。 + #[test] + fn no_token_root_is_ignored() { + let root = json!({ "account": { "uid": "u-1", "nickname": "n" } }); + assert!(imported_account_from_root(root, crate::modules::variant::WbVariant::Cn).is_none()); + } +} diff --git a/crates/wb-switch-core/src/modules/mod.rs b/crates/wb-switch-core/src/modules/mod.rs index 3c92c932..63fff1b7 100644 --- a/crates/wb-switch-core/src/modules/mod.rs +++ b/crates/wb-switch-core/src/modules/mod.rs @@ -7,6 +7,7 @@ pub mod codebuddy_ide; pub mod config; pub mod credit_usage; pub mod credits; +pub mod discover; pub mod export_import; pub mod limits; pub mod oauth; diff --git a/crates/wb-switch-server/src/api.rs b/crates/wb-switch-server/src/api.rs index d5d23b30..60dfbf59 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 { 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..d33ad906 --- /dev/null +++ b/crates/wb-switch-server/src/api_local.rs @@ -0,0 +1,49 @@ +//! 本地专属 HTTP 路由(上游无此文件 → 与上游合并零冲突)。 +//! +//! 这里只放本项目新增的接口:账号发现 / 补录。 +//! `api.rs` 只保留一行 `.merge(api_local::router())`,避免在上游热点文件里堆代码。 + +use axum::extract::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::Router; +use serde_json::{json, Value}; + +use wb_switch_core::modules::discover; + +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/accounts/discover", get(api_discover_accounts)) + .route("/api/accounts/adopt", post(api_adopt_account)) +} + +/// GET /api/accounts/discover —— 识别本机曾登录/留有数据的账号(对照在册)。 +async fn api_discover_accounts() -> Response { + json_ok(discover::discover_known_accounts()) +} + +/// POST /api/accounts/adopt —— 用最新 auth 历史备份补录指定 uid 进账号库。 +async fn api_adopt_account(Json(body): Json) -> Response { + let uid = body + .get("uid") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if uid.trim().is_empty() { + return json_err("缺少 uid".to_string(), StatusCode::BAD_REQUEST); + } + match discover::adopt_account(&uid) { + Ok(meta) => json_ok(json!({ "ok": true, "account": meta })), + Err(error) => json_err(error, 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_local.rs b/src-tauri/src/commands_local.rs new file mode 100644 index 00000000..f85c0f98 --- /dev/null +++ b/src-tauri/src/commands_local.rs @@ -0,0 +1,23 @@ +//! 本地专属 Tauri 命令(上游无此文件 → 与上游合并零冲突)。 +//! +//! 只放本项目新增的命令:账号发现 / 补录。`commands.rs` 保持上游原貌, +//! 新命令在 `lib.rs` 的 `invoke_handler` 里以 `commands_local::xxx` 注册。 + +use serde_json::{json, Value}; + +use wb_switch_core::modules::discover; + +/// 识别本机曾登录/留有数据的账号(对照在册)。 +#[tauri::command] +pub fn discover_known_accounts() -> Value { + discover::discover_known_accounts() +} + +/// 用最新 auth 历史备份补录指定 uid 进账号库。 +#[tauri::command(rename_all = "camelCase")] +pub fn adopt_account(uid: String) -> Result { + if uid.trim().is_empty() { + return Err("缺少 uid".to_string()); + } + discover::adopt_account(&uid).map(|meta| json!({ "ok": true, "account": meta })) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b795048e..07439da7 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)] @@ -175,6 +176,8 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ commands::get_status, commands::get_accounts, + commands_local::discover_known_accounts, + commands_local::adopt_account, commands::get_codebuddy_cli_status, commands::install_codebuddy_cli_helper, commands::switch_codebuddy_cli_account, diff --git a/src/components/discover-accounts-banner.tsx b/src/components/discover-accounts-banner.tsx new file mode 100644 index 00000000..bbbb8ab2 --- /dev/null +++ b/src/components/discover-accounts-banner.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { History, Loader2, UserRoundPlus } from "lucide-react"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import * as api from "@/lib/api"; +import type { DiscoveredAccount } from "@/lib/types"; + +function fmtTs(ts: number | null): string { + if (!ts) return ""; + try { + return new Date(ts).toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return ""; + } +} + +/** + * 「发现曾登录账号」提示条:扫描本机 auth 历史备份 + 数据残留,找出 + * 已登录过但不在账号库的账号,支持一键补录。无候选时整块不渲染。 + */ +export function DiscoverAccountsBanner({ + onAdopted, +}: { + onAdopted?: () => void; +}) { + const [discovered, setDiscovered] = useState(null); + const [adopting, setAdopting] = useState(null); + + useEffect(() => { + let cancelled = false; + api + .discoverKnownAccounts() + .then(({ accounts }) => { + if (!cancelled) setDiscovered(accounts); + }) + .catch(() => { + // 老版本 server 无该接口时静默隐藏,不打扰 + if (!cancelled) setDiscovered([]); + }); + return () => { + cancelled = true; + }; + }, []); + + // 只展示「不在账号库」的账号;在册的已可见,无需提示 + const candidates = (discovered ?? []).filter((account) => !account.inAccountList); + if (!discovered || candidates.length === 0) return null; + + async function onAdopt(account: DiscoveredAccount) { + if (adopting !== null) return; + setAdopting(account.uid); + try { + const res = await api.adoptAccount(account.uid); + toast.success("已加回列表", { + description: `${account.nickname || account.email || account.uid.slice(0, 8)}:${res.account.nickname || res.account.uid}`, + }); + onAdopted?.(); + } catch (error) { + toast.error("这次没加进去", { + description: `登录信息可能已经过期了,稍后可以再试一次。${api.asError(error)}`, + }); + } finally { + setAdopting(null); + } + } + + return ( + + + + 发现 {candidates.length} 个以前登录过的账号 + + 本机还留着它们的记录,加回列表就能直接管理和切换 + + + +
    + {candidates.map((account) => ( +
  • + + {account.nickname || account.email || account.uid.slice(0, 8)} + + {account.uid.slice(0, 8)} + + + {account.backedUpAt ? ( + + 上次登录 {fmtTs(account.backedUpAt)} + + ) : ( + + 只找到本地数据 + + )} + {account.restorable ? ( + + ) : ( + + 登录信息已过期,重新登录后可用「导入本机账号」 + + )} +
  • + ))} +
+
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 85632f94..b2ed3463 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -14,6 +14,7 @@ import type { CheckinResult, CreditExpiry, CreditStatistics, + DiscoveredAccount, TokenStatistics, CopyResult, GithubConfig, @@ -49,7 +50,7 @@ const DEMO_READ_COMMANDS = new Set([ "get_token_statistics", "get_checkin_logs", "get_auto_rotate_config", "rotate_status", "get_rotate_logs", "get_github_config", "check_update", "get_launch_at_login_enabled", "switch_progress", - "get_travel_status", "get_auto_travel_config", "get_rate_limits", + "discover_known_accounts", "get_travel_status", "get_auto_travel_config", "get_rate_limits", "get_rate_limit_hook_status", "get_rate_limit_config", ]); @@ -82,6 +83,8 @@ type Route = { method: "GET" | "POST"; path: string }; const ROUTES: Record = { get_status: { method: "GET", path: "/api/status" }, get_accounts: { method: "GET", path: "/api/accounts" }, + discover_known_accounts: { method: "GET", path: "/api/accounts/discover" }, + adopt_account: { method: "POST", path: "/api/accounts/adopt" }, get_codebuddy_cli_status: { method: "GET", path: "/api/codebuddy-cli/status" }, install_codebuddy_cli_helper: { method: "POST", path: "/api/codebuddy-cli/install-helper" }, switch_codebuddy_cli_account: { method: "POST", path: "/api/codebuddy-cli/switch" }, @@ -201,6 +204,16 @@ export function getAccounts(): Promise<{ accounts: AccountMeta[] }> { return call("get_accounts"); } +/** 识别本机曾登录/留有数据的账号(对照在册,只读)。 */ +export function discoverKnownAccounts(): Promise<{ accounts: DiscoveredAccount[] }> { + return call("discover_known_accounts"); +} + +/** 用最新 auth 历史备份补录指定 uid 进账号库。 */ +export function adoptAccount(uid: string): Promise<{ ok: boolean; account: AccountMeta }> { + return call("adopt_account", { uid }); +} + export function getCodebuddyCliStatus(): Promise { return call("get_codebuddy_cli_status"); } diff --git a/src/lib/screenshot-demo.ts b/src/lib/screenshot-demo.ts index 5d3f6762..226a8fe2 100644 --- a/src/lib/screenshot-demo.ts +++ b/src/lib/screenshot-demo.ts @@ -543,6 +543,22 @@ export function screenshotDemoResponse(command: string, args?: Record ({ + uid: account.uid, + nickname: account.nickname, + email: account.email, + source: "auth-history", + backupFiles: 3, + backedUpAt: futureAt(-1, 21, 0), + inAccountList: true, + accessTokenExpiresAt: account.expiresAt, + refreshTokenExpiresAt: account.refreshExpiresAt, + restorable: true, + })), + }; + case "adopt_account": return { ok: true, account: demoAccounts[0] }; case "get_codebuddy_cli_status": return cliStatus; // 让演示里存在一个「CodeBuddy IDE 当前账号」:否则 IDE 标记与选中态染色(淡紫)在演示里永远不可见。 // 取第二个账号,使三张卡各自演示一种形态(A 占位行 / B IDE 选中 / C 查看全部)。 diff --git a/src/lib/types.ts b/src/lib/types.ts index 984c193a..6ea0892e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -22,6 +22,22 @@ export interface AccountMeta { variant?: WbVariant; } +/** 本机曾登录/留有数据的账号(discover 结果,只读视图)。 */ +export interface DiscoveredAccount { + uid: string; + nickname: string | null; + email: string | null; + /** auth-history = 官方登录历史备份(含凭据,可补录);residual = 仅数据残留 */ + source: "auth-history" | "residual"; + backupFiles: number; + backedUpAt: number | null; + inAccountList: boolean; + accessTokenExpiresAt: number | null; + refreshTokenExpiresAt: number | null; + /** 有 auth 历史备份且 refresh token 未过期,可一键补录 */ + restorable: boolean; +} + export interface AppStatus { running: boolean; authFile: string; diff --git a/src/pages/AccountsPage.tsx b/src/pages/AccountsPage.tsx index 21cfe81b..6019d74c 100644 --- a/src/pages/AccountsPage.tsx +++ b/src/pages/AccountsPage.tsx @@ -18,6 +18,7 @@ import { import { AccountCard } from "@/components/account-card"; import { DemoAction } from "@/components/demo-action"; +import { DiscoverAccountsBanner } from "@/components/discover-accounts-banner"; import { CodeBuddyAiIdeMark, CodeBuddyCnIdeMark, @@ -873,6 +874,8 @@ export default function AccountsPage() { )} + void fetchAll()} /> + {codebuddyCli && (!codebuddyCli.configured || (!codebuddyUsesSettingsEnv && !codebuddyCli.helperSupportsAccountIds) ||