feat(client): show digital sync failure details
This commit is contained in:
@@ -1207,6 +1207,23 @@
|
||||
.de-management-sync-strip--danger {
|
||||
color: #ad3434; border-color: rgba(224,72,72,0.2); background: rgba(224,72,72,0.08);
|
||||
}
|
||||
.de-management-sync-failures {
|
||||
display: grid; gap: 6px; margin: -4px 0 10px;
|
||||
}
|
||||
.de-management-sync-failure {
|
||||
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 span,
|
||||
.de-management-sync-failure em,
|
||||
.de-management-sync-failure small {
|
||||
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.de-management-sync-failure strong { color: #ad3434; font-weight: 800; }
|
||||
.de-management-sync-failure em { color: #59738b; font-style: normal; }
|
||||
.de-management-sync-failure small { color: #6d5260; }
|
||||
.de-duty-card { min-height: 120px; }
|
||||
.de-duty-card--selected {
|
||||
border-color: rgba(32,191,107,0.42);
|
||||
|
||||
@@ -61,6 +61,19 @@ interface QimingclawSyncStatus {
|
||||
failed: number;
|
||||
lastSyncAt: string | null;
|
||||
lastSyncError: string | null;
|
||||
recentFailures?: QimingclawSyncFailure[];
|
||||
}
|
||||
|
||||
interface QimingclawSyncFailure {
|
||||
id: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
operation: string;
|
||||
attempts: number;
|
||||
lastAttemptAt: string | null;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface QimingclawSnapshot {
|
||||
@@ -372,6 +385,16 @@ function syncLine(snapshot: QimingclawSnapshot | null): string {
|
||||
return sync.lastSyncAt ? `管理端同步:已同步,最近 ${sync.lastSyncAt}` : '管理端同步:等待本地数据';
|
||||
}
|
||||
|
||||
function compactSyncText(value?: string | null): string {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
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}` : ''}`;
|
||||
}
|
||||
|
||||
function withRuntimePlan(plan: DebugPlan, snapshot: QimingclawSnapshot | null): DebugPlan {
|
||||
return { ...plan, status: runtimeStatusForPlan(plan, snapshot) };
|
||||
}
|
||||
@@ -543,13 +566,21 @@ function buildEvents(duties: DigitalEmployeeDuty[], confirmed: boolean, snapshot
|
||||
}));
|
||||
const sync = snapshot?.sync;
|
||||
if (sync && (sync.pending > 0 || sync.failed > 0 || sync.lastSyncAt)) {
|
||||
const recentFailureText = (sync.recentFailures || [])
|
||||
.slice(0, 3)
|
||||
.map(syncFailureLine)
|
||||
.join(';');
|
||||
events.push({
|
||||
id: 'event-management-sync',
|
||||
time: sync.syncing ? '实时' : '同步',
|
||||
plan_title: '管理端联动',
|
||||
task_title: '同步数字员工本地状态',
|
||||
step_title: sync.failed > 0 ? '同步补偿' : 'outbox 推送',
|
||||
detail: `${syncLine(snapshot)}${sync.lastSyncError ? `。错误:${sync.lastSyncError}` : ''}`,
|
||||
detail: [
|
||||
syncLine(snapshot),
|
||||
sync.lastSyncError ? `错误:${sync.lastSyncError}` : '',
|
||||
recentFailureText ? `最近失败:${recentFailureText}` : '',
|
||||
].filter(Boolean).join('。'),
|
||||
status: sync.failed > 0 ? 'failed' : sync.syncing || sync.pending > 0 ? 'running' : 'completed',
|
||||
status_label: sync.failed > 0 ? '异常' : sync.syncing || sync.pending > 0 ? '同步中' : '已同步',
|
||||
status_tone: sync.failed > 0 ? 'danger' : sync.syncing || sync.pending > 0 ? 'running' : 'success',
|
||||
|
||||
@@ -61,6 +61,19 @@ interface ManagementSyncStatus {
|
||||
failed: number;
|
||||
lastSyncAt: string | null;
|
||||
lastSyncError: string | null;
|
||||
recentFailures?: ManagementSyncFailure[];
|
||||
}
|
||||
|
||||
interface ManagementSyncFailure {
|
||||
id: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
operation: string;
|
||||
attempts: number;
|
||||
lastAttemptAt: string | null;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 技术侧 PlanTemplate 是任务模板来源;用户侧只展示为“任务”,运行时 Task/PlanStep 展示为“步骤”。
|
||||
@@ -448,6 +461,31 @@ function syncStatusSummary(status: ManagementSyncStatus | null): string {
|
||||
: '等待本地 outbox 数据。';
|
||||
}
|
||||
|
||||
function entityTypeLabel(entityType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
plan: 'Plan',
|
||||
task: 'Task',
|
||||
run: 'Run',
|
||||
event: 'Event',
|
||||
artifact: 'Artifact',
|
||||
approval: 'Approval',
|
||||
};
|
||||
return labels[entityType] || entityType || 'Entity';
|
||||
}
|
||||
|
||||
function syncFailureSummary(failure: ManagementSyncFailure): string {
|
||||
const error = summarizeSyncError(failure.error);
|
||||
return [
|
||||
`${entityTypeLabel(failure.entityType)} / ${failure.operation}`,
|
||||
`重试 ${failure.attempts} 次`,
|
||||
error,
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function syncFailureTime(failure: ManagementSyncFailure): string {
|
||||
return formatBeijingDateTime(failure.lastAttemptAt || failure.updatedAt || failure.createdAt) || '未尝试';
|
||||
}
|
||||
|
||||
async function readManagementSyncStatus(): Promise<ManagementSyncStatus | null> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.getSyncStatus) return null;
|
||||
@@ -824,6 +862,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
const managementSyncLabel = syncStatusLabel(managementSyncStatus);
|
||||
const managementSyncSummary = syncStatusSummary(managementSyncStatus);
|
||||
const managementSyncError = summarizeSyncError(managementSyncStatus?.lastSyncError);
|
||||
const managementSyncFailures = managementSyncStatus?.recentFailures ?? [];
|
||||
const resolvedVisualState = resolveEmployeeVisualState({
|
||||
confirmed: Boolean(workday?.confirmed),
|
||||
currentEvent,
|
||||
@@ -1332,6 +1371,19 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
<span>{managementSyncSummary}</span>
|
||||
</div>
|
||||
|
||||
{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}>
|
||||
<span>{syncFailureTime(failure)}</span>
|
||||
<strong>{entityTypeLabel(failure.entityType)}</strong>
|
||||
<em>{failure.id}</em>
|
||||
<small>{syncFailureSummary(failure)}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(actionNotice || actionError || planDataError || workdayProjection?.demo_reason) && (
|
||||
<div className={`de-schedule-result ${actionError ? 'de-schedule-result--error' : 'de-schedule-result--success'}`} style={{ marginBottom: 10 }}>
|
||||
{actionError || actionNotice || planDataError || workdayProjection?.demo_reason}
|
||||
|
||||
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-Bpj7JfZ9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BcFvWJ2w.css">
|
||||
<script type="module" crossorigin src="./assets/index-QTsL0vuL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-Dt_PM3Yb.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -33,6 +33,18 @@ interface OutboxRow {
|
||||
last_attempt_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeSyncFailure {
|
||||
id: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
operation: string;
|
||||
attempts: number;
|
||||
lastAttemptAt: string | null;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SyncAck {
|
||||
outbox_id?: string;
|
||||
id?: string;
|
||||
@@ -59,6 +71,7 @@ export interface DigitalEmployeeSyncStatus {
|
||||
failed: number;
|
||||
lastSyncAt: string | null;
|
||||
lastSyncError: string | null;
|
||||
recentFailures: DigitalEmployeeSyncFailure[];
|
||||
}
|
||||
|
||||
export function startDigitalEmployeeSyncWorker(): void {
|
||||
@@ -94,6 +107,7 @@ export function getDigitalEmployeeSyncStatus(): DigitalEmployeeSyncStatus {
|
||||
failed: countOutbox("failed"),
|
||||
lastSyncAt,
|
||||
lastSyncError,
|
||||
recentFailures: readRecentFailures(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -369,6 +383,45 @@ function countOutbox(status: string): number {
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
function readRecentFailures(limit = 3): DigitalEmployeeSyncFailure[] {
|
||||
const db = getDb();
|
||||
if (!db) return [];
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, entity_type, entity_id, operation, attempts,
|
||||
last_attempt_at, error, created_at, updated_at
|
||||
FROM digital_sync_outbox
|
||||
WHERE status = 'failed'
|
||||
ORDER BY COALESCE(last_attempt_at, updated_at, created_at) DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(limit) as Array<{
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
operation: string;
|
||||
attempts: number;
|
||||
last_attempt_at: string | null;
|
||||
error: string | null;
|
||||
created_at: string;
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
function entityTable(entityType: string): string | null {
|
||||
switch (entityType) {
|
||||
case "plan":
|
||||
|
||||
Reference in New Issue
Block a user