feat(client): show digital employee sync errors

This commit is contained in:
baiyanyun
2026-06-07 00:53:28 +08:00
parent e2a91d88e8
commit ac1a6fd0e6
8 changed files with 228 additions and 127 deletions

View File

@@ -1189,6 +1189,24 @@
border: 1px solid rgba(79,172,254,0.18); color: #16324e;
background: rgba(247,251,255,0.96);
}
.de-management-sync-strip {
display: flex; align-items: center; gap: 8px; min-height: 34px;
margin: -2px 0 10px; padding: 7px 10px; border-radius: 12px;
font-size: 12px; line-height: 1.35; color: #315576;
border: 1px solid rgba(79,172,254,0.14); background: rgba(79,172,254,0.07);
}
.de-management-sync-strip > span:last-child {
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.de-management-sync-strip--success {
color: #176a3a; border-color: rgba(27,156,90,0.2); background: rgba(27,156,90,0.08);
}
.de-management-sync-strip--warning {
color: #7a5509; border-color: rgba(245,173,45,0.22); background: rgba(245,173,45,0.1);
}
.de-management-sync-strip--danger {
color: #ad3434; border-color: rgba(224,72,72,0.2); background: rgba(224,72,72,0.08);
}
.de-duty-card { min-height: 120px; }
.de-duty-card--selected {
border-color: rgba(32,191,107,0.42);

View File

@@ -52,6 +52,17 @@ interface DigitalPlanItem {
source: 'plan_template';
}
interface ManagementSyncStatus {
running: boolean;
syncing: boolean;
enabled: boolean;
endpoint: string | null;
pending: number;
failed: number;
lastSyncAt: string | null;
lastSyncError: string | null;
}
// 技术侧 PlanTemplate 是任务模板来源;用户侧只展示为“任务”,运行时 Task/PlanStep 展示为“步骤”。
interface EmployeeVisualStateOption {
@@ -394,6 +405,59 @@ function formatBeijingDateTime(value?: string | null): string {
return `${part('month')}/${part('day')} ${part('hour')}:${part('minute')}`;
}
function summarizeSyncError(value?: string | null): string {
const error = compactText(value);
if (!error) return '';
return error.length > 140 ? `${error.slice(0, 140)}...` : error;
}
function syncStatusTone(status: ManagementSyncStatus | null): 'info' | 'success' | 'warning' | 'danger' {
if (!status) return 'info';
if (!status.enabled || !status.endpoint) return 'warning';
if (status.failed > 0 || status.lastSyncError) return 'danger';
if (status.syncing || status.pending > 0) return 'warning';
return status.lastSyncAt ? 'success' : 'info';
}
function syncStatusLabel(status: ManagementSyncStatus | null): string {
if (!status) return '未连接';
if (!status.enabled) return '已停用';
if (!status.endpoint) return '未配置';
if (status.syncing) return '同步中';
if (status.failed > 0 || status.lastSyncError) return '同步异常';
if (status.pending > 0) return '待同步';
return status.lastSyncAt ? '已同步' : '等待数据';
}
function syncStatusSummary(status: ManagementSyncStatus | null): string {
if (!status) return '当前环境未连接 qimingclaw 同步桥。';
if (!status.enabled) return '管理端同步已停用。';
if (!status.endpoint) return '管理端同步地址未配置。';
const error = summarizeSyncError(status.lastSyncError);
if (status.failed > 0 || error) {
return [
status.failed > 0 ? `${status.failed} 条失败` : '',
status.pending > 0 ? `${status.pending} 条待同步` : '',
error ? `失败原因:${error}` : '',
].filter(Boolean).join('');
}
if (status.syncing) return `正在同步,待同步 ${status.pending} 条。`;
if (status.pending > 0) return `仍有 ${status.pending} 条待同步记录。`;
return status.lastSyncAt
? `最近同步:${formatBeijingDateTime(status.lastSyncAt) || status.lastSyncAt}`
: '等待本地 outbox 数据。';
}
async function readManagementSyncStatus(): Promise<ManagementSyncStatus | null> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.getSyncStatus) return null;
try {
return await bridge.getSyncStatus();
} catch {
return null;
}
}
function eventDisplayTime(event: DigitalEmployeeWorkEvent): string {
const beijingTime = formatBeijingDateTime(event.updated_at);
if (beijingTime) {
@@ -569,6 +633,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
const [reportScheduleTime, setReportScheduleTime] = useState('18:00');
const [guideOpen, setGuideOpen] = useState(false);
const [guideDismissedDate, setGuideDismissedDate] = useState<string | null>(() => readGuideDismissedDate());
const [managementSyncStatus, setManagementSyncStatus] = useState<ManagementSyncStatus | null>(null);
const mountedRef = useRef(false);
const selectionDirtyRef = useRef(false);
@@ -623,7 +688,13 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
setPlanDataError(error instanceof Error ? error.message : '任务加载失败');
});
await Promise.allSettled([projectionPromise, jobsPromise]);
const syncStatusPromise = readManagementSyncStatus()
.then((status) => {
if (!mountedRef.current) return;
setManagementSyncStatus(status);
});
await Promise.allSettled([projectionPromise, jobsPromise, syncStatusPromise]);
if (mountedRef.current) {
setProjectionLoading(false);
}
@@ -749,6 +820,10 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
const isDemoProjection = workdayProjection?.source === 'demo';
const projectionSourceLabel = isDemoProjection ? '演示兜底' : '真实数据';
const projectionSourceClass = isDemoProjection ? 'de-badge-warning' : 'de-badge-success';
const managementSyncTone = syncStatusTone(managementSyncStatus);
const managementSyncLabel = syncStatusLabel(managementSyncStatus);
const managementSyncSummary = syncStatusSummary(managementSyncStatus);
const managementSyncError = summarizeSyncError(managementSyncStatus?.lastSyncError);
const resolvedVisualState = resolveEmployeeVisualState({
confirmed: Boolean(workday?.confirmed),
currentEvent,
@@ -863,6 +938,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
}
const syncStatus = await bridge.flushSync();
if (!mountedRef.current) return;
setManagementSyncStatus(syncStatus);
const latestProjection = await getDigitalEmployeeWorkday(selectedDate);
if (!mountedRef.current) return;
setWorkdayProjection(latestProjection);
@@ -1251,6 +1327,11 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
</button>
</div>
<div className={`de-management-sync-strip de-management-sync-strip--${managementSyncTone}`} title={managementSyncError || managementSyncSummary}>
<span className={`de-badge ${badgeClass(managementSyncTone)}`}>{managementSyncLabel}</span>
<span>{managementSyncSummary}</span>
</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-Ck2YE5qR.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DqV9Vw1H.css">
<script type="module" crossorigin src="./assets/index-Bpj7JfZ9.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BcFvWJ2w.css">
</head>
<body>
<div id="root"></div>

View File

@@ -444,6 +444,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
- 同步成功后回填 outbox 状态,并根据后端 ack 回填实体 `remote_id` / `last_synced_at`
- 同步失败时标记 failed 和错误信息;定时 worker 按指数退避补偿,手动 `flushSync` 可强制立即重试,不影响 `/computer/chat` 和 ACP 执行链路。
- 数字员工任务设置工具栏已增加“同步管理端”按钮,可通过 bridge 立即触发 `flushSync` 并刷新页面投影。
- 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、最近同步时间和最近失败原因摘要。
新增 IPC / Bridge
@@ -479,11 +480,12 @@ GET /api/digital-employee/sync/records
- 支持 `client_key``entity_type``entity_id``page_no``page_size` 查询参数。
- 按当前登录上下文租户隔离,默认每页 20 条,最大每页 100 条。
- 当前返回通用同步记录和原始 payload用于管理端页面或联调工具先查看同步结果后续再拆分为更丰富的 Plan / Task / Run / Event 查询模型。
- 管理端已增加“数字员工同步记录”菜单、权限资源和列表页,可按客户端 Key、实体类型、实体 ID 查询并查看 payload。
下一步:
- 在有 Maven 的环境验证 qiming-backend 编译与接口启动
- 数字员工页面继续细化同步状态交互,例如失败原因展开
- 后续再拆分管理端 Plan / Task / Run / Event 查询模型,让同步记录从通用 payload 观察面升级为业务视图
- 数字员工页面继续补充同步失败的重试历史和 outbox 明细入口
## 管理端联动原则