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:
2026-04-27 21:33:55 +08:00
parent 051976e4a9
commit c3feb7477a
15 changed files with 817 additions and 129 deletions
@@ -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 "正在执行首轮校验";
}