diff --git a/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/pages/digital/CommandDeck.tsx b/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/pages/digital/CommandDeck.tsx index b93c75ab..2172ab03 100644 --- a/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/pages/digital/CommandDeck.tsx +++ b/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/pages/digital/CommandDeck.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react'; -import { Clock3, Download, Eye, PlayCircle, RefreshCw } from 'lucide-react'; +import { Clipboard, Clock3, Download, Eye, PlayCircle, RefreshCw } from 'lucide-react'; import Avatar3D from '@/components/digital/Avatar3D'; import { basePath } from '@/lib/basePath'; import { @@ -551,6 +551,57 @@ function syncAttemptTone(status: string): 'success' | 'danger' | 'info' { return 'info'; } +function buildSyncFailureDiagnostic( + failure: ManagementSyncFailure, + status: ManagementSyncStatus | null, +): string { + return JSON.stringify( + { + generatedAt: new Date().toISOString(), + source: 'qimingclaw-digital-employee', + failure, + sync: status + ? { + running: status.running, + syncing: status.syncing, + enabled: status.enabled, + endpoint: status.endpoint, + missingCredentials: status.missingCredentials ?? [], + pending: status.pending, + failed: status.failed, + lastSyncAt: status.lastSyncAt, + lastSyncError: status.lastSyncError, + failureSummary: status.failureSummary ?? null, + } + : null, + }, + null, + 2, + ); +} + +function syncFailureDiagnosticFileName(failure: ManagementSyncFailure): string { + const entity = [failure.entityType, failure.entityId] + .filter(Boolean) + .join('-') + .replace(/[^a-z0-9._-]+/gi, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + return `qimingclaw-sync-failure-${entity || failure.id || 'diagnostic'}.json`; +} + +function downloadTextFile(filename: string, content: string): void { + const blob = new Blob([content], { type: 'application/json;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); +} + async function readManagementSyncStatus(): Promise { const bridge = window.QimingClawBridge?.digital; if (!bridge?.getSyncStatus) return null; @@ -1062,6 +1113,27 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit } }, [selectedDate]); + const copySyncFailureDiagnostic = useCallback(async (failure: ManagementSyncFailure) => { + const diagnostic = buildSyncFailureDiagnostic(failure, managementSyncStatus); + try { + await navigator.clipboard.writeText(diagnostic); + setActionError(null); + setActionNotice('同步失败诊断已复制。'); + } catch (error) { + setActionNotice(null); + setActionError(error instanceof Error ? error.message : '复制同步失败诊断失败'); + } + }, [managementSyncStatus]); + + const exportSyncFailureDiagnostic = useCallback((failure: ManagementSyncFailure) => { + downloadTextFile( + syncFailureDiagnosticFileName(failure), + buildSyncFailureDiagnostic(failure, managementSyncStatus), + ); + setActionError(null); + setActionNotice('同步失败诊断已导出。'); + }, [managementSyncStatus]); + const cancelAllTasks = useCallback(() => { setSelectedDutyIds(new Set()); selectionDirtyRef.current = true; @@ -1926,6 +1998,24 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit > 关闭 + + {selectedSyncFailure.managementRecordUrl && (