feat(client): add digital sync retry details

This commit is contained in:
baiyanyun
2026-06-07 01:17:07 +08:00
parent 0672e82fc7
commit 508406ff10
10 changed files with 230 additions and 140 deletions

View File

@@ -1211,11 +1211,15 @@
display: grid; gap: 6px; margin: -4px 0 10px;
}
.de-management-sync-failure {
width: 100%; font-family: inherit; text-align: left; cursor: pointer;
display: grid; grid-template-columns: 56px 54px minmax(54px, 0.7fr) minmax(0, 1.6fr);
align-items: center; gap: 8px; min-height: 28px; padding: 6px 9px;
border-radius: 10px; border: 1px solid rgba(224,72,72,0.14);
background: rgba(224,72,72,0.055); color: #7b4450; font-size: 11px;
}
.de-management-sync-failure:hover {
border-color: rgba(224,72,72,0.28); box-shadow: 0 0 0 2px rgba(224,72,72,0.06);
}
.de-management-sync-failure span,
.de-management-sync-failure em,
.de-management-sync-failure small {

View File

@@ -71,6 +71,8 @@ interface QimingclawSyncFailure {
operation: string;
attempts: number;
lastAttemptAt: string | null;
nextRetryAt?: string | null;
dueForRetry?: boolean;
error: string | null;
createdAt: string;
updatedAt: string;
@@ -392,7 +394,12 @@ function compactSyncText(value?: string | null): string {
function syncFailureLine(failure: QimingclawSyncFailure): string {
const error = compactSyncText(failure.error);
const detail = error.length > 120 ? `${error.slice(0, 120)}...` : error;
return `${failure.entityType}/${failure.operation} ${failure.id} 重试 ${failure.attempts}${detail ? `${detail}` : ''}`;
const retryText = failure.dueForRetry
? '等待补偿'
: failure.nextRetryAt
? `下次重试 ${failure.nextRetryAt}`
: '等待重试';
return `${failure.entityType}/${failure.operation} ${failure.id} 重试 ${failure.attempts} 次,${retryText}${detail ? `${detail}` : ''}`;
}
function withRuntimePlan(plan: DebugPlan, snapshot: QimingclawSnapshot | null): DebugPlan {

View File

@@ -71,6 +71,8 @@ interface ManagementSyncFailure {
operation: string;
attempts: number;
lastAttemptAt: string | null;
nextRetryAt?: string | null;
dueForRetry?: boolean;
error: string | null;
createdAt: string;
updatedAt: string;
@@ -478,6 +480,7 @@ function syncFailureSummary(failure: ManagementSyncFailure): string {
return [
`${entityTypeLabel(failure.entityType)} / ${failure.operation}`,
`重试 ${failure.attempts}`,
failure.dueForRetry ? '等待补偿' : `下次 ${syncFailureRetryTime(failure)}`,
error,
].filter(Boolean).join(' · ');
}
@@ -486,6 +489,11 @@ function syncFailureTime(failure: ManagementSyncFailure): string {
return formatBeijingDateTime(failure.lastAttemptAt || failure.updatedAt || failure.createdAt) || '未尝试';
}
function syncFailureRetryTime(failure: ManagementSyncFailure): string {
if (failure.dueForRetry) return '可重试';
return formatBeijingDateTime(failure.nextRetryAt) || '待计算';
}
async function readManagementSyncStatus(): Promise<ManagementSyncStatus | null> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.getSyncStatus) return null;
@@ -662,6 +670,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
const [scheduleTime, setScheduleTime] = useState('09:00');
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
const [reportPreviewOpen, setReportPreviewOpen] = useState(false);
const [selectedSyncFailure, setSelectedSyncFailure] = useState<ManagementSyncFailure | null>(null);
const [profileForm, setProfileForm] = useState({
name: '',
company: '',
@@ -1374,12 +1383,18 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
{managementSyncFailures.length > 0 && (
<div className="de-management-sync-failures" aria-label="同步失败明细">
{managementSyncFailures.map((failure) => (
<div key={failure.id} className="de-management-sync-failure" title={failure.error || failure.id}>
<button
key={failure.id}
type="button"
className="de-management-sync-failure"
title={failure.error || failure.id}
onClick={() => setSelectedSyncFailure(failure)}
>
<span>{syncFailureTime(failure)}</span>
<strong>{entityTypeLabel(failure.entityType)}</strong>
<em>{failure.id}</em>
<small>{syncFailureSummary(failure)}</small>
</div>
</button>
))}
</div>
)}
@@ -1790,6 +1805,52 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
</div>
)}
{selectedSyncFailure && (
<div className="de-task-manager-modal-overlay" role="presentation" onClick={() => setSelectedSyncFailure(null)}>
<div
className="de-event-detail-modal"
role="dialog"
aria-modal="true"
aria-label="同步失败详情"
onClick={(event) => event.stopPropagation()}
>
<div className="de-task-manager-modal-head">
<div>
<span className="de-drawer-eyebrow">SYNC FAILURE</span>
<h3></h3>
<p>{entityTypeLabel(selectedSyncFailure.entityType)} / {selectedSyncFailure.operation}</p>
</div>
<button type="button" className="de-schedule-close" onClick={() => setSelectedSyncFailure(null)}>×</button>
</div>
<div className="de-event-detail-grid">
<div><span>Outbox</span><strong>{selectedSyncFailure.id}</strong></div>
<div><span></span><strong>{selectedSyncFailure.entityId}</strong></div>
<div><span></span><strong>{entityTypeLabel(selectedSyncFailure.entityType)}</strong></div>
<div><span></span><strong>{selectedSyncFailure.operation}</strong></div>
<div><span></span><strong>{selectedSyncFailure.attempts} </strong></div>
<div><span></span><strong>{syncFailureTime(selectedSyncFailure)}</strong></div>
<div><span></span><strong>{syncFailureRetryTime(selectedSyncFailure)}</strong></div>
<div><span></span><strong>{selectedSyncFailure.dueForRetry ? '等待补偿' : '退避中'}</strong></div>
</div>
<div className="de-event-detail-result">
<span></span>
<p>{selectedSyncFailure.error || '暂无错误详情。'}</p>
</div>
<div className="de-event-modal-actions">
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => setSelectedSyncFailure(null)}></button>
<button
type="button"
className="de-btn-primary de-btn-sm"
disabled={actionBusy === 'flush_management_sync'}
onClick={() => { void flushManagementSync().then(() => setSelectedSyncFailure(null)); }}
>
{actionBusy === 'flush_management_sync' ? '同步中...' : '立即重试'}
</button>
</div>
</div>
</div>
)}
{selectedEvent && (
<div className="de-task-manager-modal-overlay" role="presentation" onClick={closeEventDetail}>
<div

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,8 +13,8 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-QTsL0vuL.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Dt_PM3Yb.css">
<script type="module" crossorigin src="./assets/index-DHy6Ywa5.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-YI8Pgcr5.css">
</head>
<body>
<div id="root"></div>

View File

@@ -40,6 +40,8 @@ export interface DigitalEmployeeSyncFailure {
operation: string;
attempts: number;
lastAttemptAt: string | null;
nextRetryAt: string | null;
dueForRetry: boolean;
error: string | null;
createdAt: string;
updatedAt: string;
@@ -409,17 +411,32 @@ function readRecentFailures(limit = 3): DigitalEmployeeSyncFailure[] {
updated_at: string;
}>;
return rows.map((row) => ({
id: row.id,
entityType: row.entity_type,
entityId: row.entity_id,
operation: row.operation,
attempts: row.attempts,
lastAttemptAt: row.last_attempt_at,
error: row.error,
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
return rows.map((row) => {
const nextRetryAt = nextRetryAtForFailure(row.last_attempt_at, row.attempts);
return {
id: row.id,
entityType: row.entity_type,
entityId: row.entity_id,
operation: row.operation,
attempts: row.attempts,
lastAttemptAt: row.last_attempt_at,
nextRetryAt,
dueForRetry: !nextRetryAt || Date.now() >= Date.parse(nextRetryAt),
error: row.error,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
});
}
function nextRetryAtForFailure(
lastAttemptAt: string | null,
attempts: number,
): string | null {
if (!lastAttemptAt) return null;
const lastAttemptTime = Date.parse(lastAttemptAt);
if (!Number.isFinite(lastAttemptTime)) return null;
return new Date(lastAttemptTime + retryDelayMs(attempts)).toISOString();
}
function entityTable(entityType: string): string | null {

View File

@@ -445,7 +445,8 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
- 同步失败时标记 failed 和错误信息;定时 worker 按指数退避补偿,手动 `flushSync` 可强制立即重试,不影响 `/computer/chat` 和 ACP 执行链路。
- 数字员工任务设置工具栏已增加“同步管理端”按钮,可通过 bridge 立即触发 `flushSync` 并刷新页面投影。
- 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、最近同步时间和最近失败原因摘要。
- 同步状态通过 bridge 返回最近失败 outbox 明细首页可直接看到实体类型、outbox ID、操作类型、重试次数失败时间。
- 同步状态通过 bridge 返回最近失败 outbox 明细首页可直接看到实体类型、outbox ID、操作类型、重试次数失败时间和下次自动重试时间
- 点击失败 outbox 可打开详情弹窗,查看完整错误、实体 ID、退避状态并可触发手动 `flushSync` 立即重试。
新增 IPC / Bridge
@@ -486,7 +487,7 @@ GET /api/digital-employee/sync/records
下一步:
- 后续再拆分管理端 Plan / Task / Run / Event 查询模型,让同步记录从通用 payload 观察面升级为业务视图。
- 数字员工页面继续补充更完整的同步重试历史和失败明细展开抽屉
- 数字员工页面继续补充更完整的同步重试历史和管理端记录深链
## 管理端联动原则