feat(desktop): add batch account probes
This commit is contained in:
@@ -26,7 +26,7 @@ const REGULAR_PROBE_MAX_MS = STALE_AFTER_MS
|
|||||||
const CONFIRM_BACKOFF_STEPS_MS = [30_000, 120_000] as const
|
const CONFIRM_BACKOFF_STEPS_MS = [30_000, 120_000] as const
|
||||||
const NETWORK_RETRY_MS = 2 * 60_000
|
const NETWORK_RETRY_MS = 2 * 60_000
|
||||||
const PREFLIGHT_MAX_AGE_MS = 15 * 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_CACHE_TTL_MS = 5 * 60_000
|
||||||
const MANUAL_PROBE_MIN_INTERVAL_MS = 15_000
|
const MANUAL_PROBE_MIN_INTERVAL_MS = 15_000
|
||||||
const ACCOUNT_PROBE_RETRY_BACKOFF_MS = [1_000, 3_000, 10_000] as const
|
const ACCOUNT_PROBE_RETRY_BACKOFF_MS = [1_000, 3_000, 10_000] as const
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
LinkOutlined,
|
LinkOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
|
import { notification } from 'ant-design-vue'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import { useDesktopRuntime } from '../composables/useDesktopRuntime'
|
import { useDesktopRuntime } from '../composables/useDesktopRuntime'
|
||||||
@@ -26,6 +28,10 @@ const consolePendingAccountId = ref<string | null>(null)
|
|||||||
const unbindPendingAccountId = ref<string | null>(null)
|
const unbindPendingAccountId = ref<string | null>(null)
|
||||||
const reauthorizePendingAccountId = ref<string | null>(null)
|
const reauthorizePendingAccountId = ref<string | null>(null)
|
||||||
const probePendingAccountIds = ref(new Set<string>())
|
const probePendingAccountIds = ref(new Set<string>())
|
||||||
|
const batchProbePending = ref(false)
|
||||||
|
const batchProbeRunningAccountIds = ref<readonly string[]>([])
|
||||||
|
const batchProbeQueuedAccountIds = ref<readonly string[]>([])
|
||||||
|
const batchProbeTotalCount = ref(0)
|
||||||
const actionSuccess = ref<string | null>(null)
|
const actionSuccess = ref<string | null>(null)
|
||||||
|
|
||||||
type AccountAuthState = 'authorized' | 'expired' | 'attention' | 'risk'
|
type AccountAuthState = 'authorized' | 'expired' | 'attention' | 'risk'
|
||||||
@@ -43,6 +49,18 @@ const overview = computed(() => ({
|
|||||||
onlineClients: snapshot.value?.summary.onlineClients ?? 0,
|
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(() =>
|
const platformCards = computed(() =>
|
||||||
desktopPublishMediaCatalog.map((platform) => {
|
desktopPublishMediaCatalog.map((platform) => {
|
||||||
const matched = accounts.value.filter((account) => account.platform === platform.id)
|
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<Error[]> {
|
||||||
|
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) {
|
async function recoverAccount(account: AccountRow) {
|
||||||
if (authState(account) !== 'expired') {
|
if (authState(account) !== 'expired') {
|
||||||
await verifyAccount(account)
|
await verifyAccount(account)
|
||||||
@@ -322,11 +430,22 @@ const tableColumns = [
|
|||||||
<h2>媒体账号管理</h2>
|
<h2>媒体账号管理</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
class="modern-btn"
|
||||||
|
:loading="batchProbePending"
|
||||||
|
:disabled="loading || accounts.length === 0"
|
||||||
|
@click="probeAllAccounts"
|
||||||
|
>
|
||||||
|
<template #icon><SafetyCertificateOutlined /></template>
|
||||||
|
{{ batchProbeButtonLabel }}
|
||||||
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
type="primary"
|
type="primary"
|
||||||
ghost
|
ghost
|
||||||
class="modern-btn"
|
class="modern-btn"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
:disabled="batchProbePending"
|
||||||
@click="refreshAccounts"
|
@click="refreshAccounts"
|
||||||
>
|
>
|
||||||
<template #icon><ReloadOutlined /></template>
|
<template #icon><ReloadOutlined /></template>
|
||||||
@@ -658,6 +777,12 @@ const tableColumns = [
|
|||||||
padding: 36px 40px;
|
padding: 36px 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import {
|
import {
|
||||||
ArrowRightOutlined,
|
ArrowRightOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
|
SafetyCertificateOutlined,
|
||||||
LinkOutlined,
|
LinkOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
@@ -17,11 +18,15 @@ import type { RuntimeAccount } from '../types'
|
|||||||
|
|
||||||
type AccountRow = Readonly<Omit<RuntimeAccount, 'tags'>> & { tags: readonly string[] }
|
type AccountRow = Readonly<Omit<RuntimeAccount, 'tags'>> & { tags: readonly string[] }
|
||||||
|
|
||||||
const { snapshot, refresh, refreshAccounts, loading } = useDesktopRuntime()
|
const { snapshot, refresh, refreshAccounts, probeAccount, loading } = useDesktopRuntime()
|
||||||
|
|
||||||
const bindPendingPlatformId = ref<string | null>(null)
|
const bindPendingPlatformId = ref<string | null>(null)
|
||||||
const openPendingAccountId = ref<string | null>(null)
|
const openPendingAccountId = ref<string | null>(null)
|
||||||
const unbindPendingAccountId = ref<string | null>(null)
|
const unbindPendingAccountId = ref<string | null>(null)
|
||||||
|
const batchProbePending = ref(false)
|
||||||
|
const batchProbeRunningAccountIds = ref<readonly string[]>([])
|
||||||
|
const batchProbeQueuedAccountIds = ref<readonly string[]>([])
|
||||||
|
const batchProbeTotalCount = ref(0)
|
||||||
|
|
||||||
const aiAccounts = computed(() =>
|
const aiAccounts = computed(() =>
|
||||||
(snapshot.value?.accounts ?? []).filter((account) =>
|
(snapshot.value?.accounts ?? []).filter((account) =>
|
||||||
@@ -53,6 +58,24 @@ const overview = computed(() => ({
|
|||||||
onlineClients: snapshot.value?.summary.onlineClients ?? 0,
|
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) {
|
function authLabel(account: AccountRow | null) {
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return '未绑定'
|
return '未绑定'
|
||||||
@@ -181,6 +204,81 @@ function showActionNotification(type: 'success' | 'info', title: string, descrip
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runLimitedAccountProbes(
|
||||||
|
accounts: readonly AccountRow[],
|
||||||
|
concurrency = 2,
|
||||||
|
): Promise<Error[]> {
|
||||||
|
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) {
|
async function bindPlatform(platformId: string) {
|
||||||
bindPendingPlatformId.value = platformId
|
bindPendingPlatformId.value = platformId
|
||||||
|
|
||||||
@@ -246,11 +344,22 @@ async function unbindPlatform(account: AccountRow) {
|
|||||||
<h2>AI 平台管理</h2>
|
<h2>AI 平台管理</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
class="modern-btn"
|
||||||
|
:loading="batchProbePending"
|
||||||
|
:disabled="loading || boundAIAccounts.length === 0"
|
||||||
|
@click="probeAllAIAccounts"
|
||||||
|
>
|
||||||
|
<template #icon><SafetyCertificateOutlined /></template>
|
||||||
|
{{ batchProbeButtonLabel }}
|
||||||
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
type="primary"
|
type="primary"
|
||||||
ghost
|
ghost
|
||||||
class="modern-btn"
|
class="modern-btn"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
:disabled="batchProbePending"
|
||||||
@click="refreshAccounts"
|
@click="refreshAccounts"
|
||||||
>
|
>
|
||||||
<template #icon><ReloadOutlined /></template>
|
<template #icon><ReloadOutlined /></template>
|
||||||
@@ -457,6 +566,12 @@ async function unbindPlatform(account: AccountRow) {
|
|||||||
padding: 36px 40px;
|
padding: 36px 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
|||||||
Reference in New Issue
Block a user