diff --git a/apps/desktop-client/src/main/account-health.ts b/apps/desktop-client/src/main/account-health.ts index 470850e..8673027 100644 --- a/apps/desktop-client/src/main/account-health.ts +++ b/apps/desktop-client/src/main/account-health.ts @@ -26,7 +26,7 @@ const REGULAR_PROBE_MAX_MS = STALE_AFTER_MS const CONFIRM_BACKOFF_STEPS_MS = [30_000, 120_000] as const const NETWORK_RETRY_MS = 2 * 60_000 const PREFLIGHT_MAX_AGE_MS = 15 * 60_000 -const MAX_CONCURRENT_ACCOUNT_PROBES = 3 +const MAX_CONCURRENT_ACCOUNT_PROBES = 2 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 diff --git a/apps/desktop-client/src/renderer/views/AccountsView.vue b/apps/desktop-client/src/renderer/views/AccountsView.vue index a0d88c7..bf65f03 100644 --- a/apps/desktop-client/src/renderer/views/AccountsView.vue +++ b/apps/desktop-client/src/renderer/views/AccountsView.vue @@ -5,9 +5,11 @@ import { DeleteOutlined, LinkOutlined, ReloadOutlined, + SafetyCertificateOutlined, SearchOutlined, SyncOutlined, } from '@ant-design/icons-vue' +import { notification } from 'ant-design-vue' import { computed, ref } from 'vue' import { useDesktopRuntime } from '../composables/useDesktopRuntime' @@ -26,6 +28,10 @@ const consolePendingAccountId = ref(null) const unbindPendingAccountId = ref(null) const reauthorizePendingAccountId = ref(null) const probePendingAccountIds = ref(new Set()) +const batchProbePending = ref(false) +const batchProbeRunningAccountIds = ref([]) +const batchProbeQueuedAccountIds = ref([]) +const batchProbeTotalCount = ref(0) const actionSuccess = ref(null) type AccountAuthState = 'authorized' | 'expired' | 'attention' | 'risk' @@ -43,6 +49,18 @@ const overview = computed(() => ({ onlineClients: snapshot.value?.summary.onlineClients ?? 0, })) +const batchProbeButtonLabel = computed(() => { + if (!batchProbePending.value) { + return '一键检测账号' + } + + const finishedCount = + batchProbeTotalCount.value - + batchProbeRunningAccountIds.value.length - + batchProbeQueuedAccountIds.value.length + return `检测中 ${Math.max(0, finishedCount)}/${batchProbeTotalCount.value}` +}) + const platformCards = computed(() => desktopPublishMediaCatalog.map((platform) => { const matched = accounts.value.filter((account) => account.platform === platform.id) @@ -280,6 +298,96 @@ function isProbePending(account: AccountRow): boolean { ) } +async function runLimitedAccountProbes( + targetAccounts: readonly AccountRow[], + concurrency = 2, +): Promise { + const queue = [...targetAccounts] + let cursor = 0 + const failures: Error[] = [] + + const runNext = async () => { + while (cursor < queue.length) { + const account = queue[cursor] + cursor += 1 + batchProbeQueuedAccountIds.value = batchProbeQueuedAccountIds.value.filter( + (accountId) => accountId !== account.id, + ) + batchProbeRunningAccountIds.value = [...batchProbeRunningAccountIds.value, account.id] + probePendingAccountIds.value = new Set([...probePendingAccountIds.value, account.id]) + + try { + await probeAccount(account.id) + } catch (error) { + failures.push(error instanceof Error ? error : new Error(String(error))) + } finally { + const next = new Set(probePendingAccountIds.value) + next.delete(account.id) + probePendingAccountIds.value = next + batchProbeRunningAccountIds.value = batchProbeRunningAccountIds.value.filter( + (accountId) => accountId !== account.id, + ) + } + } + } + + await Promise.all( + Array.from({ length: Math.min(concurrency, queue.length) }, () => runNext()), + ) + return failures +} + +async function probeAllAccounts() { + if (batchProbePending.value) { + return + } + + const targetAccounts = accounts.value + if (targetAccounts.length === 0) { + notification.info({ + message: '暂无可检测账号', + description: '请先绑定至少一个媒体账号。', + placement: 'topRight', + duration: 3.2, + }) + return + } + + batchProbePending.value = true + batchProbeRunningAccountIds.value = [] + batchProbeQueuedAccountIds.value = targetAccounts.map((account) => account.id) + batchProbeTotalCount.value = targetAccounts.length + actionSuccess.value = null + + try { + const failures = await runLimitedAccountProbes(targetAccounts, 2) + await refresh() + if (failures.length > 0) { + notification.warning({ + message: '检测完成,部分失败', + description: `已检测 ${targetAccounts.length} 个媒体账号,其中 ${failures.length} 个检测失败。`, + placement: 'topRight', + duration: 4, + }) + return + } + notification.success({ + message: '检测完成', + description: `已检测 ${targetAccounts.length} 个媒体账号。`, + placement: 'topRight', + duration: 3.2, + }) + } catch (error) { + showClientActionError('probe-account', error) + await refresh() + } finally { + batchProbePending.value = false + batchProbeRunningAccountIds.value = [] + batchProbeQueuedAccountIds.value = [] + batchProbeTotalCount.value = 0 + } +} + async function recoverAccount(account: AccountRow) { if (authState(account) !== 'expired') { await verifyAccount(account) @@ -322,11 +430,22 @@ const tableColumns = [

媒体账号管理

+ + + {{ batchProbeButtonLabel }} + @@ -658,6 +777,12 @@ const tableColumns = [ padding: 36px 40px; } +.hero-actions { + display: flex; + align-items: center; + gap: 10px; +} + .eyebrow { margin: 0 0 12px; color: #64748b; diff --git a/apps/desktop-client/src/renderer/views/AiPlatformsView.vue b/apps/desktop-client/src/renderer/views/AiPlatformsView.vue index 67c939a..b1ad049 100644 --- a/apps/desktop-client/src/renderer/views/AiPlatformsView.vue +++ b/apps/desktop-client/src/renderer/views/AiPlatformsView.vue @@ -2,6 +2,7 @@ import { ArrowRightOutlined, DeleteOutlined, + SafetyCertificateOutlined, LinkOutlined, ReloadOutlined, WarningOutlined, @@ -17,11 +18,15 @@ import type { RuntimeAccount } from '../types' type AccountRow = Readonly> & { tags: readonly string[] } -const { snapshot, refresh, refreshAccounts, loading } = useDesktopRuntime() +const { snapshot, refresh, refreshAccounts, probeAccount, loading } = useDesktopRuntime() const bindPendingPlatformId = ref(null) const openPendingAccountId = ref(null) const unbindPendingAccountId = ref(null) +const batchProbePending = ref(false) +const batchProbeRunningAccountIds = ref([]) +const batchProbeQueuedAccountIds = ref([]) +const batchProbeTotalCount = ref(0) const aiAccounts = computed(() => (snapshot.value?.accounts ?? []).filter((account) => @@ -53,6 +58,24 @@ const overview = computed(() => ({ onlineClients: snapshot.value?.summary.onlineClients ?? 0, })) +const boundAIAccounts = computed(() => + platformCards.value + .map((platform) => platform.account) + .filter((account): account is AccountRow => Boolean(account)), +) + +const batchProbeButtonLabel = computed(() => { + if (!batchProbePending.value) { + return '一键检测账号' + } + + const finishedCount = + batchProbeTotalCount.value - + batchProbeRunningAccountIds.value.length - + batchProbeQueuedAccountIds.value.length + return `检测中 ${Math.max(0, finishedCount)}/${batchProbeTotalCount.value}` +}) + function authLabel(account: AccountRow | null) { if (!account) { return '未绑定' @@ -181,6 +204,81 @@ function showActionNotification(type: 'success' | 'info', title: string, descrip }) } +async function runLimitedAccountProbes( + accounts: readonly AccountRow[], + concurrency = 2, +): Promise { + const queue = [...accounts] + let cursor = 0 + const failures: Error[] = [] + + const runNext = async () => { + while (cursor < queue.length) { + const account = queue[cursor] + cursor += 1 + batchProbeQueuedAccountIds.value = batchProbeQueuedAccountIds.value.filter( + (accountId) => accountId !== account.id, + ) + batchProbeRunningAccountIds.value = [...batchProbeRunningAccountIds.value, account.id] + + try { + await probeAccount(account.id) + } catch (error) { + failures.push(error instanceof Error ? error : new Error(String(error))) + } finally { + batchProbeRunningAccountIds.value = batchProbeRunningAccountIds.value.filter( + (accountId) => accountId !== account.id, + ) + } + } + } + + await Promise.all( + Array.from({ length: Math.min(concurrency, queue.length) }, () => runNext()), + ) + return failures +} + +async function probeAllAIAccounts() { + if (batchProbePending.value) { + return + } + + const accounts = boundAIAccounts.value + if (accounts.length === 0) { + showActionNotification('info', '暂无可检测账号', '请先绑定至少一个 AI 平台账号。') + return + } + + batchProbePending.value = true + batchProbeRunningAccountIds.value = [] + batchProbeQueuedAccountIds.value = accounts.map((account) => account.id) + batchProbeTotalCount.value = accounts.length + + try { + const failures = await runLimitedAccountProbes(accounts, 2) + await refresh() + if (failures.length > 0) { + notification.warning({ + message: '检测完成,部分失败', + description: `已检测 ${accounts.length} 个 AI 平台账号,其中 ${failures.length} 个检测失败。`, + placement: 'topRight', + duration: 4, + }) + return + } + showActionNotification('success', '检测完成', `已检测 ${accounts.length} 个 AI 平台账号。`) + } catch (error) { + showClientActionError('probe-account', error) + await refresh() + } finally { + batchProbePending.value = false + batchProbeRunningAccountIds.value = [] + batchProbeQueuedAccountIds.value = [] + batchProbeTotalCount.value = 0 + } +} + async function bindPlatform(platformId: string) { bindPendingPlatformId.value = platformId @@ -246,11 +344,22 @@ async function unbindPlatform(account: AccountRow) {

AI 平台管理

+ + + {{ batchProbeButtonLabel }} + @@ -457,6 +566,12 @@ async function unbindPlatform(account: AccountRow) { padding: 36px 40px; } +.hero-actions { + display: flex; + align-items: center; + gap: 10px; +} + .eyebrow { margin: 0 0 12px; color: #64748b;