2947 lines
132 KiB
TypeScript
2947 lines
132 KiB
TypeScript
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>
|
||
);
|
||
}
|