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}