feat(client): add digital sync retry details
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user