吸收数字员工审批历史联动

This commit is contained in:
baiyanyun
2026-06-10 10:46:28 +08:00
parent ed2f19d257
commit 047651e404
15 changed files with 893 additions and 192 deletions

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react';
import { Clipboard, Clock3, Download, Eye, PlayCircle, RefreshCw, SkipForward, Square } from 'lucide-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 {
@@ -9,6 +9,7 @@ import {
getSchedulerJobs,
governanceCommand,
postDigitalEmployeeWorkdayAction,
recordApprovalAction,
restartManagedService,
} from '@/lib/api';
import { isTauri } from '@/lib/tauri';
@@ -36,6 +37,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 ApprovalActionKind = 'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk';
interface DigitalPlanItem {
id: string;
@@ -110,6 +112,18 @@ interface ManagementSyncEntitySummary {
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 {
@@ -218,6 +232,48 @@ function badgeClass(tone?: string): string {
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');
@@ -842,6 +898,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
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');
@@ -1346,6 +1403,73 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
}
}, [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,
@@ -2659,35 +2783,120 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
{(workdayProjection?.decisions ?? []).length === 0 ? (
<p className="de-empty-text"></p>
) : (
(workdayProjection?.decisions ?? []).map((decision) => (
<div key={decision.id} className="de-task-manager-decision-card">
<div className="de-log-entry-meta">
<strong>{decision.title}</strong>
<span>{decision.status_label}</span>
</div>
<p>{decision.scenario}</p>
<div className="de-decision-chain-note">
<span className="de-badge de-badge-success"></span>
<span>{isDecisionOpen(decision.status) ? '处理结果会写入 qimingclaw runtime event。' : '该事项已在本地状态中处理。'}</span>
</div>
<div className="de-workbench-actions">
{decision.actions.map((action) => (
(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
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); }}
title="记录审批评论"
disabled={commentBusy}
onClick={() => { void submitApprovalAction(decision, 'comment'); }}
>
{actionBusy === `${decision.id}:${action.id}` ? '处理中...' : action.label}
<MessageSquare size={13} />
<span>{commentBusy ? '记录中...' : '评论'}</span>
</button>
))}
<button type="button" onClick={() => onNavigate({ tab: 'missions', date: selectedDate, missionId: decision.id })}>
</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>
))
);
})
)}
</div>
</aside>