Files
qiming/qimingclaw/crates/agent-electron-client/embedded/sgrobot-digital/src/pages/digital/CommandDeck.tsx
2026-06-10 14:49:49 +08:00

2947 lines
132 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react';
import { Clipboard, Clock3, Download, Eye, MessageSquare, PlayCircle, RefreshCw, ShieldAlert, ShieldCheck, SkipForward, Square, TimerReset, UserRoundPlus } from 'lucide-react';
import Avatar3D from '@/components/digital/Avatar3D';
import { basePath } from '@/lib/basePath';
import {
dispatchPlanNow,
getDigitalEmployeeWorkday,
getSchedulerJobs,
governanceCommand,
postDigitalEmployeeWorkdayAction,
recordApprovalAction,
restartManagedService,
triggerCronCheck,
} from '@/lib/api';
import { isTauri } from '@/lib/tauri';
import type {
CronJob,
DigitalEmployeeDailyReport,
DigitalEmployeeDecision,
DigitalEmployeeDuty,
DigitalEmployeeManagedService,
DigitalEmployeeRunDetail,
DigitalEmployeeTaskAgentState,
DigitalEmployeeTaskExecutionSummary,
DigitalEmployeeWorkEvent,
DigitalEmployeeWorkdayProjection,
GovernanceCommandKind,
} from '@/types/api';
import type { DigitalNavigationTarget } from './navigation';
const bp = basePath;
const digitalAssetBase = isTauri() ? './digital-assets' : `${bp}/digital-assets`;
const avatarAssetBase = `${digitalAssetBase}/avatars`;
const DIGITAL_EMPLOYEE_TIME_ZONE = 'Asia/Shanghai';
type EmployeeVisualState = 'skill' | 'browser' | 'call' | 'redial' | 'evidence' | 'decision' | 'reporting';
type EventStatusFilter = 'all' | 'running' | 'success' | 'danger';
type TaskListFilter = 'all' | 'selected' | 'pending';
type TaskAgentControlCommand = Extract<GovernanceCommandKind, 'retry_task' | 'skip_task' | 'cancel_task'>;
type ApprovalActionKind = 'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk';
interface DigitalPlanItem {
id: string;
planId: string;
title: string;
summary: string;
implemented: boolean;
selectable: boolean;
selected: boolean;
status: string;
statusLabel: string;
statusTone: 'running' | 'success' | 'danger' | 'warning' | 'waiting' | 'info' | string;
scheduleText: string;
resultText: string;
conversationId?: string | null;
stepCount: number;
taskCount: number;
schedulerJob?: CronJob | null;
source: 'plan_template';
}
interface ManagementSyncStatus {
running: boolean;
syncing: boolean;
enabled: boolean;
endpoint: string | null;
missingCredentials?: string[];
pending: number;
failed: number;
lastSyncAt: string | null;
lastSyncError: string | null;
failureSummary?: ManagementSyncFailureSummary;
recentFailures?: ManagementSyncFailure[];
}
interface ManagementSyncFailureSummary {
total: number;
dueForRetry: number;
backoff: number;
byEntityType: Array<{ entityType: string; count: number }>;
}
interface ManagementSyncAttempt {
attemptNo: number;
status: string;
error: string | null;
endpoint?: string | null;
startedAt: string;
finishedAt: string;
}
interface ManagementSyncFailure {
id: string;
entityType: string;
entityId: string;
entitySummary?: ManagementSyncEntitySummary | null;
operation: string;
attempts: number;
lastAttemptAt: string | null;
nextRetryAt?: string | null;
dueForRetry?: boolean;
managementRecordUrl?: string | null;
attemptHistory?: ManagementSyncAttempt[];
error: string | null;
createdAt: string;
updatedAt: string;
}
interface ManagementSyncEntitySummary {
title?: string | null;
status?: string | null;
summary?: string | null;
}
interface ApprovalActionDraft {
comment: string;
delegateTo: string;
dueAt: string;
}
const EMPTY_APPROVAL_ACTION_DRAFT: ApprovalActionDraft = {
comment: '',
delegateTo: '',
dueAt: '',
};
// 技术侧 PlanTemplate 是任务模板来源;用户侧只展示为“任务”,运行时 Task/PlanStep 展示为“步骤”。
interface EmployeeVisualStateOption {
id: EmployeeVisualState;
label: string;
shortLabel: string;
phase: string;
src: string;
bubble: string;
}
const EMPLOYEE_STATE_VALUES: EmployeeVisualState[] = ['skill', 'browser', 'call', 'redial', 'evidence', 'decision', 'reporting'];
const EVENT_STATUS_FILTERS: Array<{ id: EventStatusFilter; label: string }> = [
{ id: 'all', label: '全部' },
{ id: 'running', label: '执行中' },
{ id: 'success', label: '已完成' },
{ id: 'danger', label: '异常' },
];
const WORKDAY_GUIDE_DISMISSED_STORAGE_KEY = 'qimingclaw:digital-workday-guide-dismissed-date';
const LEGACY_WORKDAY_GUIDE_DISMISSED_STORAGE_KEY = 'sgrobot:digital-workday-guide-dismissed-date';
const MAIN_RUN_DETAIL_LIMIT = 80;
const EVENT_HISTORY_RENDER_LIMIT = 300;
const WORKDAY_GUIDE_STEPS = [
{ title: '确认日期', body: '先确认今天的值班日期,避免把昨天或明天的任务混在一起。' },
{ title: '选择任务', body: '从岗位职责里勾选今天要执行的任务,未接入能力保持待扩展。' },
{ title: '任务执行', body: '点击任务执行后,数字员工开始按已选任务推进工作。' },
{ title: '查看过程', body: '右侧滚动显示运行明细,任务中心可继续查看明细。' },
];
const EMPLOYEE_STATE_OPTIONS: Record<EmployeeVisualState, EmployeeVisualStateOption> = {
skill: {
id: 'skill',
label: '查看Skill',
shortLabel: '技能',
phase: '查看Skill与计划',
src: `${avatarAssetBase}/states/employee-idle.png`,
bubble: '我正在查看可用Skill、任务计划和今天要勾选的执行内容。',
},
browser: {
id: 'browser',
label: '操作浏览器',
shortLabel: '浏览器',
phase: '操作浏览器执行',
src: `${avatarAssetBase}/states/employee-browser.png`,
bubble: '我正在打开页面、读取信息、填写表单,并把执行过程同步到右侧。',
},
call: {
id: 'call',
label: '电话外呼',
shortLabel: '外呼',
phase: '电话外呼与重拨',
src: `${avatarAssetBase}/states/employee-running.png`,
bubble: '我正在按工单名单外呼现场人员,记录接通、未接通和后续重拨。',
},
redial: {
id: 'redial',
label: '重拨跟进',
shortLabel: '重拨',
phase: '未接通人员重拨',
src: `${avatarAssetBase}/states/employee-redial.png`,
bubble: '我正在跟进未接通人员,按间隔重拨并更新每个人的通知状态。',
},
evidence: {
id: 'evidence',
label: '数据核验',
shortLabel: '核验',
phase: '核验执行证据',
src: `${avatarAssetBase}/states/employee-evidence.png`,
bubble: '我正在核验页面数据、执行证据和任务结果,确认交付内容可信。',
},
decision: {
id: 'decision',
label: '等待决策',
shortLabel: '决策',
phase: '等待人工确认',
src: `${avatarAssetBase}/states/employee-decision.png`,
bubble: '我已完成前置处理,现在需要人工确认后再继续推进。',
},
reporting: {
id: 'reporting',
label: '汇报成果',
shortLabel: '汇报',
phase: '汇报今日成果',
src: `${avatarAssetBase}/states/employee-reporting.png`,
bubble: '我正在整理运行明细和日报,方便你直接查看成果和异常。',
},
};
function todayInputDate(): string {
const date = new Date();
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, '0'),
String(date.getDate()).padStart(2, '0'),
].join('-');
}
function displayInputDate(date: string): string {
return (date || todayInputDate()).split('-').join('/');
}
function badgeClass(tone?: string): string {
if (tone === 'success') return 'de-badge-success';
if (tone === 'danger') return 'de-badge-danger';
if (tone === 'warning' || tone === 'running') return 'de-badge-warning';
return 'de-badge-info';
}
function approvalRiskTone(value?: string | null): string {
const normalized = compactText(value).toLowerCase();
if (['high', 'critical', 'risk', 'marked'].includes(normalized)) return 'danger';
if (['medium', 'warning'].includes(normalized)) return 'warning';
if (['normal', 'low', 'clear'].includes(normalized)) return 'success';
return 'info';
}
function approvalRiskLabel(value?: string | null): string {
const normalized = compactText(value).toLowerCase();
if (['high', 'critical', 'risk', 'marked'].includes(normalized)) return '高风险';
if (['medium', 'warning'].includes(normalized)) return '中风险';
if (['normal', 'low', 'clear'].includes(normalized)) return '风险正常';
return '未标记风险';
}
function approvalSlaTone(value?: string | null): string {
const normalized = compactText(value).toLowerCase();
if (['timeout', 'overdue', 'expired'].includes(normalized)) return 'danger';
if (['tracked', 'due_soon', 'warning'].includes(normalized)) return 'warning';
if (['met', 'normal'].includes(normalized)) return 'success';
return 'info';
}
function approvalSlaLabel(value?: string | null): string {
const normalized = compactText(value).toLowerCase();
if (['timeout', 'overdue', 'expired'].includes(normalized)) return '已超时';
if (['tracked', 'due_soon', 'warning'].includes(normalized)) return 'SLA跟踪';
if (['met', 'normal'].includes(normalized)) return 'SLA正常';
return '未设置SLA';
}
function approvalActionLabel(value?: string | null): string {
const normalized = compactText(value).toLowerCase();
if (normalized === 'comment') return '评论';
if (normalized === 'delegate') return '转交';
if (normalized === 'mark_timeout') return '标记超时';
if (normalized === 'mark_risk') return '标记风险';
if (normalized === 'clear_risk') return '清除风险';
return normalized || '暂无动作';
}
function dutyCardClass(planItem: DigitalPlanItem, selected: boolean): string {
const classes = ['de-template-card-row', 'de-duty-card', 'de-duty-card--compact'];
if (!planItem.implemented) classes.push('de-duty-card--disabled');
if (selected) classes.push('de-duty-card--selected');
if (planItem.statusTone === 'running') classes.push('de-duty-card--running');
return classes.join(' ');
}
function compactPlanText(value?: string | null): string {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function cronExpressionFromJob(job?: CronJob | null): string {
if (!job) return '';
if (job.expression) return job.expression;
if (typeof job.schedule === 'string') return job.schedule;
if (job.schedule && typeof job.schedule === 'object') {
const schedule = job.schedule as Record<string, unknown>;
const expr = schedule.expr ?? schedule.expression ?? schedule.cron;
if (typeof expr === 'string') return expr;
}
return '';
}
function scheduleTextFromJob(job?: CronJob | null): string {
const expr = cronExpressionFromJob(job);
if (!expr) return '未定时';
const parts = expr.trim().split(/\s+/);
const minute = parts[0] ?? '';
const hour = parts[1] ?? '';
if (parts.length >= 2 && /^\d+$/.test(minute) && /^\d+$/.test(hour)) {
return `已定时 ${hour.padStart(2, '0')}:${minute.padStart(2, '0')} 执行`;
}
return `调度注册 ${expr}`;
}
function readRecordString(value: unknown, keys: string[]): string {
if (!value || typeof value !== 'object') return '';
const record = value as Record<string, unknown>;
for (const key of keys) {
const field = record[key];
if (typeof field === 'string' && field.trim()) return field.trim();
}
return '';
}
function templateIdForSchedulerJob(job: CronJob): string {
const direct = compactPlanText(job.template_id);
if (direct) return direct;
const delivery = readRecordString(job.delivery, ['template_id', 'plan_template_id', 'templateId']);
if (delivery) return delivery;
const schedule = readRecordString(job.schedule, ['template_id', 'plan_template_id', 'templateId']);
if (schedule) return schedule;
return job.id.replace(/^schedule-/, '');
}
function selectedDutyMatchesTemplate(selectedIds: Set<string>, templateId: string, scheduleId?: string): boolean {
if (selectedIds.has(templateId) || (scheduleId && selectedIds.has(scheduleId))) return true;
return Array.from(selectedIds).some((id) => hasRuntimeSuffixForTemplate(id, templateId));
}
function hasRuntimeSuffixForTemplate(value: string, templateId: string): boolean {
if (!value.startsWith(`${templateId}-`)) return false;
return /^-\d{8,}$/.test(value.slice(templateId.length));
}
function dutyMatchesTemplate(duty: DigitalEmployeeDuty, templateId: string, title?: string | null): boolean {
if (duty.id === templateId || duty.conversation_id === templateId) return true;
if (hasRuntimeSuffixForTemplate(duty.id, templateId) || (duty.conversation_id ? hasRuntimeSuffixForTemplate(duty.conversation_id, templateId) : false)) return true;
const normalizedTitle = compactPlanText(title).toLowerCase();
return Boolean(normalizedTitle && compactPlanText(duty.title).toLowerCase() === normalizedTitle);
}
function linkedDutyForTemplate(templateId: string, title: string | null | undefined, duties: DigitalEmployeeDuty[]): DigitalEmployeeDuty | null {
const matches = duties.filter((duty) => dutyMatchesTemplate(duty, templateId, title));
return matches.find((duty) => duty.selected)
?? matches.find((duty) => duty.id === templateId || duty.conversation_id === templateId)
?? matches[0]
?? null;
}
function submissionDutyIdForPlanTemplate(planItem: DigitalPlanItem, duties: DigitalEmployeeDuty[]): string {
return linkedDutyForTemplate(planItem.id, planItem.title, duties)?.id ?? planItem.id;
}
function templateStepCount(job: CronJob): number {
if (typeof job.step_count === 'number' && Number.isFinite(job.step_count)) return job.step_count;
return Array.isArray(job.skill_ids) ? job.skill_ids.length : 0;
}
function triggerKindLabel(job: CronJob): string {
const trigger = compactPlanText(job.trigger_kind || readRecordString(job.schedule, ['kind'])).toLowerCase();
if (trigger === 'cron') return '定时任务';
if (trigger === 'webhook') return '触发任务';
return '手动任务';
}
function buildPlanTemplateItem(job: CronJob, duties: DigitalEmployeeDuty[], selected: boolean): DigitalPlanItem {
const templateId = templateIdForSchedulerJob(job);
const linkedDuty = linkedDutyForTemplate(templateId, job.name, duties);
const stepCount = templateStepCount(job);
const skillCount = Array.isArray(job.skill_ids) ? job.skill_ids.length : stepCount;
const status = linkedDuty?.status ?? job.last_status ?? (job.enabled ? 'plan_template' : 'disabled');
const statusLabel = linkedDuty?.status_label ?? (job.enabled ? triggerKindLabel(job) : '已停用');
const statusTone = linkedDuty?.status_tone ?? (job.enabled ? 'info' : 'waiting');
return {
id: templateId,
planId: templateId,
title: compactPlanText(job.name) || templateId,
summary: compactPlanText(job.prompt) || `任务:${templateId}`,
implemented: job.enabled,
selectable: true,
selected,
status,
statusLabel,
statusTone,
scheduleText: linkedDuty?.schedule_text ?? scheduleTextFromJob(job),
resultText: linkedDuty?.result_text ?? `${stepCount} 个步骤,${skillCount} 个Skill`,
conversationId: linkedDuty?.conversation_id ?? templateId,
stepCount,
taskCount: skillCount,
schedulerJob: job,
source: 'plan_template',
};
}
function isPlanItemExecutable(planItem: DigitalPlanItem): boolean {
return planItem.implemented;
}
function planExecutionDisabledReason(planItem: DigitalPlanItem): string {
if (!planItem.implemented) return '当前任务未接入执行能力。';
return '';
}
function eventMatchesPlanItem(event: DigitalEmployeeWorkEvent, planItem: DigitalPlanItem): boolean {
const haystack = `${event.conversation_id ?? ''} ${event.plan_title} ${event.task_title} ${event.step_title ?? ''} ${event.detail}`;
return haystack.includes(planItem.id) || haystack.includes(planItem.title);
}
function fallbackSummaryForPlanItem(
planItem: DigitalPlanItem,
events: DigitalEmployeeWorkEvent[],
): DigitalEmployeeTaskExecutionSummary {
const matched = events.filter((event) => eventMatchesPlanItem(event, planItem));
const successCount = matched.filter(isSuccessfulEvent).length;
const failureCount = matched.filter(isFailedEvent).length;
const runningCount = matched.filter(isRunningEvent).length;
const waitingCount = Math.max(matched.length - successCount - failureCount - runningCount, 0);
const latest = matched.length > 0 ? matched[matched.length - 1] : undefined;
return {
id: planItem.id,
plan_id: planItem.planId,
title: planItem.title,
description: planItem.summary,
selected: true,
total_count: matched.length,
success_count: successCount,
failure_count: failureCount,
running_count: runningCount,
waiting_count: waitingCount,
latest_status_label: latest?.status_label ?? planItem.statusLabel,
latest_status_tone: latest?.status_tone ?? planItem.statusTone,
business_summary: matched.length > 0
? `${planItem.title} 今日执行 ${matched.length} 次,成功 ${successCount} 次,失败 ${failureCount} 次。`
: `${planItem.title} 尚未产生执行记录。`,
ai_summary: matched.length > 0
? `${planItem.title} 今日执行 ${matched.length} 次,成功 ${successCount} 次,失败 ${failureCount} 次。`
: `${planItem.title} 尚未产生执行记录。`,
};
}
function summaryMatchesPlanItem(summary: DigitalEmployeeTaskExecutionSummary, planItem: DigitalPlanItem): boolean {
const summaryPlanId = summary.plan_id ?? '';
if (summary.id === planItem.id || summaryPlanId === planItem.planId) return true;
if (hasRuntimeSuffixForTemplate(summary.id, planItem.id) || hasRuntimeSuffixForTemplate(summaryPlanId, planItem.planId)) return true;
return compactPlanText(summary.title).toLowerCase() === compactPlanText(planItem.title).toLowerCase();
}
function runDetailFromEvent(event: DigitalEmployeeWorkEvent): DigitalEmployeeRunDetail {
return {
id: event.id,
time: event.time,
task_name: event.plan_title,
step_name: event.step_title,
operation_name: event.task_title,
status_label: event.status_label,
status_tone: event.status_tone,
business_detail: eventBusinessResult(event),
raw_detail: event.detail,
updated_at: event.updated_at,
conversation_id: event.conversation_id,
};
}
function cronExprForDailyTime(value: string): string {
const [hour = '9', minute = '0'] = value.split(':');
const safeHour = Math.min(Math.max(Number(hour) || 9, 0), 23);
const safeMinute = Math.min(Math.max(Number(minute) || 0, 0), 59);
return `${safeMinute} ${safeHour} * * *`;
}
function eventStatusText(event: DigitalEmployeeWorkEvent): string {
return event.status_label || event.status || '已同步';
}
function compactText(value?: string | null): string {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function eventScheduleTime(event: DigitalEmployeeWorkEvent): string {
const source = `${event.plan_title} ${event.step_title ?? ''}`;
const match = source.match(/(?:^|\D)(\d{1,2})[:](\d{2})(?:\D|$)/);
if (match?.[1] && match[2]) return `${match[1].padStart(2, '0')}:${match[2]}`;
const fallback = compactText(event.time);
return fallback || '即时触发';
}
function formatBeijingDateTime(value?: string | null): string {
const raw = compactText(value);
if (!raw) return '';
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return '';
const parts = new Intl.DateTimeFormat('zh-CN', {
timeZone: DIGITAL_EMPLOYEE_TIME_ZONE,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(date);
const part = (type: string) => parts.find((item) => item.type === type)?.value ?? '';
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.missingCredentials?.length ?? 0) > 0) 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.missingCredentials?.length ?? 0) > 0) 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 '管理端同步地址未配置。';
if ((status.missingCredentials?.length ?? 0) > 0) {
return `管理端同步凭据未完整:${status.missingCredentials?.join('、')}`;
}
const error = summarizeSyncError(status.lastSyncError);
if (status.failed > 0 || error) {
const retrySummary = status.failureSummary
? [
status.failureSummary.dueForRetry > 0
? `${status.failureSummary.dueForRetry} 条可重试`
: '',
status.failureSummary.backoff > 0
? `${status.failureSummary.backoff} 条退避中`
: '',
failureGroupSummary(status.failureSummary),
].filter(Boolean).join('')
: '';
return [
status.failed > 0 ? `${status.failed} 条失败` : '',
status.pending > 0 ? `${status.pending} 条待同步` : '',
retrySummary,
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 数据。';
}
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 failureGroupSummary(summary: ManagementSyncFailureSummary): string {
const groups = summary.byEntityType
.slice(0, 3)
.map((group) => `${entityTypeLabel(group.entityType)} ${group.count}`)
.join(' / ');
return groups ? `类型:${groups}` : '';
}
function syncFailureSummary(failure: ManagementSyncFailure): string {
const error = summarizeSyncError(failure.error);
const title = compactText(failure.entitySummary?.title);
return [
title,
`${entityTypeLabel(failure.entityType)} / ${failure.operation}`,
`重试 ${failure.attempts}`,
failure.dueForRetry ? '等待补偿' : `下次 ${syncFailureRetryTime(failure)}`,
error,
].filter(Boolean).join(' · ');
}
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) || '待计算';
}
function syncAttemptStatusLabel(status: string): string {
if (status === 'synced') return '已同步';
if (status === 'failed') return '失败';
return status || '未知';
}
function syncAttemptTone(status: string): 'success' | 'danger' | 'info' {
if (status === 'synced') return 'success';
if (status === 'failed') return 'danger';
return 'info';
}
function buildSyncFailureDiagnostic(
failure: ManagementSyncFailure,
status: ManagementSyncStatus | null,
): string {
return JSON.stringify(
{
generatedAt: new Date().toISOString(),
source: 'qimingclaw-digital-employee',
failure,
sync: status
? {
running: status.running,
syncing: status.syncing,
enabled: status.enabled,
endpoint: status.endpoint,
missingCredentials: status.missingCredentials ?? [],
pending: status.pending,
failed: status.failed,
lastSyncAt: status.lastSyncAt,
lastSyncError: status.lastSyncError,
failureSummary: status.failureSummary ?? null,
}
: null,
},
null,
2,
);
}
function syncFailureDiagnosticFileName(failure: ManagementSyncFailure): string {
const entity = [failure.entityType, failure.entityId]
.filter(Boolean)
.join('-')
.replace(/[^a-z0-9._-]+/gi, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return `qimingclaw-sync-failure-${entity || failure.id || 'diagnostic'}.json`;
}
function downloadTextFile(filename: string, content: string, mimeType = 'application/json;charset=utf-8'): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function dailyReportFileName(date: string): string {
return `qimingclaw-digital-report-${date}.txt`;
}
function buildDailyReportDownloadText(
dateLabel: string,
report: DigitalEmployeeDailyReport,
summaries: DigitalEmployeeTaskExecutionSummary[],
details: DigitalEmployeeRunDetail[],
): string {
return [
report.title || `${dateLabel} 数字员工日报`,
`日期:${dateLabel}`,
`生成时间:${report.generated_at ? formatBeijingDateTime(report.generated_at) : '暂未生成'}`,
`生成来源:${report.generated_by === 'ai' ? 'AI' : 'qimingclaw adapter'}`,
'',
'## 摘要',
report.summary || '暂无摘要。',
'',
'## 指标',
`任务总数:${report.totals.task_count}`,
`执行次数:${report.totals.execution_count}`,
`成功次数:${report.totals.success_count}`,
`失败次数:${report.totals.failure_count}`,
`运行中:${report.totals.running_count ?? 0}`,
'',
'## 任务汇总',
...(summaries.length > 0
? summaries.map((summary) => `- ${summary.title}${summary.latest_status_label},执行 ${summary.total_count} 次,成功 ${summary.success_count},失败 ${summary.failure_count}${summary.ai_summary || summary.business_summary || ''}`)
: ['暂无任务汇总。']),
'',
'## 运行明细',
...(details.length > 0
? details.map((detail) => `- ${detail.time || '--:--'} ${detail.task_name} / ${detail.step_name || detail.operation_name || '任务执行'}${detail.status_label}${detail.business_detail}`)
: ['暂无运行明细。']),
'',
'## 详情',
...((report.detail_lines ?? []).length > 0 ? report.detail_lines : ['暂无详情。']),
].join('\n');
}
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) {
return beijingTime;
}
return compactText(event.time) || '待同步';
}
function runDetailDisplayTime(detail: DigitalEmployeeRunDetail): string {
return formatBeijingDateTime(detail.updated_at) || compactText(detail.time) || '--:--';
}
function eventBusinessResult(event: DigitalEmployeeWorkEvent): string {
const detail = compactText(event.detail)
.replace(/^[:]\s*$/u, '')
.replace(/[:]\s*(?=||,|;||$)/u, '')
.trim();
if (!detail) {
if (isSuccessfulEvent(event)) return '真实事件已完成,结果详情未回传。';
if (isRunningEvent(event)) return '真实事件执行中,等待结果回传。';
if (isFailedEvent(event)) return '执行异常,点击查看详情。';
return '结果详情待同步。';
}
if (/^runner unavailable/i.test(detail)) {
return '执行链路异常,点击查看详情。';
}
return detail.length > 72 ? `${detail.slice(0, 72)}...` : detail;
}
function eventTimingSummary(event: DigitalEmployeeWorkEvent): string {
return `计划时间 ${eventScheduleTime(event)} · 执行时间 ${eventDisplayTime(event)}`;
}
function eventMatchesFilter(event: DigitalEmployeeWorkEvent, filter: EventStatusFilter): boolean {
if (filter === 'all') return true;
if (filter === 'running') return isRunningEvent(event);
if (filter === 'success') return isSuccessfulEvent(event);
return isFailedEvent(event);
}
function projectionSelectedDutyIds(projection: DigitalEmployeeWorkdayProjection): Set<string> {
return new Set(
projection.duties
.filter((duty) => duty.selected)
.map((duty) => duty.id),
);
}
function managedServiceSummary(service: DigitalEmployeeManagedService): string {
const deps = service.dependent_tasks.length > 0 ? `,影响 ${service.dependent_tasks.slice(0, 2).join('、')}` : '';
return `${service.name} ${service.status_label}${service.error ? `${service.error}` : deps}`;
}
function canRestartManagedService(service: DigitalEmployeeManagedService): boolean {
return service.service_id === 'fileServer' || service.service_id === 'guiServer';
}
function taskAgentSummary(agent: DigitalEmployeeTaskAgentState): string {
const next = agent.next_action === 'retry_or_skip' ? '重试或跳过' : agent.next_action === 'watch_progress' ? '观察进度' : agent.next_action;
return `${agent.title} ${agent.status_label},下一步 ${next}`;
}
function taskAgentControlLabel(command: TaskAgentControlCommand): string {
switch (command) {
case 'retry_task': return '重试';
case 'skip_task': return '跳过';
case 'cancel_task': return '取消';
default: return command;
}
}
function isDecisionOpen(status?: string): boolean {
return ['pending', 'requested'].includes(String(status || '').toLowerCase());
}
function isExecutionPanelInteractiveTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
if (target.closest('button, a, input, select, textarea, [role="tab"]')) {
return true;
}
const roleButton = target.closest('[role="button"]');
return Boolean(roleButton && !roleButton.classList.contains('de-workday-execution--clickable'));
}
function rotateEvents(events: DigitalEmployeeWorkEvent[], offset: number): DigitalEmployeeWorkEvent[] {
if (events.length <= 4) return events;
return events.slice(offset).concat(events.slice(0, offset)).slice(0, 4);
}
function isRunningEvent(event: DigitalEmployeeWorkEvent): boolean {
const status = `${event.status} ${event.status_tone}`.toLowerCase();
return status.includes('running') || status.includes('execut') || status.includes('progress');
}
function isSuccessfulEvent(event: DigitalEmployeeWorkEvent): boolean {
const status = `${event.status} ${event.status_tone}`.toLowerCase();
return status.includes('success') || status.includes('complete') || status.includes('done');
}
function isFailedEvent(event: DigitalEmployeeWorkEvent): boolean {
const status = `${event.status} ${event.status_tone}`.toLowerCase();
return status.includes('danger') || status.includes('fail') || status.includes('error') || status.includes('异常');
}
function isCallEvent(event: DigitalEmployeeWorkEvent): boolean {
const text = `${event.plan_title} ${event.task_title} ${event.step_title ?? ''} ${event.detail}`.toLowerCase();
return ['外呼', '电话', '拨打', '接通', '未接通', '重拨', '人员'].some((keyword) => text.includes(keyword));
}
function isRedialEvent(event: DigitalEmployeeWorkEvent): boolean {
const text = `${event.plan_title} ${event.task_title} ${event.step_title ?? ''} ${event.detail}`.toLowerCase();
return ['重拨', '未接通', '再拨', '回拨', 'retry', 'callback'].some((keyword) => text.includes(keyword));
}
function isEvidenceEvent(event: DigitalEmployeeWorkEvent): boolean {
const text = `${event.plan_title} ${event.task_title} ${event.step_title ?? ''} ${event.detail}`.toLowerCase();
return ['核验', '校验', '证据', '汇总', '报告', '结果', '交付', '看板', '同步'].some((keyword) => text.includes(keyword));
}
function getVisualStateOverride(): EmployeeVisualState | null {
if (typeof window === 'undefined') return null;
const value = new URLSearchParams(window.location.search).get('visual');
return EMPLOYEE_STATE_VALUES.includes(value as EmployeeVisualState) ? (value as EmployeeVisualState) : null;
}
function visualStateForEvent(event: DigitalEmployeeWorkEvent): EmployeeVisualState {
if (isRedialEvent(event)) return 'redial';
if (isCallEvent(event)) return 'call';
if (isEvidenceEvent(event)) return 'evidence';
return 'browser';
}
function resolveEmployeeVisualState(args: {
confirmed: boolean;
currentEvent: DigitalEmployeeWorkEvent | null;
pendingDecisionCount: number;
eventsCount: number;
}): EmployeeVisualState {
if (args.currentEvent) return visualStateForEvent(args.currentEvent);
if (!args.confirmed && args.eventsCount === 0) return 'skill';
if (args.pendingDecisionCount > 0) return 'decision';
if (args.eventsCount > 0) return 'browser';
return 'skill';
}
function readGuideDismissedDate(): string | null {
if (typeof window === 'undefined') return null;
try {
return window.sessionStorage.getItem(WORKDAY_GUIDE_DISMISSED_STORAGE_KEY)
?? window.sessionStorage.getItem(LEGACY_WORKDAY_GUIDE_DISMISSED_STORAGE_KEY);
} catch {
return null;
}
}
function rememberGuideDismissedDate(date: string): void {
if (typeof window === 'undefined') return;
try {
window.sessionStorage.setItem(WORKDAY_GUIDE_DISMISSED_STORAGE_KEY, date);
window.sessionStorage.removeItem(LEGACY_WORKDAY_GUIDE_DISMISSED_STORAGE_KEY);
} catch {
// Session storage can be unavailable in restricted browser contexts.
}
}
export default function CommandDeck({ onNavigate }: { onNavigate: (target: DigitalNavigationTarget) => void }) {
const [selectedDate, setSelectedDate] = useState(todayInputDate());
const [workdayProjection, setWorkdayProjection] = useState<DigitalEmployeeWorkdayProjection | null>(null);
const [schedulerJobs, setSchedulerJobs] = useState<CronJob[]>([]);
const [selectedDutyIds, setSelectedDutyIds] = useState<Set<string>>(new Set());
const [projectionLoading, setProjectionLoading] = useState(true);
const [actionBusy, setActionBusy] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [approvalActionDrafts, setApprovalActionDrafts] = useState<Record<string, ApprovalActionDraft>>({});
const [planDataError, setPlanDataError] = useState<string | null>(null);
const [selectionDirty, setSelectionDirty] = useState(false);
const [taskListFilter, setTaskListFilter] = useState<TaskListFilter>('selected');
const [feedOffset, setFeedOffset] = useState(0);
const [feedManuallyPaused, setFeedManuallyPaused] = useState(false);
const [feedInteractionPaused, setFeedInteractionPaused] = useState(false);
const [eventHistoryOpen, setEventHistoryOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState<DigitalEmployeeWorkEvent | null>(null);
const [eventStatusFilter, setEventStatusFilter] = useState<EventStatusFilter>('all');
const [taskManagerOpen, setTaskManagerOpen] = useState(false);
const [schedulePlanItem, setSchedulePlanItem] = useState<DigitalPlanItem | null>(null);
const [executePlanItem, setExecutePlanItem] = useState<DigitalPlanItem | null>(null);
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: '',
position: '',
currentStatus: 'working',
});
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);
useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
const fetchProjection = useCallback(async () => {
let latestProjection: DigitalEmployeeWorkdayProjection | null = null;
let latestJobs: CronJob[] | null = null;
const syncDefaultSelection = () => {
if (selectionDirtyRef.current || !latestJobs) return;
const projectionIds = latestProjection?.source === 'live'
? projectionSelectedDutyIds(latestProjection)
: new Set<string>();
const matchedTemplateIds = latestJobs
.map((job) => templateIdForSchedulerJob(job))
.filter((templateId) => selectedDutyMatchesTemplate(projectionIds, templateId));
const defaultTemplateIds = matchedTemplateIds.length > 0
? matchedTemplateIds
: latestJobs.slice(0, 3).map((job) => templateIdForSchedulerJob(job));
setSelectedDutyIds(new Set(defaultTemplateIds));
};
const projectionPromise = getDigitalEmployeeWorkday(selectedDate)
.then((projection) => {
if (!mountedRef.current) return;
latestProjection = projection;
setWorkdayProjection(projection);
setActionError(null);
syncDefaultSelection();
})
.catch((error: unknown) => {
if (!mountedRef.current) return;
setActionError(error instanceof Error ? error.message : '数字员工首页数据同步失败');
});
const jobsPromise = getSchedulerJobs()
.then((jobs) => {
if (!mountedRef.current) return;
latestJobs = jobs.filter((job) => (
job.job_type === 'plan_template' || readRecordString(job.delivery, ['mode']) === 'plan_template'
));
setSchedulerJobs(latestJobs);
setPlanDataError(null);
syncDefaultSelection();
})
.catch((error: unknown) => {
if (!mountedRef.current) return;
setPlanDataError(error instanceof Error ? error.message : '任务加载失败');
});
const syncStatusPromise = readManagementSyncStatus()
.then((status) => {
if (!mountedRef.current) return;
setManagementSyncStatus(status);
});
await Promise.allSettled([projectionPromise, jobsPromise, syncStatusPromise]);
if (mountedRef.current) {
setProjectionLoading(false);
}
}, [selectedDate]);
useEffect(() => {
setProjectionLoading(true);
void fetchProjection();
}, [fetchProjection]);
useEffect(() => {
const intervalMs = workdayProjection?.workday.confirmed ? 8000 : 30000;
const timer = window.setInterval(() => { void fetchProjection(); }, intervalMs);
return () => window.clearInterval(timer);
}, [fetchProjection, workdayProjection?.workday.confirmed]);
useEffect(() => {
const feedPaused = feedManuallyPaused || feedInteractionPaused || eventHistoryOpen || selectedEvent !== null;
if (feedPaused) return undefined;
const timer = window.setInterval(() => {
setFeedOffset((current) => {
const length = workdayProjection?.source === 'live' ? workdayProjection.events.length : 0;
return length > 0 ? (current + 1) % length : 0;
});
}, 3200);
return () => window.clearInterval(timer);
}, [eventHistoryOpen, feedInteractionPaused, feedManuallyPaused, selectedEvent, workdayProjection?.events.length, workdayProjection?.source]);
const isLiveProjection = workdayProjection?.source === 'live';
const liveProjectionDuties = useMemo(
() => (isLiveProjection ? (workdayProjection?.duties ?? []) : []),
[isLiveProjection, workdayProjection?.duties],
);
const planItems = useMemo(() => {
return schedulerJobs
.slice()
.sort((left, right) => {
const leftId = templateIdForSchedulerJob(left);
const rightId = templateIdForSchedulerJob(right);
const leftSelected = selectedDutyIds.has(leftId) ? 1 : 0;
const rightSelected = selectedDutyIds.has(rightId) ? 1 : 0;
if (leftSelected !== rightSelected) return rightSelected - leftSelected;
const leftRun = new Date(left.next_run || '').getTime();
const rightRun = new Date(right.next_run || '').getTime();
const leftHasRun = Number.isFinite(leftRun);
const rightHasRun = Number.isFinite(rightRun);
if (leftHasRun !== rightHasRun) return leftHasRun ? -1 : 1;
if (leftHasRun && rightHasRun && leftRun !== rightRun) return leftRun - rightRun;
return compactPlanText(left.name).localeCompare(compactPlanText(right.name), 'zh-CN');
})
.map((job) => {
const templateId = templateIdForSchedulerJob(job);
return buildPlanTemplateItem(job, liveProjectionDuties, selectedDutyIds.has(templateId));
});
}, [liveProjectionDuties, schedulerJobs, selectedDutyIds]);
const selectedDuties = useMemo(
() => planItems.filter((planItem) => selectedDutyIds.has(planItem.id)),
[planItems, selectedDutyIds],
);
const selectedSubmissionDutyIds = useMemo(
() => selectedDuties.map((planItem) => submissionDutyIdForPlanTemplate(planItem, liveProjectionDuties)),
[liveProjectionDuties, selectedDuties],
);
const totalDuties = planItems;
const pendingDuties = planItems.filter((planItem) => !selectedDutyIds.has(planItem.id));
const visibleTaskItems = taskListFilter === 'selected'
? selectedDuties
: taskListFilter === 'pending'
? pendingDuties
: totalDuties;
const allEvents = isLiveProjection ? (workdayProjection?.events ?? []) : [];
const rotatingEvents = rotateEvents(allEvents, feedOffset);
const visibleEvents = allEvents;
const pendingDecisions = (isLiveProjection ? (workdayProjection?.decisions ?? []) : []).filter((decision) => (
isDecisionOpen(decision.status)
));
const feedPaused = feedManuallyPaused || feedInteractionPaused || eventHistoryOpen || selectedEvent !== null;
const filteredHistoryEvents = useMemo(
() => allEvents.filter((event) => eventMatchesFilter(event, eventStatusFilter)),
[allEvents, eventStatusFilter],
);
const visibleHistoryEvents = filteredHistoryEvents.slice(0, EVENT_HISTORY_RENDER_LIMIT);
const runningEvents = allEvents.filter(isRunningEvent);
const completedEvents = allEvents.filter(isSuccessfulEvent);
const failedEvents = allEvents.filter(isFailedEvent);
const currentEvent = rotatingEvents[0] ?? runningEvents[0] ?? null;
const employee = workdayProjection?.employee;
const workday = workdayProjection?.workday;
const delivery = workdayProjection?.delivery;
const managedServices = isLiveProjection ? (workdayProjection?.managed_services ?? []) : [];
const managedServiceIssues = managedServices.filter((service) => service.requires_human_action || service.status === 'degraded');
const taskAgents = isLiveProjection ? (workdayProjection?.task_agents ?? []) : [];
const actionableTaskAgents = taskAgents.filter((agent) => agent.can_retry || agent.can_skip || agent.can_cancel);
const routeSummary = isLiveProjection ? workdayProjection?.route_summary : null;
const latestRoute = routeSummary?.latest ?? null;
const diagnosticSummary = isLiveProjection ? workdayProjection?.diagnostic_summary : null;
const diagnosticIssueCount = diagnosticSummary ? diagnosticSummary.doctor.error + diagnosticSummary.doctor.warn : 0;
const auditRiskCount = diagnosticSummary ? diagnosticSummary.audit.error + diagnosticSummary.audit.warn : 0;
const latestDiagnosticIssue = diagnosticSummary?.latest_issue ?? null;
const dailyReport = isLiveProjection ? workdayProjection?.daily_report : undefined;
const runDetails = isLiveProjection
? (workdayProjection?.run_details?.length ? workdayProjection.run_details : allEvents.map(runDetailFromEvent))
: [];
const visibleRunDetails = runDetails.slice(0, MAIN_RUN_DETAIL_LIMIT);
const executionSummaries = isLiveProjection ? (workdayProjection?.execution_summaries ?? []) : [];
const taskSummaryCards = selectedDuties.map((planItem) => (
executionSummaries.find((summary) => summaryMatchesPlanItem(summary, planItem))
?? fallbackSummaryForPlanItem(planItem, allEvents)
));
const selectedDateLabel = displayInputDate(selectedDate);
const visibleTaskListLabel = taskListFilter === 'all'
? '全部任务'
: taskListFilter === 'pending'
? '待选择任务'
: '今日已选任务';
const visibleTaskListHint = taskListFilter === 'all'
? `总任务 ${totalDuties.length} 项,不代表当天都会执行。`
: taskListFilter === 'pending'
? `当前日期 ${selectedDateLabel} 还有 ${pendingDuties.length} 项未选择。`
: `当前日期 ${selectedDateLabel} 已选择 ${selectedDuties.length} 项任务。`;
const readyReportTotals = dailyReport?.status === 'ready' ? dailyReport.totals : null;
const reportTaskCount = readyReportTotals?.task_count ?? taskSummaryCards.length;
const reportExecutionCount = readyReportTotals?.execution_count ?? taskSummaryCards.reduce((total, summary) => total + summary.total_count, 0);
const reportSuccessCount = readyReportTotals?.success_count ?? taskSummaryCards.reduce((total, summary) => total + summary.success_count, 0);
const reportFailureCount = readyReportTotals?.failure_count ?? taskSummaryCards.reduce((total, summary) => total + summary.failure_count, 0);
const reportPreviewLines = dailyReport
? [dailyReport.summary, ...(dailyReport.detail_lines ?? [])].filter((line): line is string => Boolean(line && line.trim()))
: [];
const reportPreviewAvailable = dailyReport?.status === 'ready' && reportPreviewLines.length > 0;
const currentStatus = workday?.current_status ?? employee?.current_status ?? 'working';
const currentStatusLabel = workday?.current_status_label ?? employee?.current_status_label ?? (currentStatus === 'resting' ? '休息中' : '工作中');
const isResting = currentStatus === 'resting';
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 managementSyncFailures = managementSyncStatus?.recentFailures ?? [];
const resolvedVisualState = resolveEmployeeVisualState({
confirmed: Boolean(workday?.confirmed),
currentEvent,
pendingDecisionCount: pendingDecisions.length,
eventsCount: allEvents.length,
});
const visualStateOverride = getVisualStateOverride();
const employeeVisualState = visualStateOverride ?? resolvedVisualState;
const activeVisual = EMPLOYEE_STATE_OPTIONS[employeeVisualState];
const bubbleText = visualStateOverride || currentEvent || pendingDecisions.length > 0
? activeVisual.bubble
: (workday?.bubble_text ?? activeVisual.bubble);
const workdaySummaryText = totalDuties.length > 0
? `已加载 ${totalDuties.length} 项任务,当前选择 ${selectedDuties.length} 项;右侧展示真实执行状态、运行明细和日报。`
: (workday?.summary ?? '等待同步当天任务计划。');
const currentActionTitle = currentEvent?.task_title
?? (actionableTaskAgents[0] ? actionableTaskAgents[0].title : managedServiceIssues[0] ? managedServiceIssues[0].name : workday?.confirmed ? '等待下一条执行事件' : '等待确认任务设置');
const currentActionDetail = currentEvent
? `${currentEvent.plan_title}${eventBusinessResult(currentEvent)}${eventTimingSummary(currentEvent)}`
: actionableTaskAgents[0]
? taskAgentSummary(actionableTaskAgents[0])
: managedServiceIssues[0]
? managedServiceSummary(managedServiceIssues[0])
: '任务执行后会显示正在处理的任务、步骤或汇总动作。';
const nextStepText = pendingDecisions[0]
? `等待人工确认:${pendingDecisions[0].title}`
: actionableTaskAgents[0]
? `Task Agent${taskAgentSummary(actionableTaskAgents[0])}`
: managedServiceIssues[0]
? `处理服务状态:${managedServiceSummary(managedServiceIssues[0])}`
: workday?.confirmed
? (delivery?.lines[0] ?? '继续按计划轮询新工单,并把结果同步到运行明细。')
: '请先确认今天要执行的任务,确认后数字员工会开始执行。';
const needsWorkdayConfirmation = Boolean(workdayProjection && !workday?.confirmed);
useEffect(() => {
if (projectionLoading || !workdayProjection) return;
if (workdayProjection.workday.confirmed) {
setGuideOpen(false);
return;
}
}, [guideDismissedDate, projectionLoading, selectedDate, workdayProjection]);
useEffect(() => {
if (!employee) return;
setProfileForm({
name: employee.name || '',
company: employee.company || '',
position: employee.position || employee.major || '',
currentStatus: employee.current_status || workday?.current_status || 'working',
});
}, [employee, workday?.current_status]);
useEffect(() => {
setReportScheduleTime(dailyReport?.report_schedule_time || '18:00');
}, [dailyReport?.report_schedule_time]);
useEffect(() => {
setReportPreviewOpen(false);
}, [selectedDate]);
const toggleDuty = useCallback((planItem: DigitalPlanItem) => {
if (!planItem.selectable || !planItem.implemented) return;
setSelectedDutyIds((current) => {
const next = new Set(current);
if (next.has(planItem.id)) {
next.delete(planItem.id);
} else {
next.add(planItem.id);
}
return next;
});
selectionDirtyRef.current = true;
setSelectionDirty(true);
setActionNotice(null);
}, []);
const confirmWorkday = useCallback(async () => {
if (selectedDutyIds.size === 0) {
setActionError('请至少选择一项今天要执行的任务。');
return;
}
if (isResting) {
setActionError('当前状态为休息中,请切换为工作中后再执行任务。');
return;
}
setActionBusy('confirm_workday');
setActionError(null);
setActionNotice(null);
try {
const projection = await postDigitalEmployeeWorkdayAction({
action: 'confirm_workday',
date: selectedDate,
duty_ids: selectedSubmissionDutyIds,
});
if (!mountedRef.current) return;
setWorkdayProjection(projection);
setSelectedDutyIds(new Set(selectedDutyIds));
selectionDirtyRef.current = true;
setSelectionDirty(false);
setGuideOpen(false);
setActionNotice('任务设置已确认,数字员工开始按任务执行。');
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '确认任务设置失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [isResting, selectedDate, selectedDutyIds, selectedSubmissionDutyIds]);
const flushManagementSync = useCallback(async () => {
setActionBusy('flush_management_sync');
setActionError(null);
setActionNotice(null);
try {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.flushSync) {
setActionNotice('当前环境未连接 qimingclaw 同步桥。');
return;
}
const syncStatus = await bridge.flushSync();
if (!mountedRef.current) return;
setManagementSyncStatus(syncStatus);
const latestProjection = await getDigitalEmployeeWorkday(selectedDate);
if (!mountedRef.current) return;
setWorkdayProjection(latestProjection);
const statusText = syncStatus.failed > 0
? `同步完成,仍有 ${syncStatus.failed} 条失败记录。`
: syncStatus.pending > 0
? `同步完成,仍有 ${syncStatus.pending} 条待同步记录。`
: '管理端同步完成。';
setActionNotice(statusText);
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '同步管理端失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [selectedDate]);
const copySyncFailureDiagnostic = useCallback(async (failure: ManagementSyncFailure) => {
const diagnostic = buildSyncFailureDiagnostic(failure, managementSyncStatus);
try {
await navigator.clipboard.writeText(diagnostic);
setActionError(null);
setActionNotice('同步失败诊断已复制。');
} catch (error) {
setActionNotice(null);
setActionError(error instanceof Error ? error.message : '复制同步失败诊断失败');
}
}, [managementSyncStatus]);
const exportSyncFailureDiagnostic = useCallback((failure: ManagementSyncFailure) => {
downloadTextFile(
syncFailureDiagnosticFileName(failure),
buildSyncFailureDiagnostic(failure, managementSyncStatus),
);
setActionError(null);
setActionNotice('同步失败诊断已导出。');
}, [managementSyncStatus]);
const cancelAllTasks = useCallback(() => {
setSelectedDutyIds(new Set());
selectionDirtyRef.current = true;
setSelectionDirty(true);
setActionNotice('已一键取消当前选择,重新选择后点击任务执行生效。');
setActionError(null);
}, []);
const saveProfileSettings = useCallback(async () => {
setActionBusy('update_profile');
setActionError(null);
setActionNotice(null);
try {
const projection = await postDigitalEmployeeWorkdayAction({
action: 'update_profile',
date: selectedDate,
name: profileForm.name,
company: profileForm.company,
position: profileForm.position,
current_status: profileForm.currentStatus,
});
if (!mountedRef.current) return;
setWorkdayProjection(projection);
setProfileSettingsOpen(false);
setActionNotice('数字员工特性设置已保存。');
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '保存特性设置失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [profileForm, selectedDate]);
const saveReportSchedule = useCallback(async () => {
setActionBusy('update_report_schedule');
setActionError(null);
setActionNotice(null);
try {
const projection = await postDigitalEmployeeWorkdayAction({
action: 'update_report_schedule',
date: selectedDate,
report_schedule_time: reportScheduleTime,
});
if (!mountedRef.current) return;
setWorkdayProjection(projection);
setActionNotice(`日报生成时间已设置为 ${reportScheduleTime}`);
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '设置日报时间失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [reportScheduleTime, selectedDate]);
const generateDailyReport = useCallback(async () => {
setActionBusy('generate_daily_report');
setActionError(null);
setActionNotice(null);
try {
const projection = await postDigitalEmployeeWorkdayAction({
action: 'generate_daily_report',
date: selectedDate,
duty_ids: selectedSubmissionDutyIds,
report_schedule_time: reportScheduleTime,
});
if (!mountedRef.current) return;
const latestProjection = projection.daily_report?.status === 'ready'
? projection
: await getDigitalEmployeeWorkday(selectedDate);
if (!mountedRef.current) return;
setWorkdayProjection(latestProjection);
setActionNotice('日报已生成,可下载本地文本。');
if (latestProjection.daily_report?.status === 'ready') {
setReportPreviewOpen(true);
}
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '生成日报失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [reportScheduleTime, selectedDate, selectedDutyIds, selectedSubmissionDutyIds]);
const downloadDailyReport = useCallback(() => {
if (!dailyReport || dailyReport.status !== 'ready') return;
downloadTextFile(
dailyReportFileName(selectedDate),
buildDailyReportDownloadText(selectedDateLabel, dailyReport, taskSummaryCards, visibleRunDetails),
'text/plain;charset=utf-8',
);
setActionNotice('日报文本已下载。');
}, [dailyReport, selectedDate, selectedDateLabel, taskSummaryCards, visibleRunDetails]);
const resolveDecision = useCallback(async (decision: DigitalEmployeeDecision, actionId: string) => {
setActionBusy(`${decision.id}:${actionId}`);
setActionError(null);
setActionNotice(null);
try {
const projection = await postDigitalEmployeeWorkdayAction({
action: 'decide_approval',
date: selectedDate,
decision_id: decision.id,
decision: actionId,
});
if (!mountedRef.current) return;
const actionLabel = decision.actions.find((action) => action.id === actionId)?.label ?? actionId;
setWorkdayProjection(projection);
setActionNotice(`已记录“${actionLabel}”处理结果,并写入 qimingclaw 本地运行状态。`);
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '处理决策失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [selectedDate]);
const updateApprovalActionDraft = useCallback((decisionId: string, patch: Partial<ApprovalActionDraft>) => {
setApprovalActionDrafts((current) => ({
...current,
[decisionId]: { ...(current[decisionId] ?? EMPTY_APPROVAL_ACTION_DRAFT), ...patch },
}));
}, []);
const submitApprovalAction = useCallback(async (
decision: DigitalEmployeeDecision,
action: ApprovalActionKind,
) => {
const draft = approvalActionDrafts[decision.id] ?? EMPTY_APPROVAL_ACTION_DRAFT;
const comment = compactText(draft.comment);
const delegateTo = compactText(draft.delegateTo);
const dueAt = compactText(draft.dueAt);
if (action === 'comment' && !comment) {
setActionError('请先填写审批评论。');
return;
}
if (action === 'delegate' && !delegateTo) {
setActionError('请先填写转交对象。');
return;
}
const actionKey = `approval-action:${decision.id}:${action}`;
setActionBusy(actionKey);
setActionError(null);
setActionNotice(null);
try {
const options: Record<string, string | null | undefined> = {
actor: 'digital_employee_operator',
source: 'digital-command-deck',
comment: action === 'comment' ? comment : undefined,
delegate_to: action === 'delegate' ? delegateTo : undefined,
delegate_label: action === 'delegate' ? delegateTo : undefined,
due_at: action === 'mark_timeout' ? (dueAt || new Date().toISOString()) : undefined,
reason: action === 'mark_risk' || action === 'clear_risk' || action === 'mark_timeout'
? (comment || approvalActionLabel(action))
: undefined,
};
const result = await recordApprovalAction(decision.id, action, options);
if (!mountedRef.current) return;
if (!result?.ok) {
throw new Error(result?.error || '记录审批动作失败');
}
const latestProjection = await getDigitalEmployeeWorkday(selectedDate);
if (!mountedRef.current) return;
setWorkdayProjection(latestProjection);
setApprovalActionDrafts((current) => ({
...current,
[decision.id]: {
...(current[decision.id] ?? EMPTY_APPROVAL_ACTION_DRAFT),
comment: action === 'comment' ? '' : current[decision.id]?.comment ?? '',
delegateTo: action === 'delegate' ? '' : current[decision.id]?.delegateTo ?? '',
},
}));
setActionNotice(`已记录审批动作:${approvalActionLabel(action)}`);
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '记录审批动作失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [approvalActionDrafts, selectedDate]);
const controlTaskAgent = useCallback(async (
agent: DigitalEmployeeTaskAgentState,
command: TaskAgentControlCommand,
) => {
const actionKey = `task-agent:${agent.task_id}:${command}`;
setActionBusy(actionKey);
setActionError(null);
setActionNotice(null);
try {
const response = await governanceCommand({
command_kind: command,
context_refs: {
plan_id: agent.plan_id,
task_id: agent.task_id,
run_id: agent.latest_run_id,
step_id: agent.step_id,
task_agent_id: agent.agent_id,
},
payload: {
source: 'digital-home-task-agent',
task_agent_id: agent.agent_id,
task_id: agent.task_id,
plan_id: agent.plan_id,
step_id: agent.step_id,
latest_run_id: agent.latest_run_id,
title: agent.title,
requested_action: command,
previous_state: agent.execution_state,
next_action: agent.next_action,
},
correlation_id: `digital-home-task-agent-${agent.task_id}-${command}-${Date.now()}`,
});
if (!response.accepted) {
throw new Error(response.message || `Task Agent 命令未被接受:${response.command_id || command}`);
}
setActionNotice(`Task Agent 已${taskAgentControlLabel(command)}${agent.title}`);
await fetchProjection();
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : 'Task Agent 控制失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [fetchProjection]);
const restartService = useCallback(async (service: DigitalEmployeeManagedService) => {
const actionKey = `managed-service:${service.service_id}:restart`;
setActionBusy(actionKey);
setActionError(null);
setActionNotice(null);
try {
const response = await restartManagedService(service.service_id, {
reason: 'digital_home_managed_service_issue',
actor: 'digital_employee_ui',
});
if (!response.ok) {
throw new Error(response.error || response.message || `${service.name} 重启失败`);
}
setActionNotice(`${service.name} 已提交安全重启。`);
await fetchProjection();
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : `${service.name} 重启失败`);
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [fetchProjection]);
const openDuty = useCallback((planItem: DigitalPlanItem) => {
onNavigate({ tab: 'missions', date: selectedDate, missionId: planItem.conversationId || planItem.id });
}, [onNavigate, selectedDate]);
const createPlanSchedule = useCallback(async (planItem: DigitalPlanItem) => {
const cronExpr = cronExprForDailyTime(scheduleTime);
const stamp = Date.now();
const scheduleId = `digital-home-${planItem.planId}-${scheduleTime.replace(':', '')}`;
setActionBusy(`schedule:${planItem.id}`);
setActionError(null);
setActionNotice(null);
try {
const response = await governanceCommand({
command_kind: 'create_schedule',
context_refs: {
plan_id: planItem.planId,
schedule_id: scheduleId,
},
payload: {
source: 'digital-home',
plan_id: planItem.planId,
name: `定时执行:${planItem.title}`,
description: `按首页任务管理定时任务:${planItem.title}`,
prompt: `请按计划执行:${planItem.title}${planItem.summary}`,
cron_expr: cronExpr,
schedule: cronExpr,
},
correlation_id: `digital-home-create-schedule-${planItem.planId}-${stamp}`,
idempotency_key: `digital-home-create-schedule-${planItem.planId}-${scheduleTime}-${stamp}`,
});
if (!response.accepted) {
throw new Error(response.message || `调度命令未被接受:${response.command_id || 'create_schedule'}`);
}
setSchedulePlanItem(null);
setActionNotice(`已提交定时:${planItem.title}${scheduleTime} 按 cron 计划执行。`);
await fetchProjection();
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '定时执行失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [fetchProjection, scheduleTime]);
const confirmImmediatePlanExecution = useCallback(async (planItem: DigitalPlanItem) => {
if (!isPlanItemExecutable(planItem)) {
setActionError(planExecutionDisabledReason(planItem));
setActionNotice(null);
return;
}
setActionBusy(`execute:${planItem.id}`);
setActionError(null);
setActionNotice(null);
try {
const response = await dispatchPlanNow(planItem.planId);
if (response.ok !== true) {
throw new Error(response.error || '执行接口未接受请求');
}
const dispatchedCount = response.dispatched_tasks?.length ?? 0;
const schedulerResult = await triggerCronCheck();
const readyDispatched = schedulerResult.readyStepDispatch?.dispatched ?? 0;
const readyFailed = schedulerResult.readyStepDispatch?.failed ?? 0;
setExecutePlanItem(null);
setActionNotice(
readyDispatched > 0 || readyFailed > 0
? `已触发执行:${planItem.title},新建 ${dispatchedCount} 个步骤Ready 步骤自动派发 ${readyDispatched}${readyFailed > 0 ? `,失败 ${readyFailed}` : ''}`
: `已触发执行:${planItem.title},已派发 ${dispatchedCount} 个步骤。`,
);
await fetchProjection();
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '执行失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [fetchProjection]);
const dismissWorkdayGuide = useCallback(() => {
setGuideOpen(false);
setGuideDismissedDate(selectedDate);
rememberGuideDismissedDate(selectedDate);
}, [selectedDate]);
const renderDutyChecklistCard = useCallback((planItem: DigitalPlanItem, mode: 'guide' | 'manager') => {
const selected = selectedDutyIds.has(planItem.id);
const executeDisabledReason = planExecutionDisabledReason(planItem);
return (
<div
key={`${mode}-${planItem.id}`}
className={`${dutyCardClass(planItem, selected)} ${mode === 'guide' ? 'de-duty-card--guide' : ''}`}
title={planItem.summary}
>
<label className="de-task-manager-duty-check">
<input
type="checkbox"
checked={selected}
disabled={!planItem.selectable || !planItem.implemented}
onChange={() => toggleDuty(planItem)}
/>
<span>{selected ? '已选' : planItem.implemented ? '未选' : '待扩展'}</span>
</label>
<div className="de-template-card-main">
<div className="de-template-card-head">
<span className="de-template-card-name">{planItem.title}</span>
<span className={`de-badge ${badgeClass(planItem.statusTone)}`}>
{planItem.implemented ? planItem.statusLabel : '待扩展'}
</span>
</div>
<p className="de-template-card-desc">{planItem.summary}</p>
<div className="de-template-card-meta">
<span>{planItem.scheduleText}</span>
<span>{planItem.resultText}</span>
</div>
<div className="de-duty-card-foot">
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={!planItem.implemented}
onClick={() => openDuty(planItem)}
>
</button>
<div className="de-duty-card-actions">
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={!planItem.implemented || actionBusy === `schedule:${planItem.id}`}
onClick={() => setSchedulePlanItem(planItem)}
>
<Clock3 size={13} aria-hidden="true" />
<span></span>
</button>
<button
type="button"
className="de-btn-primary de-btn-sm"
disabled={!planItem.implemented || actionBusy === `execute:${planItem.id}`}
title={executeDisabledReason || '立即执行此任务'}
onClick={() => setExecutePlanItem(planItem)}
>
<PlayCircle size={13} aria-hidden="true" />
<span></span>
</button>
</div>
{!planItem.implemented && <span className="de-duty-selected-mark"></span>}
</div>
</div>
</div>
);
}, [actionBusy, openDuty, selectedDutyIds, toggleDuty]);
const openEventDetail = useCallback((event: DigitalEmployeeWorkEvent) => {
setSelectedEvent(event);
setFeedInteractionPaused(true);
}, []);
const closeEventDetail = useCallback(() => {
setSelectedEvent(null);
setFeedInteractionPaused(false);
}, []);
const closeEventHistory = useCallback(() => {
setEventHistoryOpen(false);
setFeedInteractionPaused(false);
}, []);
const openExecutionHistory = useCallback(() => {
setEventHistoryOpen(true);
setFeedInteractionPaused(true);
}, []);
const handleExecutionPanelClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
if (isExecutionPanelInteractiveTarget(event.target)) return;
openExecutionHistory();
}, [openExecutionHistory]);
const handleExecutionPanelKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
if (isExecutionPanelInteractiveTarget(event.target)) return;
event.preventDefault();
openExecutionHistory();
}, [openExecutionHistory]);
const openEvent = useCallback((event: DigitalEmployeeWorkEvent) => {
setSelectedEvent(null);
setEventHistoryOpen(false);
setFeedInteractionPaused(false);
onNavigate({ tab: 'missions', date: selectedDate, missionId: event.conversation_id || event.id });
}, [onNavigate, selectedDate]);
return (
<div className="de-dashboard de-dashboard--compact" style={{ position: 'relative', height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div className="de-connection-layer" aria-hidden="true">
<svg className="de-connection-svg" viewBox="0 0 1200 800" preserveAspectRatio="none">
<defs>
<linearGradient id="connGrad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="rgba(79,172,254,0.25)" />
<stop offset="100%" stopColor="rgba(0,242,254,0.1)" />
</linearGradient>
<filter id="connGlow"><feGaussianBlur stdDeviation="3" /></filter>
</defs>
<path d="M 80 180 Q 420 400, 580 350" stroke="url(#connGrad1)" strokeWidth="1.5" fill="none" filter="url(#connGlow)" />
<path d="M 120 620 Q 380 400, 560 380" stroke="url(#connGrad1)" strokeWidth="1.2" fill="none" filter="url(#connGlow)" />
<path d="M 640 360 Q 780 200, 1100 160" stroke="url(#connGrad1)" strokeWidth="1.3" fill="none" filter="url(#connGlow)" />
</svg>
</div>
<div className="de-dashboard-content">
<div className="de-dashboard-left">
<div className="de-card de-person-card de-person-card--compact">
<div className="de-card-title-bar">
<span className="de-card-title-bar-text"></span>
<button
type="button"
className="de-btn-secondary de-btn-sm"
onClick={() => setProfileSettingsOpen(true)}
>
</button>
</div>
<div className="de-card-body">
<div className="de-info-grid">
<div className="de-info-item"><label></label><span>{employee?.name ?? '同步中'}</span></div>
<div className="de-info-item"><label></label><span>{employee?.company ?? '同步中'}</span></div>
<div className="de-info-item"><label></label><span>{employee?.position ?? employee?.major ?? '同步中'}</span></div>
<div className="de-info-item"><label></label><span className={isResting ? 'de-status-resting' : 'de-status-working'}>{currentStatusLabel}</span></div>
</div>
</div>
</div>
<div className="de-card de-template-manager-card">
<div className="de-card-title-bar">
<span className="de-card-title-bar-text"></span>
</div>
<div className="de-card-body de-template-manager-body">
<div className="de-workday-toolbar">
<label>
<span></span>
<input
type="date"
value={selectedDate}
onChange={(event) => {
selectionDirtyRef.current = false;
setSelectionDirty(false);
setActionNotice(null);
setSelectedDate(event.target.value || todayInputDate());
}}
/>
</label>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={projectionLoading}
onClick={() => setTaskManagerOpen(true)}
>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={projectionLoading || actionBusy === 'flush_management_sync'}
onClick={flushManagementSync}
title="立即同步数字员工本地状态到管理端"
>
<RefreshCw size={13} aria-hidden="true" />
<span>{actionBusy === 'flush_management_sync' ? '同步中...' : '同步管理端'}</span>
</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>
{managementSyncFailures.length > 0 && (
<div className="de-management-sync-failures" aria-label="同步失败明细">
{managementSyncFailures.map((failure) => (
<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>
</button>
))}
</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}
</div>
)}
{managedServiceIssues.length > 0 && (
<div className="de-management-sync-failures" aria-label="服务状态处理" style={{ marginBottom: 10 }}>
{managedServiceIssues.map((service) => {
const actionKey = `managed-service:${service.service_id}:restart`;
const restartable = canRestartManagedService(service);
return (
<div key={service.service_id} className="de-management-sync-failure" title={service.error || managedServiceSummary(service)}>
<span>{service.kind}</span>
<strong>{service.name}</strong>
<em>{service.status_label}</em>
<small>{managedServiceSummary(service)}</small>
{restartable && (
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={actionBusy === actionKey}
onClick={(event) => {
event.stopPropagation();
void restartService(service);
}}
title={`安全重启 ${service.name}`}
>
<RefreshCw size={13} aria-hidden="true" />
<span>{actionBusy === actionKey ? '重启中...' : '重启'}</span>
</button>
)}
</div>
);
})}
</div>
)}
{selectionDirty && !actionNotice && !actionError && (
<div className="de-schedule-result de-schedule-result--success" style={{ marginBottom: 10 }}>
</div>
)}
<div className="de-template-overview">
<button type="button" className={`de-template-overview-item de-template-overview-item--total ${taskListFilter === 'all' ? 'active' : ''}`} onClick={() => setTaskListFilter('all')}>
<span className="de-template-overview-label"></span>
<span className="de-template-overview-value">{totalDuties.length}</span>
</button>
<button type="button" className={`de-template-overview-item de-template-overview-item--scheduled ${taskListFilter === 'selected' ? 'active' : ''}`} onClick={() => setTaskListFilter('selected')}>
<span className="de-template-overview-label"></span>
<span className="de-template-overview-value">{selectedDuties.length}</span>
</button>
<button type="button" className={`de-template-overview-item de-template-overview-item--runnable ${taskListFilter === 'pending' ? 'active' : ''}`} onClick={() => setTaskListFilter('pending')}>
<span className="de-template-overview-label"></span>
<span className="de-template-overview-value">{pendingDuties.length}</span>
</button>
</div>
<div className="de-task-date-context">
<span> {selectedDateLabel}</span>
<em>{visibleTaskListHint}</em>
<button type="button" className="de-btn-secondary de-btn-sm" onClick={cancelAllTasks} disabled={selectedDuties.length === 0}>
</button>
</div>
<div className="de-task-manager-preview de-task-manager-preview-scroll de-template-task-list">
<div className="de-task-manager-preview-head">
<span>{selectedDateLabel}</span>
<strong>{visibleTaskListLabel} {visibleTaskItems.length}</strong>
</div>
{projectionLoading ? (
<div className="de-loading-skeleton" style={{ height: 96 }} />
) : planItems.length === 0 ? (
<p className="de-empty-text"></p>
) : visibleTaskItems.length === 0 ? (
<p className="de-empty-text"></p>
) : (
visibleTaskItems.map((planItem) => (
<div
key={planItem.id}
className={`de-task-manager-preview-row ${selectedDutyIds.has(planItem.id) ? 'is-selected' : ''}`}
title={planItem.summary}
>
<button
type="button"
className="de-template-task-main"
onClick={() => openDuty(planItem)}
>
<strong>{planItem.title}</strong>
<span>{planItem.resultText}</span>
</button>
<div className="de-template-item-actions">
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={!planItem.implemented || actionBusy === `schedule:${planItem.id}`}
onClick={() => setSchedulePlanItem(planItem)}
>
<Clock3 size={13} aria-hidden="true" />
<span></span>
</button>
<button
type="button"
className="de-btn-primary de-btn-sm"
disabled={!planItem.implemented || actionBusy === `execute:${planItem.id}`}
title={planExecutionDisabledReason(planItem) || '立即执行此任务'}
onClick={() => setExecutePlanItem(planItem)}
>
<PlayCircle size={13} aria-hidden="true" />
<span></span>
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
<div className="de-dashboard-center" style={{ justifyContent: 'center', padding: '0 0 16px', overflow: 'visible' }}>
<div className="de-avatar-wrapper" style={{ minHeight: 470, maxWidth: 560, transform: 'translateY(-6px)', display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}>
<Avatar3D avatarSrc={activeVisual.src} altText={`数字员工-${activeVisual.label}`} statusLabel={activeVisual.label} />
</div>
<div className="de-speech-bubble de-speech-bubble--employee" style={{ top: '38%', left: '29%', right: 'auto', transform: 'translateX(-50%)', width: 268, padding: '10px 12px', zIndex: 16 }}>
<span className="de-speech-label"></span>
<p className="de-speech-text">{bubbleText}</p>
<button
type="button"
className="de-speech-workflow de-execution-detail-trigger"
onClick={openExecutionHistory}
title="打开运行明细"
>
<span></span>
<strong>{currentActionTitle}</strong>
<small>{currentActionDetail}</small>
<span></span>
<small>{nextStepText}</small>
</button>
</div>
</div>
<div className="de-dashboard-right">
<div className="de-right-panels">
<div
className="de-workbench de-workday-execution de-workday-execution--clickable"
role="button"
tabIndex={0}
aria-label="打开运行明细"
title="打开运行明细"
onClick={handleExecutionPanelClick}
onKeyDown={handleExecutionPanelKeyDown}
>
<div className="de-card-title-bar de-workbench-head de-workbench-head--bar">
<div className="de-workbench-title-row">
<h3></h3>
</div>
<span className={`de-badge ${projectionSourceClass}`}>{projectionSourceLabel}</span>
</div>
<div className="de-workday-execution-body" aria-live="polite">
<div className="de-execution-status-strip">
<span><em></em><strong>{selectedDuties.length}</strong></span>
<span><em></em><strong>{visibleEvents.length}</strong></span>
<span><em></em><strong>{completedEvents.length}</strong></span>
<span><em></em><strong>{failedEvents.length}</strong></span>
</div>
{(diagnosticSummary || (routeSummary && routeSummary.total > 0)) && (
<div className="de-observation-strip-stack">
{routeSummary && routeSummary.total > 0 && (
<div className="de-route-summary-strip">
<span><em></em><strong>{routeSummary.total}</strong></span>
<span><em></em><strong>{routeSummary.mapped_count}</strong></span>
<span className={routeSummary.attention_count > 0 ? 'is-warning' : ''}><em></em><strong>{routeSummary.attention_count}</strong></span>
<small>{latestRoute ? `${latestRoute.route_kind} / ${latestRoute.intent_kind} · ${latestRoute.status_label}` : '暂无路由决策'}</small>
</div>
)}
{diagnosticSummary && (
<div className="de-diagnostic-summary-strip">
<span className={diagnosticIssueCount > 0 ? 'is-warning' : ''}><em></em><strong>{diagnosticIssueCount}</strong></span>
<span className={auditRiskCount > 0 ? 'is-warning' : ''}><em></em><strong>{auditRiskCount}</strong></span>
<span><em></em><strong>{diagnosticSummary.cost.request_count}</strong></span>
<small>{latestDiagnosticIssue ? `${latestDiagnosticIssue.category} · ${latestDiagnosticIssue.message}` : '诊断、审计和成本投影正常'}</small>
</div>
)}
</div>
)}
<div className="de-task-summary-scroll">
<div className="de-task-summary-grid" aria-label="任务执行汇总">
{taskSummaryCards.length === 0 ? (
<p className="de-empty-text"></p>
) : (
taskSummaryCards.map((summary) => (
<button
type="button"
key={summary.id}
className={`de-task-summary-card de-live-feed-row--${summary.latest_status_tone || 'info'}`}
onClick={() => openDuty(selectedDuties.find((item) => item.id === summary.id) ?? selectedDuties[0]!)}
title={[summary.description, summary.ai_summary || summary.business_summary].filter(Boolean).join('\n')}
>
<span className="de-task-summary-title">{summary.title}</span>
<span className={`de-badge ${badgeClass(summary.latest_status_tone)}`}>{summary.latest_status_label}</span>
<div className="de-task-summary-counts">
<span><em></em><strong>{summary.total_count}</strong></span>
<span><em></em><strong>{summary.success_count}</strong></span>
<span><em></em><strong>{summary.failure_count}</strong></span>
</div>
</button>
))
)}
</div>
</div>
{actionableTaskAgents.length > 0 && (
<div className="de-task-agent-control-strip" aria-label="Task Agent 控制台">
{actionableTaskAgents.slice(0, 2).map((agent) => {
const retryBusy = actionBusy === `task-agent:${agent.task_id}:retry_task`;
const skipBusy = actionBusy === `task-agent:${agent.task_id}:skip_task`;
const cancelBusy = actionBusy === `task-agent:${agent.task_id}:cancel_task`;
return (
<div key={agent.agent_id} className={`de-task-agent-control de-live-feed-row--${agent.status_tone || 'info'}`}>
<div className="de-task-agent-control-main">
<span>Task Agent</span>
<strong>{agent.title}</strong>
<small>{agent.status_label} · {agent.next_action}</small>
</div>
<div className="de-task-agent-control-actions">
{agent.can_retry && (
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={Boolean(actionBusy)}
title="记录重试任务命令"
onClick={() => { void controlTaskAgent(agent, 'retry_task'); }}
>
<RefreshCw size={13} aria-hidden="true" />
<span>{retryBusy ? '重试中' : '重试'}</span>
</button>
)}
{agent.can_skip && (
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={Boolean(actionBusy)}
title="记录跳过任务命令"
onClick={() => { void controlTaskAgent(agent, 'skip_task'); }}
>
<SkipForward size={13} aria-hidden="true" />
<span>{skipBusy ? '跳过中' : '跳过'}</span>
</button>
)}
{agent.can_cancel && (
<button
type="button"
className="de-btn-secondary de-btn-sm de-task-agent-cancel-btn"
disabled={Boolean(actionBusy)}
title="记录取消任务命令"
onClick={() => { void controlTaskAgent(agent, 'cancel_task'); }}
>
<Square size={12} aria-hidden="true" />
<span>{cancelBusy ? '取消中' : '取消'}</span>
</button>
)}
</div>
</div>
);
})}
</div>
)}
<div className="de-stage-feed-tools">
<span>
{visibleEvents.length} / {allEvents.length}
{feedPaused ? ' · 已暂停' : ' · 自动滚动'}
</span>
<div>
<button
type="button"
className="de-btn-secondary de-btn-sm"
onClick={() => setFeedManuallyPaused((current) => !current)}
>
{feedManuallyPaused ? '继续滚动' : '暂停滚动'}
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
onClick={openExecutionHistory}
>
</button>
</div>
</div>
<div className="de-run-detail-panel">
<div className="de-run-detail-head">
<span></span>
<small> {visibleRunDetails.length} / {runDetails.length} </small>
</div>
<div className="de-run-detail-list de-run-detail-list-scroll" aria-label="运行明细">
<div className="de-run-detail-row de-run-detail-row--head">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{visibleRunDetails.length === 0 ? (
<p className="de-empty-text"></p>
) : (
visibleRunDetails.map((detail) => (
<button
key={detail.id}
type="button"
className={`de-run-detail-row de-live-feed-row--${detail.status_tone || 'info'}`}
onClick={() => {
const event = allEvents.find((item) => item.id === detail.id);
if (event) openEventDetail(event);
}}
title={detail.business_detail}
>
<span>{runDetailDisplayTime(detail)}</span>
<strong>{detail.task_name}</strong>
<span>{detail.step_name || detail.operation_name || '执行步骤'}</span>
<span><i className={`de-badge ${badgeClass(detail.status_tone)}`}>{detail.status_label}</i></span>
<em>{detail.business_detail}</em>
</button>
))
)}
</div>
</div>
</div>
</div>
<div className="de-report-summary">
<div className="de-card-title-bar de-report-summary-head de-report-summary-head--bar">
<div className="de-report-summary-title-row">
<h3></h3>
</div>
<span className={`de-report-badge ${dailyReport?.status === 'ready' ? 'de-report-badge--ready' : 'de-report-badge--waiting'}`}>
{dailyReport?.status === 'ready' ? '已生成' : '待生成'}
</span>
</div>
<div className="de-report-summary-body">
<div className="de-report-schedule-row">
<label>
<span></span>
<input
type="time"
value={reportScheduleTime}
onChange={(event) => setReportScheduleTime(event.target.value || '18:00')}
/>
</label>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={actionBusy === 'update_report_schedule'}
onClick={() => { void saveReportSchedule(); }}
>
</button>
<button
type="button"
className="de-btn-primary de-btn-sm"
disabled={actionBusy === 'generate_daily_report'}
onClick={() => { void generateDailyReport(); }}
>
{actionBusy === 'generate_daily_report' ? '生成中...' : '生成日报'}
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm de-report-preview-btn"
disabled={!reportPreviewAvailable}
onClick={() => setReportPreviewOpen(true)}
title={reportPreviewAvailable ? '预览日报文字内容' : '生成日报后可预览文字内容'}
>
<Eye size={14} aria-hidden="true" />
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm de-report-download-btn"
disabled={!dailyReport?.pdf_available}
onClick={downloadDailyReport}
title={dailyReport?.pdf_available ? '下载本地日报文本' : '生成日报后可下载本地日报'}
>
<Download size={14} aria-hidden="true" />
</button>
</div>
<div className="de-report-meta">
<p>{dailyReport?.summary ?? '点击生成日报后AI 会基于当天所有任务写出总分结构日报。'}</p>
{(dailyReport?.detail_lines ?? []).slice(0, 3).map((line) => <p key={line}>{line}</p>)}
</div>
</div>
</div>
</div>
</div>
</div>
{reportPreviewOpen && dailyReport && (
<div className="de-task-manager-modal-overlay de-report-preview-overlay" role="presentation" onClick={() => setReportPreviewOpen(false)}>
<div
className="de-report-preview-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">DAILY REPORT</span>
<h3>{dailyReport.title || '数字员工日报'}</h3>
<p>{selectedDateLabel} {dailyReport.report_schedule_time}{dailyReport.generated_by === 'ai' ? 'AI 生成' : '规则兜底'}</p>
</div>
<button type="button" className="de-schedule-close" onClick={() => setReportPreviewOpen(false)}>×</button>
</div>
<div className="de-report-preview-summary">
<div><span></span><strong>{reportTaskCount}</strong></div>
<div><span></span><strong>{reportExecutionCount}</strong></div>
<div><span></span><strong>{reportSuccessCount}</strong></div>
<div><span></span><strong>{reportFailureCount}</strong></div>
</div>
<div className="de-report-preview-content">
{reportPreviewLines.length === 0 ? (
<p className="de-empty-text"></p>
) : (
reportPreviewLines.map((line, index) => (
<p key={`${index}-${line}`}>{line}</p>
))
)}
</div>
<div className="de-event-modal-actions">
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => setReportPreviewOpen(false)}></button>
<button
type="button"
className="de-btn-secondary de-btn-sm de-report-download-btn"
disabled={!dailyReport.pdf_available}
onClick={downloadDailyReport}
>
<Download size={14} aria-hidden="true" />
</button>
</div>
</div>
</div>
)}
{profileSettingsOpen && (
<div className="de-task-manager-modal-overlay" role="presentation" onClick={() => setProfileSettingsOpen(false)}>
<div
className="de-profile-settings-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">PROFILE</span>
<h3></h3>
<p></p>
</div>
<button type="button" className="de-schedule-close" onClick={() => setProfileSettingsOpen(false)}>×</button>
</div>
<div className="de-profile-settings-grid">
<label>
<span></span>
<input value={profileForm.name} onChange={(event) => setProfileForm((current) => ({ ...current, name: event.target.value }))} />
</label>
<label>
<span></span>
<input value={profileForm.company} onChange={(event) => setProfileForm((current) => ({ ...current, company: event.target.value }))} />
</label>
<label>
<span></span>
<input value={profileForm.position} onChange={(event) => setProfileForm((current) => ({ ...current, position: event.target.value }))} />
</label>
<div className="de-profile-status-toggle">
<span></span>
<div>
<button
type="button"
className={profileForm.currentStatus === 'working' ? 'active' : ''}
onClick={() => setProfileForm((current) => ({ ...current, currentStatus: 'working' }))}
>
</button>
<button
type="button"
className={profileForm.currentStatus === 'resting' ? 'active' : ''}
onClick={() => setProfileForm((current) => ({ ...current, currentStatus: 'resting' }))}
>
</button>
</div>
</div>
</div>
<div className="de-event-modal-actions">
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => setProfileSettingsOpen(false)}></button>
<button
type="button"
className="de-btn-primary de-btn-sm"
disabled={actionBusy === 'update_profile'}
onClick={() => { void saveProfileSettings(); }}
>
{actionBusy === 'update_profile' ? '保存中...' : '保存设置'}
</button>
</div>
</div>
</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>
{selectedSyncFailure.entitySummary?.title && (
<div><span></span><strong>{selectedSyncFailure.entitySummary.title}</strong></div>
)}
{selectedSyncFailure.entitySummary?.status && (
<div><span></span><strong>{selectedSyncFailure.entitySummary.status}</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>
{selectedSyncFailure.entitySummary?.summary && (
<div className="de-event-detail-result">
<span></span>
<p>{selectedSyncFailure.entitySummary.summary}</p>
</div>
)}
{(selectedSyncFailure.attemptHistory?.length ?? 0) > 0 && (
<div className="de-sync-attempt-history">
<span></span>
<div className="de-sync-attempt-list">
{selectedSyncFailure.attemptHistory?.map((attempt) => (
<div
key={`${attempt.attemptNo}-${attempt.finishedAt}`}
className={`de-sync-attempt-row de-sync-attempt-row--${syncAttemptTone(
attempt.status,
)}`}
>
<div>
<strong> {attempt.attemptNo} </strong>
<em>{syncAttemptStatusLabel(attempt.status)}</em>
<span>
{formatBeijingDateTime(attempt.finishedAt) ||
attempt.finishedAt}
</span>
</div>
{attempt.error && <small>{attempt.error}</small>}
</div>
))}
</div>
</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-secondary de-btn-sm"
onClick={() => {
void copySyncFailureDiagnostic(selectedSyncFailure);
}}
>
<Clipboard size={14} />
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
onClick={() => exportSyncFailureDiagnostic(selectedSyncFailure)}
>
<Download size={14} />
</button>
{selectedSyncFailure.managementRecordUrl && (
<button
type="button"
className="de-btn-secondary de-btn-sm"
onClick={() =>
window.open(
selectedSyncFailure.managementRecordUrl || '',
'_blank',
'noopener,noreferrer',
)
}
>
<Eye size={14} />
</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
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">EXECUTION DETAIL</span>
<h3></h3>
<p></p>
</div>
<button type="button" className="de-schedule-close" onClick={closeEventDetail}>×</button>
</div>
<div className="de-event-detail-grid">
<div><span></span><strong>{selectedEvent.plan_title}</strong></div>
<div><span></span><strong>{selectedEvent.task_title}</strong></div>
<div><span></span><strong>{eventStatusText(selectedEvent)}</strong></div>
<div><span></span><strong>{eventScheduleTime(selectedEvent)}</strong></div>
<div><span></span><strong>{eventDisplayTime(selectedEvent)}</strong></div>
<div><span></span><strong>{projectionSourceLabel}</strong></div>
</div>
<div className="de-event-detail-result">
<span></span>
<p>{eventBusinessResult(selectedEvent)}</p>
</div>
<div className="de-event-modal-actions">
<button type="button" className="de-btn-secondary de-btn-sm" onClick={closeEventDetail}></button>
<button type="button" className="de-btn-primary de-btn-sm" onClick={() => openEvent(selectedEvent)}>
</button>
</div>
</div>
</div>
)}
{eventHistoryOpen && (
<div className="de-task-manager-modal-overlay" role="presentation" onClick={closeEventHistory}>
<div
className="de-event-history-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">EXECUTION HISTORY</span>
<h3></h3>
<p> {allEvents.length} {Math.min(filteredHistoryEvents.length, EVENT_HISTORY_RENDER_LIMIT)} </p>
</div>
<button type="button" className="de-schedule-close" onClick={closeEventHistory}>×</button>
</div>
<div className="de-event-history-toolbar">
{EVENT_STATUS_FILTERS.map((filter) => (
<button
key={filter.id}
type="button"
className={`de-filter-chip ${eventStatusFilter === filter.id ? 'active' : ''}`}
onClick={() => setEventStatusFilter(filter.id)}
>
{filter.label}
</button>
))}
</div>
<div className="de-event-history-list">
{filteredHistoryEvents.length === 0 ? (
<p className="de-empty-text"></p>
) : (
visibleHistoryEvents.map((event) => (
<button
key={event.id}
type="button"
className={`de-event-history-row de-live-feed-row--${event.status_tone || 'info'}`}
onClick={() => {
setSelectedEvent(event);
setEventHistoryOpen(false);
setFeedInteractionPaused(true);
}}
>
<span><em></em><strong>{event.plan_title}</strong></span>
<span><em></em><strong>{event.task_title}</strong></span>
<span><em></em><strong>{eventStatusText(event)}</strong></span>
<span><em></em><strong>{eventBusinessResult(event)}</strong><small>{eventTimingSummary(event)}</small></span>
</button>
))
)}
</div>
</div>
</div>
)}
{schedulePlanItem && (
<div className="de-task-manager-modal-overlay de-plan-action-overlay" role="presentation" onClick={() => setSchedulePlanItem(null)}>
<div
className="de-plan-action-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">PLAN SCHEDULE</span>
<h3></h3>
<p></p>
</div>
<button type="button" className="de-schedule-close" onClick={() => setSchedulePlanItem(null)}>×</button>
</div>
<div className="de-plan-action-body">
<div className="de-event-detail-result">
<span></span>
<p>{schedulePlanItem.title}</p>
</div>
<label className="de-plan-action-field">
<span></span>
<input
type="time"
value={scheduleTime}
onChange={(event) => setScheduleTime(event.target.value || '09:00')}
/>
</label>
<div className="de-event-detail-grid de-plan-action-grid">
<div><span></span><strong>{cronExprForDailyTime(scheduleTime)}</strong></div>
<div><span></span><strong>{schedulePlanItem.stepCount} </strong></div>
<div><span></span><strong>{schedulePlanItem.taskCount} </strong></div>
</div>
<div className="de-schedule-result de-schedule-result--success">
</div>
</div>
<div className="de-event-modal-actions">
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => setSchedulePlanItem(null)}></button>
<button
type="button"
className="de-btn-primary de-btn-sm"
disabled={actionBusy === `schedule:${schedulePlanItem.id}`}
onClick={() => { void createPlanSchedule(schedulePlanItem); }}
>
{actionBusy === `schedule:${schedulePlanItem.id}` ? '注册中...' : '确认定时'}
</button>
</div>
</div>
</div>
)}
{executePlanItem && (
<div className="de-task-manager-modal-overlay de-plan-action-overlay" role="presentation" onClick={() => setExecutePlanItem(null)}>
<div
className="de-plan-action-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">PLAN EXECUTION</span>
<h3></h3>
<p></p>
</div>
<button type="button" className="de-schedule-close" onClick={() => setExecutePlanItem(null)}>×</button>
</div>
<div className="de-plan-action-body">
<div className="de-event-detail-result">
<span></span>
<p>{executePlanItem.title}</p>
</div>
<div className="de-event-detail-grid de-plan-action-grid">
<div><span></span><strong>{executePlanItem.statusLabel}</strong></div>
<div><span></span><strong>{executePlanItem.stepCount} </strong></div>
<div><span></span><strong>{executePlanItem.taskCount} </strong></div>
</div>
<div className={`de-schedule-result ${isPlanItemExecutable(executePlanItem) ? 'de-schedule-result--success' : 'de-schedule-result--pending'}`}>
{isPlanItemExecutable(executePlanItem)
? '确认后立即调用后端派发接口,按任务步骤进入真实执行链路。'
: planExecutionDisabledReason(executePlanItem)}
</div>
</div>
<div className="de-event-modal-actions">
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => setExecutePlanItem(null)}></button>
<button
type="button"
className="de-btn-primary de-btn-sm"
disabled={actionBusy === `execute:${executePlanItem.id}` || !isPlanItemExecutable(executePlanItem)}
onClick={() => { void confirmImmediatePlanExecution(executePlanItem); }}
>
{actionBusy === `execute:${executePlanItem.id}` ? '触发中...' : '确认执行'}
</button>
</div>
</div>
</div>
)}
{guideOpen && needsWorkdayConfirmation && (
<div className="de-task-manager-modal-overlay de-workday-guide-overlay" role="presentation" onClick={dismissWorkdayGuide}>
<div
className="de-task-manager-modal de-workday-guide-modal"
role="dialog"
aria-modal="true"
aria-label="开始今天的任务"
onClick={(event) => event.stopPropagation()}
>
<div className="de-task-manager-modal-head de-workday-guide-head">
<div>
<span className="de-drawer-eyebrow">WORKDAY START</span>
<h3></h3>
<p></p>
</div>
<button type="button" className="de-schedule-close" onClick={dismissWorkdayGuide}>×</button>
</div>
<div className="de-workday-guide-steps" aria-label="开工引导步骤">
{WORKDAY_GUIDE_STEPS.map((step, index) => (
<div key={step.title} className="de-workday-guide-step">
<span>{index + 1}</span>
<strong>{step.title}</strong>
<p>{step.body}</p>
</div>
))}
</div>
<div className="de-workday-guide-body">
<section className="de-task-manager-column de-workday-guide-column">
<div className="de-task-manager-section-head">
<div>
<strong></strong>
<span>{selectedDuties.length} {totalDuties.length} {pendingDuties.length} </span>
</div>
<span className={`de-badge ${projectionSourceClass}`}>{projectionSourceLabel}</span>
</div>
<div className="de-workday-guide-duty-list">
{projectionLoading ? (
<div className="de-loading-skeleton" style={{ height: 220 }} />
) : planItems.length === 0 ? (
<p className="de-empty-text"></p>
) : (
planItems.map((planItem) => renderDutyChecklistCard(planItem, 'guide'))
)}
</div>
</section>
<aside className="de-workday-guide-side">
<div className="de-workday-guide-summary">
<span></span>
<strong>{selectedDate}</strong>
<p>{workdaySummaryText}</p>
</div>
<div className="de-workday-guide-summary">
<span></span>
<strong></strong>
<p></p>
</div>
{(actionError || selectionDirty || workdayProjection?.demo_reason) && (
<div className={`de-schedule-result ${actionError ? 'de-schedule-result--error' : 'de-schedule-result--success'}`}>
{actionError || (selectionDirty ? '已调整任务勾选,点击确认后生效。' : workdayProjection?.demo_reason)}
</div>
)}
<div className="de-workday-guide-actions">
<button type="button" className="de-btn-secondary de-btn-sm" onClick={dismissWorkdayGuide}>
</button>
<button
type="button"
className="de-btn-primary de-btn-sm"
disabled={actionBusy === 'confirm_workday' || projectionLoading}
onClick={() => { void confirmWorkday(); }}
>
{actionBusy === 'confirm_workday' ? '确认中...' : '确认并开始任务'}
</button>
</div>
</aside>
</div>
</div>
</div>
)}
{taskManagerOpen && (
<div className="de-task-manager-modal-overlay" role="presentation" onClick={() => setTaskManagerOpen(false)}>
<div
className="de-task-manager-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">TASK MANAGER</span>
<h3></h3>
<p></p>
</div>
<button type="button" className="de-schedule-close" onClick={() => setTaskManagerOpen(false)}>×</button>
</div>
<div className="de-task-manager-modal-body">
<section className="de-task-manager-column de-task-manager-column--duties">
<div className="de-task-manager-section-head">
<div>
<strong></strong>
<span>{selectedDuties.length} {totalDuties.length} {pendingDuties.length} </span>
</div>
<button
type="button"
className="de-btn-primary de-btn-sm"
disabled={actionBusy === 'confirm_workday' || projectionLoading}
onClick={() => { void confirmWorkday(); }}
>
{actionBusy === 'confirm_workday' ? '确认中...' : workday?.confirmed ? '重新任务执行' : '确认并开始任务'}
</button>
</div>
<div className="de-task-manager-duty-list">
{projectionLoading ? (
<div className="de-loading-skeleton" style={{ height: 180 }} />
) : planItems.length === 0 ? (
<p className="de-empty-text"></p>
) : (
planItems.map((planItem) => renderDutyChecklistCard(planItem, 'manager'))
)}
</div>
</section>
<aside className="de-task-manager-column de-task-manager-column--actions">
<div className="de-task-manager-section-head">
<div>
<strong></strong>
<span>{pendingDecisions.length > 0 ? `${pendingDecisions.length} 项需要人处理` : '暂无阻塞事项'}</span>
</div>
</div>
<div className="de-task-manager-decision-list">
{(workdayProjection?.decisions ?? []).length === 0 ? (
<p className="de-empty-text"></p>
) : (
(workdayProjection?.decisions ?? []).map((decision) => {
const draft = approvalActionDrafts[decision.id] ?? EMPTY_APPROVAL_ACTION_DRAFT;
const actionBusyPrefix = `approval-action:${decision.id}:`;
const commentBusy = actionBusy === `${actionBusyPrefix}comment`;
const delegateBusy = actionBusy === `${actionBusyPrefix}delegate`;
const timeoutBusy = actionBusy === `${actionBusyPrefix}mark_timeout`;
const markRiskBusy = actionBusy === `${actionBusyPrefix}mark_risk`;
const clearRiskBusy = actionBusy === `${actionBusyPrefix}clear_risk`;
return (
<div key={decision.id} className="de-task-manager-decision-card">
<div className="de-log-entry-meta">
<strong>{decision.title}</strong>
<span>{decision.status_label}</span>
</div>
<p>{decision.scenario}</p>
<div className="de-approval-summary-grid">
<span className={`de-badge ${badgeClass(approvalRiskTone(decision.risk_level))}`}>
{approvalRiskLabel(decision.risk_level)}
</span>
<span className={`de-badge ${badgeClass(approvalSlaTone(decision.sla_status))}`}>
{approvalSlaLabel(decision.sla_status)}
</span>
<span>{decision.due_at ? `截止 ${formatBeijingDateTime(decision.due_at) || decision.due_at}` : '未设置截止'}</span>
<span>{decision.comment_count ? `评论 ${decision.comment_count}` : '暂无评论'}</span>
<span>{decision.delegate_to ? `转交 ${decision.delegate_to}` : '未转交'}</span>
<span>{decision.last_action ? `最近 ${approvalActionLabel(decision.last_action)} · ${decision.last_actor || '未知'}` : '暂无审批动作'}</span>
</div>
<div className="de-approval-action-fields">
<input
value={draft.comment}
placeholder="评论 / 风险原因"
onChange={(event) => updateApprovalActionDraft(decision.id, { comment: event.target.value })}
/>
<input
value={draft.delegateTo}
placeholder="转交对象"
onChange={(event) => updateApprovalActionDraft(decision.id, { delegateTo: event.target.value })}
/>
<input
type="datetime-local"
value={draft.dueAt}
onChange={(event) => updateApprovalActionDraft(decision.id, { dueAt: event.target.value })}
/>
</div>
<div className="de-workbench-actions de-approval-governance-actions">
<button
type="button"
title="记录审批评论"
disabled={commentBusy}
onClick={() => { void submitApprovalAction(decision, 'comment'); }}
>
<MessageSquare size={13} />
<span>{commentBusy ? '记录中...' : '评论'}</span>
</button>
<button
type="button"
title="转交审批"
disabled={delegateBusy}
onClick={() => { void submitApprovalAction(decision, 'delegate'); }}
>
<UserRoundPlus size={13} />
<span>{delegateBusy ? '转交中...' : '转交'}</span>
</button>
<button
type="button"
title="标记 SLA 超时"
disabled={timeoutBusy}
onClick={() => { void submitApprovalAction(decision, 'mark_timeout'); }}
>
<TimerReset size={13} />
<span>{timeoutBusy ? '标记中...' : '超时'}</span>
</button>
<button
type="button"
title="标记审批风险"
disabled={markRiskBusy}
onClick={() => { void submitApprovalAction(decision, 'mark_risk'); }}
>
<ShieldAlert size={13} />
<span>{markRiskBusy ? '标记中...' : '风险'}</span>
</button>
<button
type="button"
title="清除审批风险"
disabled={clearRiskBusy}
onClick={() => { void submitApprovalAction(decision, 'clear_risk'); }}
>
<ShieldCheck size={13} />
<span>{clearRiskBusy ? '清除中...' : '清除'}</span>
</button>
</div>
<div className="de-decision-chain-note">
<span className="de-badge de-badge-success"></span>
<span>{isDecisionOpen(decision.status) ? '处理结果会写入 qimingclaw runtime event。' : '该事项已在本地状态中处理。'}</span>
</div>
<div className="de-workbench-actions">
{decision.actions.map((action) => (
<button
key={action.id}
type="button"
className={action.tone === 'primary' ? 'de-workbench-action-accent' : ''}
disabled={actionBusy === `${decision.id}:${action.id}` || !isDecisionOpen(decision.status)}
onClick={() => { void resolveDecision(decision, action.id); }}
>
{actionBusy === `${decision.id}:${action.id}` ? '处理中...' : action.label}
</button>
))}
<button type="button" onClick={() => onNavigate({ tab: 'missions', date: selectedDate, missionId: decision.id })}>
</button>
</div>
</div>
);
})
)}
</div>
</aside>
</div>
</div>
</div>
)}
</div>
);
}