From c3feb7477a5c929a776502a5d08414117b344856 Mon Sep 17 00:00:00 2001 From: liangxu Date: Mon, 27 Apr 2026 21:33:55 +0800 Subject: [PATCH] feat(desktop-client): queue account probes and report health to server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace serial probe-on-lock with a bounded probe queue (concurrency 3) that adds a "queued" probe state, manual cooldown, and retry backoff. After each probe the runtime debounces a batched POST to the new /desktop/accounts/health-reports endpoint so the server learns about live, expired, and risk transitions without waiting for a re-bind. Adds an account-scoped IPC (probeRuntimeAccount + runtimeAccountSnapshot) so the renderer can refresh a single row instead of replaying the full snapshot, and exposes the resulting "重新校验" action in AccountsView/AiPlatformsView. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../desktop-client/src/main/account-health.ts | 213 ++++++++++++++++-- apps/desktop-client/src/main/auth-types.ts | 1 + apps/desktop-client/src/main/bootstrap.ts | 14 +- .../src/main/runtime-controller.ts | 213 ++++++++++++++---- .../desktop-client/src/main/runtime-events.ts | 8 +- .../src/main/runtime-snapshot.ts | 122 ++++++---- .../src/main/transport/api-client.ts | 99 +++++++- apps/desktop-client/src/preload/bridge.ts | 8 +- .../renderer/composables/useDesktopRuntime.ts | 122 +++++++++- apps/desktop-client/src/renderer/env.d.ts | 4 +- .../src/renderer/lib/client-errors.ts | 14 +- apps/desktop-client/src/renderer/types.ts | 2 +- .../src/renderer/views/AccountsView.vue | 83 ++++++- .../src/renderer/views/AiPlatformsView.vue | 13 +- packages/shared-types/src/index.ts | 30 +++ 15 files changed, 817 insertions(+), 129 deletions(-) diff --git a/apps/desktop-client/src/main/account-health.ts b/apps/desktop-client/src/main/account-health.ts index 8bc9a47..54d8d75 100644 --- a/apps/desktop-client/src/main/account-health.ts +++ b/apps/desktop-client/src/main/account-health.ts @@ -30,6 +30,12 @@ const CONFIRM_BACKOFF_STEPS_MS = [30_000, 120_000] as const; const NETWORK_RETRY_MS = 2 * 60_000; const STALE_AFTER_MS = 45 * 60_000; const PREFLIGHT_MAX_AGE_MS = 15 * 60_000; +const MAX_CONCURRENT_ACCOUNT_PROBES = 3; +const MANUAL_PROBE_CACHE_TTL_MS = 5 * 60_000; +const MANUAL_PROBE_MIN_INTERVAL_MS = 15_000; +const ACCOUNT_PROBE_RETRY_BACKOFF_MS = [1_000, 3_000, 10_000] as const; + +type ProbeTrigger = "scheduled" | "manual" | "preflight" | "confirm"; interface PersistedAccountHealthRecord { accountId: string; @@ -56,11 +62,27 @@ interface AccountHealthRecord { confirmFailureCount: number; } +interface AccountProbeJob { + account: PublishAccountIdentity; + trigger: ProbeTrigger; + allowSilentRefresh: boolean; + force: boolean; + status: "queued" | "running"; + attempt: number; + maxAttempts: number; + resolve: (snapshot: AccountHealthSnapshot) => void; + reject: (error: unknown) => void; + promise: Promise; +} + const trackedAccounts = new Map(); const records = new Map(); const accountLocks = new Map>(); +const probeJobs = new Map(); +const probeQueue: string[] = []; let initialized = false; let probeTimer: ReturnType | null = null; +let activeProbeCount = 0; function randomInt(min: number, max: number): number { if (max <= min) { @@ -193,7 +215,7 @@ function commitRecord(record: AccountHealthRecord, previousSignature: string | n const nextSignature = snapshotSignature(record); if (previousSignature !== nextSignature) { - emitRuntimeInvalidated("account-health"); + emitRuntimeInvalidated("account-health", { accountId: record.accountId }); } return toPublicSnapshot(record); @@ -342,7 +364,7 @@ function applyNetworkResult( async function performProbeLocked( account: PublishAccountIdentity, options: { - trigger: "scheduled" | "manual" | "preflight" | "confirm"; + trigger: ProbeTrigger; allowSilentRefresh: boolean; }, ): Promise { @@ -384,6 +406,165 @@ async function performProbeLocked( } } +function canUseCachedProbe( + record: AccountHealthRecord, + options: { trigger: ProbeTrigger; force: boolean }, + now = Date.now(), +): boolean { + if (options.force) { + return Boolean(record.lastProbeAt && now - record.lastProbeAt < MANUAL_PROBE_MIN_INTERVAL_MS); + } + + if ( + options.trigger === "manual" + && record.lastProbeAt + && now - record.lastProbeAt < MANUAL_PROBE_MIN_INTERVAL_MS + ) { + return true; + } + + if ( + (options.trigger === "manual" || options.trigger === "scheduled") + && record.authState === "active" + && record.lastVerifiedAt + && now - record.lastVerifiedAt < MANUAL_PROBE_CACHE_TTL_MS + && record.probeState !== "network_error" + ) { + return true; + } + + return false; +} + +function markProbeQueued(record: AccountHealthRecord): AccountHealthSnapshot { + const previousSignature = snapshotSignature(record); + record.probeState = "queued"; + return commitRecord(record, previousSignature); +} + +function maxAttemptsForTrigger(trigger: ProbeTrigger): number { + return trigger === "preflight" ? 1 : ACCOUNT_PROBE_RETRY_BACKOFF_MS.length + 1; +} + +function enqueueProbeJob(job: AccountProbeJob): void { + if (!probeQueue.includes(job.account.id)) { + probeQueue.push(job.account.id); + } + drainProbeQueue(); +} + +function scheduleProbeRetry(job: AccountProbeJob): void { + const retryIndex = Math.min(Math.max(job.attempt - 2, 0), ACCOUNT_PROBE_RETRY_BACKOFF_MS.length - 1); + const backoff = ACCOUNT_PROBE_RETRY_BACKOFF_MS[retryIndex] + ?? ACCOUNT_PROBE_RETRY_BACKOFF_MS[ACCOUNT_PROBE_RETRY_BACKOFF_MS.length - 1]; + job.status = "queued"; + setTimeout(() => enqueueProbeJob(job), backoff); +} + +function drainProbeQueue(): void { + while (activeProbeCount < MAX_CONCURRENT_ACCOUNT_PROBES && probeQueue.length > 0) { + const accountId = probeQueue.shift(); + if (!accountId) { + continue; + } + + const job = probeJobs.get(accountId); + if (!job || job.status !== "queued") { + continue; + } + + job.status = "running"; + activeProbeCount += 1; + void runProbeJob(job); + } +} + +async function runProbeJob(job: AccountProbeJob): Promise { + try { + const snapshot = await withAccountLock(job.account.id, async () => { + const record = ensureRecord(job.account); + if (canUseCachedProbe(record, job)) { + return toPublicSnapshot(record); + } + return await performProbeLocked(job.account, { + trigger: job.trigger, + allowSilentRefresh: job.allowSilentRefresh, + }); + }); + + if (snapshot.probeState === "network_error" && job.attempt < job.maxAttempts) { + job.attempt += 1; + scheduleProbeRetry(job); + return; + } + + probeJobs.delete(job.account.id); + job.resolve(snapshot); + } catch (error) { + probeJobs.delete(job.account.id); + job.reject(error); + } finally { + activeProbeCount = Math.max(0, activeProbeCount - 1); + drainProbeQueue(); + } +} + +function enqueueAccountProbe( + account: PublishAccountIdentity, + options: { + trigger?: ProbeTrigger; + allowSilentRefresh?: boolean; + force?: boolean; + } = {}, +): Promise { + syncTrackedAccountsInternal([account], false); + + const trigger = options.trigger ?? "manual"; + const force = Boolean(options.force); + const record = ensureRecord(account); + if (canUseCachedProbe(record, { trigger, force })) { + return Promise.resolve(toPublicSnapshot(record)); + } + + const existing = probeJobs.get(account.id); + if (existing) { + if (force) { + existing.force = true; + } + existing.allowSilentRefresh = existing.allowSilentRefresh || Boolean(options.allowSilentRefresh); + if (trigger === "manual") { + existing.trigger = "manual"; + } + return existing.promise; + } + + markProbeQueued(record); + + let resolve!: (snapshot: AccountHealthSnapshot) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + + const job: AccountProbeJob = { + account, + trigger, + allowSilentRefresh: Boolean(options.allowSilentRefresh), + force, + status: "queued", + attempt: 1, + maxAttempts: maxAttemptsForTrigger(trigger), + resolve, + reject, + promise, + }; + + probeJobs.set(account.id, job); + enqueueProbeJob(job); + return promise; +} + async function runDueProbes(): Promise { const now = Date.now(); const dueAccounts = [...trackedAccounts.values()].filter((account) => { @@ -398,10 +579,14 @@ async function runDueProbes(): Promise { } const trigger = record.suspectedExpiredAt ? "confirm" : "scheduled"; - void withAccountLock(account.id, async () => { - await performProbeLocked(account, { - trigger, - allowSilentRefresh: false, + void enqueueAccountProbe(account, { + trigger, + allowSilentRefresh: false, + }).catch((error) => { + console.warn("[desktop-account-health] scheduled probe failed", { + accountId: account.id, + platform: account.platform, + message: error instanceof Error ? error.message : String(error), }); }); } @@ -454,6 +639,7 @@ function syncTrackedAccountsInternal( trackedAccounts.delete(accountId); records.delete(accountId); removed = true; + emitRuntimeInvalidated("account-health", { accountId }); } } } @@ -475,7 +661,7 @@ export function forgetTrackedAccountHealth( trackedAccounts.delete(accountId); if (records.delete(accountId)) { persistRecords(); - emitRuntimeInvalidated("account-health"); + emitRuntimeInvalidated("account-health", { accountId }); } } @@ -526,16 +712,15 @@ export function getProjectedAccountHealth(input: { export async function probeTrackedAccount( account: PublishAccountIdentity, options: { - trigger?: "scheduled" | "manual" | "preflight" | "confirm"; + trigger?: ProbeTrigger; allowSilentRefresh?: boolean; + force?: boolean; } = {}, ): Promise { - syncTrackedAccountsInternal([account], false); - return await withAccountLock(account.id, async () => { - return await performProbeLocked(account, { - trigger: options.trigger ?? "manual", - allowSilentRefresh: Boolean(options.allowSilentRefresh), - }); + return await enqueueAccountProbe(account, { + trigger: options.trigger ?? "manual", + allowSilentRefresh: Boolean(options.allowSilentRefresh), + force: Boolean(options.force), }); } diff --git a/apps/desktop-client/src/main/auth-types.ts b/apps/desktop-client/src/main/auth-types.ts index aa6df0e..ce222c0 100644 --- a/apps/desktop-client/src/main/auth-types.ts +++ b/apps/desktop-client/src/main/auth-types.ts @@ -7,6 +7,7 @@ export type AccountAuthState = | "revoked"; export type AccountProbeState = + | "queued" | "idle" | "probing" | "network_error"; diff --git a/apps/desktop-client/src/main/bootstrap.ts b/apps/desktop-client/src/main/bootstrap.ts index 53a05c1..edcc9fe 100644 --- a/apps/desktop-client/src/main/bootstrap.ts +++ b/apps/desktop-client/src/main/bootstrap.ts @@ -24,12 +24,13 @@ import { registerRendererDevtoolsProxyTarget } from "./renderer-devtools-proxy"; import { onRuntimeInvalidated } from "./runtime-events"; import { noteRuntimeAccountBound, + requestRuntimeAccountProbe, refreshRuntimeAccounts, releaseRuntimeSession, syncRuntimeSession, unbindRuntimeAccount, } from "./runtime-controller"; -import { createRuntimeSnapshot } from "./runtime-snapshot"; +import { createRuntimeAccountSnapshot, createRuntimeSnapshot } from "./runtime-snapshot"; import { resolveDesktopClientID, resolveDesktopDeviceInfo } from "./device-id"; import { initScheduler } from "./scheduler"; import { initSessionRegistry } from "./session-registry"; @@ -154,6 +155,10 @@ function captureRuntimeSnapshot() { return snapshot; } +function captureRuntimeAccountSnapshot(accountId: string) { + return createRuntimeAccountSnapshot(accountId); +} + async function ensureMainWindow(): Promise { const existing = currentMainWindow(); if (existing) { @@ -314,6 +319,9 @@ function registerBridgeHandlers(): void { }), ); ipcMain.handle("desktop:runtime-snapshot", () => captureRuntimeSnapshot()); + ipcMain.handle("desktop:runtime-account-snapshot", (_event, accountId: string) => + captureRuntimeAccountSnapshot(accountId), + ); safeHandle("desktop:bind-publish-account", async (_event, platformId: string) => { const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo; noteRuntimeAccountBound(account); @@ -328,6 +336,10 @@ function registerBridgeHandlers(): void { await refreshRuntimeAccounts(); return null; }); + safeHandle("desktop:probe-runtime-account", async (_event, accountId: string) => { + requestRuntimeAccountProbe(accountId); + return captureRuntimeAccountSnapshot(accountId); + }); safeHandle( "desktop:open-publish-account-console", async ( diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index 714e208..e57b228 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -4,6 +4,7 @@ import { ApiClientError } from "@geo/http-client"; import type { DesktopArticleContent, DesktopAccountInfo, + DesktopAccountHealthReport, DesktopClientInfo, MonitoringLeaseTask, MonitoringSourceItem, @@ -112,6 +113,7 @@ import { noteTransportHeartbeat, noteTransportPull, offlineDesktopClient, + reportDesktopAccountHealth, resumeMonitoringTasks, revokeDesktopClient, setTransportAuthState, @@ -151,6 +153,8 @@ const publishReservedSlots = 1; const minimumForegroundTotalConcurrency = 2; const maximumRuntimeTotalConcurrency = 4; const BAIJIAHAO_RISK_CONTROL_PROMPT = "您触发平台风控,请手动发稿一篇并完成验证,方可继续使用"; +const accountHealthReportDebounceMs = 1_000; +const accountHealthReportMaxBatchSize = 100; interface RuntimeTaskRecord { id: string; @@ -233,6 +237,8 @@ interface RuntimeState { publishFallbackBackoffUntil: number; heartbeatTimer: ReturnType | null; pullTimer: ReturnType | null; + accountHealthReportTimer: ReturnType | null; + accountHealthReportQueue: Map; dispatchWsClient: DispatchWsClient | null; dispatchWsConnected: boolean; lastHeartbeatAt: number; @@ -258,6 +264,8 @@ const state: RuntimeState = { publishFallbackBackoffUntil: 0, heartbeatTimer: null, pullTimer: null, + accountHealthReportTimer: null, + accountHealthReportQueue: new Map(), dispatchWsClient: null, dispatchWsConnected: false, lastHeartbeatAt: 0, @@ -496,6 +504,10 @@ function stopRuntime(): void { clearInterval(state.pullTimer); state.pullTimer = null; } + if (state.accountHealthReportTimer) { + clearTimeout(state.accountHealthReportTimer); + state.accountHealthReportTimer = null; + } if (state.dispatchWsClient) { state.dispatchWsClient.stop(); state.dispatchWsClient = null; @@ -519,6 +531,7 @@ function clearRuntimeState(): void { state.tasks.clear(); state.accounts = []; state.accountProfiles.clear(); + state.accountHealthReportQueue.clear(); state.activeExecutions.clear(); state.lastHeartbeatAt = 0; state.lastHeartbeatStatus = "idle"; @@ -897,7 +910,6 @@ async function syncAccounts(source: "startup" | "manual"): Promise { try { const accounts = await listDesktopAccounts(); - const currentClientId = state.client?.id ?? state.session?.desktop_client?.id ?? null; state.lastAccountsSyncAt = Date.now(); state.lastError = null; @@ -906,22 +918,11 @@ async function syncAccounts(source: "startup" | "manual"): Promise { const identities = accounts.map((account) => accountIdentityFromDesktopAccount(account)); syncTrackedAccounts(identities); - if (source === "manual") { - await Promise.allSettled( - identities.map((account) => - probeTrackedAccount(account, { - trigger: "manual", - allowSilentRefresh: true, - }), - ), - ); - } state.accountProfiles.clear(); - const syncedAccounts: Array = await Promise.all(accounts.map(async (account) => { + const syncedAccounts: Array = accounts.map((account) => { const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid; const isAIPlatform = isAIPlatformId(account.platform); - const identity = accountIdentityFromDesktopAccount(account); const projected = getProjectedAccountHealth({ accountId: account.id, platform: account.platform, @@ -961,41 +962,8 @@ async function syncAccounts(source: "startup" | "manual"): Promise { verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? account.verified_at, }; - const shouldRelinkClient = Boolean(currentClientId && resolvedAccount.client_id !== currentClientId); - const shouldMirrorHealth = - resolvedAccount.health !== account.health - && isDefinitiveAuthState(projected.authState); - - if (shouldRelinkClient || shouldMirrorHealth) { - try { - const updated = await upsertDesktopAccount({ - platform: resolvedAccount.platform, - platform_uid: normalizedPlatformUid, - display_name: projected.profile?.displayName ?? resolvedAccount.display_name, - avatar_url: projected.profile?.avatarUrl ?? resolvedAccount.avatar_url, - account_fingerprint: resolvedAccount.account_fingerprint ?? normalizedPlatformUid, - health: resolvedAccount.health, - verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? resolvedAccount.verified_at, - tags: resolvedAccount.tags, - }); - - resolvedAccount = { - ...updated, - platform_uid: normalizeAccountPlatformUid(updated.platform_uid) || updated.platform_uid, - health: resolvedAccount.health, - }; - } catch (error) { - console.warn("[desktop-runtime] account relink failed", { - accountId: resolvedAccount.id, - platform: resolvedAccount.platform, - platformUid: resolvedAccount.platform_uid, - message: errorMessage(error), - }); - } - } - return resolvedAccount; - })); + }); const duplicateAccounts: DesktopAccountInfo[] = []; const dedupedAccounts: DesktopAccountInfo[] = []; const seenAccountKeys = new Set(); @@ -1061,10 +1029,161 @@ async function cleanupDuplicateDesktopAccounts(accounts: DesktopAccountInfo[]): } } +function buildAccountHealthReport(account: DesktopAccountInfo): DesktopAccountHealthReport { + const projected = getProjectedAccountHealth({ + accountId: account.id, + platform: account.platform, + health: account.health, + verifiedAt: account.verified_at, + }); + return { + account_id: account.id, + platform: account.platform, + platform_uid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid, + health: projected.health, + auth_state: projected.authState, + probe_state: projected.probeState, + auth_reason: projected.authReason, + display_name: projected.profile?.displayName ?? account.display_name, + avatar_url: projected.profile?.avatarUrl ?? account.avatar_url, + verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? account.verified_at, + checked_at: new Date().toISOString(), + }; +} + +function enqueueAccountHealthReport(accountId: string): void { + const account = state.accounts.find((item) => item.id === accountId) ?? null; + if (!account) { + return; + } + + state.accountHealthReportQueue.set(account.id, buildAccountHealthReport(account)); + if (state.accountHealthReportTimer) { + return; + } + + state.accountHealthReportTimer = setTimeout(() => { + state.accountHealthReportTimer = null; + void flushAccountHealthReports(); + }, accountHealthReportDebounceMs); +} + +async function flushAccountHealthReports(): Promise { + if (state.accountHealthReportQueue.size === 0 || !canRunLive(state.session)) { + return; + } + + const reports = [...state.accountHealthReportQueue.values()].slice(0, accountHealthReportMaxBatchSize); + for (const report of reports) { + state.accountHealthReportQueue.delete(report.account_id); + } + + try { + await reportDesktopAccountHealth({ reports }); + } catch (error) { + console.warn("[desktop-runtime] account health report failed", { + count: reports.length, + message: errorMessage(error), + }); + for (const report of reports) { + state.accountHealthReportQueue.set(report.account_id, report); + } + } finally { + if (state.accountHealthReportQueue.size > 0 && !state.accountHealthReportTimer) { + state.accountHealthReportTimer = setTimeout(() => { + state.accountHealthReportTimer = null; + void flushAccountHealthReports(); + }, accountHealthReportDebounceMs); + } + } +} + +async function mirrorProjectedAccountProfile(accountId: string): Promise { + const account = state.accounts.find((item) => item.id === accountId) ?? null; + if (!account) { + return; + } + + const projected = getProjectedAccountHealth({ + accountId: account.id, + platform: account.platform, + health: account.health, + verifiedAt: account.verified_at, + }); + if (!isDefinitiveAuthState(projected.authState)) { + return; + } + + const nextDisplayName = projected.profile?.displayName ?? account.display_name; + const nextAvatarUrl = projected.profile?.avatarUrl ?? account.avatar_url; + const shouldMirrorProfile = + account.display_name !== nextDisplayName + || account.avatar_url !== nextAvatarUrl; + if (!shouldMirrorProfile) { + return; + } + + const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid; + const updated = await upsertDesktopAccount({ + platform: account.platform, + platform_uid: normalizedPlatformUid, + display_name: nextDisplayName, + avatar_url: nextAvatarUrl, + account_fingerprint: account.account_fingerprint ?? normalizedPlatformUid, + health: account.health, + verified_at: account.verified_at, + tags: account.tags, + }); + + state.accounts = state.accounts.map((item) => + item.id === account.id + ? { + ...updated, + platform_uid: normalizeAccountPlatformUid(updated.platform_uid) || updated.platform_uid, + health: account.health, + } + : item, + ); + state.accountProfiles.set(account.id, { + platformUid: normalizedPlatformUid, + displayName: nextDisplayName, + avatarUrl: nextAvatarUrl, + }); + refreshAccountNames(); +} + export async function refreshRuntimeAccounts(): Promise { await syncAccounts("manual"); } +export function requestRuntimeAccountProbe( + accountId: string, + options: { account?: PublishAccountIdentity; force?: boolean } = {}, +): void { + const existingAccount = state.accounts.find((item) => item.id === accountId) ?? null; + const account = options.account ?? (existingAccount ? accountIdentityFromDesktopAccount(existingAccount) : null); + if (!account) { + throw new Error("runtime_account_not_found"); + } + + void probeTrackedAccount(account, { + trigger: "manual", + allowSilentRefresh: true, + force: options.force ?? true, + }) + .then(async () => { + enqueueAccountHealthReport(account.id); + await mirrorProjectedAccountProfile(account.id); + }) + .catch((error) => { + console.warn("[desktop-runtime] account probe failed", { + accountId: account.id, + platform: account.platform, + message: errorMessage(error), + }); + }); +} + export function noteRuntimeAccountBound(account: DesktopAccountInfo): void { const normalizedAccount: DesktopAccountInfo = { ...account, diff --git a/apps/desktop-client/src/main/runtime-events.ts b/apps/desktop-client/src/main/runtime-events.ts index 9b03a9f..cdd2ee9 100644 --- a/apps/desktop-client/src/main/runtime-events.ts +++ b/apps/desktop-client/src/main/runtime-events.ts @@ -3,14 +3,19 @@ type RuntimeInvalidationReason = "account-health"; type RuntimeInvalidationListener = (event: { reason: RuntimeInvalidationReason; at: number; + accountId?: string; }) => void; const listeners = new Set(); -export function emitRuntimeInvalidated(reason: RuntimeInvalidationReason): void { +export function emitRuntimeInvalidated( + reason: RuntimeInvalidationReason, + details: { accountId?: string } = {}, +): void { const event = { reason, at: Date.now(), + ...details, } as const; for (const listener of listeners) { @@ -31,4 +36,3 @@ export function onRuntimeInvalidated(listener: RuntimeInvalidationListener): () listeners.delete(listener); }; } - diff --git a/apps/desktop-client/src/main/runtime-snapshot.ts b/apps/desktop-client/src/main/runtime-snapshot.ts index d3994ee..123baca 100644 --- a/apps/desktop-client/src/main/runtime-snapshot.ts +++ b/apps/desktop-client/src/main/runtime-snapshot.ts @@ -32,11 +32,83 @@ function parseTimestamp(value: string | null | undefined): number | null { return Number.isNaN(timestamp) ? null : timestamp; } +type RuntimeControllerSnapshot = ReturnType; +type RuntimeControllerAccount = RuntimeControllerSnapshot["accounts"][number]; + +function createRuntimeAccountView( + controller: RuntimeControllerSnapshot, + account: RuntimeControllerAccount, + context: { + hotViews: ReturnType; + primaryClientId: string; + clientOnline: boolean; + lastAccountsSyncAt: number; + now: number; + }, +) { + const sessionHandle = getSessionHandle(account.id); + const isHot = context.hotViews.some((item) => item.accountId === account.id); + const accountQueueDepth = controller.tasks.filter((task) => + task.accountId === account.id && ["queued", "in_progress"].includes(task.status), + ).length; + const projected = getProjectedAccountHealth({ + accountId: account.id, + platform: account.platform, + health: account.health, + verifiedAt: account.verified_at, + }); + + return { + id: account.id, + platform: account.platform, + displayName: projected.profile?.displayName ?? account.display_name, + platformUid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid, + avatarUrl: projected.profile?.avatarUrl ?? account.avatar_url ?? null, + health: projected.health, + authState: projected.authState, + probeState: projected.probeState, + authReason: projected.authReason, + tags: account.tags, + syncVersion: account.sync_version, + clientId: account.client_id ?? context.primaryClientId, + partition: projected.partition || sessionHandle?.partition || `persist:acc-${account.id}`, + sessionState: isHot ? "hot" : sessionHandle ? "warm" : "cold", + lastSyncAt: + context.lastAccountsSyncAt + || parseTimestamp(account.verified_at) + || context.now, + lastVerifiedAt: projected.lastVerifiedAt, + lastProbeAt: projected.lastProbeAt, + nextProbeAt: projected.nextProbeAt, + queueDepth: accountQueueDepth, + online: context.clientOnline, + }; +} + export function createRuntimeSnapshot() { return createLiveRuntimeSnapshot(getRuntimeControllerSnapshot()); } -function createLiveRuntimeSnapshot(controller: ReturnType) { +export function createRuntimeAccountSnapshot(accountId: string) { + const controller = getRuntimeControllerSnapshot(); + const now = Date.now(); + const primaryClientId = controller.client?.id ?? controller.session?.desktop_client?.id ?? "desktop-self"; + const clientOnline = controller.running && controller.lastHeartbeatStatus !== "failed"; + const account = controller.accounts.find((item) => item.id === accountId); + if (!account) { + return null; + } + + return createRuntimeAccountView(controller, account, { + hotViews: listHotViews(), + primaryClientId, + clientOnline, + lastAccountsSyncAt: controller.lastAccountsSyncAt, + now, + }); +} + +function createLiveRuntimeSnapshot(controller: RuntimeControllerSnapshot) { const now = Date.now(); // Electron Session instances are not safe to ship across IPC to the renderer. const sessions = listSessionHandleSnapshots(); @@ -70,45 +142,15 @@ function createLiveRuntimeSnapshot(controller: ReturnType { - const sessionHandle = getSessionHandle(account.id); - const isHot = hotViews.some((item) => item.accountId === account.id); - const accountQueueDepth = controller.tasks.filter((task) => - task.accountId === account.id && ["queued", "in_progress"].includes(task.status), - ).length; - const projected = getProjectedAccountHealth({ - accountId: account.id, - platform: account.platform, - health: account.health, - verifiedAt: account.verified_at, - }); - - return { - id: account.id, - platform: account.platform, - displayName: projected.profile?.displayName ?? account.display_name, - platformUid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid, - avatarUrl: projected.profile?.avatarUrl ?? account.avatar_url ?? null, - health: projected.health, - authState: projected.authState, - probeState: projected.probeState, - authReason: projected.authReason, - tags: account.tags, - syncVersion: account.sync_version, - clientId: account.client_id ?? primaryClientId, - partition: projected.partition || sessionHandle?.partition || `persist:acc-${account.id}`, - sessionState: isHot ? "hot" : sessionHandle ? "warm" : "cold", - lastSyncAt: - controller.lastAccountsSyncAt - || parseTimestamp(account.verified_at) - || now, - lastVerifiedAt: projected.lastVerifiedAt, - lastProbeAt: projected.lastProbeAt, - nextProbeAt: projected.nextProbeAt, - queueDepth: accountQueueDepth, - online: clientOnline, - }; - }); + const accounts = controller.accounts.map((account) => + createRuntimeAccountView(controller, account, { + hotViews, + primaryClientId, + clientOnline, + lastAccountsSyncAt: controller.lastAccountsSyncAt, + now, + }), + ); const tasks = controller.tasks.map((task) => ({ ...task })); diff --git a/apps/desktop-client/src/main/transport/api-client.ts b/apps/desktop-client/src/main/transport/api-client.ts index 99cd2ca..a77363d 100644 --- a/apps/desktop-client/src/main/transport/api-client.ts +++ b/apps/desktop-client/src/main/transport/api-client.ts @@ -16,6 +16,8 @@ import type { LeaseDesktopTaskRequest, LeaseDesktopTaskResponse, ListDesktopPublishTasksParams, + ReportDesktopAccountHealthRequest, + ReportDesktopAccountHealthResponse, MonitoringLeaseTasksPayload, MonitoringLeaseTasksResponse, MonitoringResumeTasksPayload, @@ -65,6 +67,16 @@ let transportSession: DesktopTransportSession = { baseURL: process.env.DESKTOP_API_BASE_URL ?? "http://localhost:8080", clientToken: null, }; +const ACCOUNT_UPSERT_CACHE_TTL_MS = 5 * 60_000; + +interface AccountUpsertCacheEntry { + signature: string; + expiresAt: number; + response: DesktopAccountInfo; + inFlight: Promise | null; +} + +const accountUpsertCache = new Map(); const transportState = { baseURL: transportSession.baseURL, @@ -135,6 +147,43 @@ function createDesktopClient(baseURL: string): ApiClient { return client; } +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => stableJson(item)).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function accountUpsertCacheKey(payload: UpsertDesktopAccountRequest): string { + return [ + payload.platform.trim(), + payload.platform_uid.trim(), + ].join("::"); +} + +function accountUpsertSignature(payload: UpsertDesktopAccountRequest): string { + return stableJson({ + platform: payload.platform.trim(), + platform_uid: payload.platform_uid.trim(), + account_fingerprint: payload.account_fingerprint ?? null, + display_name: payload.display_name.trim(), + avatar_url: payload.avatar_url ?? null, + health: payload.health, + tags: payload.tags ?? [], + if_sync_version: payload.if_sync_version ?? null, + }); +} + +function clearAccountUpsertCache(): void { + accountUpsertCache.clear(); +} + function currentRequestDebugMode(): "main-process" | "renderer-devtools" { return canUseRendererDevtoolsProxy() ? "renderer-devtools" : "main-process"; } @@ -319,6 +368,7 @@ export function configureTransport(session: DesktopRuntimeSessionSyncRequest | n transportState.auth = "pending"; transportState.dispatchState = "idle"; desktopApiClient = null; + clearAccountUpsertCache(); return null; } @@ -332,6 +382,7 @@ export function configureTransport(session: DesktopRuntimeSessionSyncRequest | n transportState.dispatchState = session.client_token ? "connecting" : "idle"; transportState.initializedAt = Date.now(); desktopApiClient = createDesktopClient(transportSession.baseURL); + clearAccountUpsertCache(); return desktopApiClient; } @@ -425,10 +476,52 @@ export async function retryDesktopPublishTask(taskId: string): Promise { - return desktopPost( + const now = Date.now(); + const cacheKey = accountUpsertCacheKey(payload); + const signature = accountUpsertSignature(payload); + const cached = accountUpsertCache.get(cacheKey); + if (cached && cached.signature === signature && cached.expiresAt > now) { + if (cached.inFlight) { + return cached.inFlight; + } + return cached.response; + } + + const request = desktopPost( "/api/desktop/accounts", payload, ); + accountUpsertCache.set(cacheKey, { + signature, + expiresAt: now + ACCOUNT_UPSERT_CACHE_TTL_MS, + response: cached?.response ?? ({} as DesktopAccountInfo), + inFlight: request, + }); + + try { + const response = await request; + accountUpsertCache.set(cacheKey, { + signature, + expiresAt: Date.now() + ACCOUNT_UPSERT_CACHE_TTL_MS, + response, + inFlight: null, + }); + return response; + } catch (error) { + if (accountUpsertCache.get(cacheKey)?.inFlight === request) { + accountUpsertCache.delete(cacheKey); + } + throw error; + } +} + +export async function reportDesktopAccountHealth( + payload: ReportDesktopAccountHealthRequest, +): Promise { + return desktopPost( + "/api/desktop/accounts/health-reports", + payload, + ); } export async function deleteDesktopAccount( @@ -436,9 +529,11 @@ export async function deleteDesktopAccount( ifSyncVersion: number, ): Promise { const params = new URLSearchParams({ if_sync_version: String(ifSyncVersion) }); - return desktopDelete( + const response = await desktopDelete( `/api/desktop/accounts/${encodeURIComponent(desktopId)}?${params.toString()}`, ); + clearAccountUpsertCache(); + return response; } export async function leaseDesktopTask( diff --git a/apps/desktop-client/src/preload/bridge.ts b/apps/desktop-client/src/preload/bridge.ts index 6e31958..d643c26 100644 --- a/apps/desktop-client/src/preload/bridge.ts +++ b/apps/desktop-client/src/preload/bridge.ts @@ -92,10 +92,14 @@ const desktopBridge = { ipcRenderer.invoke("desktop:client-id", scope) as Promise, runtimeSnapshot: () => ipcRenderer.invoke("desktop:runtime-snapshot") as Promise, + runtimeAccountSnapshot: (accountId: string) => + ipcRenderer.invoke("desktop:runtime-account-snapshot", accountId) as Promise, bindPublishAccount: (platformId: string) => ipcRenderer.invoke("desktop:bind-publish-account", platformId) as Promise, refreshRuntimeAccounts: () => ipcRenderer.invoke("desktop:refresh-runtime-accounts") as Promise, + probeRuntimeAccount: (accountId: string) => + ipcRenderer.invoke("desktop:probe-runtime-account", accountId) as Promise, openPublishAccountConsole: (account: { id: string; platform: string; @@ -118,9 +122,9 @@ const desktopBridge = { setWindowMode: (mode: "login" | "main") => ipcRenderer.invoke("desktop:set-window-mode", mode) as Promise, onRuntimeInvalidated: ( - listener: (event: { reason: "account-health"; at: number }) => void, + listener: (event: { reason: "account-health"; at: number; accountId?: string }) => void, ) => { - const wrapped = (_event: IpcRendererEvent, payload: { reason: "account-health"; at: number }) => { + const wrapped = (_event: IpcRendererEvent, payload: { reason: "account-health"; at: number; accountId?: string }) => { listener(payload); }; ipcRenderer.on("desktop:runtime-invalidated", wrapped); diff --git a/apps/desktop-client/src/renderer/composables/useDesktopRuntime.ts b/apps/desktop-client/src/renderer/composables/useDesktopRuntime.ts index e89e4f1..6a88747 100644 --- a/apps/desktop-client/src/renderer/composables/useDesktopRuntime.ts +++ b/apps/desktop-client/src/renderer/composables/useDesktopRuntime.ts @@ -1,14 +1,16 @@ import { computed, onMounted, onUnmounted, readonly, shallowRef } from "vue"; -import type { DesktopRuntimeSnapshot } from "../types"; +import type { DesktopRuntimeSnapshot, RuntimeAccount } from "../types"; const RUNTIME_POLL_INTERVAL_MS = 15_000; +const INVALIDATION_DEBOUNCE_MS = 250; const snapshot = shallowRef(null); const loading = shallowRef(false); const error = shallowRef(null); let intervalHandle: ReturnType | null = null; +let invalidationRefreshHandle: ReturnType | null = null; let subscribers = 0; let visibilityListenerBound = false; let runtimeInvalidationUnsubscribe: (() => void) | null = null; @@ -17,8 +19,54 @@ function isPageVisible(): boolean { return typeof document === "undefined" || document.visibilityState === "visible"; } -async function refreshRuntimeSnapshot() { - loading.value = true; +function accountSummaryPatch( + current: DesktopRuntimeSnapshot, + accounts: RuntimeAccount[], +): DesktopRuntimeSnapshot["summary"] { + const healthCounts = accounts.reduce>((acc, item) => { + acc[item.health] = (acc[item.health] ?? 0) + 1; + return acc; + }, {}); + const blockingAccountCount = accounts.filter((item) => + ["expired", "revoked", "challenge_required"].includes(item.authState), + ).length; + + return { + ...current.summary, + accountsBound: accounts.length, + healthCounts, + issuesOpen: + current.tasks.filter((item) => ["unknown", "failed"].includes(item.status)).length + + blockingAccountCount, + }; +} + +function patchRuntimeAccount(accountId: string, account: RuntimeAccount | null): void { + const current = snapshot.value; + if (!current) { + return; + } + + const existingIndex = current.accounts.findIndex((item) => item.id === accountId); + const accounts = + account && existingIndex >= 0 + ? current.accounts.map((item, index) => (index === existingIndex ? account : item)) + : account + ? [account, ...current.accounts] + : current.accounts.filter((item) => item.id !== accountId); + + snapshot.value = { + ...current, + generatedAt: Date.now(), + summary: accountSummaryPatch(current, accounts), + accounts, + }; +} + +async function refreshRuntimeSnapshot(options: { silent?: boolean } = {}) { + if (!options.silent) { + loading.value = true; + } error.value = null; try { @@ -32,7 +80,9 @@ async function refreshRuntimeSnapshot() { } catch (err) { error.value = err instanceof Error ? err.message : "runtime snapshot unavailable"; } finally { - loading.value = false; + if (!options.silent) { + loading.value = false; + } } } @@ -55,6 +105,48 @@ async function refreshAccounts() { } } +async function refreshRuntimeAccount(accountId: string) { + if (!window.desktopBridge?.app?.runtimeAccountSnapshot) { + await refreshRuntimeSnapshot({ silent: true }); + return; + } + + try { + const account = await window.desktopBridge.app.runtimeAccountSnapshot(accountId); + patchRuntimeAccount(accountId, account); + } catch (err) { + error.value = err instanceof Error ? err.message : "runtime account snapshot unavailable"; + } +} + +async function probeAccount(accountId: string) { + if (!window.desktopBridge?.app?.probeRuntimeAccount) { + await refreshAccounts(); + return; + } + + try { + const account = await window.desktopBridge.app.probeRuntimeAccount(accountId); + patchRuntimeAccount(accountId, account); + } catch (err) { + error.value = err instanceof Error ? err.message : "probe account failed"; + throw err; + } +} + +function scheduleSilentSnapshotRefresh() { + if (invalidationRefreshHandle) { + clearTimeout(invalidationRefreshHandle); + } + + invalidationRefreshHandle = setTimeout(() => { + invalidationRefreshHandle = null; + if (isPageVisible()) { + void refreshRuntimeSnapshot({ silent: true }); + } + }, INVALIDATION_DEBOUNCE_MS); +} + function stopPolling() { if (!intervalHandle) { return; @@ -75,13 +167,13 @@ function syncPollingState() { } intervalHandle = setInterval(() => { - void refreshRuntimeSnapshot(); + void refreshRuntimeSnapshot({ silent: true }); }, RUNTIME_POLL_INTERVAL_MS); } function handleVisibilityChange() { if (isPageVisible()) { - void refreshRuntimeSnapshot(); + void refreshRuntimeSnapshot({ silent: true }); } syncPollingState(); @@ -101,10 +193,17 @@ function bindRuntimeInvalidationListener() { return; } - runtimeInvalidationUnsubscribe = window.desktopBridge.app.onRuntimeInvalidated(() => { - if (isPageVisible()) { - void refreshRuntimeSnapshot(); + runtimeInvalidationUnsubscribe = window.desktopBridge.app.onRuntimeInvalidated((event) => { + if (!isPageVisible()) { + return; } + + if (event.accountId) { + void refreshRuntimeAccount(event.accountId); + return; + } + + scheduleSilentSnapshotRefresh(); }); } @@ -120,6 +219,10 @@ function unbindVisibilityListener() { function unbindRuntimeInvalidationListener() { runtimeInvalidationUnsubscribe?.(); runtimeInvalidationUnsubscribe = null; + if (invalidationRefreshHandle) { + clearTimeout(invalidationRefreshHandle); + invalidationRefreshHandle = null; + } } export function useDesktopRuntime() { @@ -151,6 +254,7 @@ export function useDesktopRuntime() { error: readonly(error), refresh: refreshRuntimeSnapshot, refreshAccounts, + probeAccount, hasSnapshot: computed(() => snapshot.value !== null), }; } diff --git a/apps/desktop-client/src/renderer/env.d.ts b/apps/desktop-client/src/renderer/env.d.ts index 3b43b9b..7de9e04 100644 --- a/apps/desktop-client/src/renderer/env.d.ts +++ b/apps/desktop-client/src/renderer/env.d.ts @@ -26,8 +26,10 @@ declare global { user_id: number; }): Promise; runtimeSnapshot(): Promise; + runtimeAccountSnapshot(accountId: string): Promise; bindPublishAccount(platformId: string): Promise; refreshRuntimeAccounts(): Promise; + probeRuntimeAccount(accountId: string): Promise; openPublishAccountConsole(account: { id: string; platform: string; @@ -42,7 +44,7 @@ declare global { releaseRuntimeSession(revoke?: boolean): Promise; setWindowMode(mode: "login" | "main"): Promise; onRuntimeInvalidated( - listener: (event: { reason: "account-health"; at: number }) => void, + listener: (event: { reason: "account-health"; at: number; accountId?: string }) => void, ): () => void; onNetworkObserved(listener: (event: DesktopObservedRequest) => void): () => void; }; diff --git a/apps/desktop-client/src/renderer/lib/client-errors.ts b/apps/desktop-client/src/renderer/lib/client-errors.ts index c175529..37dba3b 100644 --- a/apps/desktop-client/src/renderer/lib/client-errors.ts +++ b/apps/desktop-client/src/renderer/lib/client-errors.ts @@ -1,5 +1,5 @@ type ClientErrorTone = "error" | "warning"; -type ClientActionKind = "bind-account" | "open-console" | "unbind-account"; +type ClientActionKind = "bind-account" | "open-console" | "probe-account" | "unbind-account"; interface ClientErrorPresentation { tone: ClientErrorTone; @@ -42,7 +42,9 @@ function presentClientError(kind: ClientActionKind, error: unknown): ClientError ? "账号绑定失败" : kind === "unbind-account" ? "账号解绑失败" - : "打开平台失败", + : kind === "probe-account" + ? "账号校验失败" + : "打开平台失败", ); if (message === "desktop_account_bind_window_closed") { @@ -110,6 +112,14 @@ function presentClientError(kind: ClientActionKind, error: unknown): ClientError }; } + if (kind === "probe-account") { + return { + tone: "error", + title: "账号校验失败", + content: message, + }; + } + if (kind === "unbind-account") { return { tone: "error", diff --git a/apps/desktop-client/src/renderer/types.ts b/apps/desktop-client/src/renderer/types.ts index f21c70a..30d6663 100644 --- a/apps/desktop-client/src/renderer/types.ts +++ b/apps/desktop-client/src/renderer/types.ts @@ -20,7 +20,7 @@ export interface RuntimeAccount { avatarUrl: string | null; health: "live" | "expired" | "captcha" | "risk"; authState: "unknown" | "active" | "expiring_soon" | "challenge_required" | "expired" | "revoked"; - probeState: "idle" | "probing" | "network_error"; + probeState: "queued" | "idle" | "probing" | "network_error"; authReason: | "bind_success" | "probe_success" diff --git a/apps/desktop-client/src/renderer/views/AccountsView.vue b/apps/desktop-client/src/renderer/views/AccountsView.vue index c0dec50..8651e5a 100644 --- a/apps/desktop-client/src/renderer/views/AccountsView.vue +++ b/apps/desktop-client/src/renderer/views/AccountsView.vue @@ -10,7 +10,7 @@ import { formatDateTime, formatRelativeTime, formatVerifiedAtLabel, titleCaseTok import { desktopPublishMediaCatalog } from "../lib/media-catalog"; import type { RuntimeAccount } from "../types"; -const { snapshot, refresh, refreshAccounts, loading } = useDesktopRuntime(); +const { snapshot, refresh, refreshAccounts, probeAccount, loading } = useDesktopRuntime(); const selectedPlatform = ref("all"); const selectedStatus = ref("all"); @@ -18,6 +18,7 @@ const searchQuery = ref(""); const bindPendingPlatformId = ref(null); const consolePendingAccountId = ref(null); const unbindPendingAccountId = ref(null); +const probePendingAccountIds = ref(new Set()); const actionSuccess = ref(null); type AccountAuthState = "authorized" | "expired" | "attention" | "risk"; @@ -84,6 +85,13 @@ function authState(account: AccountRow): AccountAuthState { } function authStateLabel(account: AccountRow): string { + if (account.probeState === "queued") { + return "等待校验"; + } + if (account.probeState === "probing") { + return "校验中"; + } + switch (authState(account)) { case "authorized": return "已授权"; @@ -96,6 +104,23 @@ function authStateLabel(account: AccountRow): string { } } +function authTagColor(account: AccountRow): string { + if (account.probeState === "queued" || account.probeState === "probing") { + return "processing"; + } + + switch (authState(account)) { + case "authorized": + return "success"; + case "expired": + return "error"; + case "attention": + return "warning"; + default: + return "default"; + } +} + function authStateTone(account: AccountRow) { switch (authState(account)) { case "authorized": @@ -128,6 +153,9 @@ function verificationTimeLabel(account: AccountRow): string { if (account.lastVerifiedAt) { return formatVerifiedAtLabel(account.lastVerifiedAt); } + if (account.probeState === "queued") { + return "等待校验"; + } if (account.probeState === "probing") { return "正在校验"; } @@ -221,6 +249,32 @@ async function unbindAccount(account: AccountRow) { } } +async function verifyAccount(account: AccountRow) { + probePendingAccountIds.value = new Set([...probePendingAccountIds.value, account.id]); + actionSuccess.value = null; + + try { + await probeAccount(account.id); + } catch (error) { + showClientActionError("probe-account", error); + } finally { + const next = new Set(probePendingAccountIds.value); + next.delete(account.id); + probePendingAccountIds.value = next; + } +} + +function isProbePending(account: AccountRow): boolean { + return ( + probePendingAccountIds.value.has(account.id) + || account.probeState === "queued" + || account.probeState === "probing" + ); +} + +const tableVirtual = computed(() => filteredAccounts.value.length > 20); +const tableScroll = computed(() => (tableVirtual.value ? { x: 960, y: 640 } : { x: 960 })); + const tableColumns = [ { title: "昵称 / UID", key: "name", dataIndex: "name" }, { title: "平台", key: "platform", dataIndex: "platform", width: 140 }, @@ -359,7 +413,15 @@ const tableColumns = [ - + @@ -393,7 +455,7 @@ const tableColumns = [ @@ -411,7 +473,9 @@ const tableColumns = [
{{ record.lastVerifiedAt ? formatDateTime(record.lastVerifiedAt) : formatDateTime(record.lastSyncAt) }} - {{ verificationTimeLabel(record) }} · {{ record.partition }} + + {{ verificationTimeLabel(record) }} · {{ record.partition }} +
@@ -423,8 +487,8 @@ const tableColumns = [ - - + + @@ -712,6 +776,13 @@ const tableColumns = [ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } +.info-stack .subtitle--partition { + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .user-avatar-wrap { display: flex; align-items: center; diff --git a/apps/desktop-client/src/renderer/views/AiPlatformsView.vue b/apps/desktop-client/src/renderer/views/AiPlatformsView.vue index 27d756d..cb6e43c 100644 --- a/apps/desktop-client/src/renderer/views/AiPlatformsView.vue +++ b/apps/desktop-client/src/renderer/views/AiPlatformsView.vue @@ -64,7 +64,11 @@ function authLabel(account: AccountRow | null) { case "revoked": return "已停用"; default: - return account.probeState === "probing" ? "校验中" : "待校验"; + return account.probeState === "queued" + ? "等待校验" + : account.probeState === "probing" + ? "校验中" + : "待校验"; } } @@ -84,7 +88,9 @@ function authColor(account: AccountRow | null) { case "expiring_soon": return account.probeState === "network_error" ? "warning" : "default"; default: - return "default"; + return account.probeState === "queued" || account.probeState === "probing" + ? "processing" + : "default"; } } @@ -108,6 +114,9 @@ function verificationLabel(account: AccountRow): string { if (account.lastVerifiedAt) { return formatVerifiedAtLabel(account.lastVerifiedAt); } + if (account.probeState === "queued") { + return "等待首轮校验"; + } if (account.probeState === "probing") { return "正在执行首轮校验"; } diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 86b347b..41c8786 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -126,6 +126,13 @@ export interface DesktopAccountInfo { display_name: string; avatar_url: string | null; health: "live" | "expired" | "captcha" | "risk"; + runtime_health?: "live" | "expired" | "captcha" | "risk" | null; + runtime_verified_at?: string | null; + runtime_checked_at?: string | null; + runtime_auth_state?: "unknown" | "active" | "expiring_soon" | "challenge_required" | "expired" | "revoked" | null; + runtime_probe_state?: "queued" | "idle" | "probing" | "network_error" | null; + runtime_auth_reason?: string | null; + health_source?: "database" | "runtime"; client_id: string | null; account_fingerprint: string | null; verified_at: string | null; @@ -158,6 +165,29 @@ export interface PatchDesktopAccountRequest { if_sync_version: number; } +export interface DesktopAccountHealthReport { + account_id: string; + platform: string; + platform_uid: string; + health: "live" | "expired" | "captcha" | "risk"; + auth_state: "unknown" | "active" | "expiring_soon" | "challenge_required" | "expired" | "revoked"; + probe_state: "queued" | "idle" | "probing" | "network_error"; + auth_reason?: string | null; + display_name?: string | null; + avatar_url?: string | null; + verified_at?: string | null; + checked_at: string; +} + +export interface ReportDesktopAccountHealthRequest { + reports: DesktopAccountHealthReport[]; +} + +export interface ReportDesktopAccountHealthResponse { + accepted_count: number; + buffered_count: number; +} + export interface DesktopTaskInfo { id: string; job_id: string;