feat(doubao): implement challenge handling and monitoring improvements
Desktop Client Build / Resolve Build Metadata (push) Successful in 26s
Frontend CI / Frontend (push) Successful in 3m12s
Backend CI / Backend (push) Failing after 10m8s
Desktop Client Build / Build Desktop Client (push) Successful in 23m53s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 26s

- Added classification for `doubao_challenge_required` as a challenge in platform-auth-adapters.
- Enhanced account action summaries to include specific messages for Doubao human verification.
- Introduced monitoring functions to filter out blocked platforms based on account health.
- Updated task leasing to support platform-specific filtering for monitor tasks.
- Refined issue aggregation in HomeView to consolidate Doubao challenge failures into a single actionable item.
- Improved backend logic to handle platform IDs in task leasing requests and responses.
- Added tests for new functionality, ensuring proper handling of Doubao challenges and task leasing logic.
This commit is contained in:
Xu Liang
2026-06-24 02:31:35 +08:00
parent a406971187
commit 0afd082d96
11 changed files with 2456 additions and 272 deletions
@@ -3,15 +3,21 @@ import { describe, expect, it } from 'vitest'
import { __doubaoTestUtils } from './doubao'
const {
buildDoubaoDomSearchResults,
buildSourceItem,
dedupeSourceItems,
doubaoModeProviderModel,
doubaoModeKindFromLabelText,
extractQuestionText,
isDoubaoRecoverablePageError,
isNonCitationAssetDomain,
isNonCitationAssetUrl,
hasDoubaoFinishedStreamCapture,
parseDoubaoCaptures,
parseDoubaoStreamBody,
resolveDoubaoSubmissionAnswer,
shouldAllowDoubaoDomAnswer,
shouldAllowDoubaoReasoningAnswer,
shouldRetryDoubaoPageAttempt,
} = __doubaoTestUtils
@@ -92,6 +98,36 @@ describe('doubao adapter helpers', () => {
).toBe(false)
})
it('does not retry a recoverable page timeout when the page already has a usable answer', () => {
const streamSummary = parseDoubaoStreamBody(
['event: SSE_ACK', 'data: {"ack_client_meta":{"conversation_id":"c1"}}', '', ''].join('\n'),
)
expect(
shouldRetryDoubaoPageAttempt(
{
ok: false,
error: 'doubao_query_timeout',
detail: '页面轮询超时',
url: 'https://www.doubao.com/chat/local_4995117315817271',
title: '豆包',
modelLabel: '豆包',
modeLabel: '思考',
thinkingModeApplied: true,
deepThinkEnabled: true,
domAnswer:
'室内门锁供货商通常要从供货稳定性、锁体规格、表面处理、质检能力和售后响应几个维度筛选。建议优先核验工厂资质、样品一致性、交期和五金配套能力。',
domReasoning: null,
domLinks: [],
submitted: true,
questionAnchorFound: true,
historySurfaceDetected: true,
},
streamSummary,
),
).toBe(false)
})
it('maps the new expert/deep-thinking mode label to expert mode', () => {
expect(doubaoModeKindFromLabelText('专家')).toBe('expert')
expect(doubaoModeKindFromLabelText('专家 深度思考 研究级智能模型')).toBe('expert')
@@ -99,6 +135,11 @@ describe('doubao adapter helpers', () => {
expect(doubaoModeKindFromLabelText('快速')).toBe('fast')
})
it('reports Doubao default/expert mode as the fast web provider model', () => {
expect(doubaoModeProviderModel('fast')).toBe('doubao-web-fast')
expect(doubaoModeProviderModel('expert')).toBe('doubao-web-fast')
})
it('filters byteimg and bytednsdoc asset CDN links from source items', () => {
expect(isNonCitationAssetDomain('p3-spider-image-sign.byteimg.com')).toBe(true)
expect(isNonCitationAssetDomain('lf3-static.bytednsdoc.com')).toBe(true)
@@ -148,6 +189,103 @@ describe('doubao adapter helpers', () => {
).toHaveLength(1)
})
it('merges multiple thinking search-reference groups from the page fallback', () => {
const domLinks = [
{
url: 'https://example.com/guide-1',
title: '2026年全国室内分体锁批发选购指南参考',
text: '2026年全国室内分体锁批发选购指南参考',
siteName: null,
},
{
url: 'https://example.com/zhongshan-lock',
title: '中山小榄头部锁厂盘点',
text: '中山小榄头部锁厂盘点',
siteName: null,
},
{
url: 'https://example.com/brand-rank',
title: '2026室内门锁品牌实测排行',
text: '2026室内门锁品牌实测排行',
siteName: null,
},
{
url: 'https://example.com/guide-1#search-two',
title: '重复链接应按去 hash 后合并',
text: '重复链接应按去 hash 后合并',
siteName: null,
},
{
url: 'https://p11-spider-image-sign.byteimg.com/logo.jpeg',
title: 'favicon asset',
text: 'favicon asset',
siteName: null,
},
{
url: 'https://supplier.example.cn/manufacturers-of-door-locks-supplier.html',
title: 'Manufacturers of door locks supplier.html',
text: 'Manufacturers of door locks supplier.html',
siteName: null,
},
]
const results = buildDoubaoDomSearchResults(domLinks)
expect(results.map((item) => item.url)).toEqual([
'https://example.com/guide-1',
'https://example.com/zhongshan-lock',
'https://example.com/brand-rank',
'https://supplier.example.cn/manufacturers-of-door-locks-supplier.html',
])
expect(results[0]?.title).toBe('2026年全国室内分体锁批发选购指南参考')
})
it('keeps expanded Doubao reference links from the DOM fallback', () => {
const results = buildDoubaoDomSearchResults([
{
url: 'https://www.doubao.com/link?target=https%3A%2F%2Fexample.com%2Fsource%23card',
title: 'real blue source title',
text: 'real blue source title',
siteName: 'example site',
},
{
url: 'https://another.example.cn/article?from=doubao',
title: 'another expanded source',
text: 'another expanded source',
siteName: null,
},
])
expect(results.map((item) => item.url)).toEqual([
'https://example.com/source',
'https://another.example.cn/article?from=doubao',
])
expect(results[0]).toMatchObject({
title: 'real blue source title',
site_name: 'example site',
host: 'example.com',
})
})
it('does not turn Doubao gray search-query text into DOM search results', () => {
const results = buildDoubaoDomSearchResults([
{
url: '搜索 3 个关键词,参考 18 篇资料\n"无下轨折叠门套件"\nhttps://example.com/gray-text-only',
title: '搜索 3 个关键词,参考 18 篇资料',
text: '搜索 3 个关键词,参考 18 篇资料\n"无下轨折叠门套件"',
siteName: null,
},
{
url: '1. reference https://www.doubao.com/link?target=https%3A%2F%2Fexample.com%2Fsource%3Ffrom%3Dgray',
title: 'gray explanation line',
text: 'gray explanation line',
siteName: null,
},
])
expect(results).toEqual([])
})
it('uses thinking-chain reference metadata to fill unknown Doubao source titles', () => {
const body = [
'event: STREAM_CHUNK',
@@ -178,7 +316,7 @@ describe('doubao adapter helpers', () => {
})
})
it('extracts source URLs embedded in Doubao thinking-panel text', () => {
it('still extracts embedded source URLs from structured SSE source payloads', () => {
const item = buildSourceItem({
url: '1. 合肥全屋定制榜单 https://www.doubao.com/link?target=https%3A%2F%2Fexample.com%2Fsource%3Ffrom%3Dthink#card',
title: '思考链路里的来源',
@@ -330,6 +468,172 @@ describe('doubao adapter helpers', () => {
})
})
it('uses in-page incremental SSE captures before Playwright response.body resolves', () => {
const streamBody = [
'event: SSE_ACK',
'data: {"ack_client_meta":{"conversation_id":"38432170130380802"}}',
'',
'event: STREAM_CHUNK',
'data: {"references":[{"url":"https://example.com/source-a","title":"真实参考来源","site_name":"示例站点"}]}',
'',
'event: CHUNK_DELTA',
'data: {"text":"豆包 SSE 可以边生成边拿到答案,"}',
'',
'event: CHUNK_DELTA',
'data: {"text":"即使底层连接还没有完全关闭,也应优先提交这段完整正文。"}',
'',
'event: SSE_REPLY_END',
'data: {"end_type":2,"answer_finish_attr":{"has_suggest":true}}',
'',
'',
].join('\n')
const captures = [
{
captureId: 'fetch-1-1',
url: 'https://www.doubao.com/chat/completion?aid=497858',
status: 200,
contentType: 'text/event-stream',
body: streamBody,
},
]
const summary = parseDoubaoCaptures(captures)
expect(hasDoubaoFinishedStreamCapture(captures)).toBe(true)
expect(summary.answer).toBe(
'豆包 SSE 可以边生成边拿到答案,即使底层连接还没有完全关闭,也应优先提交这段完整正文。',
)
expect(summary.answer_finish_event_count).toBe(1)
expect(summary.search_results).toHaveLength(1)
expect(
resolveDoubaoSubmissionAnswer({
answer: summary.answer,
sseFinished: summary.answer_finish_event_count > 0,
}),
).toEqual({
answer:
'豆包 SSE 可以边生成边拿到答案,即使底层连接还没有完全关闭,也应优先提交这段完整正文。',
source: 'sse',
})
})
it('falls back to the page answer when SSE has not finished but the page is usable', () => {
const domAnswer =
'室内分体锁批发通常要先确认锁体中心距、面板材质、执手方向、钥匙系统和包装要求。批量采购时建议同时核验样品、交期、质检记录和售后换货规则。'
expect(
resolveDoubaoSubmissionAnswer({
answer: '室内分体锁批发',
domAnswer,
allowDomAnswer: true,
sseFinished: false,
}),
).toEqual({
answer: domAnswer,
source: 'dom',
})
})
it('keeps the finished SSE answer ahead of the page fallback', () => {
expect(
resolveDoubaoSubmissionAnswer({
answer:
'室内分体锁批发可以从规格兼容性、供货稳定性、样品一致性和售后响应四个维度筛选供应商。',
domAnswer: '页面上的室内分体锁批发答案通常更长,但完整 SSE 已经确认时仍优先使用 SSE 正文。',
allowDomAnswer: true,
sseFinished: true,
}),
).toEqual({
answer:
'室内分体锁批发可以从规格兼容性、供货稳定性、样品一致性和售后响应四个维度筛选供应商。',
source: 'sse',
})
})
it('allows DOM fallback for recoverable Doubao page errors with an anchored answer', () => {
const streamSummary = parseDoubaoStreamBody(
['event: SSE_ACK', 'data: {"ack_client_meta":{"conversation_id":"c1"}}', '', ''].join('\n'),
)
expect(
shouldAllowDoubaoDomAnswer({
streamSummary,
pageResult: {
ok: false,
error: 'doubao_query_timeout',
detail: '页面轮询超时',
url: 'https://www.doubao.com/chat/local_4995117315817271',
title: '豆包',
modelLabel: '豆包',
modeLabel: '思考',
thinkingModeApplied: true,
deepThinkEnabled: true,
domAnswer:
'室内门锁供货商通常要从供货稳定性、锁体规格、表面处理、质检能力和售后响应几个维度筛选。建议优先核验工厂资质、样品一致性、交期和五金配套能力。',
domReasoning: null,
domLinks: [],
submitted: true,
questionAnchorFound: true,
historySurfaceDetected: false,
},
}),
).toBe(true)
})
it('uses thinking-chain text as the fallback answer when page answer is missing', () => {
const reasoning =
'我计划找室内门锁供货商,先搜索国内知名品牌厂家、批发采购渠道和区域供应商信息。已经整理出主流品牌供货商、源头工厂、线上批发平台及补充采购建议,可为用户提供全面参考。'
const streamSummary = parseDoubaoStreamBody(
['event: SSE_ACK', 'data: {"ack_client_meta":{"conversation_id":"c1"}}', '', ''].join('\n'),
)
const pageResult = {
ok: false,
error: 'doubao_query_timeout',
detail: '页面轮询超时',
url: 'https://www.doubao.com/chat/local_4995117315817271',
title: '豆包',
modelLabel: '豆包',
modeLabel: '思考',
thinkingModeApplied: true,
deepThinkEnabled: true,
domAnswer: null,
domReasoning: reasoning,
domLinks: [
{
url: 'https://example.com/source',
title: '室内门锁供货商参考',
text: '室内门锁供货商参考',
siteName: null,
},
],
submitted: true,
questionAnchorFound: true,
historySurfaceDetected: false,
}
expect(
shouldAllowDoubaoReasoningAnswer({
pageResult,
streamSummary,
reasoning,
searchResultCount: 1,
}),
).toBe(true)
expect(
resolveDoubaoSubmissionAnswer({
answer: null,
domAnswer: null,
reasoning,
allowDomAnswer: false,
allowReasoningAnswer: true,
sseFinished: false,
}),
).toEqual({
answer: reasoning,
source: 'reasoning',
})
})
it('does not submit DOM answer before a finish signal allows DOM recovery', () => {
expect(
resolveDoubaoSubmissionAnswer({
@@ -357,4 +661,25 @@ describe('doubao adapter helpers', () => {
source: null,
})
})
it('does not submit search-reference panels as the DOM answer', () => {
expect(
resolveDoubaoSubmissionAnswer({
answer: null,
domAnswer: [
'已完成思考,参考 18 篇资料',
'搜索 3 个关键词,参考 18 篇资料',
'1. 2026 年五金配件采购指南',
'2. 幽灵门轨道安装注意事项',
'3. https://example.com/door-hardware',
'4. 某某官网|产品资料',
'5. 行业资讯 - 采购参考',
].join('\n'),
allowDomAnswer: true,
}),
).toEqual({
answer: null,
source: null,
})
})
})
File diff suppressed because it is too large Load Diff
@@ -73,6 +73,11 @@ function classifyFailure(input: PlatformFailureInput): PlatformFailureClassifica
}
function classifyDoubaoFailure(input: PlatformFailureInput): PlatformFailureClassification {
const code = typeof input.error?.code === 'string' ? input.error.code : ''
if (code === 'doubao_challenge_required') {
return 'challenge'
}
const text = normalizeFailureText(input)
if (!text) {
return 'ok'
@@ -454,9 +454,52 @@ function accountActionRequiredSummary(
if (authReason === 'risk_control') {
return `${label} 账号疑似触发风控,请打开平台处理验证或等待限制解除后再继续执行。`
}
if (platform === 'doubao') {
return '豆包账号触发人机验证,请在桌面客户端打开豆包完成验证码后再继续采集。'
}
return `${label} 账号需要完成人机验证后才能继续执行。`
}
function monitorPlatformBlockedReason(platform: string): string | null {
const account = state.accounts.find(
(item) => item.platform === platform && isAccountOwnedByCurrentClient(item),
)
if (!account) {
return null
}
const projected = getProjectedAccountHealth({
accountId: account.id,
platform: account.platform,
health: account.health,
verifiedAt: account.verified_at,
})
if (projected.authState === 'challenge_required') {
return accountActionRequiredSummary(account.platform, projected.authReason)
}
if (projected.authState === 'expired' || projected.authState === 'revoked') {
return `${runtimePlatformLabel(account.platform)} 账号登录态已失效,请重新授权后再继续执行。`
}
return null
}
function blockedMonitorPlatforms(): Set<string> {
const blocked = new Set<string>()
for (const platform of aiPlatformCatalog.map((item) => item.id)) {
if (monitorPlatformBlockedReason(platform)) {
blocked.add(platform)
}
}
return blocked
}
function eligibleMonitorPlatformIds(): string[] {
const blocked = blockedMonitorPlatforms()
return aiPlatformCatalog
.map((platform) => platform.id)
.filter((platform) => !blocked.has(platform))
}
function markAuthorizationFailureNonRetryable(
error: Record<string, JsonValue>,
category: 'ai_platform_authorization' | 'media_account_authorization',
@@ -620,6 +663,14 @@ function maybeRecordAccountAccessAlert(
recordActivity('warn', '百家号触发平台风控', BAIJIAHAO_RISK_CONTROL_PROMPT)
return
}
if (accountIdentity.platform === 'doubao') {
recordActivity(
'warn',
'豆包需要人工验证',
`${accountIdentity.displayName || task.accountName || '当前账号'} 触发豆包人机验证,请打开豆包完成验证码后再刷新状态。`,
)
return
}
recordActivity(
'warn',
`${runtimePlatformLabel(accountIdentity.platform)} 需要人工验证`,
@@ -1938,11 +1989,15 @@ function pumpExecutionLoop(): void {
}
if (canStartAnotherMonitorExecution()) {
const blockedMonitorExecutionPlatforms = new Set([
...blockedPlaywrightPlatforms,
...blockedMonitorPlatforms(),
])
const selected = selectNextMonitorTask({
maxConcurrency: resolveAdaptiveMonitorConcurrency(),
activePlatforms: activeMonitorPlatforms(),
activeQuestionKeys: activeMonitorQuestionKeys(),
blockedPlatforms: blockedPlaywrightPlatforms,
blockedPlatforms: blockedMonitorExecutionPlatforms,
activeCount: activeMonitorCount(),
})
if (selected) {
@@ -2041,7 +2096,15 @@ async function pullNextTask(kind: 'publish' | 'monitor'): Promise<void> {
state.leaseInFlightKinds.add('monitor')
try {
const leased = await leaseDesktopTask({ kind: 'monitor' })
const eligiblePlatformIds = eligibleMonitorPlatformIds()
if (eligiblePlatformIds.length === 0) {
state.monitorFallbackBackoffUntil = Date.now() + monitorPullFallbackIntervalMs
return
}
const leased = await leaseDesktopTask({
kind: 'monitor',
platform_ids: eligiblePlatformIds,
})
state.lastPullAt = Date.now()
state.lastPullStatus = 'success'
noteTransportPull(true)
@@ -2125,8 +2188,14 @@ async function pullMonitoringTasks(routing: RuntimeTaskRouting): Promise<void> {
state.leaseInFlightKinds.add('monitor')
try {
const eligiblePlatformIds = eligibleMonitorPlatformIds()
if (eligiblePlatformIds.length === 0) {
state.monitorFallbackBackoffUntil = Date.now() + monitorPullFallbackIntervalMs
return
}
const leased = await leaseMonitoringTasks({
limit: leaseLimit,
ai_platform_ids: eligiblePlatformIds,
})
state.lastPullAt = Date.now()
state.lastPullStatus = 'success'
@@ -2156,7 +2225,13 @@ async function resumeLeasedMonitoringTasks(source: 'startup' | 'reconnect'): Pro
}
try {
const resumed = await resumeMonitoringTasks({})
const eligiblePlatformIds = eligibleMonitorPlatformIds()
if (eligiblePlatformIds.length === 0) {
return
}
const resumed = await resumeMonitoringTasks({
ai_platform_ids: eligiblePlatformIds,
})
enqueueLeasedMonitoringTasks(resumed.tasks, 'db-recovery')
if (resumed.tasks.length > 0) {
recordActivity(
@@ -12,6 +12,7 @@ import { useDesktopRuntime } from '../composables/useDesktopRuntime'
import { formatRelativeTime } from '../lib/formatters'
import { translateDesktopPlatform } from '../lib/media-catalog'
import { healthTone } from '../lib/runtime-ui'
import type { RuntimeTask } from '../types'
function translatePlatform(platform: string) {
return translateDesktopPlatform(platform)
@@ -55,6 +56,40 @@ type RuntimeIssueItem = {
at: number
}
const DOUBAO_CHALLENGE_DETAIL =
'豆包账号触发人机验证,请在桌面客户端打开豆包完成验证码后再继续采集。'
const DOUBAO_CHALLENGE_PATTERN =
/doubao_challenge_required|challenge_required|captcha_gate|risk_control|人机验证|人工验证|验证码|风控|安全验证/i
function isDoubaoPlatform(platform: string) {
return platform.toLowerCase() === 'doubao'
}
function doubaoTaskChallengeKey(task: RuntimeTask) {
return `${task.platform.toLowerCase()}::${task.accountId || task.accountName || 'unknown'}`
}
function doubaoAccountChallengeKey(account: {
platform: string
id: string
displayName: string
platformUid: string
}) {
return `${account.platform.toLowerCase()}::${account.id || account.displayName || account.platformUid || 'unknown'}`
}
function isDoubaoChallengeTask(task: RuntimeTask) {
if (!isDoubaoPlatform(task.platform)) {
return false
}
return DOUBAO_CHALLENGE_PATTERN.test(`${task.summary || ''} ${task.title || ''}`)
}
function isDoubaoChallengeAccount(account: { platform: string; authState: string }) {
return isDoubaoPlatform(account.platform) && account.authState === 'challenge_required'
}
function translateEventTitle(title: string) {
const map: Record<string, string> = {
'Accounts Synced': '本地账号同步',
@@ -115,37 +150,110 @@ const blockedAccounts = computed(() =>
const blockedTasks = computed(() =>
tasks.value.filter((item) => ['failed', 'unknown'].includes(item.status)),
)
const issueBreakdown = computed(() => ({
tasks: blockedTasks.value.length,
accounts: blockedAccounts.value.length,
total: blockedTasks.value.length + blockedAccounts.value.length,
}))
const issueItems = computed<RuntimeIssueItem[]>(() => {
const taskIssues = blockedTasks.value.map((task) => ({
id: `task-${task.id}`,
kind: 'task' as const,
title: task.title || task.jobId,
meta: `${task.accountName} · ${translatePlatform(task.platform)}`,
detail: task.summary,
badgeLabel: translateTaskStatus(task.status),
badgeTone: 'danger' as const,
at: task.updatedAt,
}))
const accountIssues = blockedAccounts.value.map((account) => ({
id: `account-${account.id}`,
kind: 'account' as const,
title: account.displayName,
meta: `${translatePlatform(account.platform)} · ${formatUid(account.platformUid)}`,
detail:
account.authReason === 'risk_control'
? '平台触发风控,需要人工验证后恢复任务执行。'
: '授权状态不可用,需要重新登录或确认账号状态。',
badgeLabel: blockedAccountLabel(account),
badgeTone: healthTone(account.health) === 'warn' ? ('warn' as const) : ('danger' as const),
at: account.lastVerifiedAt ?? account.lastSyncAt,
}))
const doubaoChallengeTaskGroups = new Map<string, RuntimeTask[]>()
return [...taskIssues, ...accountIssues].sort((left, right) => right.at - left.at)
for (const task of blockedTasks.value) {
if (!isDoubaoChallengeTask(task)) {
continue
}
const key = doubaoTaskChallengeKey(task)
const group = doubaoChallengeTaskGroups.get(key)
if (group) {
group.push(task)
} else {
doubaoChallengeTaskGroups.set(key, [task])
}
}
const doubaoChallengeAccountKeys = new Set(
blockedAccounts.value
.filter(isDoubaoChallengeAccount)
.map((account) => doubaoAccountChallengeKey(account)),
)
const doubaoChallengeKeys = new Set([
...doubaoChallengeTaskGroups.keys(),
...doubaoChallengeAccountKeys,
])
const taskIssues = blockedTasks.value
.filter(
(task) =>
!isDoubaoPlatform(task.platform) || !doubaoChallengeKeys.has(doubaoTaskChallengeKey(task)),
)
.map((task) => ({
id: `task-${task.id}`,
kind: 'task' as const,
title: task.title || task.jobId,
meta: `${task.accountName} · ${translatePlatform(task.platform)}`,
detail: task.summary,
badgeLabel: translateTaskStatus(task.status),
badgeTone: 'danger' as const,
at: task.updatedAt,
}))
const doubaoChallengeTaskIssues = Array.from(doubaoChallengeTaskGroups.entries())
.filter(([key]) => !doubaoChallengeAccountKeys.has(key))
.map(([key, challengeTasks]) => {
const mergedTasks = blockedTasks.value.filter(
(task) => isDoubaoPlatform(task.platform) && doubaoTaskChallengeKey(task) === key,
)
const representative = mergedTasks.reduce(
(latest, task) => (task.updatedAt > latest.updatedAt ? task : latest),
challengeTasks[0],
)
const metaParts = [
`${representative.accountName || '豆包账号'} · ${translatePlatform(representative.platform)}`,
mergedTasks.length > 1 ? `已合并 ${mergedTasks.length} 个失败任务` : '',
].filter(Boolean)
return {
id: `task-doubao-challenge-${key}`,
kind: 'task' as const,
title: '豆包需要人工验证',
meta: metaParts.join(' · '),
detail: DOUBAO_CHALLENGE_DETAIL,
badgeLabel: '执行失败',
badgeTone: 'danger' as const,
at: Math.max(...mergedTasks.map((task) => task.updatedAt)),
}
})
const accountIssues = blockedAccounts.value.map((account) => {
const doubaoChallenge = isDoubaoChallengeAccount(account)
return {
id: `account-${account.id}`,
kind: 'account' as const,
title: doubaoChallenge ? '豆包需要人工验证' : account.displayName,
meta: doubaoChallenge
? `${account.displayName || '豆包账号'} · ${translatePlatform(account.platform)}`
: `${translatePlatform(account.platform)} · ${formatUid(account.platformUid)}`,
detail: doubaoChallenge
? DOUBAO_CHALLENGE_DETAIL
: account.authReason === 'risk_control'
? '平台触发风控,需要人工验证后恢复任务执行。'
: '授权状态不可用,需要重新登录或确认账号状态。',
badgeLabel: doubaoChallenge ? '执行失败' : blockedAccountLabel(account),
badgeTone: healthTone(account.health) === 'warn' ? ('warn' as const) : ('danger' as const),
at: account.lastVerifiedAt ?? account.lastSyncAt,
}
})
return [...taskIssues, ...doubaoChallengeTaskIssues, ...accountIssues].sort(
(left, right) => right.at - left.at,
)
})
const issueBreakdown = computed(() => {
const tasks = issueItems.value.filter((item) => item.kind === 'task').length
const accounts = issueItems.value.filter((item) => item.kind === 'account').length
return {
tasks,
accounts,
total: tasks + accounts,
}
})
</script>
@@ -191,7 +299,7 @@ const issueItems = computed<RuntimeIssueItem[]>(() => {
<span class="eyebrow">异常干预</span>
</div>
<div class="metric-body">
<strong>{{ snapshot?.summary.issuesOpen ?? 0 }}</strong>
<strong>{{ issueBreakdown.total }}</strong>
<p>任务异常 {{ issueBreakdown.tasks }} / 账号阻断 {{ issueBreakdown.accounts }}</p>
</div>
</article>