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