Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 111 additions & 4 deletions crates/wb-switch-core/src/modules/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ pub fn account_meta(acc: &Value) -> Value {
"refreshExpiresAt": display_value(acc, "refreshExpiresAt"),
"refreshedAt": display_value(acc, "refreshedAt"),
"createdAt": display_value(acc, "createdAt"),
"lastUsedAt": display_value(acc, "lastUsedAt"),
"lastUsedClient": display_value(acc, "lastUsedClient"),
"needsRelogin": acc.get("needs_relogin").and_then(|v| v.as_bool()) == Some(true),
"needsReloginReason": display_value(acc, "needs_relogin_reason"),
// 企业版标记(issue #167):读取时判定,供前端展示「企业版」徽标,历史数据零迁移。
Expand Down Expand Up @@ -276,8 +278,8 @@ pub fn upsert_collected_account(accounts: &mut Vec<Value>, mut collected: Value)
}
}
}
// 本地展示字段(备注 / 显示选择)不来自官方采集:采集结果缺失时保留已有值。
for key in ["note", "displayField"] {
// 本地字段(备注 / 显示选择 / 最后使用)不来自官方采集:采集结果缺失时保留已有值。
for key in ["note", "displayField", "lastUsedAt", "lastUsedClient"] {
if collected.get(key).is_none() {
if let Some(v) = existing.get(key) {
collected[key] = v.clone();
Expand Down Expand Up @@ -397,6 +399,52 @@ pub fn update_account_display_in(
Ok(accounts[index].clone())
}

/// 「最后使用」记账的来源端标识,取值与前端 `ToolId`(`src/lib/supported-tools.ts`)一致。
pub const USED_CLIENT_WORKBUDDY: &str = "workbuddy";
pub const USED_CLIENT_CODEBUDDY_CLI: &str = "codebuddyCli";
pub const USED_CLIENT_CODEBUDDY_IDE: &str = "codebuddyIde";
pub const USED_CLIENT_VSCODE_EXT: &str = "vscodeExt";
pub const USED_CLIENT_JETBRAINS: &str = "jetbrains";

/// 记录一次「该账号被切为某端的当前账号」:写入 `lastUsedAt`(毫秒)与 `lastUsedClient`。
///
/// 只补丁这两个本地字段,不触碰 token 等官方数据。由各端的切换收尾调用,
/// 此时切换已经生效,记账失败只影响「最后使用」展示,调用方忽略返回值即可。
pub fn mark_used(account_id: &str, client: &str) -> std::io::Result<()> {
let mut accounts = load_accounts();
if !mark_used_in(&mut accounts, account_id, client, now_ms()) {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"账号不存在",
));
}
save_accounts(&accounts)
}

/// 「最后使用」记账的内存实现(不落盘,便于单测)。返回是否命中账号。
pub fn mark_used_in(
accounts: &mut [Value],
account_id: &str,
client: &str,
timestamp: i64,
) -> bool {
let index = accounts
.iter()
.position(|a| get_str(a, "id").as_deref() == Some(account_id));
let Some(index) = index else {
return false;
};
let Some(obj) = accounts[index].as_object_mut() else {
return false;
};
obj.insert("lastUsedAt".to_string(), json!(timestamp));
obj.insert(
"lastUsedClient".to_string(),
Value::String(client.to_string()),
);
true
}

/// 覆盖写入的内存实现:按 id 覆盖;id 缺失或未命中时回退按 uid 收敛到已有
/// 记录;仍无归属才追加,且追加前补 id。
///
Expand Down Expand Up @@ -425,9 +473,9 @@ fn upsert_account_in(accounts: &mut Vec<Value>, updated: &Value) {
if let Some(index) = matched_index {
let mut next = updated.clone();
inherit_existing_variant(&accounts[index], &mut next);
// 本地展示字段(备注 / 显示选择)不从官方数据来:updated 缺失时保留已有值。
// 本地字段(备注 / 显示选择 / 最后使用)不从官方数据来:updated 缺失时保留已有值。
if let Some(obj) = next.as_object_mut() {
for key in ["note", "displayField"] {
for key in ["note", "displayField", "lastUsedAt", "lastUsedClient"] {
if !obj.contains_key(key) {
if let Some(v) = accounts[index].get(key) {
obj.insert(key.to_string(), v.clone());
Expand Down Expand Up @@ -1179,6 +1227,65 @@ mod tests {
assert_eq!(accounts[0]["note"], "工作号");
assert_eq!(accounts[0]["displayField"], "phone");
}

// -----------------------------------------------------------------------
// 最后使用记账
// -----------------------------------------------------------------------

#[test]
fn mark_used_records_timestamp_and_client() {
let mut accounts = vec![account("a-1", Some("uid-1"), "小明", None)];

assert!(mark_used_in(
&mut accounts,
"a-1",
USED_CLIENT_WORKBUDDY,
1_700_000_000_000
));
assert_eq!(accounts[0]["lastUsedAt"], 1_700_000_000_000_i64);
assert_eq!(accounts[0]["lastUsedClient"], "workbuddy");
let meta = account_meta(&accounts[0]);
assert_eq!(meta["lastUsedAt"], 1_700_000_000_000_i64);
assert_eq!(meta["lastUsedClient"], "workbuddy");

// 再次切到另一端:时间与来源端一起更新,其余字段不动。
assert!(mark_used_in(
&mut accounts,
"a-1",
USED_CLIENT_VSCODE_EXT,
1_700_000_009_999
));
assert_eq!(accounts[0]["lastUsedClient"], "vscodeExt");
assert_eq!(accounts[0]["nickname"], "小明");

assert!(
!mark_used_in(&mut accounts, "missing", USED_CLIENT_WORKBUDDY, 1),
"未知账号不记账"
);
}

#[test]
fn account_meta_without_last_used_is_null() {
let meta = account_meta(&account("a-1", Some("uid-1"), "小明", None));
assert!(meta["lastUsedAt"].is_null());
assert!(meta["lastUsedClient"].is_null());
}

/// 扫码重登 / 本机导入重建的记录不得丢「最后使用」。
#[test]
fn collected_upsert_preserves_last_used() {
let mut existing = account("a-1", Some("uid-1"), "小明", None);
existing["lastUsedAt"] = json!(1_700_000_000_000_i64);
existing["lastUsedClient"] = json!("workbuddy");
let mut accounts = vec![existing];

let saved = upsert_collected_account(
&mut accounts,
account("a-1", Some("uid-1"), "小明(官方更新)", None),
);
assert_eq!(saved["lastUsedAt"], 1_700_000_000_000_i64);
assert_eq!(saved["lastUsedClient"], "workbuddy");
}
}

// 手动添加账号(token 方式)已随 UI 入口「手动添加」一并下线;
Expand Down
3 changes: 3 additions & 0 deletions crates/wb-switch-core/src/modules/codebuddy_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1504,6 +1504,9 @@ pub fn switch_active_account(account_id: &str) -> Result<Value, String> {
}
}

// CLI 默认账号已改写成功,最后使用记账失败只影响展示,不再回头报错。
let _ = account::mark_used(&canonical_id, account::USED_CLIENT_CODEBUDDY_CLI);

let region_changed = previous_variant != Some(variant);

Ok(json!({
Expand Down
4 changes: 4 additions & 0 deletions crates/wb-switch-core/src/modules/codebuddy_cn_ide.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1260,6 +1260,10 @@ pub(crate) fn inject_session_and_finish(
})?;

set_active_account_id(account_id)?;
// 凭证已注入成功,最后使用记账失败只影响展示,不再回头报错。
if let Some(id) = get_str(acc, "id") {
let _ = account::mark_used(&id, account::USED_CLIENT_CODEBUDDY_IDE);
}

if restart {
eprintln!("[codebuddy-cn-ide] launching CodeBuddy CN…");
Expand Down
4 changes: 4 additions & 0 deletions crates/wb-switch-core/src/modules/codebuddy_ide.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,10 @@ pub(crate) fn inject_session_and_finish(
})?;

set_active_account_id(account_id)?;
// 凭证已注入成功,最后使用记账失败只影响展示,不再回头报错。
if let Some(id) = get_str(acc, "id") {
let _ = account::mark_used(&id, account::USED_CLIENT_CODEBUDDY_IDE);
}

if restart {
eprintln!("[codebuddy-ide] launching CodeBuddy…");
Expand Down
4 changes: 4 additions & 0 deletions crates/wb-switch-core/src/modules/jetbrains.rs
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,10 @@ fn switch_after_close_inner(
}

set_active_account_id(account_id)?;
// 凭证已注入成功,最后使用记账失败只影响展示,不再回头报错。
if let Some(id) = get_str(&acc, "id") {
let _ = account::mark_used(&id, account::USED_CLIENT_JETBRAINS);
}

let mut restarted = false;
let mut relaunch_error: Option<String> = None;
Expand Down
4 changes: 4 additions & 0 deletions crates/wb-switch-core/src/modules/switch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,10 @@ pub fn switch_account(
launch_workbuddy(variant, Some(&progress))?;
}
progress("切换完成");
// 切换已生效,最后使用记账失败只影响展示,不再回头报错。
if let Some(id) = account::get_str(&acc, "id") {
let _ = account::mark_used(&id, account::USED_CLIENT_WORKBUDDY);
}

let mut result = json!({
"ok": true,
Expand Down
4 changes: 4 additions & 0 deletions crates/wb-switch-core/src/modules/vscode_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,10 @@ fn switch_account_after_close_inner(
.map_err(describe_inject_error)?;

set_active_account_id(account_id)?;
// 凭证已注入成功,最后使用记账失败只影响展示,不再回头报错。
if let Some(id) = get_str(&acc, "id") {
let _ = account::mark_used(&id, account::USED_CLIENT_VSCODE_EXT);
}

let mut restarted = false;
let mut relaunch_error: Option<String> = None;
Expand Down
24 changes: 22 additions & 2 deletions src/components/account-card.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ArrowRight, CalendarCheck2, CalendarDays, CalendarOff, Check, CircleCheck, Clock3, Coins, Ellipsis, Gauge, Info, Loader2, PackageOpen, PlaneTakeoff, RefreshCw, Sparkles, Star, Trash2 } from "lucide-react";
import { ArrowRight, CalendarCheck2, CalendarDays, CalendarOff, Check, CircleCheck, Clock3, Coins, Ellipsis, Gauge, History, Info, Loader2, PackageOpen, PlaneTakeoff, RefreshCw, Sparkles, Star, Trash2 } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";

import { Badge } from "@/components/ui/badge";
Expand All @@ -18,6 +18,7 @@ import type { ToolId } from "@/lib/supported-tools";
import { cn } from "@/lib/utils";
import { accountIdentity, displayName } from "@/lib/account-display";
import { avatarTone } from "@/lib/avatar-tone";
import { formatRelativeTime, usedClientLabel } from "@/lib/account-order";
import { creditResourceName } from "@/lib/credit-package-names";
import { demoModeEnabled } from "@/lib/demo-mode";
import type { AccountMeta, CreditExpiry, CreditResource, RateLimitEntry, TravelStatus } from "@/lib/types";
Expand Down Expand Up @@ -57,6 +58,13 @@ function formatCreditUpdatedAt(ts: number | undefined): string {
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
}

/** 「最后使用」提示文案:来源端 + 相对时间(括号内为精确时刻)。 */
function formatLastUsedTooltip(account: AccountMeta, now: number): string {
if (!account.lastUsedAt) return "";
const client = usedClientLabel(account.lastUsedClient);
return `最近切换${client ? ` · ${client}` : ""} · ${formatRelativeTime(account.lastUsedAt, now)}(${formatFullDateTime(account.lastUsedAt)})`;
}

function expiryClass(expired: boolean, expiringSoon: boolean): string {
if (expired) return "text-destructive";
if (expiringSoon) return "text-orange-600";
Expand Down Expand Up @@ -278,6 +286,8 @@ interface Props {
/** 该账号积分最近一次查询完成时间(时间戳) */
creditUpdatedAt?: number;
creditPriority?: boolean;
/** 当前档位内「最近切换」的账号:列表里只有这一个渲染标记,用于回答「刚才切了哪个」。 */
mostRecentlyUsed?: boolean;
workbuddyActive?: boolean;
codebuddyCliConfigured?: boolean;
codebuddyCliActive?: boolean;
Expand Down Expand Up @@ -409,7 +419,7 @@ function CreditResourceRow({ resource, compact, placeholderLabel }: { resource?:
);
}

export function AccountCard({ account, onDelete, onCheckin, onRefresh, onShowInfo, 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, jetbrainsInstalled, jetbrainsPluginInstalled, jetbrainsAvailable, jetbrainsActive, jetbrainsBusy, jetbrainsLoading, onSwitchJetbrains, enabledTools, featuresDisabled = true, compact = false }: Props) {
export function AccountCard({ account, onDelete, onCheckin, onRefresh, onShowInfo, onSwitch, todayCheckedIn, autoCheckinAllowed, travelStatus, rateLimits, credit, creditLoading, creditUpdatedAt, creditPriority, mostRecentlyUsed, workbuddyActive, codebuddyCliConfigured, codebuddyCliActive, codebuddyCliBusy, onSwitchCodebuddyCli, codebuddyCliLoading, codebuddyCnIdeAvailable, codebuddyCnIdeActive, codebuddyCnIdeBusy, codebuddyCnIdeLoading, onSwitchCodebuddyCnIde, vscodeExtInstalled, vscodeExtExtensionInstalled, vscodeExtAvailable, vscodeExtActive, vscodeExtBusy, vscodeExtLoading, onSwitchVscodeExt, jetbrainsInstalled, jetbrainsPluginInstalled, jetbrainsAvailable, jetbrainsActive, jetbrainsBusy, jetbrainsLoading, onSwitchJetbrains, enabledTools, featuresDisabled = true, compact = false }: Props) {
/** 支持工具开关:关闭的端整块不渲染(缺省视为开启)。 */
const toolEnabled = (id: ToolId) => enabledTools?.[id] !== false;
const [resourcesOpen, setResourcesOpen] = useState(false);
Expand Down Expand Up @@ -478,6 +488,16 @@ export function AccountCard({ account, onDelete, onCheckin, onRefresh, onShowInf
<TooltipContent side="top">建议优先使用</TooltipContent>
</Tooltip>
)}
{mostRecentlyUsed && account.lastUsedAt && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className={cn(chipClass, "px-1")} aria-label="最近切换">
<History className="size-3.5" />
</Badge>
</TooltipTrigger>
<TooltipContent side="top">{formatLastUsedTooltip(account, now)}</TooltipContent>
</Tooltip>
)}
{!compact && activeProductCount >= 2 && <Badge variant="secondary" className={cn(chipClass, "text-muted-foreground")}>{activeProductCount} 个工具正在使用</Badge>}
</>
);
Expand Down
34 changes: 32 additions & 2 deletions src/components/account-info-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import * as api from "@/lib/api";
import { accountIdentity } from "@/lib/account-display";
import { formatRelativeTime, usedClientLabel } from "@/lib/account-order";
import { avatarTone } from "@/lib/avatar-tone";
import { cn } from "@/lib/utils";
import { accountVariant, variantLabel } from "@/lib/variant";
Expand All @@ -40,6 +41,26 @@ interface Props {
onSaved?: (account: AccountMeta) => void;
}

/**
* 「最后使用」行文案:相对时间 + 精确时刻 + 来源端;从未切换给出明确说明。
* 只统计通过本应用发起的切换,在客户端里自行登录/登出不计入。
*/
function lastUsedText(account: AccountMeta): string {
const at = account.lastUsedAt;
if (!at) return "从未切换";
const date = new Date(at);
const absolute = Number.isNaN(date.getTime())
? "—"
: date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
const client = usedClientLabel(account.lastUsedClient);
return [`${formatRelativeTime(at, Date.now())}`, `${absolute}`, client].filter(Boolean).join(" · ");
}

/**
* 账号信息弹框:查看账号信息、编辑本地备注、选择卡片显示字段。
* 备注与显示字段只保存在本机账号库,不影响官方登录数据。
Expand Down Expand Up @@ -151,13 +172,13 @@ export function AccountInfoDialog({ open, onOpenChange, account, onSaved }: Prop
{/* 次级字段:企业名与 UID(等宽 + 一键复制)。 */}
<dl className="divide-y divide-border/70 rounded-lg border text-xs">
<div className="flex items-center gap-3 px-3 py-2.5">
<dt className="w-12 shrink-0 text-muted-foreground">企业名</dt>
<dt className="w-14 shrink-0 text-muted-foreground">企业名</dt>
<dd className="min-w-0 flex-1 truncate font-medium" title={current.enterpriseName ?? ""}>
{current.enterpriseName || "—"}
</dd>
</div>
<div className="flex items-center gap-3 px-3 py-2.5">
<dt className="w-12 shrink-0 text-muted-foreground">UID</dt>
<dt className="w-14 shrink-0 text-muted-foreground">UID</dt>
<dd
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground"
title={uid ?? ""}
Expand All @@ -178,6 +199,15 @@ export function AccountInfoDialog({ open, onOpenChange, account, onSaved }: Prop
</div>
</dl>

{/* 最后使用:只记本应用触发的切换,供「刚才切了哪个」定位(时间 + 来源端)。 */}
<div className="rounded-lg border px-3 py-2.5 text-xs">
<div className="flex items-center gap-3">
<span className="w-14 shrink-0 text-muted-foreground">最后使用</span>
<span className="min-w-0 flex-1 truncate font-medium" title={lastUsedText(current)}>
{lastUsedText(current)}
</span>
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-baseline justify-between">
<Label htmlFor="account-note" className="text-xs font-medium text-muted-foreground">
Expand Down
Loading