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

2170 lines
97 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 { Clock3, Download, Eye, PlayCircle, RefreshCw } from 'lucide-react';
import Avatar3D from '@/components/digital/Avatar3D';
import { basePath } from '@/lib/basePath';
import {
digitalEmployeeReportPdfUrl,
dispatchPlanNow,
getDigitalEmployeeWorkday,
getSchedulerJobs,
governanceCommand,
postDigitalEmployeeWorkdayAction,
} from '@/lib/api';
import { isTauri } from '@/lib/tauri';
import type {
CronJob,
DigitalEmployeeDecision,
DigitalEmployeeDuty,
DigitalEmployeeRunDetail,
DigitalEmployeeTaskExecutionSummary,
DigitalEmployeeWorkEvent,
DigitalEmployeeWorkdayProjection,
} 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';
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;
pending: number;
failed: number;
lastSyncAt: string | null;
lastSyncError: string | null;
recentFailures?: ManagementSyncFailure[];
}
interface ManagementSyncFailure {
id: string;
entityType: string;
entityId: string;
operation: string;
attempts: number;
lastAttemptAt: string | null;
error: string | null;
createdAt: string;
updatedAt: string;
}
// 技术侧 PlanTemplate 是任务模板来源;用户侧只展示为“任务”,运行时 Task/PlanStep 展示为“步骤”。
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 = '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 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.failed > 0 || status.lastSyncError) return 'danger';
if (status.syncing || status.pending > 0) return 'warning';
return status.lastSyncAt ? 'success' : 'info';
}
function syncStatusLabel(status: ManagementSyncStatus | null): string {
if (!status) return '未连接';
if (!status.enabled) return '已停用';
if (!status.endpoint) return '未配置';
if (status.syncing) return '同步中';
if (status.failed > 0 || status.lastSyncError) return '同步异常';
if (status.pending > 0) return '待同步';
return status.lastSyncAt ? '已同步' : '等待数据';
}
function syncStatusSummary(status: ManagementSyncStatus | null): string {
if (!status) return '当前环境未连接 qimingclaw 同步桥。';
if (!status.enabled) return '管理端同步已停用。';
if (!status.endpoint) return '管理端同步地址未配置。';
const error = summarizeSyncError(status.lastSyncError);
if (status.failed > 0 || error) {
return [
status.failed > 0 ? `${status.failed} 条失败` : '',
status.pending > 0 ? `${status.pending} 条待同步` : '',
error ? `失败原因:${error}` : '',
].filter(Boolean).join('');
}
if (status.syncing) return `正在同步,待同步 ${status.pending} 条。`;
if (status.pending > 0) return `仍有 ${status.pending} 条待同步记录。`;
return status.lastSyncAt
? `最近同步:${formatBeijingDateTime(status.lastSyncAt) || status.lastSyncAt}`
: '等待本地 outbox 数据。';
}
function entityTypeLabel(entityType: string): string {
const labels: Record<string, string> = {
plan: 'Plan',
task: 'Task',
run: 'Run',
event: 'Event',
artifact: 'Artifact',
approval: 'Approval',
};
return labels[entityType] || entityType || 'Entity';
}
function syncFailureSummary(failure: ManagementSyncFailure): string {
const error = summarizeSyncError(failure.error);
return [
`${entityTypeLabel(failure.entityType)} / ${failure.operation}`,
`重试 ${failure.attempts}`,
error,
].filter(Boolean).join(' · ');
}
function syncFailureTime(failure: ManagementSyncFailure): string {
return formatBeijingDateTime(failure.lastAttemptAt || failure.updatedAt || failure.createdAt) || '未尝试';
}
async function readManagementSyncStatus(): Promise<ManagementSyncStatus | null> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.getSyncStatus) return null;
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 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';
}
const DECISION_CHAIN_TODO_TEXT = '真实审批链路待接入';
function readGuideDismissedDate(): string | null {
if (typeof window === 'undefined') return null;
try {
return window.sessionStorage.getItem(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);
} 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 [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 [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 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 ?? (workday?.confirmed ? '等待下一条执行事件' : '等待确认任务设置');
const currentActionDetail = currentEvent
? `${currentEvent.plan_title}${eventBusinessResult(currentEvent)}${eventTimingSummary(currentEvent)}`
: '任务执行后会显示正在处理的任务、步骤或汇总动作。';
const nextStepText = pendingDecisions[0]
? `等待人工确认:${pendingDecisions[0].title}`
: 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 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('日报已生成,可下载 PDF。');
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 downloadDailyReportPdf = useCallback(() => {
window.open(digitalEmployeeReportPdfUrl(selectedDate), '_blank', 'noopener,noreferrer');
}, [selectedDate]);
const resolveDecision = useCallback(async (decision: DigitalEmployeeDecision, actionId: string) => {
setActionBusy(`${decision.id}:${actionId}`);
setActionError(null);
setActionNotice(null);
try {
if (!mountedRef.current) return;
const actionLabel = decision.actions.find((action) => action.id === actionId)?.label ?? actionId;
setActionNotice(`待实现:已触发“${actionLabel}”演示效果,${DECISION_CHAIN_TODO_TEXT},未改动后端审批或执行状态。`);
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '处理决策失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, []);
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;
setExecutePlanItem(null);
setActionNotice(`已触发执行:${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) => (
<div key={failure.id} className="de-management-sync-failure" title={failure.error || failure.id}>
<span>{syncFailureTime(failure)}</span>
<strong>{entityTypeLabel(failure.entityType)}</strong>
<em>{failure.id}</em>
<small>{syncFailureSummary(failure)}</small>
</div>
))}
</div>
)}
{(actionNotice || actionError || planDataError || workdayProjection?.demo_reason) && (
<div className={`de-schedule-result ${actionError ? 'de-schedule-result--error' : 'de-schedule-result--success'}`} style={{ marginBottom: 10 }}>
{actionError || actionNotice || planDataError || workdayProjection?.demo_reason}
</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>
<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>
<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={downloadDailyReportPdf}
title={dailyReport?.pdf_available ? '下载日报 PDF' : '生成日报后可下载日报 PDF'}
>
<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={downloadDailyReportPdf}
>
<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>
)}
{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) => (
<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-decision-chain-note">
<span className="de-badge de-badge-warning"></span>
<span>{DECISION_CHAIN_TODO_TEXT}</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>
);
}