Files
geo/apps/desktop-client/src/renderer/views/HomeView.vue
T
root 98e3dce017
Desktop Client Build / Resolve Build Metadata (push) Successful in 26s
Backend CI / Backend (push) Successful in 16m8s
Desktop Client Build / Build Desktop Client (push) Successful in 22m48s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 29s
improve desktop issue diagnostics
2026-06-24 12:42:49 +08:00

898 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
AlertOutlined,
ClockCircleOutlined,
DesktopOutlined,
LinkOutlined,
} from '@ant-design/icons-vue'
import { computed } from 'vue'
import StatusBadge from '../components/StatusBadge.vue'
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)
}
function blockedAccountLabel(account: { authState: string; authReason: string | null }) {
switch (account.authState) {
case 'challenge_required':
return account.authReason === 'risk_control' ? '触发风控' : '待验证'
case 'revoked':
return '已停用'
case 'expired':
default:
return '已过期'
}
}
function translateTaskStatus(status: string) {
const map: Record<string, string> = {
failed: '执行失败',
unknown: '结果未知',
}
return map[status?.toLowerCase()] || status
}
type RuntimeIssueItem = {
id: string
kind: 'task' | 'account'
title: 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 isDoubaoChallengeAccount(account: { platform: string; authState: string }) {
return isDoubaoPlatform(account.platform) && account.authState === 'challenge_required'
}
function isRecord(value: unknown): value is Record<string, unknown> {
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) {
return isDoubaoPlatform(task.platform) && DOUBAO_CHALLENGE_PATTERN.test(taskDiagnosticText(task))
}
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 | null | undefined>): string[] {
return Array.from(
new Set(
values
.map((value) => normalizeDisplayText(value))
.filter((value): value is string => value !== null),
),
)
}
function translateEventTitle(title: string) {
const map: Record<string, string> = {
'Accounts Synced': '本地账号同步',
'Heartbeat Healthy': '心跳连接正常',
'Desktop Stream Connected': '单点事件流已连接',
'Desktop Runtime Started': '调度节点已启动',
'Desktop Runtime Armed': '任务消费端已就绪',
'Desktop Stream Reconnecting': '单点事件流重连中',
'Desktop Stream Backoff': '单点事件流退避等待',
'Heartbeat Failed': '心跳连接失败',
'Account Sync Failed': '账号同步失败',
'Account Unbound': '账号已解绑',
'Specific Lease Missed': '指定任务领取失败',
'Pull Fallback Failed': '兜底拉取失败',
'Task Leased': '任务已领取',
'Task Completed As Failed': '任务执行失败',
'Task Completed As Unknown': '任务执行结果待确认',
'Task Succeeded': '任务执行成功',
'Task Failed': '任务执行失败',
'Lease Extend Failed': '租约续期失败',
'Desktop Client Token Expired': '客户端令牌已过期',
}
return map[title] || title
}
function translateEventDetail(detail: string) {
const exactMap: Record<string, string> = {
'已启动 SSE、heartbeat 和 pull fallback。': '已启动 SSE、心跳上报和兜底拉取。',
'client heartbeat 已写回服务端。': '客户端心跳已回写到服务端。',
}
return (exactMap[detail] || detail)
.replace(/\brouting=rabbitmq-primary\b/gi, '路由:主消息队列通道')
.replace(/\brouting=db-recovery\b/gi, '路由:数据库兜底拉取')
.replace(/\bdesktop client\b/gi, '桌面客户端')
.replace(/\bclient heartbeat\b/gi, '客户端心跳')
.replace(/\bpull fallback\b/gi, '兜底拉取')
.replace(/\bheartbeat\b/gi, '心跳上报')
.replace(/\bClient\b/g, '客户端')
.replace(/\bunknown_error\b/gi, '未知错误')
}
function formatUid(uid: string) {
return (uid || '').replace(/^(?:platform:)+/gi, '')
}
const { snapshot } = useDesktopRuntime()
const tasks = computed(() => snapshot.value?.tasks ?? [])
const accounts = computed(() => snapshot.value?.accounts ?? [])
const activity = computed(() => snapshot.value?.activity ?? [])
const blockedAccounts = computed(() =>
accounts.value.filter((item) =>
['expired', 'revoked', 'challenge_required'].includes(item.authState),
),
)
const blockedTasks = computed(() =>
tasks.value.filter((item) => ['failed', 'unknown'].includes(item.status)),
)
const issueItems = computed<RuntimeIssueItem[]>(() => {
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-${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 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,
),
],
),
},
]
: []
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 [...doubaoChallengeIssue, ...taskIssues, ...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>
<template>
<section class="page-container">
<div class="metrics-grid">
<article class="modern-card metric-card">
<div class="metric-head">
<div class="icon-wrap brand"><DesktopOutlined /></div>
<span class="eyebrow">在线节点</span>
</div>
<div class="metric-body">
<strong>{{ snapshot?.summary.onlineClients ?? 0 }}</strong>
<p>当前处于活跃状态的桌面客户端</p>
</div>
</article>
<article class="modern-card metric-card">
<div class="metric-head">
<div class="icon-wrap info"><LinkOutlined /></div>
<span class="eyebrow">已绑账号</span>
</div>
<div class="metric-body">
<strong>{{ snapshot?.summary.accountsBound ?? 0 }}</strong>
<p>已授权挂载至本空间的媒体账号</p>
</div>
</article>
<article class="modern-card metric-card">
<div class="metric-head">
<div class="icon-wrap warn"><ClockCircleOutlined /></div>
<span class="eyebrow">排队任务</span>
</div>
<div class="metric-body">
<strong>{{ snapshot?.summary.queuedTasks ?? 0 }}</strong>
<p>当前积压等待端侧领取的发布任务</p>
</div>
</article>
<article class="modern-card metric-card">
<div class="metric-head">
<div class="icon-wrap danger"><AlertOutlined /></div>
<span class="eyebrow">异常干预</span>
</div>
<div class="metric-body">
<strong>{{ issueBreakdown.total }}</strong>
<p>任务异常 {{ issueBreakdown.tasks }} / 账号阻断 {{ issueBreakdown.accounts }}</p>
</div>
</article>
</div>
<div class="panels-grid">
<article class="modern-card panel-card">
<div class="card-header">
<div class="header-text">
<h3>异常干预明细</h3>
<p>与顶部数字同源失败或未知任务 + 需要人工处理的账号</p>
</div>
</div>
<div v-if="issueItems.length > 0" class="issue-summary-bar">
<span> {{ issueBreakdown.total }} </span>
<span>任务异常 {{ issueBreakdown.tasks }}</span>
<span>账号阻断 {{ issueBreakdown.accounts }}</span>
</div>
<div v-if="issueItems.length > 0" class="info-list">
<div v-for="issue in issueItems" :key="issue.id" class="info-row record-row issue-row">
<div class="issue-main">
<div class="issue-head">
<div class="issue-title-line">
<span :class="['issue-kind', issue.kind]">
{{ issue.kind === 'task' ? '任务' : '账号' }}
</span>
<span class="title">{{ issue.title }}</span>
</div>
<div class="issue-status">
<StatusBadge :tone="issue.badgeTone" :label="issue.badgeLabel" />
<span class="subtitle">
{{ formatRelativeTime(issue.at) }}
</span>
</div>
</div>
<dl class="issue-diagnostics">
<div>
<dt>位置</dt>
<dd>{{ issue.location || '当前任务' }}</dd>
</div>
<div>
<dt>原因</dt>
<dd>{{ issue.reason }}</dd>
</div>
<div>
<dt>处理</dt>
<dd>{{ issue.action }}</dd>
</div>
</dl>
</div>
</div>
</div>
<div v-else class="empty-state">
<p>当前没有需要人工介入的异常</p>
</div>
</article>
<article class="modern-card panel-card">
<div class="card-header">
<div class="header-text">
<h3>最近事件</h3>
<p>系统底层状态流转的历史快照主要用作诊断上下文</p>
</div>
</div>
<div v-if="activity.length > 0" class="timeline-list">
<div v-for="entry in activity" :key="entry.id" class="timeline-item record-row">
<div class="timeline-head">
<div class="timeline-title">
<span :class="['feed-dot', entry.severity]"></span>
<span class="title">{{ translateEventTitle(entry.title) }}</span>
</div>
<span class="subtitle">{{ formatRelativeTime(entry.at) }}</span>
</div>
<div class="timeline-body">
<p>{{ translateEventDetail(entry.detail) }}</p>
</div>
</div>
</div>
<div v-else class="empty-state">
<p>当前没有最近事件记录</p>
</div>
</article>
</div>
</section>
</template>
<style scoped>
.page-container {
display: flex;
flex-direction: column;
gap: 24px;
padding-bottom: 40px;
width: 100%;
max-width: none;
box-sizing: border-box;
}
/* Metrics Top Grid */
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 24px;
}
.modern-card {
display: flex;
flex-direction: column;
background: #ffffff;
border-radius: 20px;
border: 1px solid #eef2f6;
box-shadow:
0 2px 4px -1px rgba(0, 0, 0, 0.02),
0 1px 2px -1px rgba(0, 0, 0, 0.01);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.modern-card:hover {
transform: translateY(-2px);
box-shadow:
0 12px 20px -8px rgba(0, 0, 0, 0.04),
0 4px 6px -4px rgba(0, 0, 0, 0.02);
border-color: #e2e8f0;
}
.metric-card {
padding: 24px;
}
.metric-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.metric-head .eyebrow {
margin: 0;
color: #64748b;
font-size: 14px;
font-weight: 600;
}
.icon-wrap {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.icon-wrap.brand {
background: #e0f2fe;
color: #0284c7;
}
.icon-wrap.info {
background: #f1f5f9;
color: #475569;
}
.icon-wrap.warn {
background: #fef9c3;
color: #ca8a04;
}
.icon-wrap.danger {
background: #fee2e2;
color: #dc2626;
}
.metric-body strong {
display: block;
font-size: 36px;
font-weight: 800;
color: #0f172a;
font-feature-settings: 'tnum';
line-height: 1;
}
.metric-body p {
margin: 12px 0 0;
font-size: 13px;
color: #64748b;
line-height: 1.5;
}
/* Two Column Panels Grid */
.panels-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
}
.panel-card {
padding: 32px;
}
.card-header {
margin-bottom: 24px;
}
.header-text h3 {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #0f172a;
}
.header-text p {
margin: 8px 0 0;
font-size: 14px;
color: #64748b;
line-height: 1.5;
}
.issue-summary-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.issue-summary-bar span {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 10px;
border-radius: 999px;
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #475569;
font-size: 12px;
font-weight: 700;
}
/* List Styling */
.info-list,
.timeline-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 400px;
overflow-y: auto;
}
.record-row {
padding: 16px 20px;
border-radius: 14px;
background: #f8fafc;
border: 1px solid #f1f5f9;
transition: all 0.2s ease;
}
.record-row:hover {
background: #ffffff;
border-color: #e2e8f0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
}
.info-row {
display: flex;
align-items: stretch;
}
.issue-row {
padding: 18px 20px;
}
.issue-main {
min-width: 0;
width: 100%;
}
.issue-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 12px;
}
.issue-title-line {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
.issue-title-line .title {
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;
}
.issue-kind {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
min-width: 36px;
padding: 0 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 800;
}
.issue-kind.task {
color: #b91c1c;
background: #fee2e2;
}
.issue-kind.account {
color: #b45309;
background: #fef3c7;
}
.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 {
display: flex;
flex-direction: column;
gap: 8px;
}
.timeline-head {
display: flex;
justify-content: space-between;
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 {
margin: 0 0 0 18px;
font-size: 13px;
color: #475569;
line-height: 1.6;
}
.feed-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background-color: #cbd5e1;
}
.feed-dot.success {
background-color: #10b981;
}
.feed-dot.warn {
background-color: #f59e0b;
}
.feed-dot.danger {
background-color: #ef4444;
}
.feed-dot.info {
background-color: #0ea5e9;
}
.title {
color: #0f172a;
font-size: 14px;
font-weight: 600;
}
.subtitle {
color: #64748b;
font-size: 13px;
}
.empty-state {
padding: 40px;
background: #f8fafc;
border-radius: 14px;
border: 1px dashed #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state p {
margin: 0;
color: #94a3b8;
font-size: 14px;
}
/* Responsiveness */
@media (max-width: 1024px) {
.panels-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.issue-head,
.timeline-head {
grid-template-columns: 1fr;
display: grid;
}
.issue-status {
justify-content: flex-start;
}
.issue-diagnostics div {
grid-template-columns: 1fr;
gap: 2px;
}
}
</style>