feat(client): show digital sync failure details

This commit is contained in:
baiyanyun
2026-06-07 01:05:49 +08:00
parent ac1a6fd0e6
commit 0672e82fc7
10 changed files with 280 additions and 126 deletions

View File

@@ -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);

View File

@@ -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',

View File

@@ -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

View File

@@ -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>

View File

@@ -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":

View File

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