diff --git a/apps/desktop-client/src/renderer/types.ts b/apps/desktop-client/src/renderer/types.ts index c9a6d21..c59038f 100644 --- a/apps/desktop-client/src/renderer/types.ts +++ b/apps/desktop-client/src/renderer/types.ts @@ -62,6 +62,10 @@ export interface RuntimeTask { startedAt: number | null updatedAt: number summary: string + payload: Record + error: Record | null + result: Record | null + attemptId: string | null } export interface RuntimeActivity { diff --git a/apps/desktop-client/src/renderer/views/HomeView.vue b/apps/desktop-client/src/renderer/views/HomeView.vue index dd04128..0763b24 100644 --- a/apps/desktop-client/src/renderer/views/HomeView.vue +++ b/apps/desktop-client/src/renderer/views/HomeView.vue @@ -38,56 +38,206 @@ function translateTaskStatus(status: string) { return map[status?.toLowerCase()] || status } -function formatIssueDetail(issue: RuntimeIssueItem) { - if (issue.detail) { - return issue.detail - } - return issue.kind === 'task' ? '任务执行结果需要人工确认。' : '账号授权状态需要重新处理。' -} - type RuntimeIssueItem = { id: string kind: 'task' | 'account' title: string - meta: string - detail: string + location: string + reason: string + action: string badgeLabel: string badgeTone: 'danger' | 'warn' at: number } +type IssueAccount = { + id: string + platform: string + displayName: string + platformUid: string + health: 'live' | 'expired' | 'captcha' | 'risk' + authState: string + authReason: string | null + lastSyncAt: number + lastVerifiedAt: number | null +} + const DOUBAO_CHALLENGE_DETAIL = '豆包账号触发人机验证,请在桌面客户端打开豆包完成验证码后再继续采集。' +const DOUBAO_CHALLENGE_ACTION = '打开豆包完成验证码或安全验证,然后刷新账号状态并重试采集。' const DOUBAO_CHALLENGE_PATTERN = /doubao_challenge_required|challenge_required|captcha_gate|risk_control|人机验证|人工验证|验证码|风控|安全验证/i +const GENERIC_FAILURE_PATTERN = + /^(?:任务执行失败。?|执行失败。?|桌面任务执行失败。?|监控任务执行失败。?|发布任务执行失败。?|unknown_error)$/i function isDoubaoPlatform(platform: string) { return platform.toLowerCase() === 'doubao' } -function doubaoTaskChallengeKey(task: RuntimeTask) { - return `${task.platform.toLowerCase()}::${task.accountId || task.accountName || 'unknown'}` +function isDoubaoChallengeAccount(account: { platform: string; authState: string }) { + return isDoubaoPlatform(account.platform) && account.authState === 'challenge_required' } -function doubaoAccountChallengeKey(account: { - platform: string - id: string - displayName: string - platformUid: string -}) { - return `${account.platform.toLowerCase()}::${account.id || account.displayName || account.platformUid || 'unknown'}` +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function normalizeDisplayText(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const text = value.trim().replace(/\s+/g, ' ') + return text ? text : null +} + +function collectDiagnosticText(value: unknown, depth = 0): string[] { + if (depth > 4 || value == null) { + return [] + } + if (typeof value === 'string') { + const text = normalizeDisplayText(value) + return text ? [text] : [] + } + if (typeof value === 'number' || typeof value === 'boolean') { + return [String(value)] + } + if (Array.isArray(value)) { + return value.flatMap((item) => collectDiagnosticText(item, depth + 1)) + } + if (!isRecord(value)) { + return [] + } + + const priorityKeys = [ + 'summary', + 'message', + 'detail', + 'code', + 'auth_reason', + 'failure_category', + 'page_error', + 'runtime_error', + 'error', + ] + const priorityText = priorityKeys.flatMap((key) => collectDiagnosticText(value[key], depth + 1)) + const restText = Object.entries(value) + .filter(([key]) => !priorityKeys.includes(key)) + .flatMap(([, item]) => collectDiagnosticText(item, depth + 1)) + return [...priorityText, ...restText] +} + +function taskDiagnosticText(task: RuntimeTask) { + return [ + task.title, + task.summary, + ...collectDiagnosticText(task.error), + ...collectDiagnosticText(task.result), + ] + .filter(Boolean) + .join(' ') } function isDoubaoChallengeTask(task: RuntimeTask) { - if (!isDoubaoPlatform(task.platform)) { - return false - } - - return DOUBAO_CHALLENGE_PATTERN.test(`${task.summary || ''} ${task.title || ''}`) + return isDoubaoPlatform(task.platform) && DOUBAO_CHALLENGE_PATTERN.test(taskDiagnosticText(task)) } -function isDoubaoChallengeAccount(account: { platform: string; authState: string }) { - return isDoubaoPlatform(account.platform) && account.authState === 'challenge_required' +function cleanDiagnosticText(text: string | null | undefined): string | null { + const normalized = normalizeDisplayText(text) + if (!normalized || GENERIC_FAILURE_PATTERN.test(normalized)) { + return null + } + return normalized.length > 180 ? `${normalized.slice(0, 180)}...` : normalized +} + +function firstUsefulDiagnostic(task: RuntimeTask): string | null { + const candidates = [ + task.summary, + ...collectDiagnosticText(task.error), + ...collectDiagnosticText(task.result), + ] + for (const candidate of candidates) { + const cleaned = cleanDiagnosticText(candidate) + if (cleaned) { + return cleaned + } + } + return null +} + +function taskIssueReason(task: RuntimeTask): string { + const diagnosticText = taskDiagnosticText(task) + if (isDoubaoPlatform(task.platform) && DOUBAO_CHALLENGE_PATTERN.test(diagnosticText)) { + return DOUBAO_CHALLENGE_DETAIL + } + if (/login_required|login_expired|not_logged_in|登录态|未登录|重新授权/i.test(diagnosticText)) { + return `${translatePlatform(task.platform)}账号登录态不可用,任务无法继续执行。` + } + if (/captcha|challenge|验证码|人机验证|安全验证|风控/i.test(diagnosticText)) { + return `${translatePlatform(task.platform)}账号触发平台验证或风控。` + } + return firstUsefulDiagnostic(task) ?? '任务失败原因暂未返回详细信息,请结合右侧最近事件排查。' +} + +function taskIssueAction(task: RuntimeTask): string { + const diagnosticText = taskDiagnosticText(task) + if (isDoubaoPlatform(task.platform) && DOUBAO_CHALLENGE_PATTERN.test(diagnosticText)) { + return DOUBAO_CHALLENGE_ACTION + } + if (/login_required|login_expired|not_logged_in|登录态|未登录|重新授权/i.test(diagnosticText)) { + return '重新授权该账号后再重试任务。' + } + if (/captcha|challenge|验证码|人机验证|安全验证|风控/i.test(diagnosticText)) { + return '打开对应平台完成验证,再刷新账号状态。' + } + return task.status === 'unknown' + ? '等待后续补采,或打开任务详情查看原始错误。' + : '查看右侧最近事件;如已处理外部问题,可重新触发任务。' +} + +function taskIssueLocation(task: RuntimeTask): string { + const parts = [ + task.kind === 'monitor' ? '监控任务' : '发布任务', + translatePlatform(task.platform), + task.accountName ? `账号 ${task.accountName}` : '', + task.title || task.jobId, + ].filter(Boolean) + return parts.join(' · ') +} + +function accountIssueReason(account: IssueAccount): string { + if (isDoubaoChallengeAccount(account)) { + return DOUBAO_CHALLENGE_DETAIL + } + if (account.authReason === 'risk_control') { + return '平台触发风控,需要人工验证后恢复任务执行。' + } + if (account.authState === 'expired') { + return '账号登录态已过期。' + } + if (account.authState === 'revoked') { + return '账号授权已被停用或撤销。' + } + return '授权状态不可用,需要重新登录或确认账号状态。' +} + +function accountIssueAction(account: IssueAccount): string { + if (isDoubaoChallengeAccount(account)) { + return DOUBAO_CHALLENGE_ACTION + } + if (account.authReason === 'risk_control' || account.authState === 'challenge_required') { + return '打开对应平台完成验证,再刷新账号状态。' + } + return '重新授权账号后再继续执行任务。' +} + +function uniqueNonEmpty(values: Array): string[] { + return Array.from( + new Set( + values + .map((value) => normalizeDisplayText(value)) + .filter((value): value is string => value !== null), + ), + ) } function translateEventTitle(title: string) { @@ -151,97 +301,87 @@ const blockedTasks = computed(() => tasks.value.filter((item) => ['failed', 'unknown'].includes(item.status)), ) const issueItems = computed(() => { - const doubaoChallengeTaskGroups = new Map() - - 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) + const doubaoChallengeTasks = blockedTasks.value.filter(isDoubaoChallengeTask) + const doubaoChallengeAccounts = blockedAccounts.value.filter(isDoubaoChallengeAccount) + const hasDoubaoChallenge = + doubaoChallengeTasks.length > 0 || doubaoChallengeAccounts.length > 0 + const taskIssues: RuntimeIssueItem[] = blockedTasks.value + .filter((task) => !isDoubaoChallengeTask(task)) + .map((task): RuntimeIssueItem => { 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)), + id: `task-${task.id}`, + kind: 'task', + title: + task.status === 'unknown' + ? `${translatePlatform(task.platform)}结果待确认` + : `${translatePlatform(task.platform)}任务失败`, + location: taskIssueLocation(task), + reason: taskIssueReason(task), + action: taskIssueAction(task), + badgeLabel: translateTaskStatus(task.status), + badgeTone: 'danger', + at: task.updatedAt, } }) - const accountIssues = blockedAccounts.value.map((account) => { - const doubaoChallenge = isDoubaoChallengeAccount(account) + const doubaoChallengeIssue: RuntimeIssueItem[] = hasDoubaoChallenge + ? [ + { + id: 'doubao-challenge', + kind: 'account' as const, + title: '豆包需要人工验证', + location: uniqueNonEmpty([ + ...doubaoChallengeAccounts.map( + (account) => + `${account.displayName || '豆包账号'} · ${translatePlatform(account.platform)}`, + ), + ...doubaoChallengeTasks.map( + (task) => `${task.accountName || '豆包账号'} · ${task.title || task.jobId}`, + ), + ]) + .slice(0, 2) + .join(';'), + reason: DOUBAO_CHALLENGE_DETAIL, + action: [ + DOUBAO_CHALLENGE_ACTION, + doubaoChallengeTasks.length > 1 + ? `已合并 ${doubaoChallengeTasks.length} 个豆包失败任务。` + : '', + ] + .filter(Boolean) + .join(' '), + badgeLabel: '需要处理', + badgeTone: 'danger' as const, + at: Math.max( + ...[ + ...doubaoChallengeTasks.map((task) => task.updatedAt), + ...doubaoChallengeAccounts.map( + (account) => account.lastVerifiedAt ?? account.lastSyncAt, + ), + ], + ), + }, + ] + : [] - 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, - } - }) + const accountIssues: RuntimeIssueItem[] = blockedAccounts.value + .filter((account) => !isDoubaoChallengeAccount(account)) + .map((account): RuntimeIssueItem => { + return { + id: `account-${account.id}`, + kind: 'account', + title: `${translatePlatform(account.platform)}账号不可用`, + location: `${account.displayName || '未命名账号'} · ${formatUid(account.platformUid)}`, + reason: accountIssueReason(account), + action: accountIssueAction(account), + badgeLabel: blockedAccountLabel(account), + badgeTone: healthTone(account.health) === 'warn' ? 'warn' : 'danger', + at: account.lastVerifiedAt ?? account.lastSyncAt, + } + }) - return [...taskIssues, ...doubaoChallengeTaskIssues, ...accountIssues].sort( + return [...doubaoChallengeIssue, ...taskIssues, ...accountIssues].sort( (left, right) => right.at - left.at, ) }) @@ -320,21 +460,35 @@ const issueBreakdown = computed(() => {
-
-
- - {{ issue.kind === 'task' ? '任务' : '账号' }} - - {{ issue.title }} +
+
+
+ + {{ issue.kind === 'task' ? '任务' : '账号' }} + + {{ issue.title }} +
+
+ + + {{ formatRelativeTime(issue.at) }} + +
- {{ issue.meta }} -

{{ formatIssueDetail(issue) }}

-
-
- - - {{ formatRelativeTime(issue.at) }} - +
+
+
位置
+
{{ issue.location || '当前任务' }}
+
+
+
原因
+
{{ issue.reason }}
+
+
+
处理
+
{{ issue.action }}
+
+
@@ -353,7 +507,7 @@ const issueBreakdown = computed(() => {
-
+
{{ translateEventTitle(entry.title) }}
@@ -547,34 +701,43 @@ const issueBreakdown = computed(() => { .info-row { display: flex; - justify-content: space-between; - align-items: center; + align-items: stretch; } .issue-row { - align-items: flex-start; - gap: 18px; + padding: 18px 20px; } -.issue-row > .info-stack:first-child { +.issue-main { min-width: 0; - flex: 1; + width: 100%; } -.issue-row > .info-stack:last-child { - flex-shrink: 0; +.issue-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 12px; } .issue-title-line { display: flex; - align-items: center; + align-items: flex-start; gap: 8px; min-width: 0; } .issue-title-line .title { - overflow: hidden; - text-overflow: ellipsis; + min-width: 0; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.issue-status { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; white-space: nowrap; } @@ -601,11 +764,32 @@ const issueBreakdown = computed(() => { background: #fef3c7; } -.issue-detail { - margin: 6px 0 0; +.issue-diagnostics { + display: grid; + gap: 8px; + margin: 12px 0 0; +} + +.issue-diagnostics div { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 10px; +} + +.issue-diagnostics dt { + color: #64748b; + font-size: 12px; + font-weight: 800; + line-height: 1.6; +} + +.issue-diagnostics dd { + min-width: 0; + margin: 0; color: #475569; font-size: 13px; line-height: 1.55; + overflow-wrap: anywhere; } .timeline-item { @@ -617,7 +801,20 @@ const issueBreakdown = computed(() => { .timeline-head { display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; + gap: 12px; +} + +.timeline-title { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; +} + +.timeline-title .title { + line-height: 1.45; + overflow-wrap: anywhere; } .timeline-body p { @@ -647,13 +844,6 @@ const issueBreakdown = computed(() => { background-color: #0ea5e9; } -/* Base Typo */ -.info-stack { - display: flex; - flex-direction: column; - gap: 4px; -} - .title { color: #0f172a; font-size: 14px; @@ -689,12 +879,19 @@ const issueBreakdown = computed(() => { } @media (max-width: 720px) { - .issue-row { - flex-direction: column; + .issue-head, + .timeline-head { + grid-template-columns: 1fr; + display: grid; } - .issue-row > .info-stack:last-child { - align-items: flex-start !important; + .issue-status { + justify-content: flex-start; + } + + .issue-diagnostics div { + grid-template-columns: 1fr; + gap: 2px; } } diff --git a/server/internal/shared/swagger/descriptions.go b/server/internal/shared/swagger/descriptions.go index 79da067..e1a4a20 100644 --- a/server/internal/shared/swagger/descriptions.go +++ b/server/internal/shared/swagger/descriptions.go @@ -103,9 +103,12 @@ var routeDocs = map[string]routeDoc{ "POST /api/ops/desktop-client/releases/upload": {"上传桌面客户端包(兼容)", "兼容旧版单请求 Multipart 上传。"}, "POST /api/ops/desktop-client/releases/direct-upload": {"初始化客户端包 OSS 直传", "校验文件信息并生成浏览器直传 OSS 的短期 PUT 签名 URL。"}, "POST /api/ops/desktop-client/releases/direct-upload/complete": {"完成客户端包 OSS 直传", "校验直传对象是否存在、大小是否匹配,并返回 Object Key、文件大小和 SHA256。"}, + "POST /api/ops/desktop-client/releases/direct-upload/complete-jobs": {"创建 OSS 直传完成任务", "异步校验 OSS 直传对象并生成客户端版本上传完成任务。"}, + "GET /api/ops/desktop-client/release-upload-complete-jobs/:job_id": {"查询客户端上传完成任务", "查询桌面客户端安装包上传完成任务的阶段、进度和失败原因。"}, "POST /api/ops/desktop-client/release-uploads": {"初始化客户端包分片上传", "创建分片上传会话,返回 upload_id、分片大小和分片数量。"}, "POST /api/ops/desktop-client/release-uploads/:upload_id/chunks/:chunk_index": {"上传客户端包分片", "Multipart 上传单个客户端安装包或更新包分片。"}, "POST /api/ops/desktop-client/release-uploads/:upload_id/complete": {"完成客户端包分片上传", "校验全部分片,流式写入 OSS,并返回 Object Key、文件大小和 SHA256。"}, + "POST /api/ops/desktop-client/release-uploads/:upload_id/complete-jobs": {"创建分片上传完成任务", "异步合并并校验分片上传会话,生成客户端版本上传完成任务。"}, "DELETE /api/ops/desktop-client/release-uploads/:upload_id": {"取消客户端包分片上传", "取消分片上传会话并清理临时分片。"}, "POST /api/ops/desktop-client/releases": {"新建客户端版本", "创建桌面客户端版本配置。"}, "PATCH /api/ops/desktop-client/releases/:id": {"更新客户端版本", "更新桌面客户端版本配置。"},