diff --git a/README.md b/README.md index 85421fd0..0069d68f 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ xattr -rd com.apple.quarantine "/Applications/workbuddy-switch.app" | 账号管理 | OAuth 扫码登录、导入导出账号、删除账号 | | 账号切换 | 一键切换 WorkBuddy 登录账号,切换过程实时显示进度 | | 会话复制 | 把当前账号勾选的会话复制给目标账号,源账号数据不受影响 | -| 签到 | 默认关闭,可在设置页开启 | +| 签到 | 全新安装默认关闭,可在设置页开启;支持按账号关闭,刷新时跳过并提示 | | 积分到期查询 | 自动查询每个账号的积分剩余量与到期时间;7 天内到期高亮,并按紧迫程度排序、标注「建议优先使用」 | | 积分统计 | 汇总官方请求用量:总览、近 30 天趋势、模型分类、账号消耗与请求明细 | | Token 统计 | 按来源查看 Token 总览与趋势,含构成占比、活跃热力图、项目/模型 Top 10 与会话排行 | @@ -55,7 +55,7 @@ xattr -rd com.apple.quarantine "/Applications/workbuddy-switch.app" 1. **添加与导出账号**:账号页 →「OAuth 扫码登录」「导入本机账号」「导入备份」;「导出」可将勾选账号备份为 JSON 2. **切换账号**:账号卡片 →「切换」,可勾选复制当前会话 3. **查看积分与统计**:账号页自动查询各账号积分到期情况,点「刷新积分」手动更新;侧栏进入「积分统计」「Token 统计」查看用量明细 -4. **签到**:默认关闭,可在设置页开启并查看日志 +4. **签到**:全新安装默认关闭,全局开关、按账号开关与日志位于设置页。关闭某账号的自动签到后,后台轮次、页面签到状态查询与刷新附带签到均忽略该账号,积分照常刷新并提示忽略数量。单账号「手动签到」及设置页、托盘的「全部立即签到」仍可主动执行 5. **切换各客户端账号**:CodeBuddy CLI、CodeBuddy IDE、VS Code CodeBuddy 插件均可在账号卡片一键切换;其中 VS Code 插件支持在弹窗中勾选复制当前账号的会话。CodeBuddy IDE 首次使用前需先手动打开并登录一次 6. **自动轮换**:设置 → CodeBuddy CLI 自动轮换,开启后按积分紧迫程度自动设置默认账号 7. **更新**:应用会自动检查公开 GitHub Releases;发现新版本后可在左下角直接升级,也可从设置页打开 Release 页面手动下载 diff --git a/crates/wb-switch-core/src/modules/checkin.rs b/crates/wb-switch-core/src/modules/checkin.rs index ff4286a8..cce25ec5 100644 --- a/crates/wb-switch-core/src/modules/checkin.rs +++ b/crates/wb-switch-core/src/modules/checkin.rs @@ -17,6 +17,7 @@ use chrono::{Local, TimeZone}; use serde_json::{json, Value}; use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; +use std::future::Future; use std::hash::{Hash, Hasher}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::{Mutex, OnceLock}; @@ -222,6 +223,17 @@ pub async fn get_checkin_status(account: &Value) -> Value { }) } +/// 页面展示触发的状态查询遵守单账号开关;手动签到内部仍直接查询服务端。 +pub async fn get_checkin_status_for_display(account: &Value) -> Value { + with_auto_checkin_preference( + account, + &load_checkin_config(), + true, + get_checkin_status(account), + ) + .await +} + /// 国际版「功能不可用」类业务提示:签到活动未开启 / 未开放 / 已过期。 fn is_inactive_message(message: &str) -> bool { let raw = message.to_lowercase(); @@ -371,11 +383,37 @@ pub fn date_str(ts_ms: Option) -> String { } } -/// 仅支持签到的档位(国际版没有签到接口,绝不发起请求)。 -fn checkin_capable_accounts() -> Vec { - load_accounts() +/// 只匹配稳定 id,不以昵称或邮箱排除其它账号。 +fn auto_checkin_excluded(account: &Value, cfg: &Value) -> bool { + account.get("id").and_then(Value::as_str).is_some_and(|id| { + cfg.get("excluded_account_ids") + .and_then(Value::as_array) + .is_some_and(|ids| ids.iter().any(|value| value.as_str() == Some(id))) + }) +} + +fn allows_auto_checkin(account: &Value, cfg: &Value) -> bool { + variant_of(account).supports_checkin() && !auto_checkin_excluded(account, cfg) +} + +/// 在执行异步操作前检查偏好:被排除时不 poll future,因此不会查询、刷新 token 或提交。 +async fn with_auto_checkin_preference( + account: &Value, + cfg: &Value, + respect_auto_checkin: bool, + operation: impl Future, +) -> Value { + if respect_auto_checkin && auto_checkin_excluded(account, cfg) { + return json!({"ok": false, "result": "skipped", "reason": "auto_checkin_disabled"}); + } + operation.await +} + +/// 自动调度和时间段计划共用的账号集合;手动签到不受账号排除设置影响。 +fn auto_checkin_accounts(accounts: Vec, cfg: &Value) -> Vec { + accounts .into_iter() - .filter(|acc| variant_of(acc).supports_checkin()) + .filter(|acc| allows_auto_checkin(acc, cfg)) .collect() } @@ -565,10 +603,11 @@ fn plan_next_delay( /// 宿主下一轮睡眠时长。策略全在 core,宿主只读这个时长,不解释 payload。 pub fn next_cycle_delay() -> Duration { - let window = window_minutes(&load_checkin_config()); + let cfg = load_checkin_config(); + let window = window_minutes(&cfg); // 窗口未生效时不必读账号库/日志:直接走既有 30 分钟节奏。 let accounts = if window.is_some() { - checkin_capable_accounts() + auto_checkin_accounts(load_accounts(), &cfg) } else { Vec::new() }; @@ -607,12 +646,12 @@ fn write_window_receipt(win: (u32, u32), account: &Value) { /// 执行一轮自动签到。启动与周期轮次均逐账号查询服务端状态。 /// -/// 只遍历支持签到的档位(国际版没有签到接口,绝不发起请求)。并发锁防止与 +/// 只遍历支持签到且允许自动签到的账号。并发锁防止与 /// 手动签到/上一轮重复运行。 /// /// 时间段生效时只处理「到点未办」的账号:未到点整轮不发任何请求,返回 /// `{"status":"skipped","reason":"before_checkin_time","accounts":[]}`。 -/// 时间段未生效时逐字走既有路径(每轮处理全部账号)。 +/// 时间段未生效时,每轮处理全部允许自动签到的账号。 pub async fn run_checkin_cycle(_mode: CheckinCycleMode) -> Value { let Some(_guard) = RunFlagGuard::try_acquire(&CHECKIN_RUNNING) else { return json!({"status": "skipped", "reason": "already_running"}); @@ -621,12 +660,12 @@ pub async fn run_checkin_cycle(_mode: CheckinCycleMode) -> Value { if cfg.get("enabled").and_then(|v| v.as_bool()) != Some(true) { return json!({"status": "disabled"}); } - let accounts = checkin_capable_accounts(); + let accounts = auto_checkin_accounts(load_accounts(), &cfg); if accounts.is_empty() { return json!({"status": "no_accounts"}); } let window = window_minutes(&cfg); - // 窗口未生效时不读日志、不过滤账号:与改动前「每轮处理全部账号」逐字一致。 + // 窗口未生效时,不再按时间过滤允许自动签到的账号。 let accounts = match window { Some(win) => due_accounts(accounts, now_ms(), Some(win), &load_checkin_logs()), None => accounts, @@ -743,10 +782,19 @@ fn latest_today_result<'a>(logs: &'a [Value], account_id: &str, today: &str) -> /// 对全部账号立即签到(前端一键签到)。 /// -/// `variant = None` 覆盖全部档位(自动周期与设置页「立即签到」语义); +/// `variant = None` 覆盖全部档位(设置页与托盘「立即签到」语义); /// 显式传入时只处理该档位(账号页按当前档位触发,避免跨档位误签到)。 /// 无论哪种取值,都只处理支持签到的档位:国际版没有签到接口,绝不发起请求。 pub async fn run_checkin_all(variant: Option) -> Value { + run_checkin_batch(variant, false).await +} + +/// 刷新积分附带的签到:跳过关闭自动签到的账号,并逐账号返回 skipped 原因。 +pub async fn run_checkin_all_for_refresh(variant: Option) -> Value { + run_checkin_batch(variant, true).await +} + +async fn run_checkin_batch(variant: Option, respect_auto_checkin: bool) -> Value { let Some(_guard) = RunFlagGuard::try_acquire(&CHECKIN_RUNNING) else { return json!({"accounts": [], "status": "skipped", "reason": "already_running"}); }; @@ -759,7 +807,13 @@ pub async fn run_checkin_all(variant: Option) -> Value { .collect(); let mut results: Vec = Vec::new(); for acc in accounts { - let r = checkin_account(&acc).await; + let r = with_auto_checkin_preference( + &acc, + &load_checkin_config(), + respect_auto_checkin, + checkin_account(&acc), + ) + .await; let mut row = json!({ "accountId": acc.get("id").cloned().unwrap_or(Value::Null), "email": account_display_name(&acc), @@ -769,6 +823,9 @@ pub async fn run_checkin_all(variant: Option) -> Value { if r.get("inactive").and_then(Value::as_bool) == Some(true) { row["inactive"] = json!(true); } + if let Some(reason) = r.get("reason") { + row["reason"] = reason.clone(); + } results.push(row); } json!({"accounts": results}) @@ -778,6 +835,111 @@ pub async fn run_checkin_all(variant: Option) -> Value { mod tests { use super::*; + #[tokio::test] + async fn excluded_account_never_starts_passive_operation() { + let account = json!({"id": "excluded", "variant": "cn"}); + let cfg = json!({"excluded_account_ids": ["excluded"]}); + let result = with_auto_checkin_preference(&account, &cfg, true, async { + panic!("excluded account must not query status, refresh credentials or submit checkin") + }) + .await; + assert_eq!(result["result"], "skipped"); + assert_eq!(result["reason"], "auto_checkin_disabled"); + assert_eq!(result["ok"], false); + assert!(result.get("todayCheckedIn").is_none()); + } + + #[tokio::test] + async fn manual_checkin_and_reenabled_account_still_run() { + let account = json!({"id": "excluded", "variant": "cn"}); + let excluded_cfg = json!({"excluded_account_ids": ["excluded"]}); + for (respect, cfg) in [ + (false, excluded_cfg), + (true, json!({"excluded_account_ids": []})), + (true, json!({})), + ] { + let mut calls = 0; + let result = with_auto_checkin_preference(&account, &cfg, respect, async { + calls += 1; + json!({"result": "success"}) + }) + .await; + assert_eq!(calls, 1); + assert_eq!(result["result"], "success"); + } + } + + #[tokio::test] + async fn refresh_reports_skips_without_running_excluded_accounts() { + let cfg = json!({"excluded_account_ids": ["a", "c"]}); + let mut requested_ids = Vec::new(); + let mut skipped = 0; + for id in ["a", "b", "c"] { + let account = json!({"id": id}); + let result = with_auto_checkin_preference(&account, &cfg, true, async { + requested_ids.push(id); + json!({"result": "already"}) + }) + .await; + if result["reason"] == "auto_checkin_disabled" { + assert_eq!(result["result"], "skipped"); + skipped += 1; + } + } + assert_eq!(requested_ids, vec!["b"]); + assert_eq!(skipped, 2); + } + + #[test] + fn auto_checkin_excludes_only_matching_ids_and_preserves_legacy_accounts() { + let accounts = vec![ + json!({"id": "cn-excluded", "email": "shared@example.com"}), + json!({"id": "cn-allowed", "email": "shared@example.com"}), + json!({"id": "ai-account", "variant": "ai"}), + json!({"id": "legacy-account"}), + ]; + let legacy = auto_checkin_accounts(accounts.clone(), &json!({})); + assert_eq!( + legacy, + vec![ + accounts[0].clone(), + accounts[1].clone(), + accounts[3].clone() + ] + ); + + let cfg = json!({"excluded_account_ids": ["cn-excluded", "deleted-account", "shared@example.com"]}); + let eligible = auto_checkin_accounts(accounts.clone(), &cfg); + assert_eq!(eligible, vec![accounts[1].clone(), accounts[3].clone()]); + // 排除设置不影响「是否支持签到」,手动入口仍能处理该国内版账号。 + assert!(variant_of(&accounts[0]).supports_checkin()); + assert!(!allows_auto_checkin(&accounts[0], &cfg)); + assert!(allows_auto_checkin( + &accounts[0], + &json!({"excluded_account_ids": []}) + )); + } + + #[test] + fn excluded_account_is_not_due_even_after_its_target_time() { + let (account, target) = due_fixture(); + let cfg = json!({"excluded_account_ids": [account["id"]]}); + let accounts = auto_checkin_accounts(vec![account], &cfg); + assert!(due_accounts(accounts.clone(), target, Some(TEST_WINDOW), &[]).is_empty()); + assert!(due_accounts(accounts.clone(), target, None, &[]).is_empty()); + // 全部排除时不再为它们的目标时刻安排唤醒。 + assert_eq!( + plan_next_delay( + false, + FAST_RETRY_MAX, + target - 60_000, + &accounts, + Some(TEST_WINDOW) + ), + (CHECKIN_RECOVERY_INTERVAL, FAST_RETRY_MAX) + ); + } + #[test] fn checked_in_status_returns_already_without_submission() { assert_eq!( diff --git a/crates/wb-switch-core/src/modules/config.rs b/crates/wb-switch-core/src/modules/config.rs index 0b2c2d0e..7762620c 100644 --- a/crates/wb-switch-core/src/modules/config.rs +++ b/crates/wb-switch-core/src/modules/config.rs @@ -274,6 +274,7 @@ fn legacy_default_checkin_config() -> Value { fn checkin_config_with_enabled(enabled: bool) -> Value { json!({ "enabled": enabled, + "excluded_account_ids": [], "checkin_start": "", "checkin_end": "", "start_hour": 6, @@ -310,6 +311,16 @@ fn apply_checkin_config(merged: &mut Value, input: &Value) { if let Some(enabled) = map.get("enabled").and_then(Value::as_bool) { merged["enabled"] = json!(enabled); } + // 仅用稳定账号 id 排除自动签到;忽略无效项并去重,旧配置默认全部参与。 + if let Some(ids) = map.get("excluded_account_ids").and_then(Value::as_array) { + let mut seen = HashSet::new(); + let ids: Vec<&str> = ids + .iter() + .filter_map(Value::as_str) + .filter(|id| !id.trim().is_empty() && seen.insert(*id)) + .collect(); + merged["excluded_account_ids"] = json!(ids); + } for key in [ "start_hour", "end_hour", @@ -1105,6 +1116,7 @@ mod tests { fn auto_checkin_defaults_disabled_and_preserves_legacy_fields() { let cfg = default_checkin_config(); assert_eq!(cfg.get("enabled").and_then(Value::as_bool), Some(false)); + assert_eq!(cfg["excluded_account_ids"], json!([])); assert_eq!(cfg.get("start_hour").and_then(Value::as_i64), Some(6)); assert_eq!(cfg.get("end_hour").and_then(Value::as_i64), Some(12)); @@ -1117,6 +1129,64 @@ mod tests { ); } + #[test] + fn auto_checkin_exclusions_survive_config_roundtrip_and_global_toggle() { + let cfg = merge_checkin_config(&json!({ + "enabled": true, + "excluded_account_ids": ["account-b", "account-a", "account-b", "", " ", null, 42], + "checkin_start": "9:5", + "checkin_end": "12:00", + "keepalive_days": 7 + })); + assert_eq!( + cfg["excluded_account_ids"], + json!(["account-b", "account-a"]) + ); + let serialized = serde_json::to_string(&cfg).unwrap(); + let mut reloaded: Value = serde_json::from_str(&serialized).unwrap(); + assert_eq!(merge_checkin_config(&reloaded), cfg); + reloaded["enabled"] = json!(false); + let disabled = merge_checkin_config(&reloaded); + assert_eq!( + disabled["excluded_account_ids"], + cfg["excluded_account_ids"] + ); + assert_eq!(disabled["checkin_start"], "09:05"); + assert_eq!(disabled["keepalive_days"], 7); + } + + #[test] + fn auto_checkin_legacy_or_invalid_exclusions_default_to_empty() { + assert_eq!( + merge_checkin_config(&json!({}))["excluded_account_ids"], + json!([]) + ); + for invalid in [ + json!(null), + json!(true), + json!("account-a"), + json!({"id": "account-a"}), + ] { + assert_eq!( + merge_checkin_config(&json!({"excluded_account_ids": invalid})) + ["excluded_account_ids"], + json!([]) + ); + } + } + + #[test] + fn auto_checkin_exclusions_survive_usage_trace_defaults() { + let input = json!({"excluded_account_ids": ["account-a", "account-a", null]}); + for existing_install in [false, true] { + let resolved = resolve_checkin_config(Some(&input), existing_install); + assert_eq!(resolved["enabled"], json!(existing_install)); + assert_eq!(resolved["excluded_account_ids"], json!(["account-a"])); + // 保存已解析配置后,新老安装均保留开关状态和账号排除列表。 + assert_eq!(merge_checkin_config(&resolved), resolved); + } + } + #[test] fn checkin_default_follows_usage_trace() { // 全新安装(无配置文件、无签到日志):默认关闭。 diff --git a/crates/wb-switch-server/src/api.rs b/crates/wb-switch-server/src/api.rs index b1ac45ae..134fe179 100644 --- a/crates/wb-switch-server/src/api.rs +++ b/crates/wb-switch-server/src/api.rs @@ -3,12 +3,13 @@ //! 路由设计对应 Python 版 server.py 与桌面端 commands.rs。仅绑定 127.0.0.1, //! token 不出本机。 +use std::collections::HashMap; use std::sync::Mutex; #[cfg(target_os = "windows")] use std::time::{Duration, Instant}; use axum::body::Body; -use axum::extract::RawQuery; +use axum::extract::{Query, RawQuery}; use axum::http::{header, StatusCode, Uri}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; @@ -683,12 +684,20 @@ async fn api_session_links_preview(Json(body): Json) -> Response { // 签到 / 保活 // --------------------------------------------------------------------------- -/// GET /api/checkin/status —— 全部账号的签到状态(每行带 `variant`,档位取账号自身)。 -async fn api_checkin_status() -> Response { +/// GET /api/checkin/status —— 传 accountId 时只查询该账号;缺省保留旧批量响应。 +/// 两种形式都遵守单账号自动签到开关,避免展示状态时触发已关闭账号的请求。 +async fn api_checkin_status(Query(query): Query>) -> Response { + if let Some(id) = query.get("accountId") { + let Some(acc) = account::find_account(id) else { + return json_err("账号不存在".to_string(), StatusCode::BAD_REQUEST); + }; + let status = checkin::get_checkin_status_for_display(&acc).await; + return json_ok(checkin_status_item(&acc, status)); + } let list = account::load_accounts(); let mut items = Vec::new(); for acc in &list { - let status = checkin::get_checkin_status(acc).await; + let status = checkin::get_checkin_status_for_display(acc).await; items.push(checkin_status_item(acc, status)); } json_ok(json!({ "accounts": items })) @@ -829,7 +838,17 @@ async fn api_checkin_all(body: Option>) -> Response { .and_then(|Json(value)| value.get("variant")) .and_then(|value| value.as_str()) .map(|raw| WbVariant::parse(Some(raw))); - json_ok(checkin::run_checkin_all(variant).await) + let respect_auto_checkin = body + .as_ref() + .and_then(|Json(value)| value.get("respectAutoCheckin")) + .and_then(Value::as_bool) + .unwrap_or(false); + let result = if respect_auto_checkin { + checkin::run_checkin_all_for_refresh(variant).await + } else { + checkin::run_checkin_all(variant).await + }; + json_ok(result) } async fn api_checkin_config() -> Response { @@ -1042,6 +1061,18 @@ mod tests { assert_eq!(item["error"], "status failed"); } + #[test] + fn web_checkin_status_preserves_exclusion_without_inventing_today_status() { + let item = checkin_status_item( + &json!({"id": "excluded"}), + json!({"ok": false, "result": "skipped", "reason": "auto_checkin_disabled"}), + ); + assert_eq!(item["accountId"], "excluded"); + assert_eq!(item["reason"], "auto_checkin_disabled"); + assert_eq!(item["result"], "skipped"); + assert!(item.get("todayCheckedIn").is_none()); + } + #[test] fn web_checkin_status_row_carries_variant() { let item = checkin_status_item( diff --git a/npm/README.md b/npm/README.md index 48b5aadd..ed3f9ce3 100644 --- a/npm/README.md +++ b/npm/README.md @@ -37,7 +37,7 @@ webui 界面与桌面 App 一致,功能覆盖下方全部模块。 | 账号管理 | OAuth 扫码登录、导入导出账号、删除账号 | | 账号切换 | 一键切换 WorkBuddy 登录账号,切换过程实时显示进度 | | 会话复制 | 把当前账号勾选的会话复制给目标账号,源账号数据不受影响 | -| 签到 | 默认关闭,可在设置页开启 | +| 签到 | 全新安装默认关闭,设置页支持全局及按账号关闭,刷新时跳过并提示 | | 积分到期查询 | 自动查询每个账号的积分剩余量与到期时间;7 天内到期高亮,并按紧迫程度排序、标注「建议优先使用」 | | 积分统计 | 汇总官方请求用量:总览、近 30 天趋势、模型分类、账号消耗与请求明细 | | Token 统计 | 按来源查看 Token 总览与趋势,含构成占比、活跃热力图、项目/模型 Top 10 与会话排行 | @@ -54,7 +54,7 @@ webui 界面与桌面 App 一致,功能覆盖下方全部模块。 1. **添加与导出账号**:账号页 →「OAuth 扫码登录」「导入本机账号」「导入备份」;「导出」可将勾选账号备份为 JSON 2. **切换账号**:账号卡片 →「切换」,可勾选复制当前会话 3. **查看积分与统计**:账号页自动查询各账号积分到期情况,点「刷新积分」手动更新;侧栏进入「积分统计」「Token 统计」查看用量明细 -4. **签到**:默认关闭,可在设置页开启并查看日志 +4. **签到**:全新安装默认关闭,全局开关、按账号开关与日志位于设置页。关闭某账号的自动签到后,后台轮次、页面签到状态查询与刷新附带签到均忽略该账号,积分照常刷新并提示忽略数量。单账号「手动签到」及设置页、托盘的「全部立即签到」仍可主动执行 5. **切换各客户端账号**:CodeBuddy CLI、CodeBuddy IDE、VS Code CodeBuddy 插件均可在账号卡片一键切换;其中 VS Code 插件支持在弹窗中勾选复制当前账号的会话。CodeBuddy IDE 首次使用前需先手动打开并登录一次 6. **自动轮换**:设置 → CodeBuddy CLI 自动轮换,开启后按积分紧迫程度自动设置默认账号 7. **更新**:设置 → 自动更新可检查公开 GitHub Releases 源;npm 版本也可通过 `npm update -g workbuddy-switch` 升级 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 503c1951..e6077bb3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -510,7 +510,7 @@ pub async fn session_links_preview( #[tauri::command] pub async fn get_checkin_status(account_id: String) -> Result { let acc = account::find_account(&account_id).ok_or("账号不存在")?; - let mut status = checkin::get_checkin_status(&acc).await; + let mut status = checkin::get_checkin_status_for_display(&acc).await; // 结果行带档位,前端按当前档位过滤时无需再查账号。 status["variant"] = json!(account::variant_of(&acc).as_str()); Ok(status) @@ -618,9 +618,15 @@ pub async fn checkin(account_id: String) -> Result { /// POST /api/checkin/all —— 全部账号立即签到(每个账号按自身档位)。 /// `variant` 缺省为 `None`(全部档位,保持原行为);显式传入时只处理该档位。 -#[tauri::command] -pub async fn checkin_all(variant: Option) -> Value { - checkin::run_checkin_all(variant.as_deref().map(|raw| WbVariant::parse(Some(raw)))).await +/// 刷新积分附带签到时 respect_auto_checkin=true,跳过关闭自动签到的账号。 +#[tauri::command] +pub async fn checkin_all(variant: Option, respect_auto_checkin: Option) -> Value { + let variant = variant.as_deref().map(|raw| WbVariant::parse(Some(raw))); + if respect_auto_checkin == Some(true) { + checkin::run_checkin_all_for_refresh(variant).await + } else { + checkin::run_checkin_all(variant).await + } } /// GET /api/checkin/config —— 自动签到配置。 diff --git a/src/components/account-card.tsx b/src/components/account-card.tsx index c3beec40..24926c6c 100644 --- a/src/components/account-card.tsx +++ b/src/components/account-card.tsx @@ -1,4 +1,4 @@ -import { ArrowRight, CalendarCheck2, CalendarDays, Check, CircleCheck, Clock3, Coins, Ellipsis, Gauge, Loader2, PackageOpen, PlaneTakeoff, RefreshCw, Sparkles, Star, Trash2 } from "lucide-react"; +import { ArrowRight, CalendarCheck2, CalendarDays, CalendarOff, Check, CircleCheck, Clock3, Coins, Ellipsis, Gauge, Loader2, PackageOpen, PlaneTakeoff, RefreshCw, Sparkles, Star, Trash2 } from "lucide-react"; import { useEffect, useState, type ReactNode } from "react"; import { Badge } from "@/components/ui/badge"; @@ -279,6 +279,8 @@ interface Props { onRefresh?: (a: AccountMeta) => void; onSwitch?: (a: AccountMeta) => void; todayCheckedIn?: boolean; + /** 单账号参与许可,独立于全局开关;undefined 表示配置尚未加载。 */ + autoCheckinAllowed?: boolean; /** 今日旅行状态(undefined=查询中/未知,不渲染标签) */ travelStatus?: TravelStatus; /** 该账号当前受限的模型(来自本机日志台账);空/缺失=无受限,不渲染图标。 */ @@ -398,7 +400,7 @@ function CreditResourceRow({ resource, compact, placeholderLabel }: { resource?: ); } -export function AccountCard({ account, onDelete, onCheckin, onRefresh, onSwitch, todayCheckedIn, travelStatus, rateLimits, credit, creditLoading, creditUpdatedAt, creditPriority, workbuddyActive, codebuddyCliConfigured, codebuddyCliActive, codebuddyCliBusy, onSwitchCodebuddyCli, codebuddyCliLoading, codebuddyCnIdeAvailable, codebuddyCnIdeActive, codebuddyCnIdeBusy, codebuddyCnIdeLoading, onSwitchCodebuddyCnIde, vscodeExtInstalled, vscodeExtExtensionInstalled, vscodeExtAvailable, vscodeExtActive, vscodeExtBusy, vscodeExtLoading, onSwitchVscodeExt, featuresDisabled = true, compact = false }: Props) { +export function AccountCard({ account, onDelete, onCheckin, onRefresh, onSwitch, todayCheckedIn, autoCheckinAllowed, travelStatus, rateLimits, credit, creditLoading, creditUpdatedAt, creditPriority, workbuddyActive, codebuddyCliConfigured, codebuddyCliActive, codebuddyCliBusy, onSwitchCodebuddyCli, codebuddyCliLoading, codebuddyCnIdeAvailable, codebuddyCnIdeActive, codebuddyCnIdeBusy, codebuddyCnIdeLoading, onSwitchCodebuddyCnIde, vscodeExtInstalled, vscodeExtExtensionInstalled, vscodeExtAvailable, vscodeExtActive, vscodeExtBusy, vscodeExtLoading, onSwitchVscodeExt, featuresDisabled = true, compact = false }: Props) { const [resourcesOpen, setResourcesOpen] = useState(false); const [now, setNow] = useState(() => Date.now()); /** @@ -431,7 +433,14 @@ export function AccountCard({ account, onDelete, onCheckin, onRefresh, onSwitch, const statusChips = ( <> - {todayCheckedIn !== undefined && + {autoCheckinAllowed === false && + statusIconChip({ + icon: , + label: "自动签到已关闭", + tooltip: `${todayCheckedIn === undefined ? "" : todayCheckedIn ? "今日已签到。" : "今日未签到。"}该账号已关闭自动签到,刷新时也会忽略,仍可手动签到`, + variant: "secondary", + })} + {autoCheckinAllowed !== false && todayCheckedIn !== undefined && statusIconChip({ icon: todayCheckedIn ? ( @@ -526,7 +535,7 @@ export function AccountCard({ account, onDelete, onCheckin, onRefresh, onSwitch, onRefresh?.(account)}> 刷新 Token - {todayCheckedIn === false && ( + {onCheckin && todayCheckedIn !== true && ( onCheckin?.(account)}> 手动签到 diff --git a/src/lib/api.ts b/src/lib/api.ts index 6a6f4f7e..db415e20 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -468,44 +468,15 @@ export function revealAppInFinder(): Promise { export async function getCheckinStatus(accountId: string): Promise<{ ok: boolean; - todayCheckedIn: boolean; + todayCheckedIn?: boolean; + result?: string; + reason?: string; error?: string; raw?: unknown; /** 该行所属档位(档位取账号自身);缺省按国内版处理。 */ variant?: WbVariant; }> { - if (demoModeEnabled) { - return screenshotDemoResponse("get_checkin_status", { accountId }) as { - ok: boolean; - todayCheckedIn: boolean; - error?: string; - raw?: unknown; - }; - } - if (isWebui()) { - // webui 端为批量接口,按 accountId 过滤 - const all = await httpCall<{ - accounts: { - accountId: string; - email: string; - ok: boolean; - todayCheckedIn: boolean; - error?: string; - raw?: unknown; - variant?: WbVariant; - }[]; - }>("get_checkin_status"); - const one = all.accounts.find((a) => a.accountId === accountId); - return one - ? { - ok: one.ok, - todayCheckedIn: one.todayCheckedIn, - error: one.error, - raw: one.raw, - variant: one.variant, - } - : { ok: false, todayCheckedIn: false, error: "未找到账号" }; - } + // 两个宿主都只查询目标账号;Web 端不再为每个账号重复请求整份列表。 return call("get_checkin_status", { accountId }); } @@ -561,17 +532,18 @@ export function checkin(accountId: string): Promise { /** * 批量签到:不传档位时覆盖全部档位;显式传入时只处理该档位。 + * 刷新积分时传 respectAutoCheckin=true,跳过已关闭账号并返回逐账号原因;主动全部签到缺省不跳过。 * * 这里**不能**用 `variantArgs`:`checkin_all` 的缺省语义是「全部档位」,国内版若 * 缺省不传参,账号页在国内版 Tab 触发的批量签到会打到国际版账号。显式下发 `cn` * 与改造前等价(改造前账号库里只有国内版账号)。 */ -export function checkinAll(variant?: WbVariant): Promise<{ - accounts: { accountId: string; email: string; result: string; error?: string; inactive?: boolean }[]; +export function checkinAll(variant?: WbVariant, respectAutoCheckin = false): Promise<{ + accounts: { accountId: string; email: string; result: string; error?: string; inactive?: boolean; reason?: string }[]; status?: string; reason?: string; }> { - return call("checkin_all", variant ? { variant } : undefined); + return call("checkin_all", { ...(variant ? { variant } : {}), respectAutoCheckin }); } export function getAutoCheckinConfig(): Promise { diff --git a/src/lib/screenshot-demo.ts b/src/lib/screenshot-demo.ts index 7dd2edd1..f437b7d3 100644 --- a/src/lib/screenshot-demo.ts +++ b/src/lib/screenshot-demo.ts @@ -330,6 +330,7 @@ function buildStatistics(): CreditStatistics { function checkinConfig(): CheckinConfig { return { enabled: true, + excluded_account_ids: [], checkin_start: "", checkin_end: "", keepalive_days: 7, diff --git a/src/lib/types.ts b/src/lib/types.ts index 7811ccd5..2860c578 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -280,6 +280,8 @@ export interface SwitchResult { export interface CheckinConfig { enabled: boolean; + /** 关闭自动签到的账号 id;状态展示和刷新附带签到也跳过,主动手动签到不受影响。 */ + excluded_account_ids?: string[]; /** 签到时间段("HH:MM",本地时区);空串 = 不限制。两端都合法且 start < end 才生效。 */ checkin_start: string; checkin_end: string; diff --git a/src/pages/AccountsPage.tsx b/src/pages/AccountsPage.tsx index 416ba929..029b9e74 100644 --- a/src/pages/AccountsPage.tsx +++ b/src/pages/AccountsPage.tsx @@ -56,7 +56,7 @@ import { variantSupportsTravel, variantUsesIntlCodebuddyIde, } from "@/lib/variant"; -import type { AccountMeta, AppStatus, CodeBuddyCliStatus, CodeBuddyCnIdeStatus, CreditExpiry, RateLimitEntry, TravelConfig, TravelStatus, VscodeExtStatus } from "@/lib/types"; +import type { AccountMeta, AppStatus, CheckinConfig, CodeBuddyCliStatus, CodeBuddyCnIdeStatus, CreditExpiry, RateLimitEntry, TravelConfig, TravelStatus, VscodeExtStatus } from "@/lib/types"; import { cn } from "@/lib/utils"; import { useAccountsStore } from "@/stores/accounts"; @@ -113,7 +113,7 @@ async function fetchTodayCheckinMap( accountIds.map(async (id) => { try { const res = await api.getCheckinStatus(id); - if (isStale?.() || !res.ok) return null; + if (isStale?.() || !res.ok || typeof res.todayCheckedIn !== "boolean") return null; return [id, res.todayCheckedIn] as const; } catch { return null; @@ -173,6 +173,13 @@ export default function AccountsPage() { const [importOpen, setImportOpen] = useState(false); const [switchAccount, setSwitchAccount] = useState(null); const [importing, setImporting] = useState(false); + /** + * 自动签到配置(只读):只用于决定账号卡片是否展示「自动签到已关闭」chip, + * 以及状态查询、刷新时跳过哪些账号。控制入口在设置页。 + */ + const [autoCheckinConfig, setAutoCheckinConfig] = useState(null); + /** 配置是否已读取完毕(成功或失败):区分「尚未读到」与「读取失败」。 */ + const [autoCheckinSettled, setAutoCheckinSettled] = useState(false); /** 账号 id -> 今日是否已签到(undefined=查询中/未知) */ const [checkinMap, setCheckinMap] = useState>({}); /** 账号 id -> 今日旅行状态(undefined=查询中/未知) */ @@ -216,7 +223,19 @@ export default function AccountsPage() { /** 旅行 chip 与旅行状态轮询只在自动旅行开启后生效(配置未读到 = 未开启)。 */ const autoTravelEnabled = travelAvailable && autoTravelConfig?.enabled === true; /** 刷新按钮文案:国际版没有签到接口,只刷新积分。 */ - const refreshCreditsLabel = checkinAvailable ? "签到并刷新全部账号积分" : "刷新全部账号积分"; + const refreshCreditsLabel = checkinAvailable ? "刷新全部账号积分并签到(忽略已关闭自动签到的账号)" : "刷新全部账号积分"; + /** 关闭自动签到的账号 id(配置未读到/读取失败 = 空名单)。 */ + const excludedCheckinIds = useMemo( + () => new Set(autoCheckinConfig?.excluded_account_ids ?? []), + [autoCheckinConfig], + ); + /** 今日签到状态只查未关闭自动签到的账号;配置就绪前不发请求。 */ + const autoCheckinAccountIds = useMemo(() => { + if (!checkinAvailable || !autoCheckinSettled) return []; + return visibleAccounts + .filter((account) => !excludedCheckinIds.has(account.id)) + .map((account) => account.id); + }, [visibleAccounts, checkinAvailable, autoCheckinSettled, excludedCheckinIds]); /** 紧凑模式:卡片更小、同屏更多列;默认开启,持久化到 localStorage */ const [compact, setCompact] = useState(() => { try { @@ -242,6 +261,25 @@ export default function AccountsPage() { void fetchAll(); }, [fetchAll]); + // 自动签到配置(只读):读取失败静默回落为空名单(照常查询状态),不打扰用户。 + useEffect(() => { + let cancelled = false; + void api + .getAutoCheckinConfig() + .then((config) => { + if (!cancelled) setAutoCheckinConfig(config); + }) + .catch(() => { + /* 配置读取失败:按空名单处理 */ + }) + .finally(() => { + if (!cancelled) setAutoCheckinSettled(true); + }); + return () => { + cancelled = true; + }; + }, []); + /** * 首次启动自动导入本机账号(本会话只尝试一次,无本机账号时静默)。 * 仅限默认档位:切到国际版时不静默写入账号,改由空状态引导显式导入或浏览器授权登录。 @@ -317,13 +355,12 @@ export default function AccountsPage() { }; }, [accounts.length, variant]); - // 当前档位账号列表变化后并行查询各账号今日签到状态 - // 国际版没有签到接口:不查询状态(后端也不发请求)。 + // 配置就绪后查询未关闭自动签到的账号;国际版没有签到接口,不查询状态。 useEffect(() => { - if (!visibleAccounts.length || !checkinAvailable) return; + if (!autoCheckinAccountIds.length) return; let cancelled = false; void fetchTodayCheckinMap( - visibleAccounts.map((account) => account.id), + autoCheckinAccountIds, () => cancelled, ).then((next) => { if (!cancelled && Object.keys(next).length > 0) { @@ -333,7 +370,7 @@ export default function AccountsPage() { return () => { cancelled = true; }; - }, [visibleAccounts, checkinAvailable]); + }, [autoCheckinAccountIds]); // 自动旅行配置(只读):只用于决定账号卡片是否展示旅行 chip、是否轮询旅行状态。 // 读取失败静默按未开启处理(不展示 chip、不发状态请求),不打扰用户。 @@ -507,12 +544,9 @@ export default function AccountsPage() { const description = `${a.nickname || a.email || a.id}${res.error ? `:${res.error}` : ""}`; if (res.result === "error") toast.error(label, { description }); else toast.success(label, { description }); - // 刷新该账号的今日签到状态 - try { - const st = await api.getCheckinStatus(a.id); - if (st.ok) setCheckinMap((prev) => ({ ...prev, [a.id]: st.todayCheckedIn })); - } catch { - /* ignore */ + // 手动签到已完成状态核验,直接使用回执,避免为已关闭账号再触发展示查询。 + if (res.result === "success" || res.result === "already") { + setCheckinMap((prev) => ({ ...prev, [a.id]: true })); } void fetchAll(); // 签到成功/已签到会带来积分变动,force 刷新该账号积分 @@ -537,59 +571,60 @@ export default function AccountsPage() { } } - /** - * 当前档位的批量签到:后端一次调用完成(保留并发保护),只处理支持签到的 - * 账号;国际版没有签到接口,不参与批量签到。 - */ - async function runBatchCheckin() { - const res = await api.checkinAll(variant); - return res.accounts ?? []; - } - - /** - * 刷新按钮:先跑一轮当前档位的批量签到并重查今日签到状态,再强制刷新全部积分。 - * 国际版没有签到接口:跳过整块签到逻辑,只刷新积分。 - */ + /** 刷新附带的签到遵守账号开关;所有账号照常刷新积分,提示实际忽略数量。 */ async function onRefreshCredits() { if (!visibleAccounts.length || refreshingCredits || checkinAllRunning) return; setCheckinAllRunning(true); const ids = visibleAccounts.map((account) => account.id); + let summary = ""; + let notify = toast.success; + let title = "积分到期情况已刷新"; try { if (checkinAvailable) { try { - const entries = await runBatchCheckin(); + const res = await api.checkinAll(variant, true); + const entries = res.accounts ?? []; const success = entries.filter((e) => e.result === "success").length; const already = entries.filter((e) => e.result === "already").length; const failed = entries.filter((e) => e.result === "error").length; const inactive = entries.filter((e) => e.inactive === true || e.result === "inactive").length; + const skipped = entries.filter((e) => e.result === "skipped" && e.reason === "auto_checkin_disabled").length; const parts: string[] = []; if (success > 0) parts.push(`${success} 个签到成功`); if (already > 0) parts.push(`${already} 个已签到`); if (inactive > 0) parts.push(`${inactive} 个未开放签到`); if (failed > 0) parts.push(`${failed} 个失败`); - const summary = parts.length > 0 ? parts.join(",") : "无账号需要签到"; - const counted = success + already + failed; - if (failed > 0 && counted === failed) { - toast.error("签到失败", { description: summary }); - } else if (counted === 0 && inactive > 0) { - // 全部是 inactive(官方未开放签到活动):既不算成功也不算失败, - // 不得呈现为绿色成功(design D8)。 - toast.info("签到未开放", { description: summary }); - } else { - toast.success("签到完成", { description: summary }); + if (skipped > 0) parts.push(`已忽略 ${skipped} 个关闭自动签到的账号`); + summary = res.status === "skipped" && res.reason === "already_running" + ? "签到任务正在进行,本次仅刷新积分" + : parts.length > 0 ? parts.join(",") : "无账号需要签到"; + const allFailed = entries.length > 0 && failed === entries.length; + const allSkippedOrInactive = + res.status === "skipped" || + entries.length === 0 || + (success === 0 && already === 0 && failed === 0); + if (allFailed) { + // 全部失败:没有成功、已签、未开放或忽略的账号。 + notify = toast.error; + title = "积分已刷新,签到出现错误"; + } else if (allSkippedOrInactive) { + // 全部被跳过或官方未开放签到活动:既不算成功也不算失败,不呈现为绿色成功。 + notify = toast.info; } - // 批量签到后重查当前档位账号的今日签到状态,无需切换页面即反映最新结果 - const next = await fetchTodayCheckinMap(ids); + // 只重查实际处理过的账号状态;被跳过的账号本次未发请求,状态保持未知。 + const next = await fetchTodayCheckinMap(entries.filter((e) => e.result !== "skipped").map((e) => e.accountId)); if (Object.keys(next).length > 0) { setCheckinMap((prev) => ({ ...prev, ...next })); } } catch (e) { - toast.error("批量签到失败", { description: api.asError(e) }); + notify = toast.error; + title = "积分已刷新,签到出现错误"; + summary = api.asError(e); } } await refreshCredits(ids); if (autoTravelEnabled) await loadTravelMap(ids); - toast.success("积分到期情况已刷新"); + notify(title, { description: summary || undefined }); } finally { setCheckinAllRunning(false); } @@ -976,9 +1011,10 @@ export default function AccountsPage() { compact={compact} onDelete={onDelete} onSwitch={setSwitchAccount} - onCheckin={onCheckin} + onCheckin={checkinAvailable ? onCheckin : undefined} onRefresh={onRefresh} todayCheckedIn={checkinMap[a.id]} + autoCheckinAllowed={checkinAvailable && autoCheckinSettled ? !excludedCheckinIds.has(a.id) : undefined} travelStatus={autoTravelEnabled ? travelMap[a.id] : undefined} rateLimits={rateLimitEnabled ? rateLimitMap[a.id] : undefined} credit={creditMap[a.id]} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 59e5fa44..bd6f0d9c 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -13,6 +13,7 @@ import { TimePicker } from "@/components/ui/time-picker"; import * as api from "@/lib/api"; import { getThemePreference, setThemePreference, type ThemePreference } from "@/lib/theme"; import type { + AccountMeta, AppNotification, AutoRotateConfig, CheckinConfig, @@ -27,7 +28,7 @@ import type { } from "@/lib/types"; import { GITHUB_RELEASE_URL, GITHUB_REPOSITORY_URL, openReleaseUrl } from "@/lib/update"; import { cn } from "@/lib/utils"; -import { variantSupportsTravel } from "@/lib/variant"; +import { accountVariant, variantSupportsCheckin, variantSupportsTravel } from "@/lib/variant"; import { UpdateInstallDialog } from "@/components/update-install-dialog"; import { DemoAction } from "@/components/demo-action"; import { useAccountsStore } from "@/stores/accounts"; @@ -109,6 +110,8 @@ interface CollapsibleSettingsRowProps { onToggle: () => void; /** 展开/收起之外的附加操作(如通知历史的「清空」)。 */ actions?: ReactNode; + /** 位于卡片中段的行保留收起态分隔线;末行(默认)不需要。 */ + divider?: boolean; children: ReactNode; } @@ -125,12 +128,13 @@ function CollapsibleSettingsRow({ open, onToggle, actions, + divider = false, children, }: CollapsibleSettingsRowProps) { return ( <> @@ -198,9 +202,16 @@ function checkinWindowIssue(start: string, end: string): string | null { function AutoCheckinCard() { /** 自动旅行与自动签到同卡,按档位决定是否渲染该行(国际版无成长中心)。 */ const variant = useAccountsStore((s) => s.variant); + const accounts = useAccountsStore((s) => s.accounts); + const fetchAll = useAccountsStore((s) => s.fetchAll); const [cfg, setCfg] = useState(null); + /** 最近一次落盘的配置:逐账号开关只提交它 + 新名单,不带上未保存的参数草稿。 */ + const [savedCfg, setSavedCfg] = useState(null); /** 参数区(时间段 / 保活阈值 / 惰性刷新)默认收起;开关行与操作行常驻。 */ const [expanded, setExpanded] = useState(false); + /** 逐账号开关列表默认收起。 */ + const [excludedOpen, setExcludedOpen] = useState(false); + const [excludedSaving, setExcludedSaving] = useState(false); const [logsOpen, setLogsOpen] = useState(false); const [logs, setLogs] = useState(null); const [logsError, setLogsError] = useState(""); @@ -212,6 +223,11 @@ function AutoCheckinCard() { void loadConfig(); }, []); + // 账号列表来自全局 store:为空时补拉一次(只取状态与账号,不拉积分)。 + useEffect(() => { + if (accounts.length === 0) void fetchAll(); + }, [accounts.length, fetchAll]); + // 日志懒加载:展开时才请求,每次展开重新拉取;收起态无可见列表,不请求。 useEffect(() => { if (!logsOpen) return; @@ -224,7 +240,9 @@ function AutoCheckinCard() { async function loadConfig() { try { - setCfg(await api.getAutoCheckinConfig()); + const loaded = await api.getAutoCheckinConfig(); + setCfg(loaded); + setSavedCfg(loaded); } catch (e) { setMsg({ type: "err", text: api.asError(e) }); } @@ -243,12 +261,13 @@ function AutoCheckinCard() { } async function save() { - if (!cfg) return; + if (!cfg || saving || excludedSaving) return; setSaving(true); setMsg(null); try { const saved = await api.saveAutoCheckinConfig(cfg); setCfg(saved); + setSavedCfg(saved); setMsg({ type: "ok", text: "配置已保存" }); } catch (e) { setMsg({ type: "err", text: api.asError(e) }); @@ -258,6 +277,7 @@ function AutoCheckinCard() { } async function checkinAllNow() { + if (busy || saving || excludedSaving) return; setBusy(true); setMsg(null); try { @@ -286,11 +306,46 @@ function AutoCheckinCard() { } } + /** + * 逐账号开关:切换即落盘,提交「上次落盘的配置 + 新名单」。 + * + * 不带上当前草稿的时间段/阈值,避免把未保存的参数一起写盘;成功后只回填名单, + * 失败则回滚开关状态并提示。 + */ + async function onAutoCheckinChange(account: AccountMeta, allowed: boolean) { + if (!savedCfg || saving || excludedSaving) return; + const previous = savedCfg; + const excluded = new Set(previous.excluded_account_ids ?? []); + if (allowed) excluded.delete(account.id); + else excluded.add(account.id); + const next = [...excluded]; + setCfg((current) => (current ? { ...current, excluded_account_ids: next } : current)); + setMsg(null); + setExcludedSaving(true); + try { + const saved = await api.saveAutoCheckinConfig({ ...previous, excluded_account_ids: next }); + setSavedCfg(saved); + setCfg((current) => + current ? { ...current, excluded_account_ids: saved.excluded_account_ids ?? [] } : current, + ); + } catch (e) { + setCfg((current) => + current ? { ...current, excluded_account_ids: previous.excluded_account_ids ?? [] } : current, + ); + toast.error("账号自动签到设置保存失败", { description: api.asError(e) }); + } finally { + setExcludedSaving(false); + } + } + function setNum(key: keyof CheckinConfig, value: string) { if (!cfg) return; setCfg({ ...cfg, [key]: Number(value) }); } + /** 逐账号开关只列出支持签到的档位(国际版签到接口未开放)。 */ + const checkinAccounts = accounts.filter((account) => variantSupportsCheckin(accountVariant(account))); + const excludedIds = new Set(cfg?.excluded_account_ids ?? []); const windowIssue = cfg ? checkinWindowIssue(cfg.checkin_start, cfg.checkin_end) : null; return ( @@ -303,7 +358,7 @@ function AutoCheckinCard() { <>
@@ -321,6 +376,43 @@ function AutoCheckinCard() {
+ {/* 逐账号开关:切换即落盘,与同卡片「保存配置」的参数草稿互不影响。 */} + setExcludedOpen((value) => !value)} + divider + > + {checkinAccounts.length === 0 ? ( +

暂无可签到的账号

+ ) : ( +
+ {checkinAccounts.map((account) => { + const name = account.nickname || account.email || account.uid || account.id; + return ( +
+ + {name} + + + void onAutoCheckinChange(account, allowed)} + aria-label={`${name}参与自动签到`} + /> + +
+ ); + })} +
+ )} +
+ {expanded && ( <> - -