feat(desktop-client): queue account probes and report health to server
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<DesktopRuntimeSnapshot | null>(null);
|
||||
const loading = shallowRef(false);
|
||||
const error = shallowRef<string | null>(null);
|
||||
|
||||
let intervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||
let invalidationRefreshHandle: ReturnType<typeof setTimeout> | 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<Record<string, number>>((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),
|
||||
};
|
||||
}
|
||||
|
||||
+3
-1
@@ -26,8 +26,10 @@ declare global {
|
||||
user_id: number;
|
||||
}): Promise<string>;
|
||||
runtimeSnapshot(): Promise<DesktopRuntimeSnapshot>;
|
||||
runtimeAccountSnapshot(accountId: string): Promise<DesktopRuntimeSnapshot["accounts"][number] | null>;
|
||||
bindPublishAccount(platformId: string): Promise<DesktopAccountInfo>;
|
||||
refreshRuntimeAccounts(): Promise<null>;
|
||||
probeRuntimeAccount(accountId: string): Promise<DesktopRuntimeSnapshot["accounts"][number] | null>;
|
||||
openPublishAccountConsole(account: {
|
||||
id: string;
|
||||
platform: string;
|
||||
@@ -42,7 +44,7 @@ declare global {
|
||||
releaseRuntimeSession(revoke?: boolean): Promise<null>;
|
||||
setWindowMode(mode: "login" | "main"): Promise<null>;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const consolePendingAccountId = ref<string | null>(null);
|
||||
const unbindPendingAccountId = ref<string | null>(null);
|
||||
const probePendingAccountIds = ref(new Set<string>());
|
||||
const actionSuccess = ref<string | null>(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 = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-table class="modern-table" :columns="tableColumns" :data-source="filteredAccounts" :pagination="false">
|
||||
<a-table
|
||||
class="modern-table"
|
||||
row-key="id"
|
||||
:columns="tableColumns"
|
||||
:data-source="filteredAccounts"
|
||||
:pagination="false"
|
||||
:scroll="tableScroll"
|
||||
:virtual="tableVirtual"
|
||||
>
|
||||
<template #emptyText>
|
||||
<div style="padding: 60px; color: #94a3b8;"><a-empty description="暂无匹配账号,请重置过滤或绑定新账号" /></div>
|
||||
</template>
|
||||
@@ -393,7 +455,7 @@ const tableColumns = [
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="authState(record) === 'authorized' ? 'success' : authState(record) === 'expired' ? 'error' : authState(record) === 'attention' ? 'warning' : 'default'" style="border-radius: 999px; font-weight: 600; padding: 2px 10px;">
|
||||
<a-tag :color="authTagColor(record)" style="border-radius: 999px; font-weight: 600; padding: 2px 10px;">
|
||||
{{ authStateLabel(record) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
@@ -411,7 +473,9 @@ const tableColumns = [
|
||||
<div class="cell-primary-secondary">
|
||||
<div class="info-stack">
|
||||
<span class="title">{{ record.lastVerifiedAt ? formatDateTime(record.lastVerifiedAt) : formatDateTime(record.lastSyncAt) }}</span>
|
||||
<span class="subtitle">{{ verificationTimeLabel(record) }} · {{ record.partition }}</span>
|
||||
<span class="subtitle subtitle--partition" :title="`${verificationTimeLabel(record)} · ${record.partition}`">
|
||||
{{ verificationTimeLabel(record) }} · {{ record.partition }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -423,8 +487,8 @@ const tableColumns = [
|
||||
<template #icon><LinkOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="重新授权" placement="top">
|
||||
<a-button type="text" class="action-btn" :loading="bindPendingPlatformId === record.platform" @click="bindPlatform(record.platform)">
|
||||
<a-tooltip title="重新校验" placement="top">
|
||||
<a-button type="text" class="action-btn" :loading="isProbePending(record)" @click="verifyAccount(record)">
|
||||
<template #icon><SyncOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 "正在执行首轮校验";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user