吸收数字员工PlanStep阻塞处理入口

This commit is contained in:
baiyanyun
2026-06-10 20:37:11 +08:00
parent d679639175
commit a6e0246418
11 changed files with 543 additions and 197 deletions

View File

@@ -6,6 +6,7 @@ import { basePath } from '@/lib/basePath';
import {
dispatchPlanNow,
getDigitalEmployeeWorkday,
getPlanStepGraph,
getSchedulerJobs,
governanceCommand,
postDigitalEmployeeWorkdayAction,
@@ -26,6 +27,8 @@ import type {
DigitalEmployeeWorkEvent,
DigitalEmployeeWorkdayProjection,
GovernanceCommandKind,
PlanStepBusinessRow,
PlanStepDependencyDetail,
} from '@/types/api';
import type { DigitalNavigationTarget } from './navigation';
@@ -38,6 +41,7 @@ type EmployeeVisualState = 'skill' | 'browser' | 'call' | 'redial' | 'evidence'
type EventStatusFilter = 'all' | 'running' | 'success' | 'danger';
type TaskListFilter = 'all' | 'selected' | 'pending';
type TaskAgentControlCommand = Extract<GovernanceCommandKind, 'retry_task' | 'skip_task' | 'cancel_task'>;
type BlockedPlanStepControlCommand = Extract<GovernanceCommandKind, 'retry_task' | 'skip_task' | 'cancel_task' | 'provide_task_input'>;
type ApprovalActionKind = 'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk';
interface DigitalPlanItem {
@@ -60,6 +64,11 @@ interface DigitalPlanItem {
source: 'plan_template';
}
interface BlockedPlanStepInputTarget {
step: PlanStepBusinessRow;
dependency: PlanStepDependencyDetail | null;
}
interface ManagementSyncStatus {
running: boolean;
syncing: boolean;
@@ -796,6 +805,26 @@ function taskAgentControlLabel(command: TaskAgentControlCommand): string {
}
}
function blockedPlanStepControlLabel(command: BlockedPlanStepControlCommand): string {
switch (command) {
case 'retry_task': return '重试前置';
case 'skip_task': return '跳过前置';
case 'cancel_task': return '取消此步骤';
case 'provide_task_input': return '补充输入';
default: return command;
}
}
function firstActionableBlockedDependency(step: PlanStepBusinessRow): PlanStepDependencyDetail | null {
return (step.blocked_dependency_details ?? []).find((dependency) => Boolean(dependency.task_id)) ?? null;
}
function blockedPlanStepSummary(step: PlanStepBusinessRow): string {
const dependency = firstActionableBlockedDependency(step) ?? step.blocked_dependency_details?.[0] ?? null;
const dependencyLabel = dependency ? `${dependency.title} ${dependency.status}` : `阻塞依赖 ${step.blocked_dependencies.length}`;
return `${step.title} ${step.dependency_status || step.status}${dependencyLabel}`;
}
function isDecisionOpen(status?: string): boolean {
return ['pending', 'requested'].includes(String(status || '').toLowerCase());
}
@@ -916,6 +945,10 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
const [reportPreviewOpen, setReportPreviewOpen] = useState(false);
const [selectedSyncFailure, setSelectedSyncFailure] = useState<ManagementSyncFailure | null>(null);
const [blockedPlanSteps, setBlockedPlanSteps] = useState<PlanStepBusinessRow[]>([]);
const [blockedPlanStepError, setBlockedPlanStepError] = useState<string | null>(null);
const [blockedInputTarget, setBlockedInputTarget] = useState<BlockedPlanStepInputTarget | null>(null);
const [blockedInputDraft, setBlockedInputDraft] = useState('');
const [profileForm, setProfileForm] = useState({
name: '',
company: '',
@@ -986,7 +1019,19 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
setManagementSyncStatus(status);
});
await Promise.allSettled([projectionPromise, jobsPromise, syncStatusPromise]);
const planStepGraphPromise = getPlanStepGraph({ status: 'blocked', page_size: 6 })
.then((response) => {
if (!mountedRef.current) return;
setBlockedPlanSteps(response.items.filter((step) => step.status === 'blocked'));
setBlockedPlanStepError(null);
})
.catch((error: unknown) => {
if (!mountedRef.current) return;
setBlockedPlanSteps([]);
setBlockedPlanStepError(error instanceof Error ? error.message : '阻塞步骤加载失败');
});
await Promise.allSettled([projectionPromise, jobsPromise, syncStatusPromise, planStepGraphPromise]);
if (mountedRef.current) {
setProjectionLoading(false);
}
@@ -1523,6 +1568,71 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
}
}, [fetchProjection]);
const controlBlockedPlanStep = useCallback(async (
step: PlanStepBusinessRow,
command: BlockedPlanStepControlCommand,
options: { input?: string } = {},
) => {
const dependency = firstActionableBlockedDependency(step);
const target = command === 'retry_task' || command === 'skip_task' ? dependency : null;
const targetStepId = target?.step_id ?? step.step_id;
const targetTaskId = target?.task_id ?? step.task_id ?? null;
const targetRunId = target?.latest_run_id ?? step.latest_run_id ?? null;
if (!targetTaskId) {
setActionError('当前阻塞步骤缺少可治理任务,无法发送处理命令');
return;
}
const actionKey = `blocked-plan-step:${step.step_id}:${command}`;
setActionBusy(actionKey);
setActionError(null);
setActionNotice(null);
try {
const response = await governanceCommand({
command_kind: command,
context_refs: {
plan_id: step.plan_id,
task_id: targetTaskId,
run_id: targetRunId,
step_id: targetStepId,
blocked_step_id: step.step_id,
},
payload: {
source: 'digital-home-blocked-plan-step',
plan_id: step.plan_id,
task_id: targetTaskId,
run_id: targetRunId,
step_id: targetStepId,
blocked_step_id: step.step_id,
blocked_step_title: step.title,
blocked_dependency_step_id: target?.step_id ?? null,
requested_action: command,
input: options.input,
},
correlation_id: `digital-home-blocked-step-${step.step_id}-${command}-${Date.now()}`,
});
if (!response.accepted) {
throw new Error(response.message || `阻塞步骤处理未被接受:${response.command_id || command}`);
}
setActionNotice(`阻塞步骤已${blockedPlanStepControlLabel(command)}${step.title}`);
setBlockedInputTarget(null);
setBlockedInputDraft('');
await fetchProjection();
} catch (error) {
if (mountedRef.current) {
setActionError(error instanceof Error ? error.message : '阻塞步骤处理失败');
}
} finally {
if (mountedRef.current) setActionBusy(null);
}
}, [fetchProjection]);
const submitBlockedPlanStepInput = useCallback(async () => {
const target = blockedInputTarget;
const input = blockedInputDraft.trim();
if (!target || !input) return;
await controlBlockedPlanStep(target.step, 'provide_task_input', { input });
}, [blockedInputDraft, blockedInputTarget, controlBlockedPlanStep]);
const restartService = useCallback(async (service: DigitalEmployeeManagedService) => {
const actionKey = `managed-service:${service.service_id}:restart`;
setActionBusy(actionKey);
@@ -2115,6 +2225,72 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
</div>
)}
{(blockedPlanSteps.length > 0 || blockedPlanStepError) && (
<div className="de-task-agent-control-strip de-blocked-step-control-strip" aria-label="PlanStep 阻塞处理">
{blockedPlanStepError && <p className="de-empty-text">{blockedPlanStepError}</p>}
{blockedPlanSteps.slice(0, 3).map((step) => {
const dependency = firstActionableBlockedDependency(step);
const retryBusy = actionBusy === `blocked-plan-step:${step.step_id}:retry_task`;
const skipBusy = actionBusy === `blocked-plan-step:${step.step_id}:skip_task`;
const cancelBusy = actionBusy === `blocked-plan-step:${step.step_id}:cancel_task`;
const inputBusy = actionBusy === `blocked-plan-step:${step.step_id}:provide_task_input`;
const dependencyActionDisabled = Boolean(actionBusy) || !dependency?.task_id;
const stepActionDisabled = Boolean(actionBusy) || !step.task_id;
return (
<div key={step.step_id} className="de-task-agent-control de-blocked-step-control de-live-feed-row--danger">
<div className="de-task-agent-control-main">
<span>Blocked PlanStep</span>
<strong>{step.title}</strong>
<small>{blockedPlanStepSummary(step)}</small>
</div>
<div className="de-task-agent-control-actions">
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={dependencyActionDisabled}
title={dependency?.task_id ? '重试首个阻塞前置任务' : '缺少可重试的前置任务'}
onClick={() => { void controlBlockedPlanStep(step, 'retry_task'); }}
>
<RefreshCw size={13} aria-hidden="true" />
<span>{retryBusy ? '重试中' : '重试前置'}</span>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={dependencyActionDisabled}
title={dependency?.task_id ? '跳过首个阻塞前置任务' : '缺少可跳过的前置任务'}
onClick={() => { void controlBlockedPlanStep(step, 'skip_task'); }}
>
<SkipForward size={13} aria-hidden="true" />
<span>{skipBusy ? '跳过中' : '跳过前置'}</span>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
disabled={stepActionDisabled}
title={step.task_id ? '补充当前阻塞步骤所需输入' : '当前步骤缺少可补充输入的任务'}
onClick={() => { setBlockedInputTarget({ step, dependency: null }); setBlockedInputDraft(''); }}
>
<MessageSquare size={13} aria-hidden="true" />
<span>{inputBusy ? '提交中' : '补充输入'}</span>
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm de-task-agent-cancel-btn"
disabled={stepActionDisabled}
title={step.task_id ? '取消当前阻塞步骤' : '当前步骤缺少可取消的任务'}
onClick={() => { void controlBlockedPlanStep(step, '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}
@@ -2292,6 +2468,46 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
</div>
)}
{blockedInputTarget && (
<div className="de-task-manager-modal-overlay" role="presentation" onClick={() => setBlockedInputTarget(null)}>
<div
className="de-profile-settings-modal de-blocked-input-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">BLOCKED PLANSTEP</span>
<h3></h3>
<p>{blockedInputTarget.step.title}</p>
</div>
<button type="button" className="de-schedule-close" onClick={() => setBlockedInputTarget(null)}>×</button>
</div>
<label className="de-blocked-input-field">
<span></span>
<textarea
value={blockedInputDraft}
onChange={(event) => setBlockedInputDraft(event.target.value)}
placeholder="输入用于解除当前阻塞的信息"
/>
</label>
<div className="de-event-modal-actions">
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => setBlockedInputTarget(null)}></button>
<button
type="button"
className="de-btn-primary de-btn-sm"
disabled={!blockedInputDraft.trim() || Boolean(actionBusy)}
onClick={() => { void submitBlockedPlanStepInput(); }}
>
</button>
</div>
</div>
</div>
)}
{profileSettingsOpen && (
<div className="de-task-manager-modal-overlay" role="presentation" onClick={() => setProfileSettingsOpen(false)}>
<div